A central authentication broker for Hytale dedicated servers. Sign in once; your whole fleet inherits.
A Hytale server must authenticate to Hypixel's services (OAuth 2.0) to run online. Doing that per server means a device-login per server and a long-lived credential in every container. Treesinger does it once: the operator logs in a single time, Treesinger holds the one long-lived credential, and it mints a short-lived session for each server on demand. Servers then keep themselves authenticated by self-refreshing — Treesinger is not involved after boot.
The name: a Treesinger is the Kweebec elder in Hytale who raises a Seedling and certifies it as ready to go out into the world — vouching for a new identity before it leaves. That is exactly this service's job.
Treesinger is a mint-on-request HTTP service. It does not know how many servers exist, their names, or hold any list — that is deliberate.
- It holds the sole OAuth refresh token and is its only writer, so the 30-day rotation is never raced.
- It keeps a valid OAuth access token, refreshing on demand within a skew window.
- On request it mints a fresh
sessionToken/identityTokenpair for the operator profile. - Each server fetches a pair at boot, then self-refreshes indefinitely (native game behaviour). Treesinger only re-issues when a server (re)starts.
Pull keeps the broker fleet-agnostic (adding a server is zero config) and avoids
a start-order cycle: servers depends_on the broker being healthy, and the
broker never waits to discover servers.
The broker needs no Tidewaiter or Portical: restarting it never disturbs a running server (servers only ask it for a session when they start), and it must never be forwarded to the internet.
How to run it depends on where your servers run:
- Run it in Docker, beside servers in containers on the
same compose network. The image is
ghcr.io/danielbodart/treesinger. - Run it on systemd, beside servers running natively, socket-activated on a unix socket only their group can reach: from the flake's NixOS module, or from a release binary and units on any systemd distribution.
The rest of this page applies however it runs.
From a checkout:
mise install # pins Go
# One-time device login — prints a URL + code, writes the refresh token.
go run . login
# Run the broker.
go run . serve # listens on :8080Or with the released binary, as treesinger login then treesinger serve - each
page above has the commands for its setup.
| Method & path | Purpose |
|---|---|
POST /v1/session |
Mint { sessionToken, identityToken, expiresAt }. Optional body { "uuid": "<profile>" } overrides which profile. What a booting server calls. |
GET /health |
200 only when a mint would succeed (refresh token present and a valid access token obtainable). Backs compose service_healthy. |
POST /v1/login |
Runs the device login in the broker, streaming newline-delimited JSON: the verification URL and code, then {"ok":true} or {"error":"..."}. One at a time (409 otherwise). Only on the admin socket under systemd, never on TCP; what treesinger login uses. |
Endpoint auth is off by default — the boundary is Docker network isolation,
or under systemd the unix socket's group.
Set a bearer token (below) to require Authorization: Bearer <token> on
/v1/session (/health stays open).
One binary:
| Command | |
|---|---|
treesinger serve |
Run the broker. Under systemd it serves the sockets systemd passes it (socket activation); otherwise TCP on TREESINGER_HOST:TREESINGER_PORT. |
treesinger login [--socket PATH] |
The one-time device login. Through the running broker's admin socket (/run/treesinger-admin.sock, so sudo treesinger login) when it exists; otherwise locally, into TREESINGER_STORE (how Docker logs in). |
treesinger exec [--socket PATH | --url URL] -- COMMAND [ARGS...] |
Mint a session (default socket /run/treesinger/treesinger.sock), put it in HYTALE_SERVER_SESSION_TOKEN / HYTALE_SERVER_IDENTITY_TOKEN, then become COMMAND. On any failure it warns and runs COMMAND unauthenticated. For a game server's ExecStart. |
treesinger health [--socket PATH | --url URL] |
Probe a broker: exit 0 only on 200. Defaults to http://127.0.0.1:$TREESINGER_PORT/health, the image's HEALTHCHECK. |
treesinger version |
What this build calls itself. |
All via environment; every value has a default except the refresh token, which
login seeds into the store.
| Variable | Default | Meaning |
|---|---|---|
TREESINGER_STORE |
$XDG_CONFIG_HOME/treesinger/tokens.json |
Token store path (put it on a volume). |
TREESINGER_PORT |
8080 |
TCP port to listen on (and for health to probe). Not when socket-activated: the socket units have the listeners then, and setting it is an error. |
TREESINGER_HOST |
0.0.0.0 |
Address the TCP listener binds. Likewise an error when socket-activated. |
TREESINGER_SKEW_MS |
300000 (5 min) |
Refresh the access token when less than this much life remains. |
TREESINGER_OWNER_UUID |
first profile | Which profile to mint for when the account has several. |
TREESINGER_BEARER_TOKEN |
$CREDENTIALS_DIRECTORY/bearer-token, else unset |
Optional shared secret for /v1/session, for serve and exec. Under systemd, give it as LoadCredential=bearer-token:/path rather than in the environment (a trailing newline is trimmed). exec removes the variable before it runs its command, but a credential file stays readable by that command for the life of its unit (see docs/systemd.md). |
./run test # all in-memory: a fake upstream handler + a stopped clock
./run check # gofmt and go vet
nix flake check # the package (its build runs the tests) and the VM testGo and the standard library, and one module of shared helpers, gokit, which needs nothing but the standard library itself.
Design. One container builds the whole graph (internal/broker/container.go):
production wires it once, each test wires a fresh one after setting the leaf
doubles (Transport, Clock, Store, Config, ...) — a leaf left unset gets
its production default when Wire() runs, and everything downstream is derived,
never set. The upstream client is an http.RoundTripper, with retrying and
logging as gokit's httpx decorators around it, and the broker itself is an
http.Handler; httpx.HandlerTransport serves a handler in memory as a round
tripper, so the fake
Hytale in the tests is just a handler replaying recorded response shapes, with
no sockets or ports. All time-driven decisions are pure functions of the clock
and stored expiry.
Concurrent refreshes are coalesced onto a single in-flight refresh (see
OAuthClient), so the rotating refresh token is spent exactly once no matter how
many servers boot at the same moment — this, not a lock, is what makes a
double-spend structurally impossible. It assumes a single writer process; running
multiple broker instances against one store would reintroduce the race.
trunk is production. Every push to it publishes: there are no manual tags
and no release branches. The version is derived from the repository, not stored
in it — major.commit-count.ci-run (see ./run). The major is the one
deliberate part (in MAJOR); the minor only ever rises and names exactly
one commit; the patch separates two builds of the same commit. treesinger version reports what a build calls itself (development when run from source).
Each trunk push publishes a GitHub Release v<version> and a Docker image to
GHCR (ghcr.io/danielbodart/treesinger:<version> and :latest), both for
x64 and arm64. The release carries standalone binaries
(treesinger-linux-x64, treesinger-linux-arm64), the plain systemd units
(treesinger.service, treesinger.socket, treesinger-admin.socket), the
sysusers file, and SHA256SUMS. A pull request builds everything but
publishes nothing.
The binaries are static, so one build runs on any distribution, and the image is that binary on an empty base.
./run version # what this checkout would publish as
./run build # compile dist/treesinger with the version baked in
./run release # the release's binaries, unit files and checksums, into dist/release/
./run image # build the same image CI publishes, locallyApache-2.0.
