26 Commits

Author SHA1 Message Date
0710a3e33e Merge pull request 'feat(rust): cut edge over to main — --game rust, named instances, schema-2 bundles (rust phase 18)' (#32) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 15s
Release installer / release (push) Successful in 4m55s
Reviewed-on: #32
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 09:15:43 +00:00
0f19fd6413 Merge pull request 'fix(service): start the Windows service after granting it its config (D157)' (#31) from fix/windows-start-after-grant into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m2s
Reviewed-on: #31
2026-09-26 09:00:29 +00:00
70eb073826 fix(service): start the Windows service after granting it its config (D157)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m8s
`sc start` ran inside registration, before `grant_service_access` let the
new virtual account read `sidecar.toml` — which `protect_config` had
locked to SYSTEM and Administrators. The first start died on "Access is
denied (os error 5)" and the service sat STOPPED: the SCM's
restart-on-failure policy never fires for a clean exit with an error
code. A later manual start worked, because by then the grant existed.

Found on the phase 18 walk (step 4, one Rust instance under the SCM),
from the sidecar's own log. The code is shared, so the released ServUO
installer has the same first-start failure; the org lead chose to fix
both here (D157).

Registration no longer starts the service; a new `start_registered`
runs after the grant and refreshes the reported state. A no-op on Linux,
where the service user exists before the config and registration starts
the unit itself.

Walked (Windows, elevated): install -> START_PENDING/running, /health on
the first probe, clean stop and restart, doctor clean, uninstall clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:54:57 -05:00
285f7df17b Merge pull request 'fix(rust): an update on a running server reloads the plugin' (#30) from fix/rust-plugin-reload into edge
Reviewed-on: #30
2026-09-26 08:46:02 +00:00
e5611a01eb fix(service): say why the service account stays, truthfully for Rust too
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
`uninstall --game rust --server-id beta` printed "left the runicgateway
account alone — this installer did not create it" on a host where this
installer had created it, for alpha. A Rust instance's service never owns
the shared account (the last instance's removal decides), so the reason
now reads "it is not this service's to remove" — true for both games.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:45:27 -05:00
397e0b92e9 fix(rust): an update on a running server reloads the plugin
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 52s
The plugin was placed with write_atomic: remove the old file, rename a
.tmp over it. Oxide and Carbon watch the plugins directory and reload on
a change; both saw the remove as a delete and UNLOADED the bridge, and
ignored the rename, so the new file was never loaded. Every `update` (or
re-install) on a running server took the bridge down until the next
boot, silently — the phase 18 walk found it on all three instances and
both frameworks, after the move to v0.1.1.

The plugin is now overwritten in place, which is what an operator's `cp`
does and what both frameworks reload on. write_atomic stays for the
record and the configs, where atomicity is the point.

`update` on the same bundle also no longer says "nothing moved" after it
has just put back a hand-edited plugin — the remedy `doctor` names.

Walked: hand-edit, then `update`, on a running Oxide and a running
Carbon server: replaced, reloaded, reconnected, doctor clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:42:42 -05:00
0c07b8a967 Merge pull request 'fix(rust): the handoff says what the run did, not that a link exists (D156)' (#29) from fix/rust-handoff-wording into edge
Reviewed-on: #29
2026-09-26 06:27:30 +00:00
3b2881eb9c fix(rust): the handoff says what the run did, not that a link exists (D156)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 58s
`install --game rust` ended every instance with "Rust server "alpha" is
connected to its sidecar" — printed unconditionally, before the plugin had
dialled anything, on a stopped server where it had not even loaded, and
beside "No service was registered, so nothing is listening yet". The
phase 18 walk read it as a claim and then found beta's plugin connected to
the wrong sidecar.

It now reads "Rust server "alpha" is set up." followed by either "The
plugin loads now; `doctor --game rust --server-id alpha` confirms it
connected." or "The plugin connects when the server next starts.".

The top-level --help no longer describes `install` as the uo-link sidecar
and overlay for both games, and says what `uninstall` removes for Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 01:21:32 -05:00
0fad3e3b2d Merge pull request 'feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)' (#28) from feat/phase-18-rust into edge
Reviewed-on: #28
2026-09-26 05:33:51 +00:00
7027a78a23 feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m7s
Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D148, D149, D153).

Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the
schema-1 model. Schema 1 at the root is the fallback, so a pin from before
schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use
the contents API, because /raw/ is CDN-cached for six hours.

--game rust runs install, update, doctor and uninstall for Rust servers
(src/rustgame/):
- the framework is detected from its marker files, which were read off both
  rigs; both or neither is refused;
- --server-id names an instance: its own service (runicgateway-rust@<id>, or
  RunicGatewayRust-<id>), config, database and ports;
- the plugin config is written once, with ServerId and Port only. An existing
  one is never rewritten, and one naming another server refuses the run;
- each instance's sidecar.toml is written once with its ports and an absolute
  database path, and the sidecar generates the token into it;
- one binary per host. update moves every instance, and a replaced binary
  restarts all of them;
- doctor checks the plugin file hash, the plugin config's ServerId, the
  required uMod plugins (a warning), the service and /health, and passes when
  the plugin is connected;
- uninstall removes our plugin and keeps its config. --purge also removes the
  sidecar config and database. The last instance takes the binary, the
  template and the record, and the shared user only when no ServUO record
  remains.

service.rs takes the service name as a parameter internally. The ServUO
public API is unchanged.

Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins
and carbon/configs are what the installer uses. The plan expected a moved
directory to be readable there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 23:40:11 -05:00
8e4cbc6406 Merge pull request 'ci(bundle): compose schema 2 for ServUO and Rust beside schema 1 (rust phase 18, step 2)' (#27) from ci/bundle-schema-2 into main
All checks were successful
Release installer / release (push) Successful in 12s
sync-project-tree / sync (push) Successful in 21s
Compose bundle / compose (push) Successful in 15s
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 03:54:15 +00:00
4f268f7ffb ci(bundle): compose schema 2 for ServUO and Rust beside schema 1
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m12s
Module-rust phase 18, step 2 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D147). The compose job now writes three streams on the bundles branch:

  current.json         schema 1, ServUO, exactly as before
  v2/servuo/           schema 2, game "servuo"
  v2/rust/             schema 2, game "rust" (Rust-Link + Rust-Plugins)

The gates move into .gitea/scripts/compose-bundles.sh, so they can be run by
hand against the real release API or a mock without a runner. Each game
composes independently: a Rust failure still lets a ServUO bundle publish, and
the run goes red afterwards. A game whose repos have never released composes
nothing and does not fail, which is where Rust is until its cutover.

A ServUO matrix carries the same tag at both schemas, so the first run
publishes v2/servuo/bundle-2026.09.15.json rather than a second name for the
pair. Schema 1 stops being composed on 2027-01-01 and is left frozen.

Tested in debian:12 against the real releases (v2/servuo published as
2026.09.15, schema 1 unchanged, Rust skipped; rerun is a no-op; past the
retirement date schema 1 is frozen) and against a mock Gitea for Rust
(document composed, protocol mismatch refused, missing manifest key refused,
rerun a no-op, ServUO failing independently).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 22:49:47 -05:00
f865660c7c Merge pull request 'feat(doctor): the one host prerequisite the Asset Bridge added (Asset Bridge cutover, 4 of 5)' (#26) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -1m23s
Release installer / release (push) Successful in 5m56s
Compose bundle / compose (push) Successful in -1m32s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-14 23:11:53 +00:00
2285fff759 Merge pull request 'feat(doctor): the one host prerequisite the Asset Bridge added (Phase 9a)' (#25) from feat/asset-bridge-p9 into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m10s
Reviewed-on: #25
2026-09-14 22:25:52 +00:00
ead105d5bf feat(doctor): the one host prerequisite the Asset Bridge added (Phase 9a)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
`libgdiplus`, on Linux hosts only. ServUO targets net48, so on Linux it runs
under Mono, and Mono's `System.Drawing` is a thin layer over that library -- which
sits in the DECODE path, not merely the encode: `Ultima.Frame` writes ARGB1555
through a LockBits pointer, so without it the shard cannot read one sprite out of
the operator's UO client.

docs/link/v8.md §17.2 took all three answers to this rather than one -- the
prerequisite in SHARD_PREREQS.md, a named NO_IMAGING status instead of an error,
and a `doctor` check. The first two shipped in phase 1. This is the third, and
SHARD_PREREQS.md has been claiming it existed since then.

Three things about the row are deliberate:

- **Linux only, and absent elsewhere.** A Windows host ships System.Drawing with
  .NET Framework and has nothing to check, so there is no row rather than a row
  saying "not applicable" on three quarters of the hosts that run this.
- **A warning, never a failure.** Names and the shard's own spawn files have no
  pixels in them, so a host without this library still runs a useful bridge, and
  `doctor`'s exit code -- which monitoring reads -- must not go red over one
  absent feature.
- **`ldconfig -p` first, paths second.** The loader's own cache is the same
  question Mono asks at runtime, so a distro that puts the file somewhere unusual
  answers correctly; the path probe is the fallback for a slim container with no
  ldconfig, and a missing ldconfig degrades to it rather than taking doctor down.

The verdict is split from the detection so the mark and the wording are testable
on a host that has the library and one that does not; all three tests are
Linux-gated, which is where CI runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:07:23 -05:00
1996a32153 Merge pull request 'ci(release): sweep every tag for a missing release, not just this run's' (#24) from ci/release-orphan-tag-sweep into main
All checks were successful
Release installer / release (push) Successful in 8s
sync-project-tree / sync (push) Successful in 7s
Compose bundle / compose (push) Successful in -59s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 19:42:02 +00:00
65998692ae ci(release): sweep every tag for a missing release, not just this run's
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m28s
The first commit on this branch said the plan step "CAN recover an orphan, but
only on a run that reaches it". Checking link and servuo-plugins for the same
gaps showed that understated it.

The recovery is VERSION-SCOPED. It computes VERSION from the newest tag plus the
conventional-commit bump, then only checks refs/tags/v${VERSION}. So it recovers
an orphan on the very next run and is useless afterwards: once any releasable
commit lands, the next run computes a NEW version and never looks at the old tag
again. The orphan becomes permanent and silent.

servuo-plugins proved it, and the proof is pointed. Its v0.1.0 had been orphaned
since 2026-08-04 -- tag present, no release, no assets -- while v0.1.1, v0.2.0
and v1.0.0 all published normally. The commit that ADDED the recovery to that
repo was itself typed "fix(release): preflight credentials and recover the
orphaned v0.1.0 tag", so it bumped to v0.1.1, and the run that introduced the
recovery stepped straight past the tag it was written to rescue.

The retry added in the previous commit makes an orphan much less likely, but it
does not make one impossible -- a cancelled job or a dying runner produces the
same state with no 500 anywhere -- and until now nothing would ever have
mentioned it again.

So the plan step now sweeps every v* tag and warns about any without a release.

It WARNS rather than recovers, on the org lead's decision. Publishing an old
version would mean building today's tree and shipping it under a tag whose tree
it is not, which is worse than the inconsistency it fixes; and a routine push
silently republishing ancient history is not a thing this pipeline should be
able to do. Recovery stays limited to the version the run computed.

It also never fails the run. A sweep that can break a good release is a sweep
someone will delete.

Verified by running the loop against the real repositories rather than a stub,
since the only thing worth proving is that it tells a clean repo from a dirty
one:

  link (9 tags): clean
  servuo-plugins (4 tags): :⚠️:Tags with no release: v0.1.0
  installer (2 tags): clean

and again after servuo-plugins#15 deleted that tag, where all three report
clean. Every run block bash -n clean, the YAML parses, and no empty template
token.

Companion PRs: link#33 and servuo-plugins#15.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 13:57:59 -05:00
094da1776b Merge pull request 'ci(release): show the error body, and retry the release POST' (#23) from ci/release-post-retry-and-error-body into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in -59s
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 17:32:27 +00:00
188e6eb882 ci(release): show the error body, and retry the release POST
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m32s
Run 75 built every artifact, pushed tag v0.1.1, then took a 500 from
POST /releases one second later and exited 22. The tag was left orphaned with
no release and no binaries, so the handoff fix in #22 reached no operator until
the workflow was re-run by hand today.

Re-running published the same four assets untouched, via the orphan-tag
recovery the plan step already has. So the 500 was a race with the tag push --
Gitea had not finished processing the pushed tag when the POST arrived -- and
not a bad request.

Two separate gaps made that worse than it needed to be.

`curl -sSf` prints no response body on an error status. All the log carried was
"curl: (22) ... error: 500", so the cause had to be inferred from timestamps
rather than read. Every call in this step now captures the body and prints it
on failure, including the asset uploads.

And nothing retried. The plan step can recover an orphan tag, but only on a run
that reaches it, and a later push with no releasable commits stands down before
it gets there -- which is why this one sat until someone looked. The POST now
retries five times with a 5/10/15/20s backoff.

4xx is deliberately not retried: a bad token or a malformed body will not
improve by being sent again, and retrying would turn a clear failure into a
slow one. A give-up message names the orphan tag and says a re-run republishes.

The asset uploads get the same treatment, because a release whose SHA256SUMS
does not cover every binary it advertises is worse than no release -- that file
is the trust anchor for an unsigned download.

Verified by extracting the step's shell from the YAML and running the loop
against a stubbed curl: first-try success, 500-then-success (the case that
actually happened), two 500s then success, five 500s giving up, 403 and 404
aborting without retrying, and a 000 network failure being retried. bash -n
clean and the YAML parses.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no binary, so a release here would be an empty one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:54:47 -05:00
9cc109910c Merge pull request 'fix(handoff): print the shard screen's real path' (#22) from fix/admin-shard-path into main
Some checks failed
sync-project-tree / sync (push) Successful in 6s
Release installer / release (push) Failing after 4m3s
Reviewed-on: #22
2026-08-24 16:30:49 +00:00
6da385425e fix(handoff): print the shard screen's real path
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
The end-of-run block told operators to paste the four values at
`<site>/admin/shard`. That page moved when the shard screens became part of the
`uo` module: a module owns one path segment wherever it appears (website
`MODULE_SYSTEM.md` §2.8), so it is `/admin/uo/link`, labelled "Shard (uo-link)".

The old path is worse than a 404. The SPA has no route for it, so it sends the
operator to the dashboard — the link looks like it worked, and the values they
were told to paste have nowhere to go.

- The path is now a named constant, `ADMIN_SHARD_PATH`, carrying why it is not
  the obvious string and the fact that API routes are NOT affected by the module
  namespacing rule (they keep `/api/v1/admin/shard/*`).
- Both handoff tests assert the new path, so this cannot regress quietly.
- The two user-facing labels that name the screen — the `--site-url` help text
  and `update`'s protocol-change instruction — say "Admin → Shard (uo-link)",
  matching what the sidebar actually reads.

Found while writing the runicgateway.com installation journey, by pasting the
printed link into a real deployment and landing on the dashboard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:22:45 -05:00
5d4c68eaf5 Merge pull request 'docs(backup): correct why the sidecar database is not backed up (Teams cutover 3/6)' (#21) from edge into main
All checks were successful
Release installer / release (push) Successful in -51s
sync-project-tree / sync (push) Successful in 9s
Compose bundle / compose (push) Successful in 23s
Reviewed-on: #21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-19 08:56:06 +00:00
c3771d22f2 Merge pull request 'docs(backup): correct why the sidecar database is not backed up' (#20) from feat/teams-phase1-guild-roster into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m46s
Reviewed-on: #20
2026-08-17 19:28:25 +00:00
6c49217e9c docs(backup): correct why the sidecar database is not backed up
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m49s
backup.rs justified skipping the sidecar's database on two claims. Protocol 4
falsifies one and reveals the other was already wrong.

It said the database is safe because store.rs creates every table IF NOT EXISTS.
That held only while every schema change added a whole table — which, up to and
including Protocol 3.0, every one of them did. Protocol 4 adds a COLUMN to a table
that already exists, which IF NOT EXISTS cannot do, so link now carries a real
migration. A run can change the database's structure, not only its contents.

It also said every table holds state the sweeps repopulate. `events` does not: it
is never pruned, and the website backfills what it missed from GET /history on
every reconnect. So a lost database costs the gap-recovery window for whatever
happened while the site was down. That claim was untrue before this workstream
existed.

The behaviour does not change — the database is still not copied — because the
argument against backing up unbounded bulk survives both corrections: `events`
grows without limit, the migration is transactional and additive, and the website
holds its own durable copy of everything already ingested. Only the reasoning was
wrong, and a wrong reason left in place is what lets the next person extend it to
a case it never covered.

Whether that unbounded table should be pruned or protected belongs to link, on its
own merits, rather than being settled inside a backup policy.

No logic change; docs only.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:58:24 -05:00
7758724eb5 Merge pull request 'docs(readme): describe a released installer, not an unreleased one' (#19) from docs/installer-first-setup into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in -35s
Compose bundle / compose (push) Successful in 20s
Reviewed-on: #19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:30:39 +00:00
484f00ddee docs(readme): describe a released installer, not an unreleased one
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m17s
The status section still said "Phases 1 to 4 are built, on edge. Nothing
is released yet" and told visitors the way to install was by hand — both
untrue as of v0.1.0.

- Add an "Install a shard with it" section up top: verify, run, paste the
  four values. That is what a visitor to this repo is here for.
- Status: released, all five phases, both cutover gates recorded.
- Fix a broken link: bundles/current.json is no longer on main, it lives
  on the bundles branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:56 -05:00
31 changed files with 4404 additions and 486 deletions

437
.gitea/scripts/compose-bundles.sh Executable file
View File

@@ -0,0 +1,437 @@
#!/usr/bin/env bash
# Compose every bundle stream from the latest component releases.
#
# Called by .gitea/workflows/bundle.yml, which owns checkout, publishing and the
# stale-component check; this script owns the gates and the documents. It lives
# in a file rather than inline so it can be run by hand against the real release
# API — which is how it is tested — without a runner.
#
# ── The streams (docs/modules/rust/PLAN.md §34.2.2, D146/D147) ───────────────
#
# current.json, bundle-<tag>.json schema 1, ServUO (until SCHEMA1_RETIRES)
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust"
#
# A schema-2 document names ONE game. The two games release on their own
# schedules, and a document naming both would hand a ServUO host a new bundle
# every time a Rust plugin shipped.
#
# Schema 1 is still composed because every installer already in the field reads
# only `current.json` and refuses any schema but 1. After SCHEMA1_RETIRES it is
# left FROZEN at its last bundle rather than deleted, so an old installer still
# resolves something and every bundle-<tag>.json stays pinnable.
#
# ── One matrix, one tag ──────────────────────────────────────────────────────
# A ServUO matrix published at both schemas carries the SAME tag in both, so a
# `--bundle <tag>` an operator wrote down means one pair whichever installer
# reads it. The first run after schema 2 lands is the case this is for: the
# ServUO matrix has not changed, so v2/servuo takes the tag schema 1 already
# gave it rather than inventing a second name for the same pair.
#
# ── Failure is per game ──────────────────────────────────────────────────────
# A Rust release with a protocol mismatch must not stop a ServUO bundle from
# publishing, and the reverse. Each game composes in its own subshell; what
# succeeded is written, and the script exits 1 at the end if anything failed, so
# the run is still red.
#
# A game none of whose components has released yet composes nothing, and that is
# NOT a failure — it is the state Rust is in until its first cutover.
#
# ── Interface ────────────────────────────────────────────────────────────────
# PUBLISHED the bundles-branch worktree (read, and written on change)
# WORK scratch directory
# GITEA_HOST default gitea.whitlocktech.com (GITEA_BASE overrides the whole URL)
# TODAY YYYY-MM-DD, default today UTC (tests override it)
# SCHEMA1_RETIRES YYYY-MM-DD, default 2027-01-01 (PLAN.md §34.4)
#
# Results, for the workflow:
# $WORK/result.env changed=true|false, commit_subject=…
# $WORK/summary.md the job summary's body
# $WORK/stale.tsv <repo slug>\t<released tag>, one per resolved component
set -euo pipefail
: "${PUBLISHED:?PUBLISHED must name the bundles-branch worktree}"
: "${WORK:?WORK must name a scratch directory}"
GITEA_HOST="${GITEA_HOST:-gitea.whitlocktech.com}"
# Overridable so the script can be pointed at a mock Gitea (how the Rust path was
# tested before either Rust repository had released).
GITEA_BASE="${GITEA_BASE:-https://${GITEA_HOST}}"
TODAY="${TODAY:-$(date -u +%Y-%m-%d)}"
SCHEMA1_RETIRES="${SCHEMA1_RETIRES:-2027-01-01}"
SERVUO_LINK_REPO="RunicGateway/link"
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
RUST_LINK_REPO="RunicGateway/Rust-Link"
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
# Fixed top-level directories inside each payload tarball. Deliberately NOT
# versioned — a versioned prefix would mean parsing the version out of a path in
# order to read the manifest that declares the version.
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
mkdir -p "$WORK" "$PUBLISHED"
: > "$WORK/summary.md"
: > "$WORK/stale.tsv"
: > "$WORK/published.txt"
fail() { echo "::error::$*" >&2; exit 1; }
note() { printf -- '%s\n' "$*" >> "$WORK/summary.md"; }
# ── Release resolution ───────────────────────────────────────────────────────
# Read ANONYMOUSLY, on purpose: these are exactly the requests the shipped
# installer and the egg make, from a host with no Gitea credentials. A repo
# flipped to private fails CI here instead of on an operator's machine.
#
# Returns 0 with $WORK/<key>-release.json written, or 2 when the repo has never
# released (Gitea answers /releases/latest with 404). Anything else is fatal.
resolve_latest() {
local slug="$1" key="$2" code tag
code="$(curl -sS -o "$WORK/${key}-release.json" -w '%{http_code}' \
"${GITEA_BASE}/api/v1/repos/${slug}/releases/latest")" || fail "could not reach ${slug}'s releases"
case "$code" in
200) ;;
404) return 2 ;;
*) fail "${slug} /releases/latest answered HTTP ${code}" ;;
esac
tag="$(jq -r '.tag_name' "$WORK/${key}-release.json")"
[ -n "$tag" ] && [ "$tag" != "null" ] || fail "${slug}'s latest release has no tag"
printf '%s\t%s\n' "$slug" "$tag" >> "$WORK/stale.tsv"
echo "==> ${slug} latest: ${tag}"
}
release_tag() { jq -r '.tag_name' "$WORK/$1-release.json"; }
# ── GATE 2 (installer PLAN.md §7.1): assets exist, checksums match ───────────
# Every asset a bundle will reference is downloaded and verified against the
# SHA256SUMS published beside it. SHA256SUMS is the trust anchor for these
# deliberately UNSIGNED artifacts, and the installer and the egg verify against
# the hashes THIS job records; a hash copied from a file nobody checked would
# make the chain decorative.
#
# `sha256sum -c` catches a SHA256SUMS entry with no asset. The reverse — an asset
# with no entry — is checked by name, because -c would pass right over it. The
# `\*?` matches sha256sum's binary-mode marker.
verify_assets() {
local key="$1" dir="$WORK/$1" sums_url name url
rm -rf "$dir"; mkdir -p "$dir"
sums_url="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' "$WORK/${key}-release.json")"
[ -n "$sums_url" ] && [ "$sums_url" != "null" ] \
|| fail "${key} release has no SHA256SUMS asset — nothing to verify against"
curl -sSfL -o "${dir}/SHA256SUMS" "$sums_url"
jq -r '.assets[] | select(.name != "SHA256SUMS") | "\(.name)\t\(.browser_download_url)"' \
"$WORK/${key}-release.json" > "${dir}/asset-list.tsv"
[ -s "${dir}/asset-list.tsv" ] || fail "${key} release carries no assets besides SHA256SUMS"
while IFS=$'\t' read -r name url; do
[ -n "$name" ] || continue
echo " fetching ${key}/${name}"
curl -sSfL -o "${dir}/${name}" "$url"
grep -qE "[ \t]\*?${name}\$" "${dir}/SHA256SUMS" \
|| fail "${key} asset ${name} has no entry in that release's SHA256SUMS"
done < "${dir}/asset-list.tsv"
( cd "$dir" && sha256sum -c SHA256SUMS ) \
|| fail "${key} assets do not match the SHA256SUMS published with them"
echo "==> ${key}: all assets present and verified"
}
sha_of() { sha256sum "$1" | cut -d' ' -f1; }
# One asset as a bundle `{name,url,sha256}` object.
asset_json() {
local key="$1" name="$2" url
url="$(awk -F'\t' -v n="$name" '$1 == n { print $2 }' "$WORK/${key}/asset-list.tsv")"
jq -n --arg name "$name" --arg url "$url" --arg sha "$(sha_of "$WORK/${key}/${name}")" \
'{ name: $name, url: $url, sha256: $sha }'
}
# The single tarball a payload release carries, by name. Exactly one: a second
# .tar.gz would leave the installer guessing which one it deploys.
single_tarball() {
local key="$1" count
count="$(awk -F'\t' '$1 ~ /\.tar\.gz$/' "$WORK/${key}/asset-list.tsv" | wc -l)"
[ "$count" -eq 1 ] || fail "expected exactly 1 .tar.gz in the ${key} release, found ${count}"
awk -F'\t' '$1 ~ /\.tar\.gz$/ { printf "%s", $1 }' "$WORK/${key}/asset-list.tsv"
}
# ── GATE 1 (installer PLAN.md §7.1): both halves speak one protocol ──────────
# The sidecar side is PROTOCOL_VERSION in sidecar/src/main.rs, read at the
# RELEASE TAG — not from the binary, which would mean executing a downloaded
# artifact (and, for older releases, one with no way to answer). Both link and
# Rust-Link keep the constant at that path.
sidecar_protocol() {
local slug="$1" tag="$2" key="$3" p
curl -sSfL -o "$WORK/${key}-main.rs" \
"${GITEA_BASE}/${slug}/raw/tag/${tag}/sidecar/src/main.rs"
p="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' "$WORK/${key}-main.rs" | head -1)"
# Empty means the constant moved. "Could not read" must never read as "matches".
[ -n "$p" ] || fail "could not read PROTOCOL_VERSION from ${slug}@${tag}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
printf '%s' "$p"
}
# Unpack a payload tarball and return the path of its manifest.json, having
# checked the manifest names the version it was released under.
payload_manifest() {
local key="$1" tarball="$2" prefix="$3" tag="$4" m v
rm -rf "$WORK/${key}-x"; mkdir -p "$WORK/${key}-x"
tar -xzf "$WORK/${key}/${tarball}" -C "$WORK/${key}-x"
m="$WORK/${key}-x/${prefix}/manifest.json"
[ -f "$m" ] || fail "the ${key} tarball has no ${prefix}/manifest.json — the installer and the egg resolve it at that exact path"
v="$(jq -r '.version' "$m")"
# A manifest that disagrees with its tag means the release stamped one version
# and tagged another; every record of "what is installed" would then be wrong.
[ "$v" = "${tag#v}" ] || fail "${key} manifest says version ${v} but the release is tagged ${tag}"
printf '%s' "$m"
}
# ── Publishing helpers ───────────────────────────────────────────────────────
# `bundle` and `generated` describe the RUN, not the matrix. Comparing them would
# make every nightly cron look like a change and commit a dated duplicate of the
# same matrix forever, so only what an installer acts on is compared.
same_content() {
local published="$1" content="$2"
[ -f "$published" ] || return 1
cmp -s <(jq -S 'del(.bundle, .generated)' "$published") <(jq -S '.' "$content")
}
# A fresh date tag that names no bundle in any of the given directories. Two
# bundles on one day get .2, .3, … so a tag always names exactly one matrix.
fresh_tag() {
local base tag n=1 d taken
base="$(date -u -d "$TODAY" +%Y.%m.%d)"
tag="$base"
while :; do
taken=false
for d in "$@"; do [ -f "${d}/bundle-${tag}.json" ] && taken=true; done
[ "$taken" = false ] && break
n=$((n + 1)); tag="${base}.${n}"
done
printf '%s' "$tag"
}
# Write <dir>/bundle-<tag>.json and <dir>/current.json from a content document.
# The header keys go first so a person reading the file sees what it is.
publish_doc() {
local dir="$1" tag="$2" content="$3"
mkdir -p "$dir"
jq --arg bundle "$tag" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'(if has("game") then { schema: .schema, game: .game } else { schema: .schema } end)
+ { bundle: $bundle, generated: $generated } + del(.schema, .game)' \
"$content" > "${dir}/bundle-${tag}.json"
cp "${dir}/bundle-${tag}.json" "${dir}/current.json"
local rel="${dir#"$PUBLISHED"}"; rel="${rel#/}"
echo "${rel:+${rel}/}bundle-${tag}.json" >> "$WORK/published.txt"
}
# ── ServUO: schema 1 and v2/servuo ───────────────────────────────────────────
compose_servuo() {
resolve_latest "$SERVUO_LINK_REPO" servuo-link \
|| fail "${SERVUO_LINK_REPO} has no published release"
resolve_latest "$SERVUO_OVERLAY_REPO" servuo-overlay \
|| fail "${SERVUO_OVERLAY_REPO} has no published release"
local link_tag overlay_tag
link_tag="$(release_tag servuo-link)"; overlay_tag="$(release_tag servuo-overlay)"
verify_assets servuo-link
verify_assets servuo-overlay
# link's binaries onto platform keys. An unrecognized name is a hard failure,
# so a new target in link's release.yml reddens this job instead of silently
# vanishing from every bundle; a missing required key fails the same way.
local assets='{}' name url plat
while IFS=$'\t' read -r name url; do
[ -n "$name" ] || continue
case "$name" in
*-linux-x86_64) plat=linux-x86_64 ;;
*-linux-aarch64) plat=linux-aarch64 ;;
*-windows-x86_64.exe) plat=windows-x86_64 ;;
*) fail "unrecognized link asset '${name}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
esac
assets="$(jq --arg p "$plat" --argjson a "$(asset_json servuo-link "$name")" '. + { ($p): $a }' <<<"$assets")"
done < "$WORK/servuo-link/asset-list.tsv"
for plat in linux-x86_64 linux-aarch64 windows-x86_64; do
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|| fail "link release is missing a ${plat} binary; the installer ships for all three"
done
local tarball manifest overlay_protocol link_protocol
[ "$(wc -l < "$WORK/servuo-overlay/asset-list.tsv")" -eq 1 ] \
|| fail "expected exactly 1 overlay asset besides SHA256SUMS"
tarball="$(single_tarball servuo-overlay)"
manifest="$(payload_manifest servuo-overlay "$tarball" "$SERVUO_OVERLAY_PREFIX" "$overlay_tag")"
overlay_protocol="$(jq -r '.protocol' "$manifest")"
link_protocol="$(sidecar_protocol "$SERVUO_LINK_REPO" "$link_tag" servuo-link)"
echo "==> servuo: sidecar ${link_tag} protocol=${link_protocol} | overlay ${overlay_tag} protocol=${overlay_protocol}"
[ "$link_protocol" = "$overlay_protocol" ] || fail \
"PROTOCOL MISMATCH — sidecar ${link_tag} speaks ${link_protocol}, overlay ${overlay_tag} declares ${overlay_protocol}. Refusing a bundle whose shard the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
local common
common="$(jq -n \
--arg link_repo "$SERVUO_LINK_REPO" --arg link_tag "$link_tag" --argjson assets "$assets" \
--arg ov_repo "$SERVUO_OVERLAY_REPO" --arg ov_tag "$overlay_tag" \
--arg ov_commit "$(jq -r '.commit' "$manifest")" \
--arg ov_min "$(jq -r '.servuo.min_version' "$manifest")" \
--arg ov_patched "$(jq -r '.servuo.patches_verified_against' "$manifest")" \
--argjson ov_asset "$(asset_json servuo-overlay "$tarball")" \
--argjson protocol "$link_protocol" \
'{ protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets },
overlay: { repo: $ov_repo, tag: $ov_tag, version: ($ov_tag | ltrimstr("v")),
commit: $ov_commit, protocol: $protocol,
servuo: { min_version: $ov_min, patches_verified_against: $ov_patched },
asset: $ov_asset } }')"
# Schema 1: byte-for-byte the shape every shipped installer parses.
jq '{ schema: 1, protocol: .protocol, link: .sidecar, overlay: .overlay }' \
<<<"$common" > "$WORK/servuo-s1.json"
# Schema 2: the same pair, in the game-neutral shape (§34.2.2). The payload's
# `compat` is schema 1's `servuo` block.
jq '{ schema: 2, game: "servuo", protocol: .protocol, sidecar: .sidecar,
payload: ({ kind: "overlay" } + (.overlay | del(.servuo)) + { compat: .overlay.servuo }) }' \
<<<"$common" > "$WORK/servuo-s2.json"
local s1_active=false s1_changed=false s2_changed=false tag=""
[[ "$TODAY" < "$SCHEMA1_RETIRES" ]] && s1_active=true
if [ "$s1_active" = true ] && ! same_content "$PUBLISHED/current.json" "$WORK/servuo-s1.json"; then
s1_changed=true
fi
same_content "$PUBLISHED/v2/servuo/current.json" "$WORK/servuo-s2.json" || s2_changed=true
if [ "$s1_changed" = false ] && [ "$s2_changed" = false ]; then
note "- **ServUO**: no change (link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol})."
[ "$s1_active" = true ] || note " Schema 1 retired on ${SCHEMA1_RETIRES}; its \`current.json\` stays frozen."
return 0
fi
# One matrix, one tag: when schema 1 already names this pair, v2 borrows its
# tag — unless v2 somehow already holds that tag for different content, since
# a published bundle-<tag>.json is never rewritten.
if [ "$s1_active" = true ] && [ "$s1_changed" = false ] && [ -f "$PUBLISHED/current.json" ]; then
tag="$(jq -r '.bundle' "$PUBLISHED/current.json")"
if [ -f "$PUBLISHED/v2/servuo/bundle-${tag}.json" ]; then tag=""; fi
fi
[ -n "$tag" ] || tag="$(fresh_tag "$PUBLISHED" "$PUBLISHED/v2/servuo")"
[ "$s1_changed" = true ] && publish_doc "$PUBLISHED" "$tag" "$WORK/servuo-s1.json"
[ "$s2_changed" = true ] && publish_doc "$PUBLISHED/v2/servuo" "$tag" "$WORK/servuo-s2.json"
local which=""
[ "$s1_changed" = true ] && which="schema 1"
[ "$s2_changed" = true ] && which="${which:+${which} + }schema 2"
note "- **ServUO**: published \`${tag}\` (${which}) — link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol}."
echo "servuo ${tag}" >> "$WORK/published-games.txt"
}
# ── Rust: v2/rust ────────────────────────────────────────────────────────────
compose_rust() {
local rc=0 missing=""
resolve_latest "$RUST_LINK_REPO" rust-link || { rc=$?; [ "$rc" -eq 2 ] && missing="${RUST_LINK_REPO}"; }
rc=0
resolve_latest "$RUST_PLUGIN_REPO" rust-plugin || { rc=$?; [ "$rc" -eq 2 ] && missing="${missing:+${missing} and }${RUST_PLUGIN_REPO}"; }
if [ -n "$missing" ]; then
echo "==> rust: no release yet from ${missing}, so nothing to compose (not a failure)"
note "- **Rust**: skipped — no release yet from ${missing}."
return 0
fi
local link_tag plugin_tag
link_tag="$(release_tag rust-link)"; plugin_tag="$(release_tag rust-plugin)"
verify_assets rust-link
verify_assets rust-plugin
# Rust-Link's release: a binary per platform, the egg's launcher, and the egg
# itself. The egg is verified (gate 2 covers every asset) but is not part of
# the bundle — a panel admin imports it; nothing resolves it from here.
local assets='{}' launcher="" name url plat
while IFS=$'\t' read -r name url; do
[ -n "$name" ] || continue
case "$name" in
*-linux-x86_64) plat=linux-x86_64 ;;
*-windows-x86_64.exe) plat=windows-x86_64 ;;
with-sidecar.sh) launcher="$(asset_json rust-link "$name")"; continue ;;
egg-*.json) continue ;;
*) fail "unrecognized Rust-Link asset '${name}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
esac
assets="$(jq --arg p "$plat" --argjson a "$(asset_json rust-link "$name")" '. + { ($p): $a }' <<<"$assets")"
done < "$WORK/rust-link/asset-list.tsv"
# No linux-aarch64: RustDedicated has no arm64 build (D149).
for plat in linux-x86_64 windows-x86_64; do
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|| fail "Rust-Link release is missing a ${plat} binary (D149)"
done
[ -n "$launcher" ] || fail "Rust-Link release has no with-sidecar.sh — the egg's startup runs it (§34.2.6)"
local tarball manifest plugin_protocol link_protocol
tarball="$(single_tarball rust-plugin)"
manifest="$(payload_manifest rust-plugin "$tarball" "$RUST_PLUGIN_PREFIX" "$plugin_tag")"
[ -f "$(dirname "$manifest")/RunicGateway.cs" ] \
|| fail "the Rust-Plugins tarball has no ${RUST_PLUGIN_PREFIX}/RunicGateway.cs"
plugin_protocol="$(jq -r '.protocol' "$manifest")"
link_protocol="$(sidecar_protocol "$RUST_LINK_REPO" "$link_tag" rust-link)"
echo "==> rust: sidecar ${link_tag} protocol=${link_protocol} | plugin ${plugin_tag} protocol=${plugin_protocol}"
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
--argjson assets "$assets" --argjson launcher "$launcher" \
--arg p_repo "$RUST_PLUGIN_REPO" --arg p_tag "$plugin_tag" \
--slurpfile m "$manifest" \
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
--argjson protocol "$link_protocol" \
'{ schema: 2, game: "rust", protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets, launcher: $launcher },
payload: { kind: "plugin", repo: $p_repo, tag: $p_tag, version: ($p_tag | ltrimstr("v")),
commit: $m[0].commit, protocol: $protocol,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
|| fail "the Rust-Plugins manifest is missing commit, a framework floor, or requires_plugins"
if same_content "$PUBLISHED/v2/rust/current.json" "$WORK/rust-s2.json"; then
note "- **Rust**: no change (Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol})."
return 0
fi
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}
# ── Run both, independently ──────────────────────────────────────────────────
: > "$WORK/published-games.txt"
FAILED=""
for game in servuo rust; do
echo "────────── ${game} ──────────"
# NOT `if ! ( … )`: a subshell in a condition runs with errexit OFF, so an
# unchecked failed download inside it would sail on. Capture the status instead.
set +e
( set -e; "compose_${game}" )
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
FAILED="${FAILED:+${FAILED}, }${game}"
note "- **${game}**: **FAILED** — see the log. Nothing was published for it."
fi
done
CHANGED=false
[ -s "$WORK/published.txt" ] && CHANGED=true
{
echo "changed=${CHANGED}"
echo "commit_subject=chore(bundle): publish $(paste -sd ',' "$WORK/published-games.txt" | sed 's/,/, /g') [skip ci]"
} > "$WORK/result.env"
echo "────────── result ──────────"
cat "$WORK/summary.md"
[ "$CHANGED" = true ] && { echo "files:"; sed 's/^/ /' "$WORK/published.txt"; }
[ -z "$FAILED" ] || fail "compose failed for: ${FAILED}"

