Factorio Admin RCON Source code

Custom commands

Describe your own commands in a JSON file, input fields included, without handing anyone the raw console.

The built-in catalogue covers moderation: kick, ban, mute, broadcast. Anything beyond that — the Lua incantations every server ends up keeping in a text file — is described in a JSON catalogue you provide.

The point is not only convenience. A command declared here is bounded: a moderator gets a “Kill all enemies” button with a player field, without ever receiving rcon:raw — that is, without being able to run arbitrary Lua.

Where the file lives#

The path comes from CUSTOM_COMMANDS_FILE, by default /factorio-config/commands.json — already mounted read-only by the docker-compose.yml. Dropping the file into ./data/config/ is therefore enough.

A complete, ready-to-use example lives in examples/commands.json.

Re-read on change, and forgiving The file is re-read whenever it changes, so you can fix your catalogue without restarting the container. An invalid entry is skipped with a log line naming it; an unparseable file leaves the panel running with the built-in actions only, and /api/ready reports commands: false.

A complete example#

{
  "version": 1,
  "groups": {
    "cleanup": { "en": "Cleanup", "fr": "Nettoyage" }
  },
  "commands": [
    {
      "id": "kill-enemies",
      "group": "cleanup",
      "permission": "action:moderate",
      "label": { "en": "Kill all enemies", "fr": "Tuer tous les ennemis" },
      "hint": { "en": "Every enemy entity on the player's surface" },
      "confirmation": { "en": "Destroy every enemy entity?" },
      "params": [
        {
          "name": "player",
          "type": "player",
          "label": { "en": "Player" },
          "help": { "en": "Only used to pick the surface" }
        }
      ],
      "template": "/c local s = game.players[{{player}}].surface local n = 0 for _, e in pairs(s.find_entities_filtered({force = \"enemy\"})) do e.destroy() n = n + 1 end rcon.print(n .. \" entities destroyed\")"
    }
  ]
}

A command's keys#

KeyDefaultMeaning
idrequiredUnique. Exposed and audited as custom:<id>, so it can never collide with a built-in action.
labelrequiredButton text. A plain string, or an object per language: { "en": …, "fr": … }.
templaterequiredThe command, with its {{name}} markers.
groupcustomHeading in the quick actions. Its label comes from the groups table.
permissionaction:customaction:info, action:moderate, action:server or action:custom. See below.
riskdangerousdangerous colours the button as destructive.
confirmtrueAsk for confirmation before running.
previewtrueShow the rendered command in that confirmation.
hint—Help line under the button.
confirmation—Text of the confirmation dialog.
params[]The fields the user fills in.
The permission default is deliberately strict An entry that says nothing stays administrator-only. Opening a command to moderators is an explicit gesture, not an oversight. rcon:raw is not offerable: a catalogue action is bounded by definition, and the raw console already exists for the rest.

Field types#

typeAcceptsInserted as
playerNo space, quote or backslash; 60 charactersLua string
textNo line break; maxLength (200 by default)Lua string
identifierA prototype name: iron-plate, steel-processingLua string
int / floatA number within min…maxBare number
boolA checkboxtrue / false
enumOne value out of optionsLua string, or bare with "raw": true

Every field also takes required (true by default), default, label, placeholder and help.

A non-textual optional field must declare a default Otherwise an empty field has no meaning: the empty string is not a number. The loader rejects the entry rather than failing at execution time.

Writing a template#

The template never writes its own quotes#

You write game.players[{{player}}], not game.players["{{player}}"]. The panel builds the complete Lua literal, quotes included, and escapes whatever the user typed.

This is what makes the delegation safe: a value cannot break out of the string it lands in. A template that wrote its own quotes would bring the problem back, since imperfect escaping would then be enough to escape.

Two insertion modes#

MarkerProducesFor
{{name}}A complete Lua literal: "Edwins", 250, trueLua commands (/c, /silent-command)
{{arg:name}}The bare, already-validated value: EdwinsRegular commands: /kick {{arg:player}} {{arg:reason}}
-- is refused inside a template Line breaks are flattened into spaces before sending — which is what lets you write a template across several lines — but a Lua comment would then swallow the rest of the command. Negative numbers are parenthesised for the same reason: x-{{n}} would otherwise produce x--5.

Two Factorio pitfalls#

game.player does not exist over RCON#

Nearly every example on the wiki is written for a game client's console and uses game.player. Over RCON there is no current player: game.player is nil and the command fails.

Wiki (game client)Server / RCON
game.playergame.players["Name"] or game.get_player("Name")
game.player.surfacegame.players["Name"].surface or game.surfaces["nauvis"]
game.player.forcegame.forces["player"]
game.player.print(x)rcon.print(x) — otherwise the output never reaches the panel

This is why the player field type is the central case rather than an edge case, and why the shipped example catalogue is rewritten for a server instead of copied from the wiki.

/c disables achievements#

Permanently, for the save in question. They are disabled in multiplayer anyway, but it is worth knowing before handing out /c buttons.

What the panel refuses#

The file is trusted: it is written by whoever deploys the container, on the same footing as an environment variable. What is not trusted is the values typed into the interface. The boundary between the two is where everything is decided.

Concretely, a moderator typing x"] rcon.print("pwned") [ into a player field gets a 400 and nothing reaches RCON. In a text field, where quotes are legitimate, the value is escaped and the literal stays closed.

Checking your catalogue#

At startup the panel logs what it loaded:

{"level":"info","msg":"commands: catalogue loaded","file":"/factorio-config/commands.json","loaded":11,"rejected":0}

A rejected entry appears at warn level with its id and the reason. And /api/ready tells the two cases apart: file absent (feature inactive, ok) versus file present but unreadable (commands: false).