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.
| Operation | Allowed caller | RCON effect | After a timeout |
|---|---|---|---|
| Server and player status | Server member | Read | Safe to query again |
| TPS and statistics | Operations member | Read | Safe to query again |
| Whitelist change | Administrator | State change | Inspect whitelist state |
| Start backup | Administrator | Long-running job | Query operation state |
| Stop server | Restricted administrators | Destructive state change | Inspect VM and service state |
| Raw console | Never exposed on Discord | Full authority | Use 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:
- Check your Discord identity and
whitelist.addpermission. - Verify player identity and create a command.
- Record the operation ID and requester.
- Send the RCON command once from the queue.
- Regardless of the response, inspect the whitelist or server state for the effect.
- 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
No comments yet. Be the first to leave one.
Pending review