View File

@@ -1,50 +1,64 @@
# Compose and publish the bundle manifest.
# Compose and publish the bundle manifests.
#
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3).
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3), extended by
# module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.2) to a second game.
#
# ── What a bundle is ─────────────────────────────────────────────────────────
# The bundle IS the compat matrix. The installer does not hardcode component
# versions and does not resolve "latest" at run time; it fetches one small JSON
# document naming an exact, protocol-checked combination of a uo-link release
# and a servuo-plugins overlay release, and installs that. Because the bundle is
# document naming an exact, protocol-checked combination of a sidecar release
# and a game-side payload release, and installs that. Because the bundle is
# data, a new sidecar release regenerates ~30 lines of JSON and leaves the
# installer binary untouched: operators do not re-download the installer to pick
# up a sidecar patch, and this repo does not accumulate releases whose code is
# byte-identical.
#
# ── Where it is published, and why not as a release ──────────────────────────
# Bundles are COMMITTED to this repo, on their own `bundles` branch, at its root:
# ── The streams ──────────────────────────────────────────────────────────────
# Bundles are COMMITTED to this repo, on their own `bundles` branch:
#
# current.json the bundle the installer uses by default
# bundle-<tag>.json every bundle ever published, kept for --bundle
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins
#
# so the installer's two fetches are plain anonymous raw URLs on a public repo:
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
# composed beside it because every installer in the field reads only the root
# `current.json` and refuses any other schema; on 2027-01-01 it stops being
# composed and is left frozen, never deleted, so old installers still resolve
# and every bundle-<tag>.json stays pinnable. A ServUO matrix carries the same
# tag at both schemas.
#
# The installer's fetches are plain anonymous raw URLs on a public repo:
#
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json
#
# A BRANCH, not `main`, because `main` is protected and this job is unattended:
# the pre-receive hook declines a push from CI, which is not a thing a nightly
# cron can resolve. Publishing to a branch of its own keeps everything the
# original choice was for — a reviewable diff, a git history of the compat
# matrix, plain raw URLs, no auth on the shard host — and needs no protection
# exception. The alternative, whitelisting a scheduled job for pushes to the
# default branch, buys nothing this does not.
# exception.
#
# The obvious alternative — one Gitea release per bundle — was rejected because
# it collides with this repo's own product. release.yml publishes the installer
# BINARIES as v* releases, and `/releases/latest` returns whichever release is
# newest regardless of kind; interleaving bundle releases would make "latest"
# intermittently resolve to a release containing no installer binary. Committing
# also gets a reviewable diff and a git history of the compat matrix for free.
# intermittently resolve to a release containing no installer binary.
#
# `main` is never pushed to by this workflow. (release.yml does not push to it
# either — it tags and lets the release API do the rest.)
#
# ── Where the logic lives ────────────────────────────────────────────────────
# .gitea/scripts/compose-bundles.sh holds the gates and the documents, so it can
# be run by hand against the real release API (or a mock) without a runner. This
# file owns what needs the runner: checkout, the stale-component dispatch, and
# the push.
#
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
# workflow_dispatch — POSTed by link's and servuo-plugins' release workflows
# as their final step, so a new release recomposes the
# bundle immediately.
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
# Rust-Plugins' release workflows as their final step,
# so a new release recomposes the bundle immediately.
# schedule (nightly) — recomputes from whatever the latest releases actually
# are, so a missed or failed dispatch self-heals instead
# of silently pinning operators to a stale sidecar.
@@ -55,11 +69,11 @@
# ── Prerequisites (Settings → Actions → Secrets on RunicGateway/installer) ───
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token with `write:repository`. It needs write
# on THIS repo (to push the bundle commit) and on
# RunicGateway/link + RunicGateway/servuo-plugins (to fire
# their release workflows for the stale case below). A token
# without the latter degrades to a warning, not a failure —
# the bundle it composes is still valid.
# on THIS repo (to push the bundle commit) and on the four
# component repos (to fire their release workflows for the
# stale case below). A token without the latter degrades to
# a warning, not a failure — the bundle it composes is still
# valid.
#
# The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml.
@@ -73,7 +87,7 @@ on:
# Two component releases landing together dispatch this twice. Serialize rather
# than cancel: a cancelled run is a bundle that never got composed, and the
# second run would otherwise race the first on the push to main.
# second run would otherwise race the first on the push.
concurrency:
group: compose-bundle
cancel-in-progress: false
@@ -81,13 +95,6 @@ concurrency:
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/installer
LINK_REPO: RunicGateway/link
OVERLAY_REPO: RunicGateway/servuo-plugins
# Fixed top-level directory inside the overlay tarball. Deliberately NOT
# versioned (servuo-plugins/.gitea/workflows/release.yml) — a versioned prefix
# would mean parsing the version out of a path in order to read the manifest
# that declares the version.
OVERLAY_PREFIX: runicgateway-overlay
jobs:
compose:
@@ -104,9 +111,8 @@ jobs:
# The published bundles live on their own branch (see the header), so they
# are materialized into a worktree rather than being part of the checkout.
# Everything downstream reads and writes `published/`, which means the
# ".2 suffix" scan and the idempotence check both see what is actually
# published rather than a stale copy on main.
# The compose script reads and writes `published/`, so the ".2 suffix"
# scan and the idempotence check both see what is actually published.
- name: Materialize the bundles branch
run: |
set -euo pipefail
@@ -121,7 +127,7 @@ jobs:
if git ls-remote --exit-code --heads origin bundles >/dev/null 2>&1; then
git fetch origin bundles
git worktree add -B bundles published origin/bundles
echo "==> bundles branch: $(ls published/*.json 2>/dev/null | wc -l) published bundle(s)"
echo "==> bundles branch: $(find published -name 'bundle-*.json' | wc -l) published bundle(s)"
else
# First run. A root commit with an empty tree gives the worktree a
# branch to sit on without inheriting main's history, which has
@@ -140,276 +146,27 @@ jobs:
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
# ── Resolve the two component releases ───────────────────────────────
# Read ANONYMOUSLY, on purpose. These are exactly the requests the shipped
# installer makes on an operator's machine, which has no Gitea credentials
# (PLAN.md §1: no git and no token on the shard host). Authenticating here
# would hide a repo flipped to private until an operator hit it; this way
# the visibility regression fails CI instead.
- name: Resolve the latest release of each component
id: resolve
run: |
set -euo pipefail
mkdir -p work
for pair in "link:${LINK_REPO}" "overlay:${OVERLAY_REPO}"; do
KEY="${pair%%:*}"; SLUG="${pair#*:}"
curl -sSfL -o "work/${KEY}-release.json" \
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/releases/latest"
TAG="$(jq -r '.tag_name' "work/${KEY}-release.json")"
[ -n "$TAG" ] && [ "$TAG" != "null" ] || { echo "::error::${SLUG} has no published release"; exit 1; }
echo "${KEY}_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "${KEY}_version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "==> ${SLUG} latest: ${TAG}"
done
# ── GATE 2 (PLAN.md §7.1): assets exist, checksums match ─────────────
# Every asset the bundle will reference is downloaded and verified against
# the SHA256SUMS the publishing repo shipped beside it. This is not
# ceremony: SHA256SUMS is the trust anchor for these deliberately UNSIGNED
# artifacts (PLAN.md §3), and the installer will verify against the hashes
# THIS job records. A hash copied from a file nobody checked would make the
# whole chain decorative.
# ── Resolve, gate and compose every stream ───────────────────────────
# Release resolution, gate 2 (every asset present and matching its
# SHA256SUMS), gate 1 (the sidecar's PROTOCOL_VERSION at its release tag
# against the payload's declared protocol) and the documents all live in
# the script. Each game composes independently: a Rust failure still lets
# a ServUO bundle publish, and this step goes red afterwards. A game whose
# repos have never released composes nothing, and that is not a failure.
#
# `sha256sum -c` without --ignore-missing fails when SHA256SUMS names a
# file the release does not actually carry. The reverse — an asset with no
# SHA256SUMS entry — is checked separately below, because -c would not
# notice it.
- name: 'Gate 2: download assets and verify checksums'
id: assets
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
for KEY in link overlay; do
DIR="work/${KEY}"; mkdir -p "$DIR"
SUMS_URL="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' "work/${KEY}-release.json")"
[ -n "$SUMS_URL" ] && [ "$SUMS_URL" != "null" ] \
|| fail "${KEY} release has no SHA256SUMS asset — nothing to verify against"
curl -sSfL -o "${DIR}/SHA256SUMS" "$SUMS_URL"
jq -r '.assets[] | select(.name != "SHA256SUMS") | "\(.name)\t\(.browser_download_url)"' \
"work/${KEY}-release.json" > "${DIR}/asset-list.tsv"
[ -s "${DIR}/asset-list.tsv" ] || fail "${KEY} release carries no assets besides SHA256SUMS"
while IFS="$(printf '\t')" read -r NAME URL; do
[ -n "$NAME" ] || continue
echo " fetching ${KEY}/${NAME}"
curl -sSfL -o "${DIR}/${NAME}" "$URL"
# An asset absent from SHA256SUMS is unverifiable, and `-c` below
# would pass right over it. The `\*?` is not paranoia: sha256sum
# marks binary mode by prefixing the path with `*` instead of the
# two-space text separator, so a naive match on " ${NAME}" would
# miss every entry on a host that defaults to binary mode.
grep -qE "[ \t]\*?${NAME}\$" "${DIR}/SHA256SUMS" \
|| fail "${KEY} asset ${NAME} has no entry in that release's SHA256SUMS"
done < "${DIR}/asset-list.tsv"
( cd "$DIR" && sha256sum -c SHA256SUMS ) \
|| fail "${KEY} assets do not match the SHA256SUMS published with them"
echo "==> ${KEY}: all assets present and verified"
done
# Map link's binaries onto platform keys. The pattern is asserted, not
# assumed: an unrecognized asset name is a hard failure so that adding
# a target to link's release.yml (macOS, a Windows arm64) surfaces here
# as a red run, rather than being silently dropped from every bundle.
#
# linux-aarch64 was recognized here one merge BEFORE link published one
# (PLAN.md §5.2, steps 1 and 3). That order was forced by the two rules
# below being strict in opposite directions: an unknown name fails the
# run, and a missing REQUIRED key fails it too. So the name had to be
# taught before the release that carried it, and the key could only be
# required after — requiring it first would have failed every bundle
# for as long as the gap lasted. link v1.1.1 ships the binary, so the
# key is now required: a dropped target reddens this job instead of
# vanishing from every bundle.
: > work/link-platforms.tsv
while IFS="$(printf '\t')" read -r NAME URL; do
[ -n "$NAME" ] || continue
case "$NAME" in
*-linux-x86_64) PLAT=linux-x86_64 ;;
*-linux-aarch64) PLAT=linux-aarch64 ;;
*-windows-x86_64.exe) PLAT=windows-x86_64 ;;
*) fail "unrecognized link asset '${NAME}' — bundle.yml does not know what platform to file it under. Teach it this name or the bundle would silently omit the asset." ;;
esac
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
done < work/link/asset-list.tsv
for REQUIRED in linux-x86_64 linux-aarch64 windows-x86_64; do
grep -q "^${REQUIRED}$(printf '\t')" work/link-platforms.tsv \
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for all three"
done
# The overlay release carries exactly one artifact: the tarball.
OVERLAY_COUNT="$(wc -l < work/overlay/asset-list.tsv)"
[ "$OVERLAY_COUNT" -eq 1 ] \
|| fail "expected exactly 1 overlay asset besides SHA256SUMS, found ${OVERLAY_COUNT}"
OVERLAY_NAME="$(cut -f1 work/overlay/asset-list.tsv)"
case "$OVERLAY_NAME" in
*.tar.gz) ;;
*) fail "overlay asset '${OVERLAY_NAME}' is not the .tar.gz the installer expects" ;;
esac
{
echo "overlay_name=${OVERLAY_NAME}"
echo "overlay_url=$(cut -f2 work/overlay/asset-list.tsv)"
echo "overlay_sha=$(sha256sum "work/overlay/${OVERLAY_NAME}" | cut -d' ' -f1)"
} >> "$GITHUB_OUTPUT"
# ── GATE 1 (PLAN.md §7.1): the two halves speak the same protocol ────
# This is the check the whole bundle exists for. The sidecar rejects a
# protocol mismatch with 409 rather than mis-parsing, so a mismatched pair
# is not a subtle bug — it is a shard that emits into a void. Catching it
# here costs one HTTP GET; catching it on an operator's box costs them an
# evening.
#
# The two sides are read from genuinely different places because they ARE
# genuinely different:
#
# overlay — manifest.json inside the tarball. The C# plugin announces no
# version on the wire and none is queryable before ServUO
# boots (PLAN.md §2.6), so this hand-maintained declaration is
# the only statement of it that exists.
# sidecar — PROTOCOL_VERSION in sidecar/src/main.rs, read at the RELEASE
# TAG. Not from the binary: `--print-config` would answer, but
# only for releases from v1.1.0 on (it did not exist before
# Phase 0.2), and `--bundle <tag>` has to be able to recompose
# an older bundle. Reading the tag the release was built from
# works uniformly, needs no execution of a downloaded
# artifact, and does not provision a throwaway config and
# print its auth token into a CI log.
- name: 'Gate 1: sidecar and overlay protocol versions agree'
id: protocol
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
LINK_TAG="${{ steps.resolve.outputs.link_tag }}"
OVERLAY_TAG="${{ steps.resolve.outputs.overlay_tag }}"
tar -xzf "work/overlay/${{ steps.assets.outputs.overlay_name }}" -C work
MANIFEST="work/${OVERLAY_PREFIX}/manifest.json"
[ -f "$MANIFEST" ] || fail "the overlay tarball has no ${OVERLAY_PREFIX}/manifest.json — the installer resolves it at that exact path"
OVERLAY_PROTOCOL="$(jq -r '.protocol' "$MANIFEST")"
OVERLAY_COMMIT="$(jq -r '.commit' "$MANIFEST")"
MANIFEST_VERSION="$(jq -r '.version' "$MANIFEST")"
# A manifest that disagrees with the tag it shipped under means the
# release workflow stamped one version and tagged another; every
# downstream record of "what is installed" would then be wrong.
[ "$MANIFEST_VERSION" = "${OVERLAY_TAG#v}" ] \
|| fail "overlay manifest says version ${MANIFEST_VERSION} but the release is tagged ${OVERLAY_TAG}"
curl -sSfL -o work/main.rs \
"https://${GITEA_HOST}/${LINK_REPO}/raw/tag/${LINK_TAG}/sidecar/src/main.rs"
LINK_PROTOCOL="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' work/main.rs | head -1)"
# Empty means the constant moved or was renamed. Fail loudly: silently
# treating "could not read" as "matches" is how a mismatched pair ships.
[ -n "$LINK_PROTOCOL" ] \
|| fail "could not read PROTOCOL_VERSION from ${LINK_REPO}@${LINK_TAG}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
echo "==> sidecar ${LINK_TAG} protocol=${LINK_PROTOCOL} | overlay ${OVERLAY_TAG} protocol=${OVERLAY_PROTOCOL}"
[ "$LINK_PROTOCOL" = "$OVERLAY_PROTOCOL" ] || fail \
"PROTOCOL MISMATCH — sidecar ${LINK_TAG} speaks ${LINK_PROTOCOL}, overlay ${OVERLAY_TAG} declares ${OVERLAY_PROTOCOL}. Refusing to publish a bundle that would install a shard whose events the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
{
echo "protocol=${LINK_PROTOCOL}"
echo "overlay_commit=${OVERLAY_COMMIT}"
echo "overlay_min_servuo=$(jq -r '.servuo.min_version' "$MANIFEST")"
echo "overlay_patched_against=$(jq -r '.servuo.patches_verified_against' "$MANIFEST")"
} >> "$GITHUB_OUTPUT"
# ── Compose ──────────────────────────────────────────────────────────
# Note the shape difference from PLAN.md §7.1's sketch: `link` carries a
# per-platform asset map rather than one sha256. link publishes a Linux
# binary and a Windows .exe, and the installer runs on both — a single
# hash could only ever have described one of them.
- name: Compose bundle.json
# The outputs are written even when the script fails, so the publish step
# (on `always()`) can still ship the game that did compose.
- name: Compose the bundles
id: compose
run: |
set -euo pipefail
LINK_ASSETS="$(jq -R -s '
split("\n") | map(select(length > 0)) | map(split("\t"))
| map({ (.[0]): { name: .[1], url: .[2], sha256: .[3] } }) | add
' work/link-platforms.tsv)"
jq -n \
--arg link_repo "${LINK_REPO}" \
--arg link_tag "${{ steps.resolve.outputs.link_tag }}" \
--arg link_version "${{ steps.resolve.outputs.link_version }}" \
--argjson link_assets "${LINK_ASSETS}" \
--arg ov_repo "${OVERLAY_REPO}" \
--arg ov_tag "${{ steps.resolve.outputs.overlay_tag }}" \
--arg ov_version "${{ steps.resolve.outputs.overlay_version }}" \
--arg ov_commit "${{ steps.protocol.outputs.overlay_commit }}" \
--arg ov_name "${{ steps.assets.outputs.overlay_name }}" \
--arg ov_url "${{ steps.assets.outputs.overlay_url }}" \
--arg ov_sha "${{ steps.assets.outputs.overlay_sha }}" \
--arg ov_min "${{ steps.protocol.outputs.overlay_min_servuo }}" \
--arg ov_patched "${{ steps.protocol.outputs.overlay_patched_against }}" \
--argjson protocol "${{ steps.protocol.outputs.protocol }}" \
'{
schema: 1,
protocol: $protocol,
link: {
repo: $link_repo,
tag: $link_tag,
version: $link_version,
protocol: $protocol,
assets: $link_assets
},
overlay: {
repo: $ov_repo,
tag: $ov_tag,
version: $ov_version,
commit: $ov_commit,
protocol: $protocol,
servuo: {
min_version: $ov_min,
patches_verified_against: $ov_patched
},
asset: { name: $ov_name, url: $ov_url, sha256: $ov_sha }
}
}' > work/content.json
echo "----- composed content -----"
cat work/content.json
# Idempotence. `bundle` and `generated` are metadata ABOUT this run, so
# comparing them would make every nightly cron look like a change and
# commit a dated duplicate of the same matrix forever. Compare only
# what the installer would actually act on.
CHANGED=true
if [ -f published/current.json ]; then
if jq -S 'del(.bundle, .generated)' published/current.json > work/old-content.json \
&& jq -S '.' work/content.json > work/new-content.json \
&& cmp -s work/old-content.json work/new-content.json; then
CHANGED=false
fi
rc=0
PUBLISHED="$PWD/published" WORK="$PWD/work" GITEA_HOST="$GITEA_HOST" \
bash .gitea/scripts/compose-bundles.sh || rc=$?
if [ -f work/result.env ]; then
grep '^changed=' work/result.env >> "$GITHUB_OUTPUT"
fi
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
if [ "$CHANGED" = false ]; then
echo "==> identical to the published current.json — nothing to publish."
exit 0
fi
# Bundle tags are dates (PLAN.md §7.1). Two bundles on one day — a
# sidecar release in the morning and an overlay release in the
# afternoon is the normal way that happens — get .2, .3, … so a tag
# always names exactly one matrix and `--bundle` stays reproducible.
BASE="$(date -u +%Y.%m.%d)"
TAG="$BASE"; N=1
while [ -f "published/bundle-${TAG}.json" ]; do
N=$((N+1)); TAG="${BASE}.${N}"
done
jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \
work/content.json > "published/bundle-${TAG}.json"
cp "published/bundle-${TAG}.json" published/current.json
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "==> composed bundle ${TAG}"
exit "$rc"
# ── Stale-component check: dispatch, don't wait (PLAN.md §7.3) ───────
# Each component self-releases on merge to its own main, so by the time
@@ -424,10 +181,14 @@ jobs:
# counts would report every README fix as a stuck release and re-dispatch
# a workflow that correctly declines to run, every single night.
#
# This runs even when the bundle is unchanged: an unchanged bundle is the
# exact symptom of a component release that never happened.
# This runs even when the bundle is unchanged, or a game failed: an
# unchanged bundle is the exact symptom of a component release that never
# happened. work/stale.tsv names each component the compose resolved; a
# repo that has never released is absent from it, and has no release to
# be stale against.
- name: Check for components with unreleased work, and dispatch them
id: stale
if: always()
continue-on-error: true
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -440,11 +201,12 @@ jobs:
# message change what that script does. (Do not write that token
# literally in a comment: the runner parses it, fails, and silently
# skips the whole step.)
mkdir -p work
: > work/stale-warnings.md
[ -f work/stale.tsv ] || exit 0
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
"${OVERLAY_REPO}:${{ steps.resolve.outputs.overlay_tag }}"; do
SLUG="${pair%:*}"; TAG="${pair##*:}"
while IFS="$(printf '\t')" read -r SLUG TAG; do
[ -n "$SLUG" ] || continue
if ! curl -sSfL -o work/compare.json \
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/compare/${TAG}...main"; then
echo "::warning::could not compare ${SLUG} ${TAG}...main; skipping its stale check"
@@ -483,7 +245,7 @@ jobs:
else
echo "==> ${SLUG}: nothing releasable after ${TAG}"
fi
done
done < work/stale.tsv
# ── Publish ──────────────────────────────────────────────────────────
# Preflighted for the same reason the release workflows are: actions/
@@ -491,8 +253,11 @@ jobs:
# config, so a push can succeed on that leftover even with the secrets
# empty. That makes "the push worked" no evidence at all that the repo is
# configured, and the failure surfaces somewhere less obvious later.
#
# `always()`: when one game failed and the other composed, the one that
# composed still ships. The compose step has already made the run red.
- name: Verify publish credentials are configured
if: ${{ steps.compose.outputs.changed == 'true' }}
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -502,19 +267,18 @@ jobs:
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
if [ -n "$MISSING" ]; then
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit to main."
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit."
exit 1
fi
echo "Publish credentials present."
- name: Commit and push the bundle
if: ${{ steps.compose.outputs.changed == 'true' }}
- name: Commit and push the bundles
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.compose.outputs.bundle_tag }}"
# Secrets can arrive with a trailing newline depending on how they were
# pasted, and a stray CR/LF corrupts the remote URL ("credential url
# cannot be parsed").
@@ -522,9 +286,14 @@ jobs:
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
# The subject is the script's ("publish servuo 2026.09.27, rust
# 2026.09.27 [skip ci]"), read from a file rather than interpolated
# from a template value, so nothing in it can change this script.
grep '^commit_subject=' work/result.env | cut -d= -f2- > work/commit-msg.txt
cd published
git add -A
git commit -m "chore(bundle): publish ${TAG} (link ${{ steps.resolve.outputs.link_tag }}, overlay ${{ steps.resolve.outputs.overlay_tag }}, protocol ${{ steps.protocol.outputs.protocol }}) [skip ci]"
git commit -F ../work/commit-msg.txt
# Two runs can compose at once — a component release dispatches this
# while the nightly cron is mid-flight — so losing the race is normal
@@ -538,7 +307,8 @@ jobs:
git rebase origin/bundles
git push origin bundles
fi
echo "==> published bundle-${TAG}.json and current.json on the bundles branch"
echo "==> published on the bundles branch:"
sed 's/^/ /' ../work/published.txt
- name: Job summary
if: always()
@@ -547,16 +317,17 @@ jobs:
{
echo "## Bundle compose"
echo
echo "| Component | Release | Protocol |"
echo "|---|---|---|"
echo "| uo-link sidecar | \`${{ steps.resolve.outputs.link_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
echo "| servuo-plugins overlay | \`${{ steps.resolve.outputs.overlay_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
echo
case "${{ steps.compose.outputs.changed }}" in
true) echo "**Published \`${{ steps.compose.outputs.bundle_tag }}\`** → \`bundles/current.json\`" ;;
false) echo "No change — \`bundles/current.json\` already names this combination." ;;
*) echo "Compose did not complete — see the failing step above." ;;
esac
if [ -s work/summary.md ]; then
cat work/summary.md
else
echo "Compose did not complete — see the failing step above."
fi
if [ -s work/published.txt ]; then
echo
echo "Written to the bundles branch:"
echo
sed 's/^/- /' work/published.txt
fi
if [ -s work/stale-warnings.md ]; then
echo
echo "### ⚠ Components with unreleased work"

