Discord and RCON Operational Boundaries

An operations design that turns Discord interactions into constrained RCON operations and separates asynchronous execution, concurrency, audit logs, and credential boundaries.

The Discord bot ran as a systemd service on the same VM as Minecraft. It exposed server status, players, statistics, and constrained operations such as whitelist changes without requiring SSH or the GCP console. Start, stop, and backup results were also sent to Discord.

Discord and RCON are separate authentication domains. Discord provides guilds, channels, users, and roles. Minecraft authenticates one RCON connection with a shared password and does not know who made the original request. Unless the bot verifies Discord identity and maps it to a constrained operation, Discord input inherits the authority of the full RCON console.

Separating Request Receipt from Task Completion

A Discord interaction requires an initial response within a short deadline. The production handler first deferred the response, moved the blocking RCON call to an executor, and then sent a follow-up.

@tree.command(name="server_status")
async def server_status(interaction):
    await interaction.response.defer(ephemeral=True)
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(None, run_rcon, "list")
    await interaction.followup.send(result, ephemeral=True)

defer means only that Discord accepted the interaction. It does not prove that a Minecraft command succeeded or that the requested state change completed. An RCON string is the immediate command result, not necessarily completion of the longer operation.

sequenceDiagram
  accTitle: From Discord interaction to RCON results
  accDescr: The bot defers the initial slash-command response, checks identity and operation policy, executes an approved RCON command through a queue, and reports receipt and completion separately.
  actor U as Discord user
  participant D as Discord API
  participant B as Bot handler
  participant P as permission and input policy
  participant Q as RCON queue
  participant M as Minecraft
  U->>D: slash command
  D->>B: interaction
  B-->>D: defer and request ID
  B->>P: guild, user, role, operation
  alt reject
    P-->>B: deny reason
    B-->>D: ephemeral rejection response
  else allow
    P->>Q: typed operation
    Q->>M: RCON command
    M-->>Q: response
    Q-->>B: result and duration
    B-->>D: follow-up
  end

Long-running tasks should return an operation_id immediately and persist their state separately. The lifetime of a Discord interaction token should not define the maximum backup duration. Completion or failure notifications include the operation ID, while structured logs or a small job store retain the authoritative state.

Concurrency an Executor Does Not Solve

Calling a synchronous RCON client directly from an async handler blocks the event loop. An executor moves that blocking work to a worker thread, but does not make a shared client safe under concurrent use. The production bot had no common lock or queue to serialize state-changing commands.

stateDiagram-v2
  accTitle: RCON command queue and uncertain result status
  accDescr: Validated operations run in queue order. A timeout or disconnect produces an unknown result, and mutating operations are reconciled against server state before any retry.
  [*] --> VALIDATED
  VALIDATED --> QUEUED
  QUEUED --> RUNNING
  RUNNING --> SUCCEEDED: definite success
  RUNNING --> FAILED: definite failure
  RUNNING --> UNKNOWN: timeout/disconnection
  UNKNOWN --> RECONCILING: Check the actual server status
  RECONCILING --> SUCCEEDED: effect confirmed
  RECONCILING --> FAILED: not applied

Queries and mutations need different policies. Retrying list or tps after a short timeout has no side effect. Immediately repeating stop, a whitelist change, or a backup start after losing the response makes the real state even less certain.

class RconExecutor:
    def __init__(self):
        self._mutation_lock = asyncio.Lock()

    async def query(self, command: str) -> str:
        return await asyncio.wait_for(
            asyncio.to_thread(run_rcon, command), timeout=5
        )

    async def mutate(self, command: str) -> str:
        async with self._mutation_lock:
            return await asyncio.wait_for(
                asyncio.to_thread(run_rcon, command), timeout=10
            )

This lock only prevents overlap inside one bot process. It cannot serialize changes across two bot instances or a separate backup script. It is a reasonable starting point for one VM and one bot; as executors multiply, control should converge on a single worker, Pub/Sub queue, or database lease.

Exposing Operations Instead of a Raw Console

The raw RCON path could pass a user-provided string directly to the console. The bot checked whether the request came from a designated channel, but did not consistently enforce the administrator-role check on every execution path. In effect, permission to view a channel could expand into permission to operate Minecraft.

flowchart LR
  accTitle: Trust boundary from Discord identity to Minecraft permissions
  accDescr: Discord-authenticated identity passes through guild and role policy, operation-specific argument validation, and a command builder before the bot uses its single RCON credential.
  ID[Discord user, guild, and roles] --> AUTH[Bot authorization]
  AUTH --> OP[Permitted operation]
  OP --> VALID[argument validator]
  VALID --> BUILD[RCON command builder]
  BUILD --> CRED[single RCON credential]
  CRED --> MC[Minecraft console]

API surfaces are defined by operations, not strings.

PLAYER_NAME = re.compile(r"^[A-Za-z0-9_]{3,16}$")

def build_command(operation: str, arguments: dict[str, str]) -> str:
    if operation == "players.list":
        return "list"
    if operation == "whitelist.add":
        player = arguments["player"]
        if not PLAYER_NAME.fullmatch(player):
            raise ValueError("invalid player name")
        return f"whitelist add {player}"
    raise PermissionError("operation is not allowed")

