-
Notifications
You must be signed in to change notification settings - Fork 0
Sysadmin Reference
Audience: Sysadmin Status: ✅ Ready
Standing up and operating a TelosMUD fleet in production: deploying the services, wiring OAuth, managing content, and scaling. This is the section landing page; work through the four guides in order, and use the production checklist below as your pre-flight.
Four long-running services plus one-shot tools, all built from one Dockerfile (SERVICE build
arg):
-
telos-gate— the edge: terminates telnet (TLS), runs OAuth login, proxies players to world shards. Horizontal, stateless. -
telos-world— the simulation: hosts zone shards, one single-writer goroutine per zone. Horizontal by zone. -
telos-director— orchestration: placement planning, scope broadcasts, scheduled spawns. Leader-elected singleton per scope; optional (no published image — build it yourself), and the fleet runs without it. -
telos-account— auth: accounts, characters, trust tiers, the OAuth broker. Horizontal, stateless behind Postgres. -
One-shots/tools:
telos-migrate(schema),telos-seed(dev content import),telos-pull(versioned content install),telos-botswarm(load testing).
Dependencies to provision: Postgres (durable state), Redis (directory/leases/presence/ device-auth), NATS + JetStream (comms + events).
- Deployment — images and the service-role model, the port map and the "one public edge" firewall posture, transports & TLS, the fail-closed boot gates, bare-process vs. container, and the cross-link to the cloud IaC repo.
- OAuth Setup — production GitHub OAuth: a real domain, secure cookies, a persistent session key, and the gate↔account trust secrets.
-
Content Pack Operations — the external versioned store,
telos-pull/ImportVersion, the enabled-set registry, and director-coordinatedpull/reload. - Running at Scale — per-component scaling, claim-from-pool placement, the zero-drop drain for rolling upgrades, metrics, and logging.
Before you go live:
-
Supply a TLS cert + key for the gate (
TELOS_GATE_TLS_LISTEN/CERT/KEY) and leave plain telnet off — there is no built-in cert. -
Set the trust secrets: the shared
TELOS_ACCOUNT_CALLER_TOKEN, the account signing key and the world verify key, and the handoff keypair. -
Set none of the
TELOS_ALLOW_INSECUREgates — supply the real secrets instead so the fail-closed boots stay closed. -
Configure production OAuth: the GitHub App on your real domain,
TELOS_WEB_PUBLIC_URL,TELOS_WEB_SECURE_COOKIES=1, and a persistentTELOS_WEB_SESSION_KEY. -
Keep the gRPC mesh and datastores private — only the gate's TLS port and the account
:8080broker face the internet. - Run
telos-migratethentelos-seed/telos-pullbefore any world starts. -
Wire OTel (
OTEL_EXPORTER_OTLP_ENDPOINT) if you want metrics — export is off by default. -
Pin the first admin with
TELOS_BOOTSTRAP_ADMIN(a GitHub login) — see First Admin Setup.
The real reference deployment (Terraform + Kustomize + k3s, bare-metal or cloud) lives in the separate telosMUD-infra repo; this section is the source of truth for the gomud-side artifacts and configuration.
Every inter-shard handoff RPC that mutates state is authenticated with the shared handoff keypair
(WithHandoffKeys): Prepare binds the carried player snapshot, and AdoptZone — which makes a shard host a
draining peer's zone — binds zone_id, the destination shard, and the zone lease's generation.
A captured AdoptZone is therefore worthless at any other shard, and worthless at its own destination once the
handover it authorized has completed. It is a single-use token for one specific handover, not a
time-bounded capability: the directory increments a zone's lease generation on every ownership change, and the
source's own lease flip is an ownership change. There is no clock anywhere in this path.
- A shard with no verify key refuses inbound handoffs outright unless
TELOS_ALLOW_INSECUREis set. - A keyed shard always enforces the signature.
TELOS_ALLOW_INSECUREcannot loosen it. - Refusals log locally with the reason.
adopt zone refused: stale lease generationmeans the handover this request authorized already completed, or another shard won the flip — normal under a racing drain, and a replay otherwise.adopt zone refused: signature authentication failedmeans a wrong key or a forgery. -
adopt zone refused: could not read the zone's lease generationmeans the destination cannot reach Redis. The fence needs a directory read, so a drain now needs the directory reachable from both peers. It fails closed: that zone degrades from a zero-drop redirect to reclaim-from-durable (players reconnect).
telos-world, telos-director and telos-gate read the eviction configuration at startup. What happens
next depends on whether you have told them the directory has its own Redis (redis.directory_addr):
| Shape | redis.directory_addr |
On an evicting policy |
|---|---|---|
| Shared (the default) | unset | Error logged, boots anyway — read the caveat below first |
| Dedicated | set | Fatal — refuses to boot |
Dedicated is fatal because nothing on that instance wants eviction: the keyspace is small and bounded, and
there is no worse configuration an operator could be ordered into. Shared stays warn-only because the
safe-for-coordination remedy is genuinely dangerous on a cache-sized instance — an operator running
allkeys-lru there may be right for the Redis they actually have.
Three further behaviours worth knowing:
-
An unreadable configuration never refuses.
CONFIG GETis disabled on most managed Redis, so refusing there would make the engine unbootable on exactly those platforms; the probe falls back toINFO memory. -
The periodic re-check is warn-only on both shapes. That asymmetry with the boot gate is deliberate: a
live shard owns zone leases and player sockets, so exiting over a
CONFIG SETwould convert an operator mistake into an outage plus an unplanned lease handover, self-inflicted at the worst moment. Boot is the only place where refusing is cheap. - A configured-but-unreachable coordination Redis is fatal, rather than falling back to the cache instance — a fallback would silently place coordination state on the instance policied for eviction.
The directory is coordination state, not cache. Every key in it is a fact the fleet cannot re-derive, so losing one produces a wrong answer, not a slow one.
Not TTL'd (so only allkeys-*, FLUSHDB, a lossy failover, or an old restore can take them):
| key | what its loss does |
|---|---|
dir:zone:<id> |
the lease owner + its monotonic generation — the fence that makes a signed AdoptZone a single-use token for one handover rather than a standing capability. Stored with PERSIST for exactly this reason |
dir:player:<id> |
the placement epoch that stops a stale handoff routing a player back to a shard they left |
TTL'd — and these are why volatile-* is not an acceptable alternative either. Each one fails open:
its absence isn't an error, it's a confident wrong answer.
| key | what its loss does |
|---|---|
dir:lease:<id> |
the director's leader-election lease. The claim script refuses only when a different owner holds a live lease, so an evicted key lets the incumbent's renewal and a standby's claim both succeed — two directors both believing they lead, issuing rebalance directives against the same zones |
dir:shard:<id> |
the endpoint registration. It refuses a different endpoint only while a live registration exists, so eviction lets two processes sharing a shard id both register — defeating the guard against a duplicated shard id becoming two writers |
dir:tmplinuse:<template> |
parties are inside live instances of this template. It carries the shortest TTL in the directory, so volatile-ttl evicts it first by construction, and its absence is indistinguishable from "nobody is running copies" — letting a content pull strip the pack out from under them |
dir:draining:<shard> |
the draining marker; losing it sends peers at a shard on its way out |
dir:occ:<zone>, dir:cooldown:<zone>
|
rebalancer inputs: a busy zone reads as empty, and the anti-thrash guard disappears. Every extra rebalance bumps a zone's lease generation |
dir:drainresv:<target> |
reserved drain headroom. Listed last on purpose — that ceiling is deliberately soft (a caller proceeds over it rather than stall a drain), so its loss is real but not in the class of the two above |
Earlier guidance here allowed volatile-*, reasoning that the zone hash is PERSISTed and therefore immune.
That is true of the zone hash and false of the directory as a whole.
This Redis is not only the directory. The same instance carries the checkpoint tier — full character
JSON on a 1h TTL, the ~10s rung of the durability ladder. Under any volatile-* policy those are prime
eviction candidates, and losing them silently demotes that rung to save-on-logout: a state-rollback surface
across a crash.
PERSIST also does not defend against FLUSHDB/FLUSHALL, a failover to a replica that lost writes, or an
RDB/AOF restore from an older point. The lease generation is seeded from the Redis clock rather than from
zero precisely so a wipe restarts it above every value it ever issued — a backstop, not a first line of
defense, and one that assumes the clock never jumps backwards by more than a zone's ownership-change count.
Effects replication is required. redis.call('TIME') in a write script is only safe under effects
replication (Redis 5+, mandatory in 7+). The drain-target reservations rely on it, and so does the lease
generation's clock seeding.
On the shared shape the check warns rather than refusing, because noeviction is not unambiguously
safe on a shared instance. With redis.directory_addr unset, the directory shares one Redis with the
checkpoint tier, the presence roster and the account service's device-auth codes — and maxmemory-policy is
server-wide, so the policy is necessarily chosen by whichever tier is loudest, which is the checkpoint tier
(full character JSON on a 1 h TTL), not the directory keys.
- With no
maxmemoryceiling,noevictionnever returns OOM to clients. Redis grows until the container limit and is OOM-killed, wiping every lease, generation and placement epoch at once — the FLUSHALL-equivalent this whole section is about, caused by the remedy. - With a ceiling set, writes at the ceiling error. That includes lease renewal, which fences shards — and since they share the instance, fleet-wide and simultaneously.
So the fix is to split, not to flip the policy. Give the directory its own Redis, point
redis.directory_addr at it, and set noeviction there (that keyspace is small and bounded); leave the
cache instance on an evicting policy with a sensible ceiling. Declaring the split is what upgrades the check
from a warning to a boot refusal, so the engine then holds the guarantee for you. Setting noeviction on a
shared, cache-dominated instance without a ceiling is worse than the warning you are trying to silence.
Note the asymmetry now that the dedicated shape is fatal: an unreachable shared Redis degrades to single-shard and boots, while a reachable dedicated one with a bad policy refuses. An operator under pressure could "fix" a boot refusal by firewalling Redis and land in a worse state than either branch intends — so fix the policy, never the reachability. (A configured coordination Redis that is unreachable is itself fatal, precisely to close the tempting middle path of silently falling back to the evicting instance.)
A wipe is fail-closed, which is the right direction but is not self-healing. After a reset, a drain reads
curGen == 0 for an un-reclaimed zone and is refused — so a Redis data-loss event escalates into stuck
drains rather than into a security hole. The remedy is to let each shard re-claim its zones (they do this on
their normal lease cadence) and then re-run the drain. If drains stay refused, confirm the shards are
actually renewing leases before touching anything else.
Rolling-upgrade note.
AdoptZonesigning is a fail-closed change, in both directions. While a rollout is in flight, a not-yet-upgraded shard cannot produce a request an upgraded destination will accept, so its drains toward an upgraded shard are refused — the drain errors and the zone is retained by the source (never lost, never double-owned). An upgraded source draining toward an old destination is refused too (the old shard's clock-skew check sees no issue time). There is no way to temporarily loosen a keyed cluster to ride this out. So: do not rely on graceful drain to fully empty a node mid-rollout, and complete the rollout promptly to restore drain liveness.
Two sandbox bounds are settable per deployment. Both default to the compiled-in values, so an untouched
deployment is byte-identical to before they existed, and config.example.yaml ships them commented out
(setting them pins them, and an explicit deadline defeats the automatic -race scaling).
| Setting | Env | Default | Bounds |
|---|---|---|---|
tunables.lua_instr_budget |
TELOS_LUA_INSTR_BUDGET |
100,000 | must be reachable inside the deadline |
tunables.lua_call_deadline_ms |
TELOS_LUA_CALL_DEADLINE_MS |
5 ms | must stay below one zone pulse |
Set them as a pair, or don't set them. They are not independent: at the 5 ms default the instruction budget stops firing at roughly 850k instructions, and past that the wall clock always wins. Raising the budget to 10M therefore does not raise the primary bound — it disables it, and silently weakens the circuit breaker with it (a runaway then aborts on the deadline, which is weighted 0.1 rather than the 0.5 an instruction abort carries, precisely so transient host load can't quarantine a correct script). A script failing four calls in five would stop tripping the breaker at all. The engine refuses such a pair at boot and tells you the deadline the budget would need — see Lua Sandbox Internals.
Operational notes:
- A malformed
TELOS_LUA_*value refuses the boot rather than falling back to the default, so a typo can never leave you believing a setting took effect when it didn't. - A deadline at or above the zone pulse is refused: a Lua call that outlives a heartbeat stops combat rounds and affect ticks for every player in that zone.
-
Memory has its own per-call cap (a string-allocation budget), so a single script call can no longer
allocate without bound — an important guard, since the worst case at shipping defaults was a
s = s .. sdoubling loop reaching 64 GB. Note it is a separate dimension: no instruction budget bounds memory (bytes-per-instruction spans five orders of magnitude), so raisinglua_instr_budgetstill raises heap pressure — a call at the ceiling can allocate hundreds of MB in a table-building loop. Raise it only with a container memory limit you have actually tested against.
The per-account cap, the per-shard cap and the mint burst/window are settable the same way
(tunables.*, TELOS_*), following the shape the Lua caps settled on: fail-closed at boot, 0 means
the compiled default and never "unlimited", and a refused set changes nothing. Every malformed
TELOS_* value is reported, not just the last — six knobs on one surface makes multiple typos ordinary.
These are not free-standing workload bounds. Two cross-field invariants are enforced, not merely documented:
-
instances_per_shardis bounded by the drain's instance-eject barrier, which ejects every instance under one shared 5 s deadline sized against the default. Overshoot doesn't crash — it drops occupants to straggler reclaim on every rolling deploy, which is a far quieter failure and therefore a worse one. - The mint burst is meaningless without its window, so the pair is validated as a rate.
One correction worth carrying: the per-account cap does not bound mint churn. Slot reservation excludes abandoned records from the per-account count, so mint-abandon-mint is bounded per account by the rate limit alone. If you are tuning against churn, that is the knob.
Every bound is per process, so a fleet's effective ceiling is the per-shard cap times the shard count — see Running at Scale.
Tells (COMMS_TELL) and durable scoped events (WORLD_EVENTS) ride JetStream. A delivery that fails for a
transient reason — the target is mid cross-shard handoff, the gate bus blipped, a zone is overloaded — is
retried on a ramp-then-hold backoff schedule (200ms, 1s, 3s, 10s, 30s, then holding at 30s) across 10
attempts, ≈ 164 s of covered outage. Anything that fails for the whole window is parked, which is
permanent loss.
- Alert on
telos.commbus.durable_stalled_total(labeled bystream) — this is the one that gives you time to act, firing while the message is still recoverable rather than after it is lost. It matters most onWORLD_EVENTS, and the reason isMaxAckPending = 1: a stalled message blocks every later message on its consumer for the whole retry window, and that stream has one consumer per scope for the entire fleet. So the incident it signals is not "a message is being retried" but "this scope's orchestration has applied nothing for N seconds" — treat it accordingly. It counts messages rather than attempts, so one increment means one stuck message, not a flurry of routine hiccups; the paired WARN log carries the scope identity.Known miss: a wedged handler blocks its consumer entirely, so the callback is never re-entered and the crossing delivery never reaches the counter. The broker still parks the message, so a wedged actor produces the permanent-loss alarm with no early warning — and a wedged actor is a prime stall cause. A restart also under-counts. Don't treat a silent stall counter as proof nothing is stuck.
- Alert on
telos.commbus.durable_parked_total(labeled bystream). Any non-zero value means an outage outlived the entire retry schedule — an incident, not routine. The count comes from the broker's authoritativeMAX_DELIVERIESadvisory (a per-stream queue group counts each park once cluster-wide), so it also catches a park from a hung ack or across a restart, not just an in-process final delivery — but treat it as an alert signal, not an exact ledger: the advisory is ephemeral core NATS, so a park while the whole fleet is down at that instant isn't replayed. -
telos.commbus.durable_poisoned_totalcounts malformed/unroutable messages dropped without retry. Routine and bounded, but a spike means bad content is being published. - Consumers run
MaxAckPending=1, so one stuck message head-of-line-blocks that consumer for up to the full window before parking. A player whose tells stall for ~2–3 minutes and then resume is this mechanism.
The guarantee is precise: never lost to any transient shorter than the window, from publish through the world→gate handoff. The final world→gate frame is at-most-once — a gate that drops it (slow-consumer overflow, mid-reconnect) loses the message. There is no end-to-end render ack.
Upgrade note (durable consumer config). The per-player durable consumers are long-lived and never deleted, so a build that changes
BackOff/MaxAckPending/MaxDeliverupdates them in place viaCreateOrUpdateConsumer. Confirm your nats-server version accepts those updates on an existing consumer. If you seeCreateOrUpdateConsumerconfig-conflict errors after a rollout, that player simply gets no tell delivery — their tells stay durable in the stream, so it is an availability blip, not data loss. Deleting theCOMMS_TELL/WORLD_EVENTSconsumers once is safe: the per-sender delivered-cursor makes the re-drain idempotent, so the only visible effect is a one-time backlog replay, paced byMaxAckPending=1and the login drain pace.
TelosMUD — Wiki under construction.
- Builder Reference
- Builder Commands
- Trust Tier Model
- Pack Authoring
- Pack MUD Settings
- Pack Lua Scripting
- Pack Lua Hooks
- Pack Entity Reference
- Building Instanced Zones
- Engine Developer Reference
- Architecture Overview
- Entity Component Model
- Zone Runtime & Actor Model
- Instanced Zones
- Command Parser & Targeting
- Edge & Protocol
- GMCP Reference
- Persistence & Durability
- Content Loading & Hot Reload
- Abilities & Effects
- Combat System
- Loot, Spawns & Crafting
- Accounts & Auth Internals
- Orchestration & Directors
- Scoped Event Bus
- Cross-Shard Handoff
- Lua Sandbox Internals
- Distributed Systems Model
- RPC & Protobuf