View File

@@ -165,6 +165,39 @@ jobs:
fi
fi
# ── Orphan sweep ────────────────────────────────────────────────
#
# The check above is VERSION-SCOPED: it only ever asks about the one
# version this run computed. That is enough to recover an orphan on
# the very next run, and useless afterwards — once any releasable
# commit lands, the next run computes a NEW version, never looks at
# the old tag again, and the orphan becomes permanent and silent.
#
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
# commit that ADDED the recovery above was itself typed
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
# v0.1.1 — and the run that introduced the recovery stepped straight
# past the tag it was written to rescue. That tag is still orphaned.
#
# So every v* tag is checked, and anything missing a release is
# WARNED about. Deliberately not recovered: publishing an old version
# would mean building today's tree and shipping it under a tag whose
# tree it is not, which is worse than the inconsistency it fixes.
# A human decides whether to recover or drop it.
#
# Never fails the run. A sweep that can break a good release is a
# sweep someone will delete.
ORPHANS=""
for T in $(git tag -l 'v*' --sort=-v:refname); do
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
done
if [ -n "${ORPHANS}" ]; then
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
@@ -395,17 +428,74 @@ jobs:
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# This POST is the step that orphaned tag v0.1.1 (run 75): it landed one
# second after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running the workflow published the same
# four assets untouched, so the failure was a race, not a bad request.
#
# Two things went wrong there, and both are fixed here.
#
# 1. `curl -sSf` prints NO response body on an error status, so all the
# log carried was "curl: (22) ... error: 500" and the cause had to be
# inferred from timestamps. Capture the body and print it.
# 2. Nothing retried, so a transient 5xx became a permanent orphan tag.
# The plan step CAN recover one, but only on a run that reaches it --
# and a later push with no releasable commits stands down before it
# gets there, so in practice the tag sits until a human notices.
#
# 4xx is deliberately NOT retried: a bad token or a malformed body does
# not improve by being sent again, and retrying only turns a clear
# failure into a slow one.
REL_ID=""
for attempt in 1 2 3 4 5; do
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" || echo 000)"
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
REL_ID="$(jq -r '.id' /tmp/rel.json)"
break
fi
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
echo "--- response body ---"
cat /tmp/rel.json || true
echo
echo "---------------------"
case "$HTTP" in
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
esac
if [ "$attempt" = 5 ]; then
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
exit 1
fi
sleep $(( attempt * 5 ))
done
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
echo "::error::Release created but no id came back; refusing to upload assets blind."
exit 1
fi
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
# Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every binary it advertises, which is worse
# than no release at all -- that file IS the trust anchor.
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
-F "attachment=@dist/${f}" || echo 000)"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::uploading ${f} returned HTTP ${HTTP}"
cat /tmp/asset.json || true
exit 1
fi
echo " uploaded ${f}"
done

1
Cargo.lock generated
View File

@@ -402,6 +402,7 @@ name = "runicgateway-installer"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"flate2",
"serde",

View File

@@ -45,6 +45,10 @@ sha2 = "0.11"
# host, which is the whole point of shipping the plugin as a release tarball.
sha1 = "0.11"
# Schema-2 bundles are read through Gitea's contents API, which returns the file
# base64-encoded — the /raw/ route is CDN-cached for hours (bundle.rs). Pure Rust.
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

111
README.md
View File