This builder covers Java identities only. Do not force Bedrock users through the same whitelist add string. Give them a separate bedrock_whitelist.add operation that calls Floodgate’s fwhitelist add <player>. Do not manually prepend Floodgate’s username prefix; verify that Floodgate can resolve the Bedrock identity. Neither operation should accept spaces, line breaks, or RCON command delimiters. A small operation and argument schema is safer than trying to escape arbitrary console input.

Permissions are linked to each task.

OperationAllowed callerRCON effectAfter a timeout
Server and player statusServer memberReadSafe to query again
TPS and statisticsOperations memberReadSafe to query again
Whitelist changeAdministratorState changeInspect whitelist state
Start backupAdministratorLong-running jobQuery operation state
Stop serverRestricted administratorsDestructive state changeInspect VM and service state
Raw consoleNever exposed on DiscordFull authorityUse a separate path such as SSH

Guild, user, and role IDs are more stable than display names, but Discord configuration can still change them. Treat policy updates as reviewed code or configuration changes. For rejected requests, record the actor, operation, reason, and time without retaining sensitive arguments or secrets.

RCON Responses Versus State Changes

RCON exchanges command strings and text responses. After a network timeout, the client cannot know whether the command failed to arrive or ran and lost its response. Mutating operations therefore need a separate observation of server state.

For example, whitelist additions are tracked in the following order:

  1. Check your Discord identity and whitelist.add permission.
  2. Verify player identity and create a command.
  3. Record the operation ID and requester.
  4. Send the RCON command once from the queue.
  5. Regardless of the response, inspect the whitelist or server state for the effect.
  6. Finish as applied, not applied, or unconfirmed.

Because stop closes the RCON connection, a successful command can look like a network error. With Restart=always, systemd may then restart the JVM despite the administrator’s intent. Decide whether “stop” means a Minecraft command, a unit stop, or a VM stop, and do not let one Discord operation silently mix those layers.

The Limits of a Bot on the Same VM

The bot unit used Requires and After to connect its lifecycle to the Minecraft unit and ran on the same VM.

[Unit]
Description=Minecraft Discord bot
Requires=minecraft.service
After=minecraft.service

[Service]
WorkingDirectory=/opt/minecraft/discord-bot
EnvironmentFile=/etc/minecraft/discord-bot.env
ExecStart=/usr/bin/python3 bot.py
Restart=always
RestartSec=10

After controls startup order; it does not guarantee Minecraft readiness. Requires also ties the bot lifecycle to Minecraft unit failure or shutdown. If the bot should answer status requests and send diagnostics during an outage, weaken that coupling and make RCON-unavailable an ordinary handler state.

When the VM stops, so does the bot. A co-located bot can manage a running Minecraft process but cannot wake the VM that hosts it. Discord-based VM startup would require a bot or webhook receiver in an external control plane such as Cloud Run, with Compute API permissions and its own authentication policy. In production, VM recovery belonged to the Logging, Monitoring, Pub/Sub, and function path—not Discord.

Secrets Delivered Through Metadata and .env

In the production configuration, the Discord token, webhook, and RCON password could pass through Terraform variables and state, VM metadata, and .env files. A 0600 mode protects a file at rest but does not solve secret delivery, rotation, or Terraform-state exposure. Debug output could also print webhook URLs. The entire webhook URL is a credential and must never appear in logs.

In the current structure, the secret value and IAM relationship are separated.

resource "google_secret_manager_secret" "discord_token" {
  secret_id = "minecraft-discord-token"
  replication {
    auto {}
  }
}

resource "google_secret_manager_secret_iam_member" "bot_access" {
  secret_id = google_secret_manager_secret.discord_token.id
  role = "roles/secretmanager.secretAccessor"
  member = "serviceAccount:${google_service_account.minecraft.email}"
}

Terraform should create the secret container and IAM relationship, not the secret value. A value declared in HCL can remain in state, so adding secret versions should be a separate operational procedure. The VM’s dedicated service account receives read access only, while administrators can hold version-management permission without routine read access.

Where systemd credentials are available, deliver the value at service startup as a restricted credential file. The application reads that file and keeps its path and contents out of exceptions, process arguments, and Discord responses.

Rotation proceeds by adding a new version, making the service read it, restarting the bot, testing Discord and RCON, and then disabling the previous version. If exposure is suspected, revoke and replace the Discord token, webhook, or RCON password immediately rather than waiting for a storage redesign.

Minimum Audit Log Fields

Operational records should support incident analysis without retaining unnecessary secrets or personal data.

{
  "operation_id": "example-operation-id",
  "guild_id": "example-guild",
  "actor_id": "example-user",
  "operation": "whitelist.add",
  "authorization": "allowed",
  "rcon_result": "unknown",
  "reconciled_result": "applied",
  "duration_ms": 812
}

Do not record raw commands, tokens, passwords, or webhook URLs. For required arguments such as a player name, define retention and access explicitly and minimize the value to a separate field or hash where appropriate. Discord messages are a user-facing projection; structured logs are the event record.

References

Share

Share

Image preview