Administration API
Moongate embeds an optional gRPC plugin for private-network administration. It exposes account login, server information, paginated account listing, account creation and session revocation. A future React panel should call its own backend; that backend calls these gRPC endpoints. The browser does not connect directly to shard ports.
MoongateAdminPlugin is registered in Program.cs after MoongateUltimaPlugin. It ships with the server, never through the plugins/ directory. Its default port is 2590. It is disabled by default.
Enable an endpoint
Section titled “Enable an endpoint”For an offline, self-signed setup, run:
mgctl init /srv/moongate --generate-admin-certificate \ --admin-certificate-hosts "login.example.test,192.0.2.10"Use the actual endpoint names instead of these examples. This creates
certificates/admin.pfx without a password, exports the public admin.crt, and
sets [admin_api].enabled = true with TLS enabled. The existing bind address and
port remain unchanged. Trust admin.crt in your backend clients and restart the
server. See certificate setup and renewal.
For an operator-provided certificate, configure <MOONGATE_ROOT>/config/moongate.toml:
[admin_api]enabled = truelisten_address = "127.0.0.1"port = 2590session_lifetime_minutes = 30session_max_age_minutes = 480max_receive_message_bytes = 65536max_concurrent_calls = 64allow_insecure_loopback = falsecertificate_path = "certificates/admin.pfx"certificate_password = "$MOONGATE_ADMIN_CERTIFICATE_PASSWORD"Use the interface address reachable by the private panel backend. Both listen_address = "*" and listen_address = "0.0.0.0" bind all IPv4 interfaces; "*" is an alias for "0.0.0.0", including inside a private container network. Use "::" to bind the IPv6 wildcard address. Wildcard binds require TLS. Restrict network access to trusted hosts. Restart after configuration changes.
The endpoint uses standard server TLS over HTTP/2. It does not use mTLS, peer certificates or the Redis game-handoff secret. Obtain a server PFX from your private CA with a private key, server-authentication usage and DNS names matching the endpoints clients use. Put it at the configured path, readable only by the Moongate service account. Distribute the public CA certificate, not the server private key, to backend clients. Clients must validate both the trust chain and hostname.
A relative certificate path resolves against MOONGATE_ROOT; environment variables and ~ are supported. A passwordless PFX uses certificate_password = "". Otherwise supply the password through an environment reference populated from your secret store. Normal server startup does not generate certificates; use mgctl init <root> --generate-admin-certificate explicitly. Missing/unreadable/expired certificates, missing environment variables or an occupied port fail enabled startup with a redacted error. Disabled endpoints neither load certificates nor resolve password variables.
For local development only:
[admin_api]enabled = truelisten_address = "127.0.0.1"port = 2590allow_insecure_loopback = trueThis explicit override allows plaintext HTTP/2 only on literal loopback addresses (127.0.0.1 or ::1). Hostnames, wildcard addresses and Docker bridge interfaces cannot use the override. It logs a warning. The supplied sample clients require TLS; use an HTTP/2 client configured for plaintext when testing this override.
Settings accept ports 1–65535, session lifetimes and maximum ages 1–1440 minutes, receive limits 1024–1048576 bytes, and concurrent-call limits 1–1024. Defaults are shown above. The API accepts calls only after server startup subscribers finish; shutdown rejects new calls and drains existing work before dependencies stop.
Provision the first administrator
Section titled “Provision the first administrator”Run these in the Login or Standalone local console (press * to unlock it):
account create <username> <password> Administratoraccount api-access <username> onReplace placeholders with credentials from your secret store. Console arguments cannot contain spaces. account api-access rejects in-game invocation. Disable access with account api-access <username> off; this also revokes prior administrative sessions.
Accounts created by account create have API access disabled. CreateAccount also disables it when can_access_api is omitted; an authorized caller can explicitly request access. These creation paths set the permission explicitly rather than relying on the entity initializer. Migration auth/0004_account_admin_api_access.sql adds the column with a database default of false; if an existing development schema already has the column, explicit true values are preserved. Apply pending SQL with the migration workflow. Ordinary game login is independent of API access.
Roles and permissions
Section titled “Roles and permissions”| RPC | Login / Standalone | Game | Permission |
|---|---|---|---|
AdminLogin.Login |
Yes | No | Valid password, unlocked account, CanAccessApi = true, defined account type |
AdminSession.Logout |
Yes | Yes | Well-formed presented token; already absent is successful |
AdminSession.Refresh |
Yes | Yes | A valid, unexpired token that has not reached the session’s maximum age; returns a new token and ends the presented one |
AdminServer.GetServerInfo |
Yes | Yes | Any valid administrative session |
AdminServer.GetMetrics |
Yes | Yes | Any valid administrative session |
AdminLogs.GetRecentLogs |
Yes | Yes | Administrator |
AdminAccounts.ListAccounts |
Yes | No | Administrator |
AdminAccounts.GetAccount |
Yes | No | Administrator |
AdminAccounts.CreateAccount |
Yes | No | Administrator |
AdminAccounts.UpdateAccountAccess |
Yes | No | Administrator |
AdminAccounts.ChangeAccountPassword |
Yes | No | Administrator |
AdminAccounts.BanAccount |
Yes | No | Administrator |
AdminAccounts.UnbanAccount |
Yes | No | Administrator |
AdminAccountSessions.RevokeAccountSessions |
Yes | No | Administrator |
AdminFirewall.ListIpBans |
Yes | No | Administrator |
AdminFirewall.BanIp |
Yes | No | Administrator |
AdminFirewall.UnbanIp |
Yes | No | Administrator |
AdminPlayers.ListOnlinePlayers |
Standalone only | Yes | GameMaster or Administrator |
AdminPlayers.ListAccountCharacters |
Standalone only | Yes | GameMaster or Administrator |
AdminHelp.ListHelpPages |
Standalone only | Yes | GameMaster or Administrator |
AdminHelp.TakeHelpPage |
Standalone only | Yes | GameMaster or Administrator |
AdminHelp.AnswerHelpPage |
Standalone only | Yes | GameMaster or Administrator |
AdminHelp.CloseHelpPage |
Standalone only | Yes | GameMaster or Administrator |
AdminSchedule.GetSchedule |
Standalone only | Yes | Any valid administrative session |
AdminJail.GetJail |
Standalone only | Yes | GameMaster or Administrator |
AdminJail.PardonJailPrisoner |
Standalone only | Yes | GameMaster or Administrator |
AdminTemplates.SearchItemTemplates |
Standalone only | Yes | Administrator |
AdminTemplates.GetItemTemplate |
Standalone only | Yes | Administrator |
AdminTemplates.CreateItemTemplate |
Standalone only | Yes | Administrator |
AdminTemplates.UpdateItemTemplate |
Standalone only | Yes | Administrator |
AdminTemplates.DeleteItemTemplate |
Standalone only | Yes | Administrator |
AdminTemplates.SearchMobileTemplates |
Standalone only | Yes | Administrator |
AdminTemplates.GetMobileTemplate |
Standalone only | Yes | Administrator |
AdminTemplates.CreateMobileTemplate |
Standalone only | Yes | Administrator |
AdminTemplates.UpdateMobileTemplate |
Standalone only | Yes | Administrator |
AdminTemplates.DeleteMobileTemplate |
Standalone only | Yes | Administrator |
AdminPlayers.KickPlayer |
Standalone only | Yes | GameMaster or Administrator, see below |
AdminCharacters.GetCharacter |
Standalone only | Yes | GameMaster or Administrator |
AdminCharacters.RenameCharacter |
Standalone only | Yes | GameMaster or Administrator |
AdminCharacters.SetCharacterFame |
Standalone only | Yes | GameMaster or Administrator |
AdminCharacters.SetCharacterKarma |
Standalone only | Yes | GameMaster or Administrator |
AdminCharacters.TeleportCharacter |
Standalone only | Yes | GameMaster or Administrator |
AdminCharacters.ResurrectCharacter |
Standalone only | Yes | GameMaster or Administrator |
AdminOperations.Broadcast |
Standalone only | Yes | Administrator |
AdminOperations.SaveWorld |
Standalone only | Yes | Administrator |
AdminOperations.CreateSqlBackup |
Standalone only | Yes | Administrator |
Game hosts never resolve Accounts services or connect to the Accounts database. Unavailable role-specific services return UNIMPLEMENTED. Configure endpoint addresses in the panel backend; administration endpoints are not advertised in realm discovery.
The firewall of the login and standalone hosts: the banned ranges of addresses of auth.ip_bans, the ones every listener refuses a connection from. BanIp bans a single address or a CIDR range, IPv4 or IPv6, with a reason (the same rules as an account ban: required, at most 200 characters, no control character) and an optional expiry in the future - absent means the ban does not end on its own; a range whose network part is not zero is masked to its network, and banning a range that is already banned replaces its ban. ListIpBans gives the bans that still hold with the keyset pagination of ListAccounts, and UnbanIp removes one (NOT_FOUND when its row is gone). A ban reaches the listeners of the host at once, dropping the connections it covers; a host that shares the database but did not write the ban picks it up on the next firewall reload, about every half a minute, and refuses new connections from then on. A realm host without the accounts database has no firewall of its own: its connections are refused only where they are made, on the login host. The writes are Administrator only, and the audit line of UnbanIp names the ban.
Game hosts (and a standalone host, which has both) also expose the world: who is online, a kick and the daily operations. A Login host does not have them (UNIMPLEMENTED).
ListOnlinePlayers gives the characters that are in the world, by character id, with the keyset pagination of ListAccounts (after_character_id, next_after_character_id, default page size 50, maximum 200): the session, account and character ids, the name, the account type, the map and the coordinates. It does not give account names: a game host has no access to the accounts database. ListAccountCharacters gives the characters of one account by slot, offline ones included: the character id, the slot (-1 for one pending deletion, which gave up its slot), the name, whether the character is online and the deletion request date. It cannot tell an unknown account from one without characters: both give an empty list. It reads the world database, the same read the game login uses to build the client’s character list.
One character is readable wherever it is, and the one that is in the world is also workable. GetCharacter gives it - the live one when it is in the world, the saved one when it is not (online tells them apart) - with its account, name, title, body, place, the three stats with hits, mana and stamina and their maxima, fame, karma, the kills with the short-term murders, and whether it is a murderer, a criminal, hidden, frozen, dead, jailed, or pending deletion. No character has the id, or the world has no record of one, and the call is NOT_FOUND; an NPC is not a character. RenameCharacter gives it a name that follows the rules of character creation (2 to 16 letters, a space, dash, period or apostrophe inside it, and no banned word - INVALID_ARGUMENT for any other), SetCharacterFame and SetCharacterKarma take the ranges of the .fame and .karma commands, TeleportCharacter takes a map and coordinates (INVALID_ARGUMENT when one cannot stand there) and ResurrectCharacter raises a dead one (FAILED_PRECONDITION when it is alive). A change needs the character in the world (FAILED_PRECONDITION when it is not) and runs on the game loop, as the commands do; the answer is the character as it stands after it. The level rules of KickPlayer hold: a GameMaster cannot act on an account of its own level or higher (PERMISSION_DENIED), an Administrator can act on anyone, and the caller may act on its own.
The help queue of the game masters, the one of the .pages gump, is also readable and workable from outside the world. ListHelpPages gives the queue oldest first, open and taken; include_closed adds the answered and closed ones, whose answers the player may still not have read. TakeHelpPage marks a request taken by the caller, AnswerHelpPage answers and closes it (the answer reaches the player at once or at its next login, and follows the rules of the player’s text: nonblank, at most 128 characters, no control character), and CloseHelpPage closes it with no answer. The staff name of a taken page is the account username of the caller: the administration token carries it. The changes of the queue run on the game loop, as the gump does; an unknown or already closed page is NOT_FOUND.
GetSchedule gives the calendar of data/schedule.toml as it stands: every timed task with its action (shutdown, broadcast or Lua call), its when rule (every hour, day or week; at; days of the week), its next occurrence after now (the stop, for a shutdown), the shutdown warnings and the broadcast or Lua details; and every seasonal event with its date window, its mode (auto, on or off) and whether it is on now. It is read-only: switching an event through the API is future work. It accepts any valid administrative session.
The jail is readable and workable from outside the world too. GetJail gives whether there is one, the map its cells are on, the longest sentence in days, every cell with the sentence that holds it now, and every sentence: the prisoner’s id and name, player or NPC, cell, days, when it began and ends, who jailed and why, whether it is pending (waiting for an offline prisoner, whose days start at its login) or pardoned, and whether the prisoner is online. PardonJailPrisoner ends a sentence now with no fine and no note, as the in-game pardon does; a character with no sentence is NOT_FOUND. Everything reads and changes the jail on the game loop, as the gump does. Jailing through the API is future work: the in-game command needs the mobile of the jailer.
The item and mobile templates of templates/items/ and templates/mobiles/ are readable through the API, and the ones the API itself wrote are changeable and removable. SearchItemTemplates and SearchMobileTemplates find the ids and the names that hold the text, without regard to case, and the templates of the graphic when the text is a number; GetItemTemplate and GetMobileTemplate give one by id. The answer carries the authored entry - what the file says, an absent field inheriting from base_id - plus the resolved graphic and name, and the name of the file that defines it. Create writes a new template into admin.toml, Update replaces the entry of a template that lives there, and Delete removes it; a template of a shipped file is read by the API and edited by hand (FAILED_PRECONDITION), because rewriting a shipped file would destroy the comments and the layout an operator may have curated. The first contract covers the core fields: id, base_id, graphic, name, comment, script and tags, and for items also movable, stackable, weight and prices. Every write validates the whole catalog with the same loaders the startup uses - a duplicate id, a missing base_id, a cycle or an invalid value is INVALID_ARGUMENT and the file is rolled back. The writes take effect at the next restart: the running world keeps the templates it loaded. A write is for administrators. KickPlayer closes the connection of a character; a GameMaster cannot kick an account of its own level or higher (PERMISSION_DENIED), an Administrator can kick any, and nobody can kick their own account (FAILED_PRECONDITION). An unknown or offline character is NOT_FOUND. The reason (at most 200 characters, no control character, so it cannot pass for another line of the log) goes to the server log of the call, not to the player. The call does not wait for the connection of the player to drain: a socket that was already half dead is still a kick, and the audit line names the account of the player.
Broadcast sends a nonblank text of at most 200 characters (no control character) to everyone in the world and gives the number of recipients; a text the transport cannot carry once compressed is INVALID_ARGUMENT. A repeated call repeats the message: there is no request id. SaveWorld saves the world as save does and gives the milliseconds it took; a save already running (the autosave) is the one the call waits for, so changes made just before the call may not be in it, and while the server stops the call is UNAVAILABLE. CreateSqlBackup runs the backup of sql_backup (it saves the world first) and gives the file names, not their paths, the size of each and the failures, each with a fixed text (the reason is in the server log); already_running is set when another backup is running. A save or a backup goes on when the client hangs up or the deadline ends: the call stops waiting, the work does not stop in the middle of a file. At most two saves and backups are asked for at a time (RESOURCE_EXHAUSTED for the next), so they cannot fill the admission gate of the host. These three are Administrator only, and a call to this service may last up to 5 minutes, counted from the moment it is authenticated and authorized, where any other has 15 seconds.
Regular and GameMaster accounts may read server information when API-enabled, but cannot list/create accounts or revoke others’ sessions. Unknown account types are rejected. CreateAccount can assign any defined type and explicitly enable API access because only Administrators can invoke it.
Call the API
Section titled “Call the API”The wire package is moongate.admin.v1. Obtain generated C# clients from Moongate.Admin.Contracts, or generate any supported language from the raw files under proto/moongate/admin/v1/. Each release includes moongate-admin-protos-<version>.zip. Standard google/protobuf imports come from the compiler distribution.
A request sequence:
- Call
AdminLogin.Loginon a Login endpoint withusernameandpassword. - Keep the returned
access_tokenin backend memory. Sendauthorization: Bearer <token>metadata on protected calls to either Login or Game. - Use
AdminAccounts.CreateAccountandListAccountson Login, orAdminServer.GetServerInfoon either endpoint. - Before the token expires, call
AdminSession.Refreshon either endpoint to get a newaccess_tokenwith a new lifetime; the presented token stops working at once, so replace it in memory. A session can be renewed untilsession_max_age_minutesafter its login; then log in again. - Call
AdminSession.Logouton either endpoint to remove the current token globally.
Runnable examples: C# client, Python client. Both use standard TLS verification, environment-supplied credentials, bounded deadlines and no automatic mutation retries. They create a test account that remains in Accounts; use disposable environments for verification.
ListAccounts uses database keyset pagination: start with after_account_id = 0, send the returned next_after_account_id on the next call, stop when it is zero. Default page size is 50; maximum is 200. IDs are nonzero uint32 for existing accounts. Summaries contain username, ID, role, access/lock flags, the ban reason and expiry, and UTC creation time, never passwords, hashes or email.
GetAccount gives the whole record of one account the accounts database owns: the summary fields plus the UTC last login and updated times. The last login is empty for an account that never logged in. Email stays out, as in the summaries.
GetMetrics gives the latest diagnostic snapshot of the host: the collection sequence and time, the flat metric list (names qualified as provider.name, with value, unit and gauge or counter type) and the providers that failed the last collection. It accepts any valid administrative session, like GetServerInfo, and adds no collection of its own: the response is empty until the first collection completes.
GetRecentLogs gives the latest log events of the process, newest first, out of a bounded in-memory buffer of the same pipeline the console and the .clef files write: timestamp, level, rendered message and, when there is one, the full exception. minimum_level filters (zero keeps everything the pipeline accepted), limit bounds (zero asks for the default of 100; above 1000 is INVALID_ARGUMENT). The console and the files stay exactly as they are. It is for administrators only: log lines can carry data a game master has no business seeing.
UpdateAccountAccess changes the lock, the API access and the account type of an account: only the fields that are sent change (the fields have presence, so sending is_locked = false unlocks and leaving it out leaves it alone), and at least one must be sent. It returns the summary of the account as it is after the change. The account of the caller cannot lock itself, lose its API access or lower its type (FAILED_PRECONDITION), and no change may leave the server without an unlocked administrator with API access (FAILED_PRECONDITION too). The other administrators are locked, in the order of their ids, before they are counted, so two administrators changing each other at the same moment cannot both succeed: one of them fails and may retry. ChangeAccountPassword sets a new password (the same rules as creating an account); the password is never logged. For the caller’s own account it also needs current_password (INVALID_ARGUMENT when missing, PERMISSION_DENIED when wrong, and the session stays), so a stolen token alone does not take the account; for another account current_password is ignored.
BanAccount locks an account as a ban: it records a reason (required, nonblank, at most 200 characters, no control character) and an optional expiry (banned_until; absent means the ban does not end on its own), and the lock refuses every login, ordinary and administrative. A ban cannot be aimed at the caller’s own account (FAILED_PRECONDITION), cannot leave no unlocked administrator with API access (FAILED_PRECONDITION), and an expiry in the past is INVALID_ARGUMENT. An expired ban releases itself at the next login of the account, ordinary or administrative: no timer, no restart. A lock without ban fields, the one of UpdateAccountAccess, never releases itself. UnbanAccount unlocks the account and clears the ban fields. Both end the sessions of the account and return its summary as it is after the change.
Every change that goes through reaches the sessions of the target, even one that changes nothing, such as unlocking an account that is not locked. When the target is the caller’s own account the caller’s session ends too and it logs in again. A refused change ends nobody’s session. If a call fails after the change was saved (for example the dependency that reopens the sessions is unavailable), the client sees UNAVAILABLE or INTERNAL although the change stands; repeating the call is safe, since applying the same patch twice changes nothing more.
Omitting CreateAccount.account_type means Regular. Explicit UNSPECIFIED and unknown enum values fail. can_access_api defaults to false. Usernames must be nonblank and at most 255 characters; passwords are nonblank and at most 1024 UTF-8 bytes. NUL characters are rejected. Username matching retains existing case-sensitive account semantics.
Sessions, revocation and failures
Section titled “Sessions, revocation and failures”Tokens have 256 random bits and an absolute lifetime of 30 minutes by default. There is no refresh token: AdminSession.Refresh rotates the access token itself. It needs a token that is still valid and issues a new one with a new lifetime, and the old token is removed in the same Redis step, so two refreshes of one token at once give one new token. A session ends for good session_max_age_minutes after its login (480 by default, never less than the lifetime), however often it was renewed; Refresh then answers UNAUTHENTICATED and the client logs in again. A renewed session is revoked by exactly the same events as a new one: logout, ban, lock, role change, api-access off and RevokeAccountSessions. The lifetime and the maximum age belong to the session: they are those of the host that logged it in, kept in Redis, so a renewal served by another host does not change them. A session issued before this feature existed carries neither and cannot be renewed; it simply expires.
Only the newest token of a session works, so keep the one Refresh returned and use it for Logout: logging out with an older token is a successful no-op. Refresh is not retried: the old token is gone once the new one is issued, so a call whose answer is lost means logging in again. A token that is stolen can be renewed by its holder up to the maximum age; the events above, RevokeAccountSessions among them, end it. To turn renewal off, set session_max_age_minutes equal to session_lifetime_minutes: the deadline is then the first expiry and a refresh gives no extra time. Redis stores only token digests and safe identity fields under moongate:admin:, separately from realm leases/handoff tickets. Up to 64 active sessions are allowed per account. Administrative sessions contain no encryption layer of their own; secure your private Redis deployment and credentials as described in configuration.
Each protected call checks Redis. Logout and account-wide revocation apply across hosts immediately for newly admitted requests. An ephemeral Redis restart invalidates sessions; a server restart does not renew their expiry. A Redis outage fails closed with UNAVAILABLE. Login is limited across hosts to 10 attempts/minute per exact username and 30/minute per direct peer address; forwarded-address headers are not trusted.
Supported account security changes go through IAccountAdminAccessService.SetApiAccessAsync, UpdateAccessAsync, PatchAccessAsync, ChangePasswordAsync, ChangeOwnPasswordAsync, BanAsync, UnbanAsync, or RevokeSessionsAsync. These coordinate PostgreSQL row locks with Redis authorization generations. A login racing a security change cannot retain a stale privileged session. A partially failed mutation leaves access blocked; a later valid login recovers against the authoritative committed account state. Direct SQL and generic IDataAccess updates bypass immediate revocation. Use the supported service methods for security changes.
| Status | Meaning |
|---|---|
INVALID_ARGUMENT |
Invalid input, enum, cursor ID or page size |
UNAUTHENTICATED |
Invalid credentials or missing, expired or revoked token; a Refresh of a session past its maximum age |
PERMISSION_DENIED |
Valid session lacks the required role, or the current password given to change your own is wrong |
ALREADY_EXISTS |
Username already exists |
NOT_FOUND |
Revocation, update or password target does not exist |
FAILED_PRECONDITION |
The call would lock the caller out of its own account, or leave no unlocked administrator with API access |
RESOURCE_EXHAUSTED |
Login/session/concurrent-call limit reached, or request too large |
UNAVAILABLE |
Dependency outage or endpoint not ready/stopping |
UNIMPLEMENTED |
This server role does not expose the RPC |
CANCELLED / DEADLINE_EXCEEDED |
Caller cancellation or bounded execution ended |
INTERNAL |
Unexpected failure; inspect safe server logs |
Calls have a maximum server execution window of 15 seconds (5 minutes for AdminOperations). Cancellation does not undo a committed account creation. If a create response is lost, a retry may return ALREADY_EXISTS; use listing to reconcile. There is no exactly-once guarantee or automatic retry policy. Revocation does not roll back writes from already admitted requests.
Audits record operation, account IDs, outcome and request correlation; no passwords, tokens or request bodies. Future live-world mutations must be scheduled through the GameLoop. This version exposes no character editing or arbitrary command execution.
Docker and verification
Section titled “Docker and verification”The image documents port 2590, but EXPOSE does not enable or publish it. The private Compose override enables TLS with mounted operator-provided PFX files and publishes no administration port. See the Compose guide.
Repository checks:
bash scripts/verify-admin-protos.shbash examples/docker/login-realms/admin-smoke.shThe first needs PostgreSQL and Redis: Docker, or MOONGATE_TEST_POSTGRES_CONNECTION_STRING and MOONGATE_TEST_REDIS_CONNECTION_STRING (see Verify your changes); it uses a temporary Python environment plus real TLS fixtures. The second builds the server images, creates its own disposable Compose project and test CA, runs the Python client from the private Docker network, and removes only its own containers/volumes/certificates.
Made with ❤️ by Tom