@@ -25,9 +25,48 @@ It also **does not replace ServUO startup behavior.** ServUO keeps running throu
its existing release/start scripts; the installer never writes a launcher and
never restarts the shard.
## Install a shard with it
Grab a binary and `SHA256SUMS` from the
[releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
verify the checksum, and run it as Administrator/root against a **stopped** shard:
```bash
sha256sum -c SHA256SUMS --ignore-missing
chmod +x runicgateway-installer-linux-x86_64
sudo ./runicgateway-installer-linux-x86_64 install
```
```powershell
# Windows, from an elevated PowerShell
.\runicgateway-installer-windows-x86_64.exe install
```
It ends by printing the four values to paste into **Admin → Shard** on your site.
The full operator guide — what it asks, where it writes, the patch tier, day-two
commands and troubleshooting — is
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
Prefer to place everything yourself, or on a host that cannot run the binary?
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done with `curl`, `tar` and `systemctl`, and stays
supported.
## Status
**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.**
**Released.** All five phases are built and the `edge → main` cutover (#17) cut
the first release, [`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
publishing `linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` with
`SHA256SUMS`.
| Phase | State |
|---|---|
| 0 — prerequisites in the other repos | ✅ merged |
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ released |
| 2 — uo-link install + service registration | ✅ released |
| 3 — the opt-in stock-file patch tier | ✅ released |
| 4 — `doctor`, `update`, `uninstall` | ✅ released |
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | ✅ released |
The binary does everything
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
@@ -40,39 +79,18 @@ The design of record is
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
`link`, and the bundle-manifest CI here), all of which have landed —
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
sidecar + overlay combination, recomposed on every component release and nightly
(see [`bundles/README.md`](bundles/README.md)).
[`bundles/current.json`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json)
names the current protocol-checked sidecar + overlay combination, recomposed on
every component release and nightly (see [`bundles/README.md`](bundles/README.md)).
| Phase | State |
|---|---|
| 0 — prerequisites in the other repos | ✅ merged |
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
| 2 — uo-link install + service registration | ✅ on `edge` |
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
**Why `edge`:** `release.yml` publishes an installer binary on every push to
`main`, so nothing lands there until the whole tool is worth handing to an
operator. The `edge → main` cutover cuts the first release. PRs into `edge` run
the same gates as PRs into `main`.
**What the cutover is waiting on**, per PLAN.md §5:
1. **Phase 5**, packaging polish — deliberately *before* the first release rather
than after it, because it changes the release layout, and shipping first would
mean a first release immediately superseded by the next. There is no `.deb`
and no MSI: both would give the sidecar binary, its service unit and its
service account a second owner beside this tool.
2. **The Windows SCM half verified on a real host.** `sc create`, the virtual
service account, the failure actions and the token-file ACL have never been
executed anywhere. Running the *systemd* half for real is what turned up a bug
no unit test had, so this is not a formality.
Until the cutover, the way to install is by hand —
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done with `curl`, `tar` and `systemctl`.
**`main` publishes.** `release.yml` cuts a release from every push to `main`, which
is why the crate was integrated on `edge` until it was worth handing to an
operator. Both cutover gates were met first: Phase 5 (its scope settled as **no
`.deb` and no MSI** — either would give the sidecar binary, its service unit and
its service account a second owner beside this tool), and the **Windows SCM half
verified on a real host**. That second one earned its place: `sc start` failed
with 1053 on its first real run and needed a sidecar fix (link#29) before it
passed 13/13.
## Related repos
@@ -93,6 +111,33 @@ is the same deployment done with `curl`, `tar` and `systemctl`.
| `update` | Resolve the current bundle manifest, then update the sidecar (replace + restart) and the overlay (re-sync + tell the operator to restart ServUO). |
| `uninstall` | Remove only what the installer exclusively owns. It **never edits the ServUO tree** — it prints the overlay files to delete and the patch hunks to revert, and leaves that call to the operator. |
### Rust: `--game rust`
The same four verbs set up **Rust servers** (Oxide or Carbon) from a schema-2 Rust bundle — a
Rust-Link sidecar and a Rust-Plugins plugin, checked by CI to speak one protocol. A Rust host
commonly runs several servers, so each is a **named instance** with its own service, config,
database and ports; every instance shares one sidecar binary, and `update` moves them together.
```bash
sudo ./runicgateway-installer-linux-x86_64 install --game rust --rust /srv/rust --server-id main
```
- The framework is **detected**, never asked, and the plugin goes to `oxide/plugins/` or
`carbon/plugins/`. A tree with both, or neither, is refused.
- `--server-id` is the id the website knows the server by. Where the plugin has no config yet the
installer writes one holding just `ServerId` and `Port`; an existing one is never rewritten, and
one naming another server refuses the run.
- Services: `runicgateway-rust@<id>.service` (a systemd template) or `RunicGatewayRust-<id>`.
Records: `rust/install.json`, beside and separate from ServUO's.
- `doctor` checks the framework, the plugin file, the plugin config's `ServerId`, the required uMod
plugins (reported, never installed), the service, and `/health` through to *plugin connected*.
- `uninstall` removes the plugin file and keeps its config — that file is the website's.
A Pterodactyl panel uses the **egg** instead, published with each
[Rust-Link release](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases). The Rust
operator guide is
[`rust-link/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/INSTALL.md).
## Design constraints worth knowing up front
- **Releases are unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen and

View File

@@ -3,26 +3,45 @@
**These files are generated. Do not edit them by hand.**
A *bundle* names one exact, protocol-checked combination of the two components the installer
deploys — a `uo-link` sidecar release and a `servuo-plugins` overlay release. The installer does not
deploys for one game: a sidecar release and a game-side payload release. For ServUO that is `link`
and a `servuo-plugins` overlay; for Rust it is `Rust-Link` and a `Rust-Plugins` plugin. The installer does not
hardcode versions and does not resolve "latest" at run time; it fetches one of these documents and
installs what it names. **The bundle is the compat matrix.**
They are written by [`.gitea/workflows/bundle.yml`](../.gitea/workflows/bundle.yml), which composes
They are written by [`.gitea/workflows/bundle.yml`](../.gitea/workflows/bundle.yml) (the logic is
in [`.gitea/scripts/compose-bundles.sh`](../.gitea/scripts/compose-bundles.sh)), which composes
one whenever a component publishes a release (dispatched by that release's own workflow) and
nightly, so a missed dispatch self-heals. A run that finds nothing changed writes nothing.
See `docs/installer/PLAN.md` §7 for the design.
See `docs/installer/PLAN.md` §7 for the design, and `docs/modules/rust/PLAN.md` §34.2.2 for
schema 2.
## Where they live: the `bundles` branch
**The JSON documents are not in this directory.** They are published to a branch of their own,
[`bundles`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles), at its root:
[`bundles`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles). There is one
stream per game and schema, each a directory holding the same two kinds of file:
| Directory | Stream |
|---|---|
| *(root)* | Schema 1, ServUO. What every installer up to v0.2.x reads. **Retires 2027-01-01** |
| `v2/servuo/` | Schema 2, `"game": "servuo"` |
| `v2/rust/` | Schema 2, `"game": "rust"` |
| File | What it is |
|---|---|
| `current.json` | The bundle the installer uses by default. Always a copy of the newest `bundle-*.json`. |
| `bundle-<tag>.json` | Every bundle ever published, kept forever so `--bundle <tag>` stays reproducible. |
**Schema 1 retires on 2027-01-01.** Until then it is composed exactly as before, beside schema 2, so
every installer already in the field keeps updating. After that date it stops being composed and is
left **frozen at its last bundle, never deleted**: an old installer still resolves something, and
every `bundle-<tag>.json` stays pinnable. A ServUO matrix carries the **same tag** at both schemas.
A schema-2 document names one game rather than both, because the games release on their own
schedules: a document naming both would hand a ServUO host a new bundle every time a Rust plugin
shipped. A game none of whose repos has released composes nothing, and that is not a failure.
Tags are UTC dates — `2026.08.04`. A second bundle on the same day (a sidecar release in the
morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names
exactly one matrix.
@@ -45,6 +64,7 @@ Plain anonymous `GET`s against a public repo. The shard host gets no git and no
```
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json
```
Bundles are committed rather than published as Gitea releases because this repo's *own* releases are
@@ -52,7 +72,7 @@ the installer binaries, and `/releases/latest` returns whichever release is newe
kind — interleaving the two would make "latest" intermittently resolve to a release containing no
installer binary.
## Schema
## Schema 1 (ServUO, retiring)
`schema` is the version of *this document's* shape, and is unrelated to `protocol` (the uo-link wire
protocol) or to either component's release version. All three move independently.
@@ -104,3 +124,48 @@ rejects a protocol mismatch with `409` rather than mis-parsing, so a mismatched
emitting into a void. CI refuses to publish one: it reads `PROTOCOL_VERSION` from the sidecar's
source at its release tag and the declared `protocol` from the overlay tarball's `manifest.json`, and
fails if they differ. The top-level `protocol` is that agreed value.
## Schema 2
The same document for either game: a `game` discriminant, a `sidecar`, and a `payload` whose `kind`
says what it is. A reader switches on `game`, and refuses a `schema` it does not know.
```jsonc
{
"schema": 2,
"game": "rust", // or "servuo"
"bundle": "2026.09.27",
"generated": "2026-09-27T…Z",
"protocol": 12,
"sidecar": {
"repo": "RunicGateway/Rust-Link", "tag": "v…", "version": "…", "protocol": 12,
"assets": { // Rust: no linux-aarch64 — RustDedicated has no arm64 build
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
},
"launcher": { "name": "with-sidecar.sh", "url": "…", "sha256": "…" } // Rust only: the egg's startup
},
"payload": {
"kind": "plugin", // "overlay" for ServUO
"repo": "RunicGateway/Rust-Plugins", "tag": "v…", "version": "…", "commit": "…",
"protocol": 12,
"compat": { // per game; for ServUO it is schema 1's `servuo` block
"frameworks": { "oxide": { "min_version": "2.0.7585" },
"carbon": { "min_version": "2.0.259" } },
"requires_plugins": ["Kits", "ZoneManager"]
},
"asset": { "name": "runicgateway-rust-plugin-….tar.gz", "url": "…", "sha256": "…" }
}
}
```
A ServUO schema-2 document is schema 1's content re-shaped: `link` becomes `sidecar` (no
`launcher`), `overlay` becomes a `payload` of `"kind": "overlay"`, and its `servuo` block becomes
`compat` (`min_version`, `patches_verified_against`).
The two gates hold for both games. For Rust, gate 1 reads `PROTOCOL_VERSION` from `Rust-Link`'s
`sidecar/src/main.rs` at its release tag and `protocol` from the `manifest.json` inside the plugin
tarball (`runicgateway-rust-plugin/manifest.json`). The stakes are higher there than for ServUO: the
Rust game link has no `409`, so a mismatched plugin would mis-parse rather than be refused.

View File

@@ -3,10 +3,27 @@
//! ## Scoped by what cannot be fetched again
//!
//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are
//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema
//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state
//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not
//! free: it would bury the two things that matter.
//! re-downloadable and hash-named in the bundle, and the sidecar's database is overwhelmingly a
//! projection of shard state that the sweeps repopulate. Backing it up would be bulk with little
//! recovery value, and the bulk is not free: it would bury the two things that matter.
//!
//! That reasoning used to be stated two ways that are no longer true, and the correction is worth
//! keeping rather than quietly deleting:
//!
//! - It said the database is safe because `store.rs` creates every table `IF NOT EXISTS`. That held
//! only while every schema change added a whole *table*. Protocol 4 adds a *column* to a table
//! that already exists, which `IF NOT EXISTS` cannot do, so `link` now carries a real migration
//! (`PRAGMA user_version` steps). A run can therefore change the database's structure, not just
//! its contents.
//! - It said every table holds state the sweeps repopulate. `events` does not: it is never pruned,
//! and the website backfills the events it missed from `GET /history` on every reconnect. So a
//! lost database costs the gap-recovery window for anything that happened while the site was down.
//!
//! The decision is unchanged — this still does not copy the database — because the argument against
//! backing up unbounded bulk survives both corrections: `events` grows without limit, the migration
//! is transactional and additive, and the website holds its own durable copy of everything it has
//! already ingested. Only the *reason* was wrong. Whether that table should be pruned or protected
//! is a question for `link`, on its own merits, not something to settle inside a backup policy.
//!
//! What a run can destroy irrecoverably is short:
//!

View File

@@ -14,6 +14,7 @@
//! half-understood.
use anyhow::{bail, Context, Result};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -27,9 +28,6 @@ use std::collections::BTreeMap;
const BUNDLE_BASE: &str =
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles";
/// The only `schema` this build understands.
const SUPPORTED_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Bundle {
pub schema: u32,
@@ -126,7 +124,10 @@ pub fn platform_key() -> Result<&'static str> {
}
}
/// URL of the current bundle, or of a specific one when `--bundle <tag>` pins it.
/// URL of a schema-1 bundle: the current one, or a specific one when `--bundle <tag>` pins it.
///
/// Schema 1 is ServUO only and stops being composed on 2027-01-01 (docs/modules/rust/PLAN.md
/// §34.4). This build reads it only as a fallback — see [`fetch`].
pub fn url_for(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
@@ -134,33 +135,137 @@ pub fn url_for(tag: Option<&str>) -> String {
}
}
/// Fetches and validates a bundle.
/// Which game a schema-2 bundle describes, and so which stream it is read from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Game {
ServUo,
Rust,
}
impl Game {
/// The `game` discriminant in the document, and the directory under `v2/`.
pub fn as_str(self) -> &'static str {
match self {
Self::ServUo => "servuo",
Self::Rust => "rust",
}
}
}
/// The Gitea **contents** API for the `bundles` branch.
///
/// Not the `/raw/` route [`BUNDLE_BASE`] names: that one is answered by the CDN with
/// `Cache-Control: public, max-age=21600`, so a bundle read through it can be hours behind the
/// branch — an `install` run right after a release would install the release before it. The
/// contents API is `private, must-revalidate` and always answers with the branch as it is. It
/// returns the file base64-encoded inside a JSON envelope, which [`fetch_v2`] unwraps.
const CONTENTS_BASE: &str =
"https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents";
/// The file name of a bundle in its stream.
fn file_name(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("bundle-{tag}.json"),
None => "current.json".to_string(),
}
}
/// The address a person opens to read a schema-2 bundle — what `install.json` records. The same
/// document [`fetch_v2`] reads; a support question about "which bundle?" wants a link that opens.
pub fn v2_url(game: Game, tag: Option<&str>) -> String {
format!("{BUNDLE_BASE}/v2/{}/{}", game.as_str(), file_name(tag))
}
/// Fetches a schema-2 document's text from its game's stream.
fn fetch_v2(game: Game, tag: Option<&str>) -> Result<String> {
let url = format!(
"{CONTENTS_BASE}/v2/{}/{}?ref=bundles",
game.as_str(),
file_name(tag)
);
let envelope = crate::net::get_text(&url)?;
#[derive(Deserialize)]
struct Contents {
content: String,
}
let contents: Contents = serde_json::from_str(&envelope)
.context("the bundles branch answered with something that is not a file")?;
// Gitea wraps the base64 at 76 columns; the decoder wants it whole.
let packed: String = contents
.content
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let bytes = base64::engine::general_purpose::STANDARD
.decode(packed)
.context("the bundle file is not valid base64")?;
String::from_utf8(bytes).context("the bundle file is not UTF-8")
}
/// Fetches and validates a ServUO bundle.
///
/// **Schema 2 first, schema 1 as the fallback** (PLAN.md §34.2.2, D147). The current bundle and any
/// pin published since schema 2 are read from `v2/servuo/`; a `--bundle <tag>` from before it exists
/// only at schema 1, and is read from the root and lifted into the same model, so a pin somebody
/// wrote down still reproduces. The fallback also covers the current bundle, which costs nothing
/// while both are composed and means this build never depends on the new stream alone.
///
/// The returned URL is the document that was actually used.
pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> {
let url = url_for(tag);
let body = crate::net::get_text(&url).with_context(|| match tag {
Some(tag) => format!(
"cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \
against {BUNDLE_BASE}/"
),
None => "cannot read the current bundle manifest".to_string(),
})?;
let v2 = fetch_v2(Game::ServUo, tag);
let (body, url) = match v2 {
Ok(body) => (body, v2_url(Game::ServUo, tag)),
Err(v2_error) => {
let url = url_for(tag);
let body = crate::net::get_text(&url)
.map_err(|_| v2_error)
.with_context(|| match tag {
Some(tag) => format!(
"cannot read bundle {tag}. Every published bundle is kept forever, so \
check the tag against {BUNDLE_BASE}/v2/servuo/"
),
None => "cannot read the current bundle manifest".to_string(),
})?;
(body, url)
}
};
let bundle = parse(&body)?;
Ok((bundle, url))
}
/// Parses a bundle document and applies the checks that must hold before anything is downloaded.
/// Parses a ServUO bundle, schema 1 or 2, and applies the checks that must hold before anything is
/// downloaded.
///
/// Schema 2 is **lowered** into the schema-1 model rather than the other way round: every ServUO
/// code path, and `install.json`, already speaks it, and the two documents carry exactly the same
/// facts (`sidecar` is `link`, the `overlay` payload's `compat` is its `servuo` block). A ServUO
/// deployment therefore records the same thing whichever schema it was resolved from.
pub fn parse(body: &str) -> Result<Bundle> {
let bundle: Bundle = serde_json::from_str(body)
let head: SchemaHead = serde_json::from_str(body)
.context("the bundle manifest is not in the shape this installer understands")?;
if bundle.schema != SUPPORTED_SCHEMA {
bail!(
"bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \
let bundle: Bundle = match head.schema {
1 => serde_json::from_str(body)
.context("the bundle manifest is not in the shape this installer understands")?,
2 => {
if head.game.as_deref() != Some(Game::ServUo.as_str()) {
bail!(
"bundle {} describes game {:?}, not ServUO. Pass --game rust to install a \
Rust bundle.",
head.bundle,
head.game.unwrap_or_default()
);
}
let v2: ServUoBundleV2 = serde_json::from_str(body).context(
"the schema-2 ServUO bundle is not in the shape this installer understands",
)?;
v2.lower()
}
other => bail!(
"bundle {} declares schema {other} and this installer understands 1 and 2. \
Update the installer — the bundle format changed.",
bundle.bundle,
bundle.schema
);
}
head.bundle,
),
};
// Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all.
// Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated
@@ -187,6 +292,202 @@ pub fn parse(body: &str) -> Result<Bundle> {
Ok(bundle)
}
/// Just enough of any bundle to decide how to read the rest of it.
#[derive(Deserialize)]
struct SchemaHead {
schema: u32,
#[serde(default)]
game: Option<String>,
#[serde(default)]
bundle: String,
}
/// A schema-2 ServUO bundle as published. Read only to be [lowered](ServUoBundleV2::lower).
#[derive(Deserialize)]
struct ServUoBundleV2 {
bundle: String,
generated: String,
protocol: u32,
sidecar: LinkComponent,
payload: OverlayPayloadV2,
}
#[derive(Deserialize)]
struct OverlayPayloadV2 {
kind: String,
repo: String,
tag: String,
version: String,
commit: String,
protocol: u32,
compat: ServUoCompat,
asset: Asset,
}
impl ServUoBundleV2 {
fn lower(self) -> Bundle {
// `kind` is informational here: `game` already said ServUO, and a ServUO payload is an
// overlay by definition. Kept in the struct so a document missing it fails to parse.
let _ = self.payload.kind;
Bundle {
schema: 2,
bundle: self.bundle,
generated: self.generated,
protocol: self.protocol,
link: self.sidecar,
overlay: OverlayComponent {
repo: self.payload.repo,
tag: self.payload.tag,
version: self.payload.version,
commit: self.payload.commit,
protocol: self.payload.protocol,
servuo: self.payload.compat,
asset: self.payload.asset,
},
}
}
}
// ── Rust ─────────────────────────────────────────────────────────────────────
/// A schema-2 Rust bundle: one exact Rust-Link release and one Rust-Plugins release, checked by CI
/// to speak the same protocol (docs/modules/rust/PLAN.md §34.2.2).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustBundle {
pub schema: u32,
pub game: String,
pub bundle: String,
pub generated: String,
pub protocol: u32,
pub sidecar: RustSidecar,
pub payload: PluginPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustSidecar {
pub repo: String,
pub tag: String,
pub version: String,
pub protocol: u32,
/// `linux-x86_64` and `windows-x86_64`. No arm64: RustDedicated has no arm64 build (D149).
pub assets: BTreeMap<String, Asset>,
/// The egg's launcher. The installer does not use it — a host runs the sidecar as a service —
/// but it is part of the pair CI checked, so it is carried rather than dropped.
pub launcher: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginPayload {
pub kind: String,
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
pub protocol: u32,
pub compat: PluginCompat,
/// One tarball: `runicgateway-rust-plugin/{RunicGateway.cs, manifest.json}`.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginCompat {
/// `oxide` and `carbon`, each with the oldest build the plugin is known good on. Printed by
/// `doctor` beside a failure; never measured from a DLL (§34.2.3).
pub frameworks: BTreeMap<String, FrameworkFloor>,
/// Third-party plugins the features expect. Reported, never installed (D153).
pub requires_plugins: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FrameworkFloor {
pub min_version: String,
}
impl RustBundle {
/// The sidecar binary for this host. Resolved before anything is written, like ServUO's.
pub fn sidecar_asset(&self) -> Result<&Asset> {
let key = platform_key()?;
self.sidecar.assets.get(key).ok_or_else(|| {
anyhow::anyhow!(
"Rust bundle {} has no rust-link binary for {key} (it has: {}). RustDedicated \
itself runs on linux-x86_64 and windows-x86_64 only.",
self.bundle,
self.sidecar
.assets
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)
})
}
}
/// Fetches and validates a Rust bundle: the current one, or `--bundle <tag>`.
pub fn fetch_rust(tag: Option<&str>) -> Result<(RustBundle, String)> {
let body = fetch_v2(Game::Rust, tag).with_context(|| match tag {
Some(tag) => format!(
"cannot read Rust bundle {tag}. Every published bundle is kept forever, so check the \
tag against {BUNDLE_BASE}/v2/rust/"
),
None => "cannot read the current Rust bundle. If Rust-Link or Rust-Plugins has never \
released, there is none yet."
.to_string(),
})?;
Ok((parse_rust(&body)?, v2_url(Game::Rust, tag)))
}
/// Parses a Rust bundle and applies the checks that must hold before anything is downloaded.
pub fn parse_rust(body: &str) -> Result<RustBundle> {
let head: SchemaHead = serde_json::from_str(body)
.context("the Rust bundle is not in the shape this installer understands")?;
if head.schema != 2 {
bail!(
"Rust bundle {} declares schema {} and this installer reads Rust bundles at schema 2",
head.bundle,
head.schema
);
}
if head.game.as_deref() != Some(Game::Rust.as_str()) {
bail!(
"bundle {} describes game {:?}, not Rust",
head.bundle,
head.game.unwrap_or_default()
);
}
let bundle: RustBundle = serde_json::from_str(body)
.context("the Rust bundle is not in the shape this installer understands")?;
// Gate 1 again, for a document that never went through CI. It matters more here than for
// ServUO: the Rust game link has no 409, so a mismatched plugin mis-parses instead of being
// refused.
if bundle.sidecar.protocol != bundle.payload.protocol
|| bundle.protocol != bundle.sidecar.protocol
{
bail!(
"Rust bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, plugin {}",
bundle.bundle,
bundle.protocol,
bundle.sidecar.protocol,
bundle.payload.protocol
);
}
if bundle.payload.kind != "plugin" {
bail!(
"Rust bundle {} carries a {:?} payload; this installer deploys a plugin",
bundle.bundle,
bundle.payload.kind
);
}
if bundle.payload.asset.url.is_empty() || bundle.payload.asset.sha256.is_empty() {
bail!(
"Rust bundle {} names a plugin asset with no URL or checksum",
bundle.bundle
);
}
Ok(bundle)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -262,9 +563,78 @@ mod tests {
#[test]
fn a_newer_schema_is_refused_rather_than_guessed_at() {
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2");
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 3");
let err = parse(&body).unwrap_err().to_string();
assert!(err.contains("schema 2"), "{err}");
assert!(err.contains("schema 3"), "{err}");
}
/// Published on the bundles branch as `v2/servuo/bundle-2026.09.15.json` and at the root as
/// `bundle-2026.09.15.json` — the same matrix at both schemas, under the same tag.
const V2_SERVUO: &str = include_str!("../tests/fixtures/published-bundle-v2-servuo.json");
const V1_SAME: &str = include_str!("../tests/fixtures/published-bundle-2026.09.15.json");
/// What the compose job wrote for Rust from real Rust-Link and Rust-Plugins artifacts, with
/// the mock host's URLs pointed back at Gitea.
const RUST: &str = include_str!("../tests/fixtures/rust-bundle-v2.json");
#[test]
fn a_schema_2_servuo_bundle_lowers_to_what_schema_1_says() {
// The lowering is the whole claim that a ServUO host records the same deployment whichever
// stream it read. Only `schema` and `generated` describe the document rather than the pair.
let mut v2 = parse(V2_SERVUO).unwrap();
let mut v1 = parse(V1_SAME).unwrap();
assert_eq!(v2.schema, 2);
v2.schema = 1;
v2.generated.clear();
v1.generated.clear();
assert_eq!(v2, v1);
}
#[test]
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
let err = parse(RUST).unwrap_err().to_string();
assert!(err.contains("--game rust"), "{err}");
}
#[test]
fn the_rust_bundle_parses() {
let bundle = parse_rust(RUST).unwrap();
assert_eq!(bundle.game, "rust");
assert_eq!(bundle.protocol, 12);
assert_eq!(bundle.payload.kind, "plugin");
assert_eq!(
bundle.payload.compat.frameworks["carbon"].min_version,
"2.0.259"
);
assert_eq!(
bundle.payload.compat.requires_plugins,
["Kits", "ZoneManager"]
);
assert!(!bundle.sidecar.assets.contains_key("linux-aarch64"));
assert_eq!(bundle.sidecar.launcher.name, "with-sidecar.sh");
}
#[test]
fn a_servuo_bundle_is_refused_by_the_rust_reader() {
let err = parse_rust(V2_SERVUO).unwrap_err().to_string();
assert!(err.contains("not Rust"), "{err}");
let err = parse_rust(V1_SAME).unwrap_err().to_string();
assert!(err.contains("schema 1"), "{err}");
}
#[test]
fn a_protocol_disagreement_inside_a_rust_bundle_is_refused() {
let mut doc: serde_json::Value = serde_json::from_str(RUST).unwrap();
doc["payload"]["protocol"] = 11.into();
let err = parse_rust(&doc.to_string()).unwrap_err().to_string();
assert!(err.contains("internally inconsistent"), "{err}");
}
#[test]
fn schema_2_urls_name_their_game() {
assert!(v2_url(Game::Rust, None).ends_with("/v2/rust/current.json"));
assert!(
v2_url(Game::ServUo, Some("2026.09.15")).ends_with("/v2/servuo/bundle-2026.09.15.json")
);
}
#[test]

View File

@@ -56,6 +56,14 @@ pub enum PatchChoice {
No,
}
/// Which game's shard side this run is about (docs/modules/rust/PLAN.md §34.2.3). ServUO is the
/// default, so every command line written before Rust existed means exactly what it meant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Game {
ServUo,
Rust,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Run(Command),
@@ -66,6 +74,15 @@ pub enum Mode {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cli {
pub mode: Mode,
/// `--game servuo|rust`.
pub game: Game,
/// `--rust <path>`: a Rust server root (the directory holding `RustDedicated`).
pub rust: Option<String>,
/// `--server-id <id>`: names one Rust instance — its service, config, database and ports — and
/// is the plugin's `ServerId` (D148).
pub server_id: Option<String>,
/// `--web-port <port>`: the port the website reaches a Rust instance's sidecar on.
pub web_port: Option<u16>,
/// `--verify`: report every change that would be made, write nothing.
pub verify: bool,
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
@@ -77,7 +94,7 @@ pub struct Cli {
pub patches_unsupported_servuo: bool,
/// `--host <name>`: the hostname to print in the website URLs.
pub host: Option<String>,
/// `--site-url <url>`: the site's base URL, for the Admin → Shard link.
/// `--site-url <url>`: the site's base URL, for the Admin → Shard (uo-link) link.
pub site_url: Option<String>,
/// `--yes`: assume the default answer to every prompt.
pub assume_yes: bool,
@@ -92,6 +109,10 @@ impl Default for Cli {
fn default() -> Self {
Self {
mode: Mode::Help,
game: Game::ServUo,
rust: None,
server_id: None,
web_port: None,
verify: false,
servuo: None,
bundle: None,
@@ -107,20 +128,23 @@ impl Default for Cli {
}
pub const USAGE: &str = "\
Runic Gateway installer — connects a ServUO shard to a Runic Gateway website.
Runic Gateway installer — connects a ServUO shard, or Rust servers, to a Runic
Gateway website.
Usage: runicgateway-installer <COMMAND> [OPTIONS]
Commands:
install Deploy the plugin overlay, install the uo-link sidecar and its
service, record what was deployed, and print the values the
website needs.
install Deploy the game-side plugin (the ServUO overlay, or the Rust
plugin), install the sidecar and its service, record what was
deployed, and print the values the website needs.
doctor Diagnose an existing deployment end to end.
update Re-resolve the bundle and move both components to it.
uninstall Remove what the installer exclusively owns. Never edits the
ServUO tree — it prints what to remove there.
ServUO tree — it prints what to remove there. For Rust it
removes only its own plugin file and keeps the plugin config.
Options:
--game <servuo|rust> Which game's shard side. Default servuo.
--verify install, update. Dry run: report every
change that would be made, write nothing.
--servuo <PATH> install, doctor, update. The ServUO root,
@@ -137,7 +161,7 @@ Options:
--host <NAME> install. The hostname to print in the
website URLs.
--site-url <URL> install. Your site's base URL, for the
Admin → Shard link.
Admin → Shard (uo-link) link.
--yes Assume the default answer to every prompt.
On uninstall it means yes: that prompt
defaults to no, and typing `uninstall
@@ -149,6 +173,16 @@ Options:
--purge uninstall. Also delete sidecar.toml,
uo-link.db, the cached patch set and every
backup, all of which are otherwise kept.
Rust (with --game rust):
--rust <PATH> install. The Rust server root — the
directory holding RustDedicated. Oxide or
Carbon is detected, never asked.
--server-id <ID> install: required. doctor, uninstall:
one instance instead of all. The id the
website knows this server by, and the
name of its sidecar's service.
--web-port <PORT> install. The sidecar's website port.
Default: the first free from 8090.
-V, --version Print the installer version and exit.
-h, --help Print this help and exit.
@@ -199,6 +233,30 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
"--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?),
"--host" => cli.host = Some(take_value(&name, inline, &mut it)?),
"--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?),
"--game" => {
cli.game = match take_value(&name, inline, &mut it)?.as_str() {
"servuo" => Game::ServUo,
"rust" => Game::Rust,
other => return Err(format!("--game must be servuo or rust, not {other:?}")),
}
}
"--rust" => cli.rust = Some(take_value(&name, inline, &mut it)?),
"--server-id" => {
let id = take_value(&name, inline, &mut it)?;
if !valid_server_id(&id) {
return Err(format!(
"--server-id {id:?} is not a valid server id: lowercase letters, digits \
and '-', 1 to 64 characters, not starting with '-' (the website's rule)"
));
}
cli.server_id = Some(id);
}
"--web-port" => {
let raw = take_value(&name, inline, &mut it)?;
cli.web_port = Some(raw.parse::<u16>().ok().filter(|p| *p >= 1024).ok_or_else(
|| format!("--web-port must be a port from 1024 to 65535, not {raw:?}"),
)?);
}
other if other.starts_with('-') => {
return Err(format!("unrecognized argument: {other}"))
}
@@ -212,6 +270,20 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
}
}
// The Rust flags belong to `--game rust` only. Silently ignoring one on a ServUO run would leave
// an operator believing they had named a server they had not.
if cli.game == Game::ServUo {
for (flag, set) in [
("--rust", cli.rust.is_some()),
("--server-id", cli.server_id.is_some()),
("--web-port", cli.web_port.is_some()),
] {
if set {
return Err(format!("{flag} needs --game rust"));
}
}
}
match command {
Some(c) => cli.mode = Mode::Run(c),
// No verb is not an error worth an exit code — it is someone typing the binary's name to
@@ -221,6 +293,17 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
Ok(cli)
}
/// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`. The same rule the plugin and the
/// egg apply, so an id that passes here is one all three accept.
pub fn valid_server_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& !id.starts_with('-')
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// Pulls a flag's value, from `--flag=value` or from the next token.
///
/// A missing value is an error rather than a default: `--servuo` with nothing after it would
@@ -339,6 +422,50 @@ mod tests {
assert!(parse_str(&["install", "update"]).is_err());
}
#[test]
fn servuo_stays_the_default_game() {
assert_eq!(parse_str(&["install"]).unwrap().game, Game::ServUo);
let rust = parse_str(&[
"install",
"--game",
"rust",
"--rust",
"/srv/rust",
"--server-id",
"alpha",
"--web-port=8091",
])
.unwrap();
assert_eq!(rust.game, Game::Rust);
assert_eq!(rust.server_id.as_deref(), Some("alpha"));
assert_eq!(rust.web_port, Some(8091));
}
#[test]
fn a_rust_flag_without_game_rust_is_refused() {
for args in [
vec!["install", "--rust", "/srv/rust"],
vec!["doctor", "--server-id", "alpha"],
vec!["install", "--web-port", "8091"],
] {
let err = parse_str(&args).unwrap_err();
assert!(err.contains("--game rust"), "{args:?}: {err}");
}
}
#[test]
fn a_server_id_follows_the_websites_rule() {
for good in ["main", "alpha", "eu-2", "0", &"a".repeat(64)] {
assert!(valid_server_id(good), "{good}");
}
for bad in ["", "-a", "Alpha", "a_b", "a.b", &"a".repeat(65)] {
assert!(!valid_server_id(bad), "{bad}");
}
assert!(parse_str(&["install", "--game", "rust", "--server-id", "Bad"]).is_err());
assert!(parse_str(&["install", "--game", "rust", "--web-port", "80"]).is_err());
assert!(parse_str(&["install", "--game", "minecraft"]).is_err());
}
#[test]
fn order_does_not_matter() {
let a = parse_str(&["--verify", "install", "--yes"]).unwrap();

View File

@@ -68,7 +68,7 @@ pub struct Row {
}
impl Row {
fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
Self {
mark,
label: label.to_string(),
@@ -77,24 +77,24 @@ impl Row {
}
}
fn ok(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn ok(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Ok, label, detail)
}
fn warn(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn warn(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Warn, label, detail)
}
fn fail(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn fail(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Fail, label, detail)
}
fn note(mut self, note: impl Into<String>) -> Self {
pub(crate) fn note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
pub(crate) fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
self.notes.extend(notes);
self
}
@@ -158,12 +158,22 @@ pub fn run(cli: &Cli) -> Result<i32> {
// ── The bundle ───────────────────────────────────────────────────────────
rows.push(bundle_row(&record));
// ── The host ─────────────────────────────────────────────────────────────
// Linux only, and absent entirely elsewhere (see `imaging_row`).
rows.extend(imaging_row());
// ── Backups ──────────────────────────────────────────────────────────────
rows.push(backup_row(&layout));
// ── Report ───────────────────────────────────────────────────────────────
Ok(report(&rows))
}
/// Prints the rows and the one-line verdict, and returns the exit code: `1` when anything failed.
/// Shared with `doctor --game rust`, so both games' reports read the same way.
pub(crate) fn report(rows: &[Row]) -> i32 {
println!();
for row in &rows {
for row in rows {
println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail);
for note in &row.notes {
println!(" {note}");
@@ -181,7 +191,11 @@ pub fn run(cli: &Cli) -> Result<i32> {
INSTALL.md's Troubleshooting table is keyed to these symptoms."
),
}
Ok(if failed > 0 { 1 } else { 0 })
if failed > 0 {
1
} else {
0
}
}
/// The ServUO root to inspect: `--servuo` if given, else the one the record names.
@@ -544,7 +558,7 @@ fn live_config(link: &LinkRecord) -> Option<sidecar::ConfigDoc> {
///
/// Always over loopback, never over the configured bind: `[web] bind` is regularly `0.0.0.0`, and
/// this check is about whether the process on *this* host is answering.
fn health_of(bind: &str) -> Option<Health> {
pub(crate) fn health_of(bind: &str) -> Option<Health> {
let port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind);
let url = format!("http://127.0.0.1:{port}/health");
let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?;
@@ -755,6 +769,71 @@ fn bundle_row(record: &InstallRecord) -> Row {
Row::warn("Bundle", detail).note("run `update` to move both halves to one checked combination")
}
/// `libgdiplus` on a Linux shard host — the one host prerequisite the Asset Bridge added
/// (docs/link/v8.md §4.4, docs/link/SHARD_PREREQS.md).
///
/// ServUO targets `net48`, so on Linux it runs under Mono, and Mono's `System.Drawing` is a thin
/// layer over this library — which sits in the **decode** path, not merely the encode: without it
/// the shard cannot read a single sprite out of the operator's UO client. Windows hosts ship
/// `System.Drawing` with .NET Framework and need nothing, which is why this row exists only on
/// Linux rather than reporting "not applicable" on three quarters of the hosts that run it.
///
/// **A `⚠`, never a `✗`.** Everything else on this plane works without it: the cliloc table and
/// the shard's own spawn files have no pixels in them, and a bridge that serves names and an atlas
/// but no artwork is a working bridge with one feature missing. It is also not the last word — the
/// shard reports `NO_IMAGING` on the asset plane itself, from inside the process that would do the
/// decoding. This row exists to move that discovery from "the bestiary is empty, weeks later" to
/// "the host is missing a package, now".
#[cfg(target_os = "linux")]
fn imaging_row() -> Option<Row> {
Some(imaging_verdict(imaging_present()))
}
/// Is the library on this host? Two answers, in the order that is most likely to be right.
#[cfg(target_os = "linux")]
fn imaging_present() -> bool {
// `ldconfig -p` is the loader's own cache, which is the same question Mono asks at runtime —
// strictly better than probing paths, because a distro that puts the file somewhere unusual has
// told the loader about it and would otherwise read here as missing.
let cached = crate::util::run("ldconfig", &["-p"])
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("libgdiplus.so"))
.unwrap_or(false);
// The fallback is for a host with no `ldconfig` on PATH (a slim container, mostly), where a
// present library would otherwise be reported absent.
cached
|| [
"/usr/lib/libgdiplus.so",
"/usr/lib64/libgdiplus.so",
"/usr/lib/x86_64-linux-gnu/libgdiplus.so",
"/usr/lib/aarch64-linux-gnu/libgdiplus.so",
"/usr/local/lib/libgdiplus.so",
]
.iter()
.any(|p| Path::new(p).exists())
}
/// The operator-visible half, split out so the wording and the mark are testable on a host that
/// has the library and on one that does not — which the detection itself is not.
#[cfg(target_os = "linux")]
fn imaging_verdict(found: bool) -> Row {
if found {
return Row::ok("Imaging (libgdiplus)", "present");
}
Row::warn("Imaging (libgdiplus)", "not found on this host")
.note("this shard cannot decode artwork out of its UO client — creature portraits and")
.note("item pictures will be absent; names and the spawn atlas are unaffected")
.note("install it: apt-get install libgdiplus / dnf install libgdiplus")
.note("see docs/link/SHARD_PREREQS.md — Windows hosts need nothing")
}
/// Windows and macOS hosts do not need it, so there is no row to print.
#[cfg(not(target_os = "linux"))]
fn imaging_row() -> Option<Row> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -913,4 +992,39 @@ mod tests {
assert_eq!(row.mark, Mark::Ok);
assert!(row.detail.contains("operator-owned"), "{}", row.detail);
}
// ── The host row (Linux only; see `imaging_row`) ─────────────────────────
#[cfg(target_os = "linux")]
#[test]
fn a_missing_libgdiplus_warns_and_names_the_package() {
let row = imaging_verdict(false);
// A ⚠, never a ✗: the cliloc table and the spawn atlas have no pixels in them, so a host
// without this library still runs a useful bridge. `doctor`'s exit code must not turn red
// over one absent feature.
assert_eq!(row.mark, Mark::Warn);
let notes = row.notes.join(" ");
assert!(notes.contains("apt-get install libgdiplus"), "{notes}");
assert!(notes.contains("SHARD_PREREQS.md"), "{notes}");
}
#[cfg(target_os = "linux")]
#[test]
fn a_present_libgdiplus_is_one_quiet_ok_line() {
let row = imaging_verdict(true);
assert_eq!(row.mark, Mark::Ok);
assert!(
row.notes.is_empty(),
"a satisfied prerequisite needs no advice"
);
}
#[cfg(target_os = "linux")]
#[test]
fn detection_answers_rather_than_panicking_on_a_host_with_no_ldconfig() {
// The value depends on the host and is not asserted — what is asserted is that a missing
// `ldconfig` degrades to the path probe instead of taking `doctor` down, which is the rule
// every row in this module follows.
let _ = imaging_present();
}
}

View File

@@ -576,8 +576,9 @@ fn install_sidecar(
layout.relocated,
)?;
let outcome = service::register(&prepared, layout, action.writes())?;
let mut outcome = service::register(&prepared, layout, action.writes())?;
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
service::start_registered(&mut outcome, &layout.sidecar_bin, &config_path)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
@@ -694,7 +695,7 @@ fn preflight_writable(layout: &paths::Layout) -> Result<()> {
/// Only ever printed, never connected to — which is why a non-interactive run falls back to the
/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard;
/// aborting a completed install over an unanswerable prompt costs them the whole run.
fn resolve_host(cli: &Cli) -> String {
pub(crate) fn resolve_host(cli: &Cli) -> String {
if let Some(host) = &cli.host {
return host.clone();
}

View File

@@ -9,6 +9,12 @@
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
//! commands `doctor`, `update` and `uninstall`.
//!
//! **`--game rust`** (module-rust phase 18, docs/modules/rust/PLAN.md §34) runs the same four verbs
//! for Rust servers, in [`rustgame`]: schema-2 Rust bundles, named instances, a Rust-Link sidecar
//! and service per instance, and the plugin placed for Oxide or Carbon. ServUO's pipeline, files
//! and record are untouched by it; ServUO bundles are now read at schema 2 with schema 1 as the
//! fallback ([`bundle::fetch`]).
//!
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
//! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that
//! found something broken, so it can be read by a monitoring script; a `⚠` row never does that.
@@ -39,6 +45,7 @@ pub mod overlay;
pub mod patch;
pub mod paths;
pub mod record;
pub mod rustgame;
pub mod service;
pub mod servuo;
pub mod sidecar;
@@ -76,6 +83,7 @@ pub fn run() -> i32 {
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
Ok(0)
}
Mode::Run(command) if parsed.game == cli::Game::Rust => rustgame::run(&parsed, command),
Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0),
Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0),
Mode::Run(Command::Doctor) => doctor::run(&parsed),

View File

@@ -29,6 +29,9 @@ pub struct Layout {
pub data_dir: PathBuf,
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
pub sidecar_bin: PathBuf,
/// `/usr/bin/runicgateway-rust-link` — the rust-link sidecar, one binary for every Rust
/// instance on the host (docs/modules/rust/PLAN.md §34.2.3).
pub rust_sidecar_bin: PathBuf,
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
/// deployment. Service registration is skipped when it is set — see [`layout`].
pub relocated: bool,
@@ -83,6 +86,53 @@ impl Layout {
pub fn systemd_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
}
// ── Rust ─────────────────────────────────────────────────────────────────
//
// Rust's files live under a `rust/` of their own, beside ServUO's rather than among them, so a
// host running both games has two records that cannot corrupt each other (§34.4) and an
// uninstall of one never reaches the other.
/// `/etc/runicgateway/rust/install.json` — the Rust record: the bundle and every instance.
pub fn rust_record(&self) -> PathBuf {
self.state_dir.join("rust").join("install.json")
}
/// Where instance configs live on Linux — the directory the template unit names with `%i`.
pub fn rust_config_dir(&self) -> PathBuf {
self.state_dir.join("rust")
}
/// One instance's `sidecar.toml`.
///
/// **Linux:** `/etc/runicgateway/rust/<id>.toml`, beside its siblings, because the template
/// unit derives the path from the instance name. **Windows:** inside the instance's own
/// directory, because a Windows service logs beside its config — two instances sharing a
/// directory would share, and fight over, one log file.
pub fn rust_config(&self, server_id: &str) -> PathBuf {
if cfg!(windows) {
self.rust_data_dir(server_id).join("sidecar.toml")
} else {
self.rust_config_dir().join(format!("{server_id}.toml"))
}
}
/// `/var/lib/runicgateway/rust/<id>` — one instance's store (and, on Windows, its config and
/// logs).
pub fn rust_data_dir(&self, server_id: &str) -> PathBuf {
self.data_dir.join("rust").join(server_id)
}
/// One instance's SQLite store. Written into its config as an absolute path, so the store never
/// depends on anybody's working directory or on a shared default name.
pub fn rust_db(&self, server_id: &str) -> PathBuf {
self.rust_data_dir(server_id).join("rust-link.db")
}
/// The shared systemd template for every Rust instance.
pub fn rust_template_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::RUST_TEMPLATE_UNIT)
}
}
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
@@ -107,8 +157,14 @@ pub fn layout() -> Layout {
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
let rust_bin_name = layout
.rust_sidecar_bin
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("rust-link-sidecar"));
layout.data_dir = root.join("data");
layout.sidecar_bin = root.join("bin").join(bin_name);
layout.rust_sidecar_bin = root.join("bin").join(rust_bin_name);
layout.state_dir = root;
layout.relocated = true;
}
@@ -134,6 +190,9 @@ fn platform_layout() -> Layout {
sidecar_bin: program_files
.join("RunicGateway")
.join("uo-link-sidecar.exe"),
rust_sidecar_bin: program_files
.join("RunicGateway")
.join("rust-link-sidecar.exe"),
relocated: false,
}
}
@@ -144,6 +203,7 @@ fn platform_layout() -> Layout {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
relocated: false,
}
}
@@ -185,6 +245,25 @@ mod tests {
}
}
#[test]
fn two_rust_instances_share_nothing_but_the_binary() {
// The failure this prevents: two instances on one database, one token file, or (on
// Windows) one log file.
let l = platform_layout();
assert_ne!(l.rust_config("alpha"), l.rust_config("beta"));
assert_ne!(l.rust_db("alpha"), l.rust_db("beta"));
// A Windows service logs beside its config, so there each instance needs its own directory.
if cfg!(windows) {
assert_ne!(
l.rust_config("alpha").parent(),
l.rust_config("beta").parent()
);
}
assert_ne!(l.rust_record(), l.install_record());
assert_ne!(l.rust_sidecar_bin, l.sidecar_bin);
assert!(l.rust_db("alpha").is_absolute());
}
#[test]
fn a_relocated_layout_moves_the_binary_too() {
// The failure this prevents: a test run that writes its config and database under the

297
src/rustgame/doctor.rs Normal file
View File

@@ -0,0 +1,297 @@
//! `doctor --game rust` — every recorded instance, or one with `--server-id`.
//!
//! The same rule as ServUO's doctor: every row asks the host what is true now, and the record only
//! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - the framework, and whether the plugin file is still the one deployed;
//! - that the plugin's config names this instance;
//! - `requires_plugins` present, as a warning (D153);
//! - the service registered and running;
//! - `/health`: reachable, the protocol, and whether the plugin is connected.
//!
//! **The framework version floor is not measured from files.** A plugin that compiled and connected
//! on this framework is the fact a floor only approximates, so a connected plugin is the pass, and
//! the floors are printed beside a plugin that is not.
use std::path::Path;
use anyhow::Result;
use super::plugin;
use super::record::{Instance, RustRecord};
use super::server;
use crate::cli::Cli;
use crate::doctor::{health_of, report, Row};
use crate::{bundle, paths, service, ui};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.rust_record();
println!(
"\nRunic Gateway installer {} — doctor (Rust)",
env!("CARGO_PKG_VERSION")
);
let Some(record) = RustRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"No Rust deployment is recorded on this host.\n Looked for {}\n Run `install --game \
rust` first. If you installed with {} set, set it again for this run.",
record_path.display(),
paths::STATE_DIR_ENV
));
return Ok(1);
};
let mut rows = vec![Row::ok(
"Rust record",
format!(
"{} (bundle {}, {} instance(s), installer {})",
record_path.display(),
record.bundle.tag,
record.instances.len(),
record.installer.version
),
)];
let selected: Vec<(&String, &Instance)> = match &cli.server_id {
Some(id) => match record.instances.get_key_value(id) {
Some(pair) => vec![pair],
None => {
rows.push(Row::fail(
"Instance",
format!(
"{id:?} is not recorded (recorded: {})",
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
));
return Ok(report(&rows));
}
},
None => record.instances.iter().collect(),
};
// The floors, fetched once, for the note beside a plugin that has not connected. Read from the
// installed bundle rather than the current one: they describe the plugin that is installed.
let installed_bundle = bundle::fetch_rust(Some(&record.bundle.tag)).ok();
for (id, instance) in selected {
rows.extend(instance_rows(
id,
instance,
&record,
installed_bundle.as_ref().map(|b| &b.0),
));
}
rows.push(bundle_row(&record));
Ok(report(&rows))
}
fn instance_rows(
id: &str,
instance: &Instance,
record: &RustRecord,
bundle: Option<&bundle::RustBundle>,
) -> Vec<Row> {
let mut rows = Vec::new();
let label = |what: &str| format!("{id}: {what}");
// ── The server and its framework ─────────────────────────────────────────
let server = server::open(Path::new(&instance.server_root));
match &server {
Ok(s) if s.framework.as_str() == instance.framework => rows.push(Row::ok(
&label("server"),
format!("{} ({})", s.path.display(), instance.framework),
)),
Ok(s) => rows.push(
Row::fail(
&label("server"),
format!(
"{} now runs {}, but the plugin was installed for {}",
s.path.display(),
s.framework.as_str(),
instance.framework
),
)
.note(format!(
"reinstall it: install --game rust --server-id {id} --rust {}",
s.path.display()
)),
),
Err(e) => rows.push(Row::fail(&label("server"), e.to_string())),
}
// ── The plugin file: ours ────────────────────────────────────────────────
let plugin_path = Path::new(&instance.plugin_path);
match std::fs::read(plugin_path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == instance.plugin_sha256 => {
rows.push(Row::ok(
&label("plugin"),
format!(
"{} (Rust-Plugins {})",
instance.plugin_path, record.plugin.tag
),
))
}
Ok(_) => rows.push(
Row::fail(
&label("plugin"),
format!("{} is not the file that was deployed", instance.plugin_path),
)
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(Row::fail(
&label("plugin"),
format!("{} is missing", instance.plugin_path),
)),
}
// ── The plugin's config: the website's ───────────────────────────────────
match plugin::read_config(Path::new(&instance.plugin_config)) {
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
&label("plugin config"),
format!(
"ServerId {id}, Port {}",
view.port
.map(|p| p.to_string())
.unwrap_or_else(|| "default".into())
),
)),
Ok(Some(view)) => rows.push(Row::fail(
&label("plugin config"),
format!(
"{} says ServerId {:?}; this instance is {id:?}",
instance.plugin_config,
view.server_id.unwrap_or_else(|| "main (default)".into())
),
)),
Ok(None) => rows.push(Row::warn(
&label("plugin config"),
format!(
"{} is gone — the plugin will write one with ServerId \"main\" on its next load",
instance.plugin_config
),
)),
Err(e) => rows.push(Row::fail(&label("plugin config"), e.to_string())),
}
// ── Third-party plugins: reported, never installed (D153) ────────────────
if let (Ok(s), Some(b)) = (&server, bundle) {
let missing = plugin::missing_plugins(&s.plugins_dir(), &b.payload.compat.requires_plugins);
rows.push(if missing.is_empty() {
Row::ok(
&label("required plugins"),
b.payload.compat.requires_plugins.join(", "),
)
} else {
Row::warn(
&label("required plugins"),
format!(
"missing {} in {}",
missing.join(", "),
s.plugins_dir().display()
),
)
.note("install from uMod; the features that use them stay off until then")
});
}
// ── The service ──────────────────────────────────────────────────────────
match &instance.service {
Some(svc) => {
let status = service::observe(&svc.kind, &svc.name);
let detail = format!("{} {}", svc.name, status.detail);
rows.push(if status.running && status.enabled {
Row::ok(&label("service"), detail)
} else {
Row::fail(&label("service"), detail)
});
}
None => rows.push(Row::fail(
&label("service"),
"none registered — the install printed the steps to register it by hand",
)),
}
// ── The sidecar, and the plugin through it ───────────────────────────────
let health = health_of(&format!("127.0.0.1:{}", instance.web_port));
let Some(health) = health else {
rows.push(Row::fail(
&label("sidecar"),
format!(
"nothing answered http://127.0.0.1:{}/health",
instance.web_port
),
));
return rows;
};
rows.push(Row::ok(
&label("sidecar"),
format!(
"127.0.0.1:{} /health {}, database {}",
instance.web_port,
health.status.as_deref().unwrap_or("ok"),
health.database.as_deref().unwrap_or("unknown")
),
));
match health.protocol {
Some(p) if p == record.bundle.protocol => {
rows.push(Row::ok(&label("protocol"), p.to_string()))
}
Some(p) => rows.push(Row::fail(
&label("protocol"),
format!(
"the sidecar speaks {p}; bundle {} is {}",
record.bundle.tag, record.bundle.protocol
),
)),
None => rows.push(Row::warn(&label("protocol"), "the sidecar did not say")),
}
let floors = bundle
.map(|b| {
b.payload
.compat
.frameworks
.iter()
.map(|(f, v)| format!("{f} {}", v.min_version))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let running = server.as_ref().is_ok_and(|s| server::is_running(&s.path));
rows.push(match (health.plugin_connected, running) {
(Some(true), _) => Row::ok(&label("plugin connected"), "yes"),
(_, false) => Row::warn(&label("plugin connected"), "no — the server is not running"),
(_, true) => {
Row::fail(&label("plugin connected"), "no, and the server is running").note(format!(
"the plugin has not dialled 127.0.0.1:{}. Check the server console for a compile \
error; the plugin is known good on {floors}",
instance.game_port
))
}
});
rows
}
/// Whether a newer Rust bundle than the installed one is published.
fn bundle_row(record: &RustRecord) -> Row {
match bundle::fetch_rust(None) {
Ok((current, _)) if current.bundle == record.bundle.tag => {
Row::ok("Bundle", format!("{} is current", record.bundle.tag))
}
Ok((current, _)) => Row::warn(
"Bundle",
format!(
"{} is installed; {} is published",
record.bundle.tag, current.bundle
),
)
.note("update --game rust moves every instance to it"),
Err(e) => Row::warn("Bundle", format!("could not check for a newer one: {e}")),
}
}

777
src/rustgame/install.rs Normal file
View File

@@ -0,0 +1,777 @@
//! `install --game rust` and `update --game rust` — one pipeline, as for ServUO.
//!
//! docs/modules/rust/PLAN.md §34.2.3. What an instance is: one Rust server root, its plugin, and a
//! sidecar of its own — service, config, database, game port and web port — named by `--server-id`
//! (D148). What instances share: the one sidecar binary and the one bundle, so `update` moves every
//! instance on the host together (§34.4).
//!
//! The order follows ServUO's, for the same reasons:
//!
//! 1. **Resolve everything that can fail cheaply first** — the bundle, this host's binary, the
//! server root and its framework, the plugin's config and the ports — so a run that cannot
//! finish ends before anything is written.
//! 2. **The sidecar before the plugin.** Both frameworks load a plugin file the moment it lands,
//! even on a running server (§34.2.3), so the sidecar it dials is registered first.
//! 3. **Provision the config before registering the service**, record last, handoff after that.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
use crate::bundle::{self, RustBundle};
use crate::cli::Cli;
use crate::install::Mode;
use crate::record::{now_rfc3339, BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
use crate::sidecar::BinaryAction;
use crate::util::{write_atomic, TempDir};
use crate::{paths, service, ui};
/// The plugin's own default game port, and where the search for a free one starts.
const GAME_PORT_START: u16 = 7799;
/// Where the search for a free web port starts. Clear of ServUO's uo-link default (8080).
const WEB_PORT_START: u16 = 8090;
/// One instance this run is about, with every decision made and nothing written.
struct Planned {
id: String,
server: RustServer,
running: bool,
plugin_config: Option<ConfigView>,
plugin_action: BinaryAction,
game_port: u16,
web_port: u16,
config_path: PathBuf,
db_path: PathBuf,
config_exists: bool,
}
pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let layout = paths::layout();
let record_path = layout.rust_record();
let prior = RustRecord::load(&record_path)?;
// ── Which instances ──────────────────────────────────────────────────────
let targets: Vec<(String, PathBuf)> = match mode {
Mode::Install => {
let id = cli.server_id.clone().ok_or_else(|| {
anyhow::anyhow!(
"install --game rust needs --server-id <id>: the id the website will know this \
server by, which also names its sidecar. For example:\n \
runicgateway-installer install --game rust --rust /srv/rust --server-id main"
)
})?;
let recorded = prior
.as_ref()
.and_then(|p| p.instances.get(&id))
.map(|i| PathBuf::from(&i.server_root));
let root = match (&cli.rust, recorded) {
(Some(path), _) => PathBuf::from(path),
(None, Some(root)) => root,
(None, None) => bail!(
"install --game rust needs --rust <path>: the Rust server root, the directory \
holding RustDedicated"
),
};
vec![(id, root)]
}
Mode::Update => {
if cli.server_id.is_some() || cli.rust.is_some() {
bail!(
"update --game rust moves every Rust instance on this host together — they \
share one sidecar binary, and a host whose instances spoke two protocols is one \
no bundle describes. Run it without --server-id or --rust."
);
}
let Some(prior) = prior.as_ref() else {
bail!(
"nothing to update — no Rust deployment is recorded on this host (looked for {}). \
Run `install --game rust` first.",
record_path.display()
);
};
prior
.instances
.iter()
.map(|(id, i)| (id.clone(), PathBuf::from(&i.server_root)))
.collect()
}
};
// ── What to install ──────────────────────────────────────────────────────
let (bundle, bundle_url) = bundle::fetch_rust(cli.bundle.as_deref())?;
let asset = bundle.sidecar_asset()?.clone();
println!(
"\nRunic Gateway installer {} — Rust {} to bundle {} (protocol {}){}",
env!("CARGO_PKG_VERSION"),
if mode.is_update() {
"update"
} else {
"install"
},
bundle.bundle,
bundle.protocol,
if cli.verify {
" [--verify: nothing will be written]"
} else {
""
}
);
println!();
ui::row(
"Sidecar",
&format!(
"{:<24} protocol {}",
format!("rust-link {}", bundle.sidecar.tag),
bundle.sidecar.protocol
),
);
ui::row(
"Plugin",
&format!(
"{:<24} protocol {}",
format!("Rust-Plugins {}", bundle.payload.tag),
bundle.payload.protocol
),
);
// The plugin is fetched before anything is planned: its manifest is the last statement of the
// protocol to check, and a pair that disagrees must stop the run before any file moves.
let scratch = TempDir::new("runicgateway-rust")?;
let tarball = scratch.path().join(&bundle.payload.asset.name);
crate::net::download_verified(
&bundle.payload.asset.url,
&tarball,
&bundle.payload.asset.sha256,
)?;
let released = plugin::read_tarball(&tarball)?;
if released.manifest.version != bundle.payload.version {
ui::warn(&format!(
"the plugin tarball says version {} but bundle {} names {}. The checksum matched, so \
this is a labelling mismatch in the release rather than a wrong download.",
released.manifest.version, bundle.bundle, bundle.payload.version
));
}
if released.manifest.protocol != bundle.protocol {
bail!(
"the plugin in bundle {} declares protocol {}, and the bundle {}. The Rust game link \
has no version handshake, so a mismatched plugin mis-parses rather than being \
refused — refusing the pair here.",
bundle.bundle,
released.manifest.protocol,
bundle.protocol
);
}
// ── Plan every instance ──────────────────────────────────────────────────
let mut planned = Vec::new();
for (id, root) in &targets {
planned.push(plan_instance(
cli,
&layout,
prior.as_ref(),
id,
root,
&released,
)?);
}
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
print_plan(&layout, &planned, binary_action, &bundle);
if cli.verify {
println!(
"\nNothing was written. Re-run without --verify to {}.",
if mode.is_update() { "update" } else { "deploy" }
);
return Ok(());
}
preflight_writable(&layout)?;
// ── The sidecar binary, shared by every instance ─────────────────────────
let prepared = service::prepare(layout.relocated);
let binary_sha256 = if binary_action.writes() {
// Every instance runs this one file: on Windows it is locked while any of them runs, and on
// Linux replacing it leaves each one serving old code until restarted.
let mut running: Vec<String> = prior
.as_ref()
.map(|p| p.instances.keys().cloned().collect())
.unwrap_or_default();
running.extend(planned.iter().map(|p| p.id.clone()));
running.sort();
running.dedup();
service::stop_rust_instances(&prepared.manager, &running)?;
let sha = crate::sidecar::place(&asset, &layout.rust_sidecar_bin, scratch.path())?;
ui::ok(&format!(
"rust-link binary verified sha256 {}…",
&asset.sha256[..8.min(asset.sha256.len())]
));
sha
} else {
asset.sha256.trim().to_ascii_lowercase()
};
// ── Each instance ────────────────────────────────────────────────────────
let mut record = prior
.clone()
.unwrap_or_else(|| empty_record(&bundle, &bundle_url));
let mut handoffs = Vec::new();
for plan in &planned {
let (instance, doc, registered) = deploy_instance(
&layout,
&prepared,
&bundle,
&released,
plan,
binary_action.writes(),
prior
.as_ref()
.and_then(|p| p.instances.get(&plan.id))
.map(|i| i.plugin_config_written)
.unwrap_or(false),
)?;
record.instances.insert(plan.id.clone(), instance);
handoffs.push((plan.id.clone(), doc, registered, plan.running));
}
// Instances this run did not touch still run the binary it just replaced.
if binary_action.writes() {
if let Some(prior) = &prior {
for (id, instance) in &prior.instances {
if planned.iter().any(|p| &p.id == id) || instance.service.is_none() {
continue;
}
match service::restart_rust_instance(id) {
Ok(()) => ui::ok(&format!(
"restarted {} on the new binary",
service::rust_service_name(id)
)),
Err(e) => ui::warn(&format!("could not restart {id} on the new binary: {e}")),
}
}
}
}
// ── Record ───────────────────────────────────────────────────────────────
record.schema = SCHEMA;
record.installer = InstallerInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
};
record.updated = now_rfc3339();
record.bundle = BundleRef {
tag: bundle.bundle.clone(),
protocol: bundle.protocol,
url: bundle_url.clone(),
};
record.sidecar = component(
&bundle.sidecar.repo,
&bundle.sidecar.tag,
&bundle.sidecar.version,
"",
bundle.sidecar.protocol,
);
record.binary = BinaryRef {
path: layout.rust_sidecar_bin.display().to_string(),
sha256: binary_sha256,
};
record.plugin = component(
&bundle.payload.repo,
&bundle.payload.tag,
&bundle.payload.version,
&bundle.payload.commit,
bundle.payload.protocol,
);
record.service_user_created |= prepared.user_created;
match prior.as_ref() {
Some(previous) if previous.same_deployment_as(&record) => {
println!("\n {} unchanged", record_path.display());
}
_ => {
record.save(&record_path).with_context(|| {
format!(
"cannot write {} — run as root/Administrator, or set {} for a test run",
record_path.display(),
paths::STATE_DIR_ENV
)
})?;
println!("\n Recorded {}", record_path.display());
}
}
// ── What is left for the operator ────────────────────────────────────────
match mode {
Mode::Install => {
let host = crate::install::resolve_host(cli);
for (id, doc, registered, running) in &handoffs {
println!(
"{}",
sidecar::handoff(
id,
doc,
&host,
cli.site_url.as_deref(),
*registered,
*running
)
);
}
}
Mode::Update => {
let before = prior.as_ref().map(|p| p.bundle.clone());
match before {
Some(b) if b.tag == bundle.bundle => {
// The same bundle can still have written something: a plugin edited by hand is
// put back, which is what `doctor` tells an operator to run `update` for.
let restored = planned.iter().filter(|p| p.plugin_action.writes()).count();
if restored == 0 && !binary_action.writes() {
println!("\nAlready on bundle {} — nothing moved.", bundle.bundle)
} else {
println!(
"\nAlready on bundle {} — put back what no longer matched it (see above).",
bundle.bundle
)
}
}
Some(b) => {
println!(
"\nMoved {} instance(s) from bundle {} to {}.",
planned.len(),
b.tag,
bundle.bundle
);
if b.protocol != bundle.protocol {
ui::warn(&format!(
"The protocol moved from {} to {}. The website's Rust module must speak \
{} too, or it will be refused with 409 — update the site first.",
b.protocol, bundle.protocol, bundle.protocol
));
}
}
None => {}
}
}
}
Ok(())
}
/// Every decision for one instance, made without writing anything.
fn plan_instance(
cli: &Cli,
layout: &paths::Layout,
prior: Option<&RustRecord>,
id: &str,
root: &Path,
released: &plugin::Released,
) -> Result<Planned> {
let server = server::open(root)
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
let recorded = prior.and_then(|p| p.instances.get(id));
if let Some(instance) = recorded {
if Path::new(&instance.server_root) != server.path {
bail!(
"instance {id:?} is recorded for {}, not {}. An id names one server; uninstall it \
first (`uninstall --game rust --server-id {id}`) to move it.",
instance.server_root,
server.path.display()
);
}
}
if let Some((other, _)) = prior.and_then(|p| {
p.instances
.iter()
.find(|(other, i)| other.as_str() != id && Path::new(&i.server_root) == server.path)
}) {
bail!(
"{} is already installed as instance {other:?}. One server root has one plugin and one \
ServerId; run with --server-id {other}.",
server.path.display()
);
}
let plugin_config = plugin::read_config(&server.plugin_config_path())?;
let held_game = prior.map(|p| p.game_ports_except(id)).unwrap_or_default();
let game_port = match &plugin_config {
Some(view) => {
plugin::check_server_id(view, id, &server.plugin_config_path())?;
// A config without a Port gets the plugin's default.
let port = view.port.unwrap_or(GAME_PORT_START);
if held_game.contains(&port) {
bail!(
"{} says the plugin dials port {port}, which another Rust instance on this host \
already listens on. Give it a free Port and run again.",
server.plugin_config_path().display()
);
}
port
}
None => sidecar::choose_port(GAME_PORT_START, &held_game, recorded.map(|i| i.game_port))?,
};
let held_web = prior.map(|p| p.web_ports_except(id)).unwrap_or_default();
if let Some(port) = cli.web_port {
if held_web.contains(&port) {
bail!("--web-port {port} is already another Rust instance's web port");
}
}
let web_port = match (cli.web_port, recorded) {
(Some(port), _) => port,
(None, Some(instance)) => instance.web_port,
(None, None) => sidecar::choose_port(WEB_PORT_START, &held_web, None)?,
};
let plugin_action = match std::fs::read(server.plugin_path()) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == released.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
};
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
running: server::is_running(&server.path),
config_exists: config_path.exists(),
config_path,
db_path: layout.rust_db(id),
server,
plugin_config,
plugin_action,
game_port,
web_port,
})
}
fn print_plan(
layout: &paths::Layout,
planned: &[Planned],
binary: BinaryAction,
bundle: &RustBundle,
) {
ui::row(
"binary",
&format!("{} {}", layout.rust_sidecar_bin.display(), binary.label()),
);
for p in planned {
ui::heading(&format!("Instance {}", p.id));
ui::row(
"server",
&format!(
"{} ({}, {})",
p.server.path.display(),
p.server.framework.as_str(),
if p.running {
"running — the plugin loads at once"
} else {
"not running — the plugin loads at next boot"
}
),
);
ui::row(
"plugin",
&format!(
"{} {}",
p.server.plugin_path().display(),
p.plugin_action.label()
),
);
ui::row(
"plugin config",
&match &p.plugin_config {
Some(_) => format!(
"{} kept (the website's)",
p.server.plugin_config_path().display()
),
None => format!(
"{} written: ServerId {}, Port {}",
p.server.plugin_config_path().display(),
p.id,
p.game_port
),
},
);
ui::row(
"sidecar config",
&format!(
"{} {}",
p.config_path.display(),
if p.config_exists { "kept" } else { "written" }
),
);
ui::row(
"ports",
&format!(
"game 127.0.0.1:{} web 127.0.0.1:{}",
p.game_port, p.web_port
),
);
let missing = plugin::missing_plugins(
&p.server.plugins_dir(),
&bundle.payload.compat.requires_plugins,
);
if !missing.is_empty() {
ui::warn(&format!(
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
The installer does not fetch third-party plugins (D153).",
missing.join(", "),
p.server.plugins_dir().display(),
if missing.len() == 1 { "it" } else { "them" },
if missing.len() == 1 { "it" } else { "them" },
));
}
}
}
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
fn deploy_instance(
layout: &paths::Layout,
prepared: &service::Prepared,
bundle: &RustBundle,
released: &plugin::Released,
plan: &Planned,
binary_changed: bool,
plugin_config_written_before: bool,
) -> Result<(Instance, sidecar::ConfigDoc, bool)> {
ui::heading(&format!("Instance {}", plan.id));
let data_dir = layout.rust_data_dir(&plan.id);
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("cannot create {}", data_dir.display()))?;
// The plugin's config, once, and only if it does not exist (§34.2.3).
let plugin_config_path = plan.server.plugin_config_path();
let wrote_plugin_config = plan.plugin_config.is_none();
if wrote_plugin_config {
if let Some(parent) = plugin_config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
write_atomic(
&plugin_config_path,
plugin::initial_config(&plan.id, plan.game_port).as_bytes(),
)?;
ui::ok(&format!("wrote {}", plugin_config_path.display()));
}
// The sidecar's config, once; the token is the sidecar's to generate.
if !plan.config_exists {
if let Some(parent) = plan.config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
write_atomic(
&plan.config_path,
sidecar::instance_config(&plan.id, plan.game_port, plan.web_port, &plan.db_path)
.as_bytes(),
)?;
}
let doc = sidecar::print_config(&layout.rust_sidecar_bin, &plan.config_path)?;
if doc.protocol != bundle.protocol {
bail!(
"the installed rust-link sidecar reports protocol {} but bundle {} was composed at {}. \
Refusing to register a service for a pair that was never checked together.",
doc.protocol,
bundle.bundle,
bundle.protocol
);
}
if doc.version != bundle.sidecar.version {
ui::warn(&format!(
"the installed binary reports version {} but bundle {} names {}. The checksum matched, \
so this is a labelling mismatch in the release rather than a wrong download.",
doc.version, bundle.bundle, bundle.sidecar.version
));
}
// The sidecar's server_id is a cross-check against the plugin's hello. An edited config that
// names another server makes every hello log a disagreement, so it is said here, once.
if !doc.game.server_id.is_empty() && doc.game.server_id != plan.id {
ui::warn(&format!(
"{} cross-checks server id {:?}, but this instance is {:?}; the sidecar will log a \
disagreement on every connect.",
doc.config_path, doc.game.server_id, plan.id
));
}
// An existing sidecar config is the operator's; what it says is what runs. Say so when it
// disagrees with the plugin, because that pair never meets.
if let Some(port) = sidecar::port_of(&doc.game.bind) {
if port != plan.game_port {
ui::warn(&format!(
"{} listens for the plugin on {port}, but the plugin dials {}. Make them agree, or \
the plugin never connects.",
doc.config_path, plan.game_port
));
}
}
ui::row(
"sidecar config",
&format!(
"{} {}",
doc.config_path,
if doc.token_generated {
"token generated"
} else {
"token kept"
}
),
);
service::protect_config(
&plan.config_path,
&data_dir,
prepared.user.as_deref(),
layout.relocated,
)?;
let mut outcome = service::register_rust(
prepared,
layout,
&plan.id,
&plan.config_path,
binary_changed,
)?;
service::grant_service_access(&plan.config_path, &data_dir, &outcome)?;
service::start_registered(&mut outcome, &layout.rust_sidecar_bin, &plan.config_path)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
name,
user,
state,
..
} => {
ui::row("service", &format!("{name} {state}"));
Some(ServiceRecord {
kind: (*kind).to_string(),
name: name.clone(),
unit_path: None,
user: user.clone(),
// The account is shared with every instance and possibly with ServUO's sidecar, so
// no single instance's removal may delete it (see `RustRecord`).
user_created: false,
})
}
service::Outcome::Skipped { reason, manual } => {
ui::warn(&format!(
"service NOT REGISTERED — {reason}.\n The binary and config are in place; nothing is \
running them. Do this by hand:"
));
print!("{manual}");
None
}
};
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
let plugin_path = plan.server.plugin_path();
if plan.plugin_action.writes() {
std::fs::create_dir_all(plan.server.plugins_dir())
.with_context(|| format!("cannot create {}", plan.server.plugins_dir().display()))?;
// Overwritten in place, NOT `write_atomic`. Oxide and Carbon watch the plugins directory
// and reload on a CHANGE; `write_atomic` removes the old file and renames a `.tmp` over it,
// which both frameworks see as a delete — they unload the bridge — and a rename they
// ignore, so the new file is never loaded. On a running server that was an `update` that
// silently took the bridge down until the next boot (the phase 18 walk, on all three
// instances and both frameworks). A write in place is what an operator's `cp` does, and
// it reloads. A crash mid-write leaves a file that fails to compile; `doctor` reports it
// as not the deployed file and `update` writes it again.
std::fs::write(&plugin_path, &released.source)
.with_context(|| format!("cannot write {}", plugin_path.display()))?;
ui::ok(&format!(
"plugin {} {}",
if plan.plugin_action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
plugin_path.display()
));
}
Ok((
Instance {
server_root: plan.server.path.display().to_string(),
framework: plan.server.framework.as_str().to_string(),
plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(),
plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port,
web_port: sidecar::port_of(&doc.web.bind).unwrap_or(plan.web_port),
config_path: doc.config_path.clone(),
db_path: doc.store.path.clone(),
service: service_record,
},
doc,
outcome.registered(),
))
}
fn component(repo: &str, tag: &str, version: &str, commit: &str, protocol: u32) -> ComponentRecord {
ComponentRecord {
repo: repo.to_string(),
tag: tag.to_string(),
version: version.to_string(),
commit: commit.to_string(),
protocol,
}
}
fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
RustRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
},
updated: String::new(),
bundle: BundleRef {
tag: bundle.bundle.clone(),
protocol: bundle.protocol,
url: url.to_string(),
},
sidecar: component("", "", "", "", 0),
binary: BinaryRef {
path: String::new(),
sha256: String::new(),
},
plugin: component("", "", "", "", 0),
service_user_created: false,
instances: Default::default(),
extra: Default::default(),
}
}
/// Fails before anything is written if this process cannot write where it must.
fn preflight_writable(layout: &paths::Layout) -> Result<()> {
let bin_dir = layout
.rust_sidecar_bin
.parent()
.unwrap_or(&layout.rust_sidecar_bin)
.to_path_buf();
for dir in [
layout.rust_config_dir(),
layout.data_dir.join("rust"),
bin_dir,
] {
std::fs::create_dir_all(&dir)
.and_then(|_| {
let probe = dir.join(".runicgateway-write-test");
std::fs::write(&probe, b"")?;
std::fs::remove_file(&probe)
})
.with_context(|| {
format!(
"cannot write to {}. Run as {}, or set {} for a test run (no service is \
registered then).",
dir.display(),
if cfg!(windows) {
"Administrator"
} else {
"root (sudo)"
},
paths::STATE_DIR_ENV
)
})?;
}
Ok(())
}

