Skip to content

xunx · Multi-user shared service

xunx uses one image to maintain a long-lived container per user, then forwards requests by URL prefix through a single gateway process. Users see http://gateway:18960/alice/chat/, while the container still runs xuns --base-path /alice internally.

xunx user-add alice
xunx serve --host 0.0.0.0 --port 18960

Concepts

Concept Description
User store SQLite database at {XUN_HOME}/x/xunx.db, fields name / token / generation / paused (databases from an older version get the missing columns added automatically)
Token one random token per user (secrets.token_urlsafe(24)), which also serves as xuns --token=<token> for their container; joining it with = is what keeps a token starting with - from being parsed as an option
URL prefix fixed to /{username}, so the container generates the correct relative links
Container name xunx-<first 8 chars of instance ID>-<username>; the instance ID is a hash of the user store path
Port a free port picked at random within --port-range, bound to 127.0.0.1 only (host port = container port)
generation incremented by every upgrade; during reconciliation a stale generation triggers a container rebuild
paused one persistent flag per user; reconciliation freezes or thaws the container with docker pause/unpause (the cgroup freezer) and never removes it, so process memory and saved sessions survive. The STATE column of user-list shows this flag (the desired state), not the live container state
reconcile runs once when serve starts, then every --interval seconds: adopt existing containers, start missing ones, clean up out-of-range containers, sync the paused flag; removing orphaned containers happens only in the first reconciliation

Subcommands

xunx user-add alice

User names may contain only letters, digits, _ and -; adding a duplicate raises an error. The output is a table of users and tokens, where the token is the access password.

xunx user-del alice      # delete the user; their container is cleaned up on the next reconciliation
xunx user-list           # list USER / TOKEN / BASE PATH / STATE (the desired state stored in the database)
xunx pause alice         # freeze this user's container
xunx resume alice        # thaw it; the container picks up exactly where it left off

Both commands only rewrite the paused flag in the database and print "The container state changes on the next serve reconciliation"; the running serve performs the actual docker pause/unpause on its next reconciliation, and a missing user is an error. Unlike upgrade, pausing does not discard anything inside the container.

xunx upgrade alice       # or xunx upgrade --all

This only increments the generation in the database and prints "Queued upgrade"; the actual rebuild happens on the next serve reconciliation, and the data inside the container (workspace, saved sessions) is discarded with it.

Parameter Default Description
--host 0.0.0.0 gateway listen address
--port 18960 gateway port (excluded from the available port pool)
--port-range format START-END; the built-in default covers 17960-18958 port range available to containers; the default in code is range(17960, 18959), whose upper bound is exclusive, so 18959 is not in the default pool
--image xun image to use
--env empty same meaning as xunc --env: NAME=VALUE assignment or wildcard forwarding; XUN_*/_XUN_* are always forwarded
--interval 5 reconciliation interval in seconds (hidden in the help output), must be greater than 0

How requests are forwarded

flowchart LR
  U["browser<br/>/alice/chat/"] --> G["xunx gateway<br/>:18960"]
  G --> R["user store<br/>names and tokens"]
  G --> C["xuns in container<br/>--base-path /alice"]
  C -.->|"relative links keep the /alice prefix"| G
  • Paths are forwarded verbatim (including the /alice prefix and the query string), with no rewriting: xuns inside the container already knows it lives under /alice thanks to --base-path.
  • Unknown user name → 404 User not found; upstream unavailable → 502 Upstream unavailable.
  • While a user's container is paused the gateway answers 503 itself with Retry-After: 60: front-end paths (/<user>, /<user>/login, /<user>/chat/*, /<user>/docs/*) get a "Temporarily unavailable" HTML notice, every other path gets the plain text Service paused.
  • WebSockets (the event stream) are supported, forwarded by two pumps with a 20 second heartbeat; hop-by-hop headers (connection, transfer-encoding, upgrade, etc.) are stripped in both directions.
  • What the container prints is its own address, http://0.0.0.0:<container port>/alice/chat/?session=%2F&token=<token> (plus the localhost:<container port> equivalent; host and container port are the same). To go through the gateway, swap in http://<gateway>:18960 and keep the /alice prefix.

Persistence and reconciliation

Restarting serve does not rebuild containers: during reconciliation it first adopts containers that are already running (or paused), matched by container name (reading their published port and labels), and rebuilds only when the token or generation is stale; containers belonging to deleted users or containers that have exited are stopped; pruning only touches containers labelled with this instance's xunx.instance label (the first reconciliation also clears orphaned containers this instance left behind), so containers of other instances are never disturbed. Logs of newly created containers are forwarded to serve's stderr, prefixed with the container name (containers adopted after a serve restart do not get their logs forwarded). Containers are created with auto_remove=True, so a docker daemon restart takes them with it: serve can only rebuild them at the next reconciliation, and any conversation stored inside the container goes with them.

Every reconciliation converges the containers onto the flags stored in the database: token and generation decide whether a container is rebuilt, paused decides whether it is frozen.

flowchart LR
  S([serve starts]) --> R["running"]
  R -->|"xunx pause"| P["paused"]
  P -->|"xunx resume"| R
  R -->|"xunx upgrade: rebuild"| R
  P -->|"xunx upgrade: rebuilt, still frozen"| P
  R --> E(["user deleted / container exited"])
  P --> E

upgrade rebuilds the container from the newer image: if the user is marked paused, the freshly created container is frozen again straight away.

xunx user-list            # the STATE column is the desired state in the database; containers catch up on the next reconciliation
xunx upgrade --all        # after an image update, have all users rebuilt with the new image on the next reconciliation

Publishing it externally

When the gateway listens on 0.0.0.0, the token is the only credential; in production, put it behind an HTTPS reverse proxy and pass through X-Forwarded-Proto so that the login cookie gets Secure. By default uvicorn only trusts X-Forwarded-* coming from 127.0.0.1 / ::1; when the reverse proxy is not on this machine, FORWARDED_ALLOW_IPS has to be set as well.