34
src/rustgame/mod.rs Normal file
View File

@@ -0,0 +1,34 @@
//! `--game rust`: the installer's second game (docs/modules/rust/PLAN.md §34, R4).
//!
//! The same four verbs as ServUO and the same bundle discipline — an exact, protocol-checked pair
//! from CI, never "latest of each" — over a different shape of host. A Rust host commonly runs
//! several servers, and R8 gives each its own sidecar, so where ServUO has one deployment this has
//! **named instances** (D148): one server root, one plugin, and one sidecar service per
//! `--server-id`, all sharing one binary and one bundle.
//!
//! Kept in its own module rather than threaded through the ServUO pipeline: the two share the
//! bundle reader, the download and checksum code, the service machinery and the report format, and
//! share nothing about what is deployed or where. ServUO's files and record are untouched by any
//! of this.
mod doctor;
mod install;
mod plugin;
mod record;
mod server;
mod sidecar;
mod uninstall;
use anyhow::Result;
use crate::cli::{Cli, Command};
/// Runs one verb for Rust. The `i32` is the exit code, as for ServUO.
pub fn run(cli: &Cli, command: Command) -> Result<i32> {
match command {
Command::Install => install::deploy(cli, crate::install::Mode::Install).map(|()| 0),
Command::Update => install::deploy(cli, crate::install::Mode::Update).map(|()| 0),
Command::Doctor => doctor::run(cli),
Command::Uninstall => uninstall::run(cli),
}
}

238
src/rustgame/plugin.rs Normal file
View File

@@ -0,0 +1,238 @@
//! The plugin: its released tarball, and its config in the server root.
//!
//! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle
//! moves, and is removed by `uninstall`.
//! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the
//! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist
//! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose
//! `ServerId` is not this instance's refuses the run, naming both. That is the same rule the
//! ServUO overlay follows for `Bridge.cfg`.
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::util::sha256_bytes;
/// The fixed top directory inside the plugin tarball (Rust-Plugins' release.yml).
const PREFIX: &str = "runicgateway-rust-plugin";
/// `runicgateway-rust-plugin/manifest.json`, which the release folds from `overlay.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub version: String,
pub protocol: u32,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
/// The released plugin, read out of its tarball.
#[derive(Debug, Clone)]
pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
pub sha256: String,
}
/// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree.
pub fn read_tarball(path: &Path) -> Result<Released> {
let file =
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None;
for entry in archive
.entries()
.context("the plugin tarball is not a tar.gz")?
{
let mut entry = entry.context("the plugin tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/");
let slot = if name == format!("{PREFIX}/manifest.json") {
&mut manifest
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
&mut source
} else {
continue;
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
*slot = Some(bytes);
}
let manifest = manifest
.ok_or_else(|| anyhow::anyhow!("the plugin tarball has no {PREFIX}/manifest.json"))?;
let source = source.ok_or_else(|| {
anyhow::anyhow!(
"the plugin tarball has no {PREFIX}/{}",
super::server::PLUGIN_FILE
)
})?;
let manifest: Manifest =
serde_json::from_slice(&manifest).context("the plugin's manifest.json is unreadable")?;
let sha256 = sha256_bytes(&source);
if let Some(declared) = manifest.files.get(super::server::PLUGIN_FILE) {
if !declared.eq_ignore_ascii_case(&sha256) {
bail!(
"the plugin in the tarball does not match its own manifest (sha256 {sha256}, \
manifest says {declared}). The tarball matched the bundle's checksum, so the \
release itself is inconsistent — refusing it."
);
}
}
Ok(Released {
manifest,
source,
sha256,
})
}
/// What the plugin's config says, as far as the installer cares.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigView {
pub server_id: Option<String>,
pub port: Option<u16>,
}
/// Reads `RunicGateway.json`, or `None` when the plugin has never written one.
pub fn read_config(path: &Path) -> Result<Option<ConfigView>> {
if !path.exists() {
return Ok(None);
}
let text =
std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let value: serde_json::Value = serde_json::from_str(&text).with_context(|| {
format!(
"{} is not valid JSON. The plugin will not load it either; fix or remove it.",
path.display()
)
})?;
Ok(Some(ConfigView {
server_id: value
.get("ServerId")
.and_then(|v| v.as_str())
.map(str::to_string),
port: value
.get("Port")
.and_then(|v| v.as_u64())
.and_then(|p| u16::try_from(p).ok()),
}))
}
/// The config the installer writes when there is none: just the two keys it decides. The plugin
/// fills in every other key on its first load, with its own defaults (§34.2.3).
pub fn initial_config(server_id: &str, port: u16) -> String {
let doc = serde_json::json!({ "ServerId": server_id, "Port": port });
let mut text = serde_json::to_string_pretty(&doc).unwrap_or_default();
text.push('\n');
text
}
/// Refuses an existing config that names another server.
pub fn check_server_id(view: &ConfigView, server_id: &str, path: &Path) -> Result<()> {
match view.server_id.as_deref() {
Some(id) if id == server_id => Ok(()),
Some(id) => bail!(
"{} already names this server {id:?}, and this run was asked to install {server_id:?}.\n \
The website locks a server's id once it has seen it, and this installer never rewrites \
the plugin's config. Run again with --server-id {id}, or — if this server really is \
new to the site — remove that file and run again.",
path.display()
),
// A config with no ServerId gets the plugin's default ("main") on load. Treat that as what
// it will become rather than as agreement.
None => {
if server_id == "main" {
Ok(())
} else {
bail!(
"{} has no ServerId, so the plugin will call this server \"main\"; this run was \
asked to install {server_id:?}. Add \"ServerId\": \"{server_id}\" to it, or run \
with --server-id main.",
path.display()
)
}
}
}
}
/// Which of `required` are missing from a plugins directory. Reported, never installed (D153).
pub fn missing_plugins(plugins_dir: &Path, required: &[String]) -> Vec<String> {
required
.iter()
.filter(|name| !plugins_dir.join(format!("{name}.cs")).is_file())
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
#[test]
fn the_first_config_holds_only_what_the_installer_decides() {
let text = initial_config("alpha", 7800);
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value.as_object().unwrap().len(), 2);
assert_eq!(value["ServerId"], "alpha");
assert_eq!(value["Port"], 7800);
}
#[test]
fn an_existing_config_for_another_server_refuses_the_run() {
let path = Path::new("oxide/config/RunicGateway.json");
let view = ConfigView {
server_id: Some("rust-oxide".into()),
port: Some(7799),
};
assert!(check_server_id(&view, "rust-oxide", path).is_ok());
let err = check_server_id(&view, "alpha", path)
.unwrap_err()
.to_string();
assert!(
err.contains("\"rust-oxide\"") && err.contains("\"alpha\""),
"{err}"
);
}
#[test]
fn a_config_without_an_id_is_main() {
let path = Path::new("x.json");
let view = ConfigView {
server_id: None,
port: None,
};
assert!(check_server_id(&view, "main", path).is_ok());
assert!(check_server_id(&view, "alpha", path).is_err());
}
#[test]
fn the_plugins_config_is_read_back_without_the_rest_of_its_keys() {
let dir = TempDir::new("rg-rust-cfg").unwrap();
let path = dir.path().join("RunicGateway.json");
assert_eq!(read_config(&path).unwrap(), None);
// What the rig's plugin actually wrote, 2026-09-26.
std::fs::write(
&path,
r#"{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "rust-oxide",
"EventsEnabled": true, "MapMaxBases": 2000 }"#,
)
.unwrap();
let view = read_config(&path).unwrap().unwrap();
assert_eq!(view.server_id.as_deref(), Some("rust-oxide"));
assert_eq!(view.port, Some(7799));
}
#[test]
fn a_missing_required_plugin_is_named() {
let dir = TempDir::new("rg-rust-req").unwrap();
std::fs::write(dir.path().join("Kits.cs"), b"").unwrap();
let missing = missing_plugins(dir.path(), &["Kits".into(), "ZoneManager".into()]);
assert_eq!(missing, ["ZoneManager"]);
}
}

208
src/rustgame/record.rs Normal file
View File

@@ -0,0 +1,208 @@
//! `rust/install.json` — what this host has deployed for Rust.
//!
//! Its own file rather than a list added to ServUO's `install.json` (docs/modules/rust/PLAN.md
//! §34.4): a host running both games has two records that cannot corrupt each other, and an
//! installer that predates Rust never sees this one at all.
//!
//! One bundle and one sidecar binary for the host, and a map of instances. `update` moves every
//! instance together because they share the binary (§34.4), which is why the bundle is recorded once
//! rather than per instance. Like `install.json`, **the auth tokens are not here** — they live in
//! each instance's `sidecar.toml` — and fields this build has no name for are carried through.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::record::{BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
use crate::util::write_atomic;
/// The shape of this document.
pub const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustRecord {
pub schema: u32,
pub installer: InstallerInfo,
pub updated: String,
pub bundle: BundleRef,
pub sidecar: ComponentRecord,
pub binary: BinaryRef,
pub plugin: ComponentRecord,
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
#[serde(default)]
pub service_user_created: bool,
pub instances: BTreeMap<String, Instance>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
/// A released component, as installed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ComponentRecord {
pub repo: String,
pub tag: String,
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit: String,
pub protocol: u32,
}
/// One Rust server and its sidecar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Instance {
pub server_root: String,
/// `oxide` or `carbon`, as detected when this was recorded.
pub framework: String,
pub plugin_path: String,
/// What was deployed, so `doctor` can tell an edited plugin file from the release's.
pub plugin_sha256: String,
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
pub plugin_config: String,
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
#[serde(default)]
pub plugin_config_written: bool,
/// The loopback port the plugin dials — the plugin config's `Port`.
pub game_port: u16,
/// The port the website reaches this instance's sidecar on.
pub web_port: u16,
pub config_path: String,
pub db_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<ServiceRecord>,
}
impl RustRecord {
/// Everything but the timestamp, so a second run with nothing to do writes nothing.
pub fn same_deployment_as(&self, other: &Self) -> bool {
let mut a = self.clone();
let mut b = other.clone();
a.updated.clear();
b.updated.clear();
a == b
}
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let record = serde_json::from_str(&body).with_context(|| {
format!(
"{} exists but is not a record this installer understands. \
Move it aside to start over, or install a newer installer.",
path.display()
)
})?;
Ok(Some(record))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
let mut body =
serde_json::to_string_pretty(self).context("cannot serialize rust/install.json")?;
body.push('\n');
write_atomic(path, body.as_bytes())
}
/// Game ports held by instances other than `except`.
pub fn game_ports_except(&self, except: &str) -> Vec<u16> {
self.instances
.iter()
.filter(|(id, _)| id.as_str() != except)
.map(|(_, i)| i.game_port)
.collect()
}
/// Web ports held by instances other than `except`.
pub fn web_ports_except(&self, except: &str) -> Vec<u16> {
self.instances
.iter()
.filter(|(id, _)| id.as_str() != except)
.map(|(_, i)| i.web_port)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
pub fn sample() -> RustRecord {
let instance = |root: &str, game: u16, web: u16| Instance {
server_root: root.into(),
framework: "oxide".into(),
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
plugin_sha256: "ab".repeat(32),
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
plugin_config_written: true,
game_port: game,
web_port: web,
config_path: "/etc/runicgateway/rust/x.toml".into(),
db_path: "/var/lib/runicgateway/rust/x/rust-link.db".into(),
service: None,
};
RustRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.3.0".into(),
},
updated: "2026-09-26T00:00:00Z".into(),
bundle: BundleRef {
tag: "2026.09.26".into(),
protocol: 12,
url: "https://example/v2/rust/current.json".into(),
},
sidecar: ComponentRecord {
repo: "RunicGateway/Rust-Link".into(),
tag: "v0.1.0".into(),
version: "0.1.0".into(),
commit: String::new(),
protocol: 12,
},
binary: BinaryRef {
path: "/usr/bin/runicgateway-rust-link".into(),
sha256: "cd".repeat(32),
},
plugin: ComponentRecord {
repo: "RunicGateway/Rust-Plugins".into(),
tag: "v0.1.0".into(),
version: "0.1.0".into(),
commit: "abc".into(),
protocol: 12,
},
service_user_created: true,
instances: BTreeMap::from([
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
("beta".to_string(), instance("/srv/b", 7800, 8091)),
]),
extra: BTreeMap::new(),
}
}
#[test]
fn a_record_round_trips_and_holds_no_token() {
let dir = TempDir::new("rg-rust-record").unwrap();
let path = dir.path().join("rust").join("install.json");
let record = sample();
record.save(&path).unwrap();
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("auth_token") && !text.contains("token\""));
}
#[test]
fn ports_held_by_other_instances_exclude_the_one_being_installed() {
let record = sample();
assert_eq!(record.game_ports_except("alpha"), [7800]);
assert_eq!(record.web_ports_except("beta"), [8090]);
}
}

219
src/rustgame/server.rs Normal file
View File

@@ -0,0 +1,219 @@
//! A Rust server root: that it is one, and which modding framework it runs.
//!
//! **The framework is detected, never asked** (docs/modules/rust/PLAN.md §34.2.3). The plugin is one
//! file that runs unchanged on Oxide and on Carbon (R19); what differs is only the directory it goes
//! in, and the server itself already says which framework it has. Asking would be one more answer an
//! operator could get wrong.
//!
//! The marker files were read off the two rigs (2026-09-26), one per framework, before this code
//! relied on them:
//!
//! - **Oxide:** `RustDedicated_Data/Managed/Oxide.Rust.dll`.
//! - **Carbon:** `carbon/managed/Carbon.dll`.
//!
//! Both present is refused: Oxide and Carbon cannot run in one install (R21), so a tree carrying both
//! is one mid-migration, and guessing would put the plugin where one of them never looks.
//!
//! **Carbon's directories are its defaults.** The plan expected a moved plugins directory to be
//! readable from `carbon/config.json`; on 2.0.259, the build R19 was proven on, that file has no
//! folder keys at all — Carbon's directories move only by a launch argument, which an installer
//! cannot see. `carbon/plugins/` and `carbon/configs/` are what every stock install uses.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// Which framework loads the plugin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
Oxide,
Carbon,
}
impl Framework {
pub fn as_str(self) -> &'static str {
match self {
Self::Oxide => "oxide",
Self::Carbon => "carbon",
}
}
/// The file whose presence says this framework is installed.
fn marker(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root
.join("RustDedicated_Data")
.join("Managed")
.join("Oxide.Rust.dll"),
Self::Carbon => root.join("carbon").join("managed").join("Carbon.dll"),
}
}
/// Where the framework loads plugins from.
pub fn plugins_dir(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root.join("oxide").join("plugins"),
Self::Carbon => root.join("carbon").join("plugins"),
}
}
/// Where the framework keeps each plugin's config. Oxide says `config`, Carbon `configs`.
pub fn config_dir(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root.join("oxide").join("config"),
Self::Carbon => root.join("carbon").join("configs"),
}
}
}
/// A validated Rust server root.
#[derive(Debug, Clone)]
pub struct RustServer {
pub path: PathBuf,
pub framework: Framework,
}
/// The file this installer owns in a server root: the plugin.
pub const PLUGIN_FILE: &str = "RunicGateway.cs";
/// The plugin's config, which is the website's once written (§34.2.3).
pub const PLUGIN_CONFIG: &str = "RunicGateway.json";
impl RustServer {
pub fn plugins_dir(&self) -> PathBuf {
self.framework.plugins_dir(&self.path)
}
pub fn plugin_path(&self) -> PathBuf {
self.plugins_dir().join(PLUGIN_FILE)
}
pub fn plugin_config_path(&self) -> PathBuf {
self.framework.config_dir(&self.path).join(PLUGIN_CONFIG)
}
}
/// Validates `path` as a Rust server root and detects its framework.
pub fn open(path: &Path) -> Result<RustServer> {
let path = std::fs::canonicalize(path)
.map(crate::servuo::strip_extended_prefix)
.with_context(|| format!("{} does not exist", path.display()))?;
if !path.is_dir() {
bail!("{} is not a directory", path.display());
}
if !path.join("RustDedicated").is_file() && !path.join("RustDedicated.exe").is_file() {
bail!(
"{} is not a Rust server root: it has no RustDedicated or RustDedicated.exe",
path.display()
);
}
let oxide = Framework::Oxide.marker(&path).is_file();
let carbon = Framework::Carbon.marker(&path).is_file();
let framework = match (oxide, carbon) {
(true, false) => Framework::Oxide,
(false, true) => Framework::Carbon,
(true, true) => bail!(
"{} has both Oxide ({}) and Carbon ({}). They cannot run in one install, so this tree \
is part-way through a migration. Remove the one you are leaving, then run again.",
path.display(),
Framework::Oxide.marker(&path).display(),
Framework::Carbon.marker(&path).display()
),
(false, false) => bail!(
"{} has neither Oxide nor Carbon installed, and the bridge is a plugin for one of \
them. Install one (looked for {} and {}), start the server once so it lays out its \
directories, and run this again.",
path.display(),
Framework::Oxide.marker(&path).display(),
Framework::Carbon.marker(&path).display()
),
};
Ok(RustServer { path, framework })
}
/// Whether a RustDedicated is running out of this root.
///
/// Informational, unlike ServUO's refusal: both frameworks load and unload a plugin file while the
/// server runs, so an install under a live server is simply one that takes effect at once (§34.2.3).
/// `doctor` also uses it, to tell a server that is down from one whose plugin is silent.
pub fn is_running(root: &Path) -> bool {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let root = normalize(&root.to_string_lossy());
system.processes().values().any(|process| {
let exe = process
.exe()
.map(|p| normalize(&p.to_string_lossy()))
.unwrap_or_default();
exe.starts_with(&root) && exe.contains("rustdedicated")
})
}
fn normalize(s: &str) -> String {
s.to_lowercase().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn server(dir: &Path, frameworks: &[Framework]) {
std::fs::write(dir.join("RustDedicated"), b"").unwrap();
for f in frameworks {
let marker = f.marker(dir);
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(marker, b"").unwrap();
}
}
#[test]
fn each_framework_is_detected_and_places_the_plugin_its_own_way() {
for (f, plugins, config) in [
(Framework::Oxide, "oxide/plugins", "oxide/config"),
(Framework::Carbon, "carbon/plugins", "carbon/configs"),
] {
let dir = TempDir::new("rg-rust-root").unwrap();
server(dir.path(), &[f]);
let root = open(dir.path()).unwrap();
assert_eq!(root.framework, f);
let rel = |p: PathBuf| {
p.strip_prefix(&root.path)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
};
assert_eq!(rel(root.plugins_dir()), plugins);
assert_eq!(
rel(root.plugin_config_path()),
format!("{config}/RunicGateway.json")
);
}
}
#[test]
fn both_frameworks_are_refused_and_so_is_neither() {
let dir = TempDir::new("rg-rust-both").unwrap();
server(dir.path(), &[Framework::Oxide, Framework::Carbon]);
let err = open(dir.path()).unwrap_err().to_string();
assert!(
err.contains("both Oxide") && err.contains("Carbon"),
"{err}"
);
let dir = TempDir::new("rg-rust-none").unwrap();
server(dir.path(), &[]);
let err = open(dir.path()).unwrap_err().to_string();
assert!(err.contains("neither Oxide nor Carbon"), "{err}");
}
#[test]
fn a_directory_without_the_game_is_not_a_server_root() {
let dir = TempDir::new("rg-rust-empty").unwrap();
let err = open(dir.path()).unwrap_err().to_string();
assert!(err.contains("RustDedicated"), "{err}");
}
}

315
src/rustgame/sidecar.rs Normal file
View File

@@ -0,0 +1,315 @@
//! One instance's rust-link sidecar: its config, and asking the installed binary about it.
//!
//! **The installer writes each instance's `sidecar.toml` itself, once.** ServUO's sidecar is one per
//! host and its defaults are right as shipped; a Rust host runs several, and every one of them needs
//! its own game port, web port and database, which no default can give it. So the first run writes
//! the file with those three decided and the token blank, and `--print-config` then generates the
//! token and saves it into that same file — the sidecar still owns the secret, exactly as for ServUO.
//! An existing file is never rewritten: after the first run it is the operator's, and the ports it
//! names are the ones recorded.
//!
//! The same rule as `crate::sidecar` holds for the token: it crosses one process boundary, here,
//! and goes to the terminal and nowhere else.
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
/// What `rust-link-sidecar --print-config` prints (Rust-Link `sidecar/src/config.rs::describe`).
#[derive(Debug, Clone, Deserialize)]
pub struct ConfigDoc {
pub component: String,
pub version: String,
pub protocol: u32,
pub config_path: String,
pub token_generated: bool,
pub game: GameDoc,
pub web: WebDoc,
pub store: StoreDoc,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GameDoc {
pub bind: String,
#[serde(default)]
pub server_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebDoc {
pub bind: String,
/// **A secret.** Printed in the handoff and recorded nowhere.
pub auth_token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StoreDoc {
pub path: String,
}
/// The first `sidecar.toml` for an instance. Pure, so its content is a test.
///
/// Paths are TOML **literal** strings (single quotes): a Windows path is all backslashes, and a basic
/// string would read each one as an escape. A literal string cannot hold a single quote, and neither
/// a server id nor any path this installer builds contains one.
pub fn instance_config(server_id: &str, game_port: u16, web_port: u16, db: &Path) -> String {
format!(
"# rust-link sidecar for Rust server {server_id:?}.\n\
#\n\
# Written once by the Runic Gateway installer, which decided the ports and the database\n\
# below; it is never rewritten, so it is yours to edit from here. The token is generated\n\
# by the sidecar on first start and saved into this file.\n\
\n\
[game]\n\
# Where the plugin dials in. Loopback: there is no token on this link.\n\
bind = '127.0.0.1:{game_port}'\n\
# Cross-checked against the serverId the plugin announces.\n\
server_id = '{server_id}'\n\
\n\
[web]\n\
# Where the website reaches this sidecar. Put a TLS proxy in front to reach it from\n\
# another machine (docs/rust-link/INSTALL.md).\n\
bind = '127.0.0.1:{web_port}'\n\
auth_token = \"\"\n\
\n\
[store]\n\
path = '{db}'\n",
db = db.display(),
)
}
/// Runs the installed binary's `--print-config` against one instance's config, provisioning the
/// token on first use. **Must not print or attach the child's stdout** — it holds the token.
pub fn print_config(binary: &Path, config: &Path) -> Result<ConfigDoc> {
let output = std::process::Command::new(binary)
.arg("--print-config")
.arg("--config")
.arg(config)
// An operator's shell may export RUSTLINK_* while testing; an installed instance is
// described by its file alone, so none of them may leak into this read.
.env_remove("RUSTLINK_CONFIG")
.env_remove("RUSTLINK_GAME_BIND")
.env_remove("RUSTLINK_WEB_BIND")
.env_remove("RUSTLINK_WEB_TOKEN")
.env_remove("RUSTLINK_DB_PATH")
.env_remove("RUSTLINK_SERVER_ID")
.output()
.with_context(|| {
format!(
"cannot run {} --print-config — the binary cannot execute here",
binary.display()
)
})?;
if !output.status.success() {
let reason = String::from_utf8_lossy(&output.stderr)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("(nothing on stderr)")
.to_string();
bail!(
"{} --print-config --config {} failed: {reason}",
binary.display(),
config.display()
);
}
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
"the rust-link sidecar's --print-config output is not the document this installer \
expects. It is not shown here because it contains the auth token.",
)?;
if doc.component != "rust-link-sidecar" {
bail!(
"the binary at {} identifies itself as {:?}, not rust-link-sidecar",
binary.display(),
doc.component
);
}
if doc.web.auth_token.trim().is_empty() {
bail!(
"the sidecar reported an empty auth token from {}; refusing to continue",
doc.config_path
);
}
Ok(doc)
}
/// The port half of a bind.
pub fn port_of(bind: &str) -> Option<u16> {
bind.rsplit_once(':').and_then(|(_, p)| p.parse().ok())
}
/// The lowest port from `start` that no recorded instance holds and nothing on this host is bound
/// to. `keep`, when given, is the port this instance already has, which is always kept.
pub fn choose_port(start: u16, held: &[u16], keep: Option<u16>) -> Result<u16> {
if let Some(port) = keep {
return Ok(port);
}
(start..=u16::MAX)
.take(1000)
.find(|p| !held.contains(p) && std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok())
.ok_or_else(|| anyhow::anyhow!("no free port found in the 1000 from {start}"))
}
/// The end-of-run block for one instance: what `/admin/rust/servers` asks for.
///
/// It says what the run did, not what it hopes: nothing here has seen the plugin connect — on a
/// stopped server it has not even loaded — so the first line never claims a link. `doctor` is the
/// command that checks one (D156).
pub fn handoff(
server_id: &str,
doc: &ConfigDoc,
host: &str,
site_url: Option<&str>,
registered: bool,
running: bool,
) -> String {
let port = port_of(&doc.web.bind)
.map(|p| p.to_string())
.unwrap_or_else(|| doc.web.bind.clone());
let site = site_url
.map(|s| s.trim_end_matches('/').to_string())
.unwrap_or_else(|| "https://<your-site>".to_string());
let loopback = doc.web.bind.starts_with("127.") || doc.web.bind.starts_with("[::1]");
format!(
"\nRust server {server_id:?} is set up. {when}\n\n\
One manual step remains — add it to the website:\n\n \
Server id {server_id}\n \
Sidecar URL http://{host}:{port}\n \
Auth token {token}\n \
(also in {config})\n \
Protocol {protocol}\n\n\
Add these at {site}/admin/rust/servers\n{loopback_note}{service_note}",
token = doc.web.auth_token,
config = doc.config_path,
protocol = doc.protocol,
when = if running {
format!(
"The plugin loads now; `doctor --game rust --server-id {server_id}` confirms it connected."
)
} else {
"The plugin connects when the server next starts.".to_string()
},
loopback_note = if loopback {
format!(
"\nThe sidecar listens on {} only. If the website runs on another machine, put a \
TLS proxy in\nfront of it and give the site the proxy's URL \
(docs/rust-link/INSTALL.md).\n",
doc.web.bind
)
} else {
String::new()
},
service_note = if registered {
""
} else {
"\nNo service was registered, so nothing is listening yet — see the steps above.\n"
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_instance_config_parses_and_keeps_a_windows_path_intact() {
let db = Path::new(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db");
let text = instance_config("alpha", 7800, 8091, db);
let value: toml_lite::Doc = toml_lite::parse(&text);
assert_eq!(value.get("game", "bind"), Some("127.0.0.1:7800"));
assert_eq!(value.get("game", "server_id"), Some("alpha"));
assert_eq!(value.get("web", "bind"), Some("127.0.0.1:8091"));
assert_eq!(value.get("web", "auth_token"), Some(""));
assert_eq!(
value.get("store", "path"),
Some(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db")
);
}
#[test]
fn the_print_config_document_parses() {
// What Rust-Link v0.1.0 prints (sidecar/src/config.rs::describe).
let doc: ConfigDoc = serde_json::from_str(
r#"{ "component": "rust-link-sidecar", "version": "0.1.0", "protocol": 12,
"config_path": "/etc/runicgateway/rust/alpha.toml", "config_created": false,
"token_generated": true,
"game": { "bind": "127.0.0.1:7800", "server_id": "alpha" },
"web": { "bind": "127.0.0.1:8091", "ws_path": "/ws", "auth_required": true,
"auth_token": "t0k" },
"store": { "path": "/var/lib/runicgateway/rust/alpha/rust-link.db" } }"#,
)
.unwrap();
assert_eq!(port_of(&doc.web.bind), Some(8091));
let text = handoff("alpha", &doc, "rust.example", None, true, false);
for needle in [
"alpha",
"http://rust.example:8091",
"t0k",
"/admin/rust/servers",
"12",
] {
assert!(text.contains(needle), "{needle} missing from:\n{text}");
}
assert!(text.contains("TLS proxy"), "{text}");
// D156: never a claim about a link nothing has seen.
assert!(!text.contains("is connected"), "{text}");
assert!(
text.contains("The plugin connects when the server next starts."),
"{text}"
);
let running = handoff("alpha", &doc, "rust.example", None, true, true);
assert!(!running.contains("is connected"), "{running}");
assert!(
running.contains("doctor --game rust --server-id alpha"),
"{running}"
);
}
#[test]
fn a_held_port_is_skipped_and_a_kept_one_is_kept() {
let port = choose_port(47_990, &[47_990, 47_991], None).unwrap();
assert!(port >= 47_992);
assert_eq!(
choose_port(47_990, &[47_990], Some(47_990)).unwrap(),
47_990
);
}
/// Enough TOML to read back the file `instance_config` writes — `[section]` and
/// `key = 'literal'` / `key = "basic"` lines. The installer has no TOML dependency and this
/// test is the only reader it needs.
mod toml_lite {
pub struct Doc(Vec<(String, String, String)>);
impl Doc {
pub fn get(&self, section: &str, key: &str) -> Option<&str> {
self.0
.iter()
.find(|(s, k, _)| s == section && k == key)
.map(|(_, _, v)| v.as_str())
}
}
pub fn parse(text: &str) -> Doc {
let mut section = String::new();
let mut out = Vec::new();
for line in text.lines().map(str::trim) {
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
section = name.to_string();
} else if let Some((k, v)) = line.split_once('=') {
let v = v.trim();
let v = v
.strip_prefix('\'')
.and_then(|v| v.strip_suffix('\''))
.or_else(|| v.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
.unwrap_or(v);
out.push((section.clone(), k.trim().to_string(), v.to_string()));
}
}
Doc(out)
}
}
}

199
src/rustgame/uninstall.rs Normal file
View File

@@ -0,0 +1,199 @@
//! `uninstall --game rust [--server-id <id>] [--purge]`.
//!
//! What it removes, per instance (docs/modules/rust/PLAN.md §34.2.3, §34.4):
//!
//! - **the service**, and **our plugin file** — `RunicGateway.cs` is the installer's;
//! - **never the plugin's config** — `RunicGateway.json` is the website's, and it holds the
//! server's id; a reinstall must find it;
//! - with `--purge`, the instance's sidecar config (its token) and database.
//!
//! With the last instance gone: the shared binary, the template unit, the record, and — only if
//! this installer created it and no ServUO deployment uses it — the service account.
use std::path::Path;
use anyhow::Result;
use super::record::RustRecord;
use crate::cli::Cli;
use crate::{paths, service, ui};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.rust_record();
println!(
"\nRunic Gateway installer {} — uninstall (Rust)",
env!("CARGO_PKG_VERSION")
);
let Some(mut record) = RustRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"Nothing to uninstall — no Rust deployment is recorded on this host (looked for {}).",
record_path.display()
));
return Ok(0);
};
let ids: Vec<String> = match &cli.server_id {
Some(id) if record.instances.contains_key(id) => vec![id.clone()],
Some(id) => anyhow::bail!(
"{id:?} is not a recorded Rust instance (recorded: {})",
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
None => record.instances.keys().cloned().collect(),
};
let last = ids.len() == record.instances.len();
// ── Say what will happen, then ask ───────────────────────────────────────
println!();
for id in &ids {
let i = &record.instances[id];
ui::heading(&format!("Instance {id}"));
if let Some(svc) = &i.service {
println!(" · remove the service {}", svc.name);
}
println!(" · remove {}", i.plugin_path);
println!(
" · keep {} (the website's; it names this server)",
i.plugin_config
);
if cli.purge {
println!(
" · remove {} and the database {}",
i.config_path, i.db_path
);
} else {
println!(
" · keep {} and {} (--purge removes them)",
i.config_path, i.db_path
);
}
}
if last {
println!(
"\n and, with no instance left: {} and {}",
record.binary.path,
record_path.display()
);
}
println!();
let proceed = if cli.assume_yes {
println!("Remove the components listed above? [y/N] (--yes)");
true
} else {
ui::confirm("Remove the components listed above?", false, false)?
};
if !proceed {
println!("\nNothing was removed.");
return Ok(0);
}
// ── Remove ───────────────────────────────────────────────────────────────
let mut done = Vec::new();
let mut problems = Vec::new();
for id in &ids {
let Some(instance) = record.instances.remove(id) else {
continue;
};
if let Some(svc) = &instance.service {
let removal = service::remove(svc);
done.extend(removal.done);
problems.extend(removal.problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);
let data_dir = layout.rust_data_dir(id);
if data_dir.exists() {
match std::fs::remove_dir_all(&data_dir) {
Ok(()) => done.push(format!("removed {}", data_dir.display())),
Err(e) => problems.push(format!("cannot remove {}: {e}", data_dir.display())),
}
}
}
}
if record.instances.is_empty() {
remove_file(Path::new(&record.binary.path), &mut done, &mut problems);
done.extend(service::remove_rust_template(&layout));
remove_shared_user(&record, &layout, &mut done, &mut problems);
remove_file(&record_path, &mut done, &mut problems);
} else {
match record.save(&record_path) {
Ok(()) => done.push(format!(
"{} keeps {} instance(s): {}",
record_path.display(),
record.instances.len(),
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)),
Err(e) => problems.push(format!("cannot update {}: {e}", record_path.display())),
}
}
for line in &done {
println!(" · {line}");
}
for problem in &problems {
println!();
ui::warn(problem);
}
println!(
"\nRemove each server from Admin -> Rust -> Servers on the website too; the installer never \
contacts it."
);
Ok(if problems.is_empty() { 0 } else { 1 })
}
/// The `runicgateway` account is shared: every Rust instance runs as it, and so does ServUO's
/// sidecar. It goes only when this installer created it for Rust and no ServUO deployment remains.
fn remove_shared_user(
record: &RustRecord,
layout: &paths::Layout,
done: &mut Vec<String>,
problems: &mut Vec<String>,
) {
if !record.service_user_created || cfg!(windows) {
return;
}
if layout.install_record().exists() {
done.push(format!(
"left the {} account — the ServUO deployment on this host runs as it",
service::SERVICE_USER
));
return;
}
let removed = crate::util::run_ok("userdel", &[service::SERVICE_USER])
.or_else(|_| crate::util::run_ok("deluser", &[service::SERVICE_USER]));
match removed {
Ok(_) => done.push(format!(
"removed the {} service user",
service::SERVICE_USER
)),
Err(e) => problems.push(format!(
"cannot remove the {} service user ({}); remove it by hand if you want it gone",
service::SERVICE_USER,
e.to_string().replace('\n', " ")
)),
}
}
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
match std::fs::remove_file(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
done.push(format!("{} was already gone", path.display()))
}
Err(e) => problems.push(format!("cannot remove {}: {e}", path.display())),
}
}

View File

@@ -231,7 +231,21 @@ fn register_systemd(
) -> Result<Outcome> {
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
let text = systemd_unit_text(binary, config, db, &user);
register_systemd_unit(prepared, SYSTEMD_UNIT, unit_path, &text, restart)
}
/// Writes a unit file (when its content changed), then enables and starts `name`.
///
/// `name` and the file differ for a template: the file is `runicgateway-rust@.service` and what is
/// enabled is `runicgateway-rust@alpha.service`, one per Rust instance (D148).
#[cfg(unix)]
fn register_systemd_unit(
prepared: &Prepared,
name: &str,
unit_path: &Path,
text: &str,
restart: bool,
) -> Result<Outcome> {
// An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on
// every run is a change an operator watching /etc has to investigate and then dismiss.
let current = std::fs::read_to_string(unit_path).unwrap_or_default();
@@ -241,31 +255,31 @@ fn register_systemd(
run_ok("systemctl", &["daemon-reload"])?;
}
run_ok("systemctl", &["enable", SYSTEMD_UNIT])?;
run_ok("systemctl", &["enable", name])?;
if restart {
// The binary underneath a running service has just been replaced; `start` on an already
// active unit is a no-op and would leave the old code running.
run_ok("systemctl", &["restart", SYSTEMD_UNIT])?;
run_ok("systemctl", &["restart", name])?;
} else {
run_ok("systemctl", &["start", SYSTEMD_UNIT])?;
run_ok("systemctl", &["start", name])?;
}
Ok(Outcome::Registered {
kind: "systemd",
name: SYSTEMD_UNIT.to_string(),
name: name.to_string(),
unit_path: Some(unit_path.to_path_buf()),
user: prepared.user.clone(),
user_created: prepared.user_created,
state: systemd_state(),
state: systemd_state(name),
})
}
/// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl
/// start` succeeding and the service still being up a second later are different claims.
#[cfg(unix)]
fn systemd_state() -> String {
let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT]));
let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT]));
fn systemd_state(name: &str) -> String {
let active = one_word(run("systemctl", &["is-active", name]));
let enabled = one_word(run("systemctl", &["is-enabled", name]));
format!("{active}, {enabled}")
}
@@ -300,7 +314,14 @@ fn prepare_platform() -> Prepared {
/// SERVICE\<name>` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name
/// is translated.
pub fn windows_service_account() -> String {
format!("NT SERVICE\\{WINDOWS_SERVICE}")
windows_account_for(WINDOWS_SERVICE)
}
/// The virtual account for any service this installer registers — `NT SERVICE\<name>`. A Rust
/// instance's is `NT SERVICE\RunicGatewayRust-<id>`, so two instances cannot read each other's
/// token.
pub fn windows_account_for(name: &str) -> String {
format!("NT SERVICE\\{name}")
}
/// The `binPath=` value: the executable and the `--config` it must always be started with.
@@ -325,7 +346,19 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
/// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good
/// config — so the config is the last thing to look at, not the first.
pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String {
let command = crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]);
windows_start_failure_for(WINDOWS_SERVICE, MIN_SERVICE_SIDECAR, code, binary, config)
}
/// [`windows_start_failure`] for any service: `min_sidecar` is the first release of that sidecar
/// that speaks the SCM handshake.
pub fn windows_start_failure_for(
name: &str,
min_sidecar: &str,
code: i32,
binary: &Path,
config: &Path,
) -> String {
let command = crate::util::command_line("sc.exe", &["start", name]);
match code {
1053 => format!(
"`{command}` failed with 1053 — the service did not respond to the start request in \
@@ -339,6 +372,7 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String
\"{config}\"",
binary = binary.display(),
config = config.display(),
MIN_SERVICE_SIDECAR = min_sidecar,
),
// ERROR_SERVICE_LOGON_FAILED. The account is the virtual one the SCM makes itself, so this
// is a policy that forbids virtual service accounts rather than a wrong password.
@@ -348,11 +382,11 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String
password, so this is a local policy forbidding them rather than a bad credential. \
Register the service by hand against an account this host allows — INSTALL.md \
Appendix A4.",
account = windows_service_account(),
account = windows_account_for(name),
),
_ => format!(
"`{command}` failed with exit code {code}.\n\n Check the Windows event log \
(System, source \"Service Control Manager\"), and `sc query {WINDOWS_SERVICE}` for \
(System, source \"Service Control Manager\"), and `sc query {name}` for \
the service's own exit code. A sidecar that exits immediately usually cannot read its \
config: {}\n\n Running it in the foreground prints the reason:\n\n \
\"{}\" --config \"{}\"",
@@ -365,23 +399,29 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String
#[cfg(windows)]
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
let bin_path = windows_bin_path(binary, config);
let account = windows_service_account();
register_windows_named(WINDOWS_SERVICE, DISPLAY_NAME, binary, config, restart)
}
if windows_service_exists() {
/// Registers (or reconfigures) one SCM service and sets its restart policy. It does not start it:
/// [`start_registered`] does, after [`grant_service_access`].
#[cfg(windows)]
fn register_windows_named(
name: &str,
display: &str,
binary: &Path,
config: &Path,
restart: bool,
) -> Result<Outcome> {
let bin_path = windows_bin_path(binary, config);
let account = windows_account_for(name);
if windows_service_exists(name) {
// `config` rather than delete-and-recreate: recreating would drop the failure actions and,
// more to the point, would briefly leave a host with no service if the create half failed.
run_ok(
"sc.exe",
&[
"config",
WINDOWS_SERVICE,
"binPath=",
&bin_path,
"start=",
"auto",
"obj=",
&account,
"config", name, "binPath=", &bin_path, "start=", "auto", "obj=", &account,
],
)?;
} else {
@@ -389,7 +429,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"sc.exe",
&[
"create",
WINDOWS_SERVICE,
name,
"binPath=",
&bin_path,
"start=",
@@ -397,7 +437,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"obj=",
&account,
"DisplayName=",
DISPLAY_NAME,
display,
],
)
.context(
@@ -405,7 +445,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
(no password); if this host's policy forbids them, register the service by hand — \
INSTALL.md Appendix A4.",
)?;
let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]);
let _ = run("sc.exe", &["description", name, display]);
}
// Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400`
@@ -415,7 +455,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"sc.exe",
&[
"failure",
WINDOWS_SERVICE,
name,
"reset=",
"86400",
"actions=",
@@ -423,42 +463,38 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
],
)?;
if restart && windows_service_state().contains("RUNNING") {
stop_windows_service()?;
}
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(windows_start_failure(
start.status.code().unwrap_or(-1),
binary,
config
));
if restart && windows_service_state(name).contains("RUNNING") {
stop_windows_service(name)?;
}
// NOT started here: the virtual account `sc create` just made cannot read the config yet —
// `protect_config` locked it to SYSTEM and Administrators, and `grant_service_access` is what
// lets the account in. Started first, the sidecar died on `Access is denied` and the service
// sat STOPPED; the restart policy never fires for a clean exit with an error code. The phase 18
// walk found it (D157). The caller grants, then calls `start_registered`.
Ok(Outcome::Registered {
kind: "windows-scm",
name: WINDOWS_SERVICE.to_string(),
name: name.to_string(),
unit_path: None,
user: Some(account),
user_created: false,
state: format!("{}, automatic start", windows_service_state()),
state: format!("{}, automatic start", windows_service_state(name)),
})
}
/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
#[cfg(windows)]
fn windows_service_exists() -> bool {
match run("sc.exe", &["query", WINDOWS_SERVICE]) {
fn windows_service_exists(name: &str) -> bool {
match run("sc.exe", &["query", name]) {
Ok(output) => output.status.code() != Some(1060),
Err(_) => false,
}
}
#[cfg(windows)]
fn windows_service_state() -> String {
let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else {
fn windows_service_state(name: &str) -> String {
let Ok(output) = run("sc.exe", &["query", name]) else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
@@ -474,12 +510,12 @@ fn windows_service_state() -> String {
}
#[cfg(windows)]
fn stop_windows_service() -> Result<()> {
fn stop_windows_service(name: &str) -> Result<()> {
// 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for.
let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?;
let stop = run("sc.exe", &["stop", name])?;
if !stop.status.success() && stop.status.code() != Some(1062) {
anyhow::bail!(
"cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \
"cannot stop {name} (exit code {}). The sidecar binary is locked while the \
service runs, so the install cannot replace it.",
stop.status.code().unwrap_or(-1)
);
@@ -488,17 +524,259 @@ fn stop_windows_service() -> Result<()> {
// actually exits. Polling is the only way to know, and a fixed sleep would be either too short
// or a delay on every run.
for _ in 0..30 {
if windows_service_state() == "STOPPED" {
if windows_service_state(name) == "STOPPED" {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
anyhow::bail!(
"{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \
sc.exe stop {WINDOWS_SERVICE}"
"{name} did not stop within 15 seconds. Stop it by hand and re-run: \
sc.exe stop {name}"
)
}
// ── Rust instances (docs/modules/rust/PLAN.md §34.2.3, D148) ─────────────────
//
// One sidecar binary per host and one service per instance. On Linux that is a systemd TEMPLATE
// unit, so every instance runs the same definition with its own config; on Windows it is one SCM
// service per instance, each under its own virtual account. The ServUO service above is untouched:
// a host running both games has three kinds of name and none of them collide.
/// The template unit file. Instances are `runicgateway-rust@<id>.service`.
pub const RUST_TEMPLATE_UNIT: &str = "runicgateway-rust@.service";
/// The prefix of every Rust instance's SCM service name, `RunicGatewayRust-<id>`. Rust-Link's
/// `windows.rs` carries the same literal.
pub const RUST_WINDOWS_PREFIX: &str = "RunicGatewayRust-";
/// The first Rust-Link release whose sidecar speaks the SCM handshake — its first release at all.
#[cfg(windows)]
const MIN_RUST_SERVICE_SIDECAR: &str = "v0.1.0";
/// The systemd instance name for a server id.
pub fn rust_systemd_instance(server_id: &str) -> String {
format!("runicgateway-rust@{server_id}.service")
}
/// The Windows service name for a server id.
pub fn rust_windows_service(server_id: &str) -> String {
format!("{RUST_WINDOWS_PREFIX}{server_id}")
}
/// The service name this platform uses for an instance.
pub fn rust_service_name(server_id: &str) -> String {
if cfg!(windows) {
rust_windows_service(server_id)
} else {
rust_systemd_instance(server_id)
}
}
/// Whether a Windows service name is one this installer registers — the ServUO sidecar's, or a Rust
/// instance's. Anything else in a record is not ours to query, stop or delete.
pub fn is_ours(name: &str) -> bool {
name == WINDOWS_SERVICE || name.starts_with(RUST_WINDOWS_PREFIX)
}
/// The template unit. `%i` is the server id, so each instance reads its own config — which names
/// its own ports and its own database (see `crate::rustgame`). Pure, so it is a test.
pub fn rust_template_unit_text(binary: &Path, config_dir: &Path, user: &str) -> String {
format!(
"# Runic Gateway rust-link sidecar, one instance per Rust server.\n\
#\n\
# Generated by the Runic Gateway installer {installer}. Instances are\n\
# {instance}; each reads {config_dir}/<id>.toml.\n\
# Local edits belong in a drop-in: systemctl edit runicgateway-rust@<id>.service\n\
\n\
[Unit]\n\
Description=Runic Gateway rust-link sidecar (%i)\n\
After=network.target\n\
\n\
[Service]\n\
Type=simple\n\
User={user}\n\
Environment=RUSTLINK_CONFIG={config_dir}/%i.toml\n\
ExecStart={binary}\n\
Restart=on-failure\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
installer = env!("CARGO_PKG_VERSION"),
instance = rust_systemd_instance("<id>"),
config_dir = config_dir.display(),
binary = binary.display(),
)
}
/// Registers, enables and starts one Rust instance — or explains why it did not.
///
/// `restart` is true when the shared binary was just replaced: every instance runs it, so every
/// instance the run touches is restarted rather than started.
pub fn register_rust(
prepared: &Prepared,
layout: &crate::paths::Layout,
server_id: &str,
config: &Path,
restart: bool,
) -> Result<Outcome> {
let binary = &layout.rust_sidecar_bin;
match &prepared.manager {
Manager::Unavailable(reason) => Ok(Outcome::Skipped {
reason: reason.clone(),
manual: rust_manual_steps(layout, server_id, config, !layout.relocated),
}),
#[cfg(unix)]
Manager::Systemd => {
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
let text = rust_template_unit_text(binary, &layout.rust_config_dir(), &user);
let mut outcome = register_systemd_unit(
prepared,
&rust_systemd_instance(server_id),
&layout.rust_template_unit(),
&text,
restart,
)?;
// The template file is shared by every instance, so it is not this instance's to
// remove: `uninstall` deletes it with the last one.
if let Outcome::Registered { unit_path, .. } = &mut outcome {
*unit_path = None;
}
Ok(outcome)
}
#[cfg(windows)]
Manager::WindowsScm => register_windows_named(
&rust_windows_service(server_id),
&format!("Runic Gateway rust-link sidecar ({server_id})"),
binary,
config,
restart,
),
#[allow(unreachable_patterns)]
other => Ok(Outcome::Skipped {
reason: format!("{} is not supported by this build", other.kind()),
manual: rust_manual_steps(layout, server_id, config, !layout.relocated),
}),
}
}
/// Stops every listed Rust instance so the shared binary can be replaced under them.
pub fn stop_rust_instances(manager: &Manager, server_ids: &[String]) -> Result<()> {
let _ = server_ids;
match manager {
#[cfg(unix)]
Manager::Systemd => {
for id in server_ids {
let _ = run("systemctl", &["stop", &rust_systemd_instance(id)]);
}
Ok(())
}
#[cfg(windows)]
Manager::WindowsScm => {
for id in server_ids {
let name = rust_windows_service(id);
if windows_service_exists(&name) {
stop_windows_service(&name)?;
}
}
Ok(())
}
_ => Ok(()),
}
}
/// Starts (or restarts) an instance that is already registered, without re-registering it. Used by
/// `update` for instances whose own files did not change but whose binary did.
pub fn restart_rust_instance(server_id: &str) -> Result<()> {
#[cfg(unix)]
{
run_ok("systemctl", &["restart", &rust_systemd_instance(server_id)])?;
}
#[cfg(windows)]
{
let name = rust_windows_service(server_id);
let start = run("sc.exe", &["start", &name])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(
"cannot start {name} (exit code {})",
start.status.code().unwrap_or(-1)
);
}
}
Ok(())
}
/// Removes the shared template unit, once the last Rust instance is gone. Linux only; Windows has
/// no shared definition.
pub fn remove_rust_template(layout: &crate::paths::Layout) -> Option<String> {
#[cfg(unix)]
{
let path = layout.rust_template_unit();
if path.exists() && std::fs::remove_file(&path).is_ok() {
let _ = run("systemctl", &["daemon-reload"]);
return Some(format!("removed {}", path.display()));
}
}
let _ = layout;
None
}
/// What to do by hand for one instance when no service was registered.
pub fn rust_manual_steps(
layout: &crate::paths::Layout,
server_id: &str,
config: &Path,
config_protected: bool,
) -> String {
let binary = &layout.rust_sidecar_bin;
let data_dir = layout.rust_data_dir(server_id);
#[cfg(windows)]
{
let name = rust_windows_service(server_id);
let account = windows_account_for(&name);
format!(
" From an elevated PowerShell:\n\n \
sc.exe create {name} binPath= '{bin_path}' obj= '{account}' start= auto\n \
sc.exe failure {name} reset= 86400 actions= restart/5000\n \
icacls '{config}' /grant '{account}:(R)'\n \
icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \
sc.exe start {name}\n\n{token_note}",
bin_path = windows_bin_path(binary, config),
config = config.display(),
data_dir = data_dir.display(),
token_note = if config_protected {
" That config file has already been restricted to Administrators and SYSTEM, \
because it holds\n the auth token.\n"
} else {
" That config file holds the auth token, and this run did NOT restrict it. \
Lock it down too.\n"
},
)
}
#[cfg(not(windows))]
{
let _ = config_protected;
format!(
" Write this to /etc/systemd/system/{RUST_TEMPLATE_UNIT}:\n\n{unit}\n \
Then:\n\n \
useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \
chown {SERVICE_USER} {config}\n \
chown -R {SERVICE_USER} {data_dir}\n \
systemctl daemon-reload\n \
systemctl enable --now {instance}\n\n \
Without systemd, run {binary} with RUSTLINK_CONFIG={config}\n \
as an unprivileged user that can write {data_dir}.\n",
unit = indent(&rust_template_unit_text(
binary,
&layout.rust_config_dir(),
SERVICE_USER
)),
config = config.display(),
data_dir = data_dir.display(),
instance = rust_systemd_instance(server_id),
binary = binary.display(),
)
}
}
// ── Shared entry points ──────────────────────────────────────────────────────
/// Stops a running service so its binary can be replaced.
@@ -517,8 +795,8 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
}
#[cfg(windows)]
Manager::WindowsScm => {
if windows_service_exists() {
stop_windows_service()?;
if windows_service_exists(WINDOWS_SERVICE) {
stop_windows_service(WINDOWS_SERVICE)?;
}
Ok(())
}
@@ -585,13 +863,13 @@ fn observe_platform(kind: &str, name: &str) -> Status {
if kind != "windows-scm" {
return Status::absent(format!("recorded as {kind}, which this host does not run"));
}
// Only the service this installer registers is queried by name; anything else would be reading
// Only services this installer registers are queried by name; anything else would be reading
// another product's service out of a hand-edited record.
if name != WINDOWS_SERVICE || !windows_service_exists() {
if !is_ours(name) || !windows_service_exists(name) {
return Status::absent("not registered with the service manager".to_string());
}
let state = windows_service_state();
let start = windows_start_type();
let state = windows_service_state(name);
let start = windows_start_type(name);
Status {
present: true,
running: state.contains("RUNNING"),
@@ -603,8 +881,8 @@ fn observe_platform(kind: &str, name: &str) -> Status {
/// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but
/// was set to manual start is reported as such rather than as healthy.
#[cfg(windows)]
fn windows_start_type() -> String {
let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else {
fn windows_start_type(name: &str) -> String {
let Ok(output) = run("sc.exe", &["qc", name]) else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
@@ -681,8 +959,11 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
)),
}
} else if let Some(user) = record.user.as_deref() {
// Not "this installer did not create it": a Rust instance's service never owns the account
// even when this installer made it, because every instance shares it — the last instance's
// removal decides (`rustgame::uninstall`). Said that way, the line is true for both games.
out.done.push(format!(
"left the {user} account alone — this installer did not create it"
"left the {user} account alone — it is not this service's to remove"
));
}
out
@@ -699,7 +980,15 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
));
return out;
}
if !windows_service_exists() {
if !is_ours(&record.name) {
out.problems.push(format!(
"the record names a service called {}, which is not one this installer registers - \
left alone",
record.name
));
return out;
}
if !windows_service_exists(&record.name) {
out.done
.push(format!("{} was already unregistered", record.name));
return out;
@@ -708,7 +997,7 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
// Stopping first is not politeness: `sc delete` on a running service only marks it for deletion,
// and the service — and its lock on the binary this uninstall is about to remove — survives
// until the process exits.
if let Err(error) = stop_windows_service() {
if let Err(error) = stop_windows_service(&record.name) {
out.problems
.push(error.to_string().replace('\n', " ").to_string());
} else {
@@ -877,6 +1166,47 @@ pub fn protect_config(
protect_config_platform(config, data_dir, user, relocated)
}
/// Starts a service that [`register`] or [`register_rust`] has just registered, once
/// [`grant_service_access`] has let its account read the config.
///
/// A no-op on Linux, where registration starts the unit itself: the service user was known before
/// the config existed, so `protect_config` had already handed the file to it. On Windows the order
/// is the whole point — see `register_windows_named`. The outcome's `state` is refreshed so the
/// run reports what the service is doing now, not what it was doing before it was started.
pub fn start_registered(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
start_registered_platform(outcome, binary, config)
}
#[cfg(unix)]
fn start_registered_platform(_outcome: &mut Outcome, _binary: &Path, _config: &Path) -> Result<()> {
Ok(())
}
#[cfg(windows)]
fn start_registered_platform(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
let Outcome::Registered { name, state, .. } = outcome else {
return Ok(());
};
let min_sidecar = if name.as_str() == WINDOWS_SERVICE {
MIN_SERVICE_SIDECAR
} else {
MIN_RUST_SERVICE_SIDECAR
};
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", name])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(windows_start_failure_for(
name,
min_sidecar,
start.status.code().unwrap_or(-1),
binary,
config
));
}
*state = format!("{}, automatic start", windows_service_state(name));
Ok(())
}
/// Grants the registered service account access to what it must read and write.
///
/// A no-op on Linux, where the account was known before the config existed and `protect_config`
@@ -954,10 +1284,14 @@ fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outco
// Nothing to grant when nothing was registered: the account only exists because `sc create`
// made it, and a skipped registration leaves the config locked to Administrators — which is the
// right resting state for a host where no service is going to read it.
if !outcome.registered() {
// The account comes from what was registered: the ServUO service's, or one Rust instance's.
let Outcome::Registered {
user: Some(account),
..
} = outcome
else {
return Ok(());
}
let account = windows_service_account();
};
run_ok(
"icacls",
@@ -1155,6 +1489,7 @@ mod tests {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
relocated: true,
};
let outcome = register(&prepare(true), &layout, false).unwrap();

View File

@@ -163,7 +163,7 @@ pub fn normalize_version(raw: &str) -> String {
}
#[cfg(windows)]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(stripped) => PathBuf::from(stripped),
None => path,
@@ -171,7 +171,7 @@ fn strip_extended_prefix(path: PathBuf) -> PathBuf {
}
#[cfg(not(windows))]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf {
path
}

View File

@@ -294,6 +294,18 @@ fn port_of(bind: &str) -> &str {
}
}
/// Where the shard settings live in the website's admin panel.
///
/// NOT `/admin/shard`, which is what this printed until 2026-08-24 and what an operator who ran
/// an older build still has in their scrollback. Those screens belong to the `uo` MODULE now, and
/// a module owns one path segment wherever it appears (website `MODULE_SYSTEM.md` §2.8), so the
/// page moved. The old path does not 404 — the SPA sends it to the dashboard, which is the worst
/// way for a link in a handoff to be wrong, because it looks like it worked.
///
/// API routes are NOT affected by that rule and keep `/api/v1/admin/shard/*`. This is the SPA URL
/// a person types.
const ADMIN_SHARD_PATH: &str = "/admin/uo/link";
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
///
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
@@ -312,12 +324,13 @@ pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
Protocol version {protocol}\n \
Auth token {token}\n \
(also in {config})\n\n\
Paste these into Admin → Shard on your Runic Gateway site:\n \
{site}/admin/shard\n\n\
Paste these into Admin → Shard (uo-link) on your Runic Gateway site:\n \
{site}{admin_path}\n\n\
The token is write-only once saved — the site will never show it back to you.\n",
protocol = doc.protocol,
token = doc.web.auth_token,
config = doc.config_path,
admin_path = ADMIN_SHARD_PATH,
)
}
@@ -401,7 +414,7 @@ mod tests {
assert!(block.contains(&doc.web.auth_token), "{block}");
// The trailing slash on the site URL must not produce a double slash in the link.
assert!(
block.contains("https://my-site.example/admin/shard"),
block.contains("https://my-site.example/admin/uo/link"),
"{block}"
);
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
@@ -412,7 +425,10 @@ mod tests {
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
// link the operator does not need.
let block = handoff(&doc(), "shard", None);
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
assert!(
block.contains("https://<your-site>/admin/uo/link"),
"{block}"
);
assert!(block.contains("4f9c"), "{block}");
}

View File

@@ -731,6 +731,7 @@ mod tests {
state_dir: std::path::PathBuf::from("/etc/runicgateway"),
data_dir: std::path::PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"),
rust_sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-rust-link"),
relocated: false,
}
}

View File

@@ -623,6 +623,7 @@ mod tests {
state_dir: dir.to_path_buf(),
data_dir: dir.join("data"),
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
rust_sidecar_bin: dir.join("bin").join("rust-link-sidecar"),
relocated: true,
}
}

View File

@@ -81,7 +81,7 @@ pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallReco
println!();
ui::warn(&format!(
"The protocol version changed: {} → {}.\n \
Update the Protocol version field in Admin → Shard on your website. Nothing else \
Update the Protocol version field in Admin → Shard (uo-link) on your website. Nothing else \
changed —\n the URLs and the auth token are the same, and the sidecar answers a \
website still set to\n {} with 409 rather than mis-parsing it.",
previous_protocol.unwrap_or(bundle.protocol),

View File

@@ -0,0 +1,45 @@
{
"schema": 1,
"bundle": "2026.09.15",
"generated": "2026-09-15T00:12:17Z",
"protocol": 8,
"link": {
"repo": "RunicGateway/link",
"tag": "v2.3.0",
"version": "2.3.0",
"protocol": 8,
"assets": {
"linux-aarch64": {
"name": "uo-link-sidecar-linux-aarch64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64",
"sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf"
},
"linux-x86_64": {
"name": "uo-link-sidecar-linux-x86_64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64",
"sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36"
},
"windows-x86_64": {
"name": "uo-link-sidecar-windows-x86_64.exe",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe",
"sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110"
}
}
},
"overlay": {
"repo": "RunicGateway/servuo-plugins",
"tag": "v1.3.0",
"version": "1.3.0",
"commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641",
"protocol": 8,
"servuo": {
"min_version": "57.4",
"patches_verified_against": "57.4"
},
"asset": {
"name": "runicgateway-overlay-1.3.0.tar.gz",
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz",
"sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1"
}
}
}

View File

@@ -0,0 +1,47 @@
{
"schema": 2,
"game": "servuo",
"bundle": "2026.09.15",
"generated": "2026-09-26T03:55:28Z",
"protocol": 8,
"sidecar": {
"repo": "RunicGateway/link",
"tag": "v2.3.0",
"version": "2.3.0",
"protocol": 8,
"assets": {
"linux-aarch64": {
"name": "uo-link-sidecar-linux-aarch64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64",
"sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf"
},
"linux-x86_64": {
"name": "uo-link-sidecar-linux-x86_64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64",
"sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36"
},
"windows-x86_64": {
"name": "uo-link-sidecar-windows-x86_64.exe",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe",
"sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110"
}
}
},
"payload": {
"kind": "overlay",
"repo": "RunicGateway/servuo-plugins",
"tag": "v1.3.0",
"version": "1.3.0",
"commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641",
"protocol": 8,
"asset": {
"name": "runicgateway-overlay-1.3.0.tar.gz",
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz",
"sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1"
},
"compat": {
"min_version": "57.4",
"patches_verified_against": "57.4"
}
}
}

57
tests/fixtures/rust-bundle-v2.json vendored Normal file
View File

@@ -0,0 +1,57 @@
{
"schema": 2,
"game": "rust",
"bundle": "2026.09.26",
"generated": "2026-09-26T04:06:03Z",
"protocol": 12,
"sidecar": {
"repo": "RunicGateway/Rust-Link",
"tag": "v0.1.0",
"version": "0.1.0",
"protocol": 12,
"assets": {
"linux-x86_64": {
"name": "rust-link-sidecar-linux-x86_64",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-linux-x86_64",
"sha256": "3f69c56a78ea0f9a886286078744710a983b0058b61cd0be0e69a2b4db312bfe"
},
"windows-x86_64": {
"name": "rust-link-sidecar-windows-x86_64.exe",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-windows-x86_64.exe",
"sha256": "e8cc61bb5681ead44baf2dcd661c323630b53a84b834447410a0bc1e5b1b1e8c"
}
},
"launcher": {
"name": "with-sidecar.sh",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/with-sidecar.sh",
"sha256": "b1d785873f0528663f12cf1b008ec2581ba7bd7282395203d8d5915e19ceb712"
}
},
"payload": {
"kind": "plugin",
"repo": "RunicGateway/Rust-Plugins",
"tag": "v0.2.3",
"version": "0.2.3",
"commit": "deadbeef",
"protocol": 12,
"compat": {
"frameworks": {
"oxide": {
"min_version": "2.0.7585"
},
"carbon": {
"min_version": "2.0.259"
}
},
"requires_plugins": [
"Kits",
"ZoneManager"
]
},
"asset": {
"name": "runicgateway-rust-plugin-0.2.3.tar.gz",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases/download/v0.2.3/runicgateway-rust-plugin-0.2.3.tar.gz",
"sha256": "2ca6269dba329b06d0d36e5786f6f88c10757c38a0b1dd7efdbcd4e803b94c78"
}
}
}