14 Commits

Author SHA1 Message Date
915f0296a9 Merge pull request 'fix(release): recover a tag whose release never published' (#27) from fix/release-tag-without-release into main
All checks were successful
sync-project-tree / sync (push) Successful in 6s
SonarQube / analysis (push) Successful in 44s
Release sidecar / release (push) Successful in 9m6s
Reviewed-on: #27
2026-08-05 18:09:39 +00:00
07021d38c9 fix(release): recover a tag whose release never published
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m15s
An existing tag was treated as "nothing to release", unconditionally.
That is wrong in the one case it matters: a tag with no release behind
it means an earlier run tagged and then died before publishing -- which
is exactly what happened on servuo-plugins' first release, where absent
REGISTRY_* secrets took the release API call to 401 after the tag had
been pushed. Standing down on the tag alone makes that permanent. Every
later run sees the tag, sets RELEASE=false, and the release never
appears; the version is unpublishable forever.

The tag now decides nothing on its own. The API does:

  200 -> a release exists, stand down
  404 -> tag without release, reuse the tag and publish what is missing
  else -> refuse and exit 1

The last arm matters as much as the others. A 000 from a network failure
or a 401 from a bad token is not evidence of absence, and guessing "no
release" would republish over a good one.

Note the 404 arm deliberately overrides the RELEASE=false decided just
above it: with the tag in place there are no releasable commits after
it, so the normal path always stands down -- which is why this could
never self-heal on its own.

Two consequences handled with it. The changelog range is now
previous-tag..this-tag on a recovery run, since a run finishing an
earlier one has nothing after the tag and would otherwise publish an
empty change list. And the tagging step tolerates the tag already
existing, because `git tag` on an existing name fails under `set -e`
while pushing an identical tag is a harmless no-op -- a push that does
fail there means the remote tag points somewhere else, which should
stop the run.

This is the same handling installer/release.yml already carries; link
was the copy that still had the trap.

Verified by extracting this step and driving it through four scenarios
against a real clone with the live tags, with curl stubbed to return
each status: a normal patch bump (v1.1.1, release=true), a tag with no
release (recovers, release=true), a tag with a release (stands down),
and an unreachable API (exit 1, publishes nothing).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 13:05:31 -05:00
2724c292a0 Merge pull request 'ci(release): build and publish a linux-aarch64 sidecar' (#26) from ci/release-linux-aarch64 into main
All checks were successful
sync-project-tree / sync (push) Successful in 6s
Release sidecar / release (push) Successful in -33s
SonarQube / analysis (push) Successful in 44s
Reviewed-on: #26
2026-08-05 17:52:40 +00:00
65b12f815b ci(release): build and publish a linux-aarch64 sidecar
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m17s
Step 2 of installer PLAN.md §5.2. The shard dials the sidecar out on
loopback, so wherever ServUO runs this binary has to run too -- and
Ampere/Graviton instances and Pi-class boxes are a realistic ServUO
home. Until now the release cross-compiled x86_64 Linux and Windows
only, so the installer refused every arm64 host by name.

Cross-compiling this crate is not Rust-only: sqlx's sqlite feature
pulls libsqlite3-sys, which compiles bundled SQLite from C. The build
therefore needs a CC/AR pair as well as a linker, the same shape the
Windows step already has.

libc6-dev-arm64-cross is named explicitly because gcc-aarch64-linux-gnu
only recommends it and this install passes --no-install-recommends: with
the compiler alone, SQLite's build dies on

  /usr/include/stdio.h:27: fatal error: bits/libc-header-start.h

Both the failure and the fix were reproduced in a rust:1-slim-bookworm
container against this crate before the workflow was written.

The new binary is added to SHA256SUMS and to the upload list. Every
artifact has to appear in both: the installer verifies its download
against those sums, and `sha256sum -c` passes silently over a file the
sums list does not mention.

Merge order matters -- installer#9 teaches the bundle CI this asset
name, and must land first. An unrecognized link asset is a hard failure
there, by design, so a release published before it would redden the
compose job.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:29:12 -05:00
eb78059bb5 Merge pull request 'ci(release): recompose the installer bundle after publishing' (#25) from ci/dispatch-bundle into main
All checks were successful
Release sidecar / release (push) Successful in 6s
sync-project-tree / sync (push) Successful in 7s
SonarQube / analysis (push) Successful in 42s
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 16:18:52 +00:00
f8d80c07db ci(release): recompose the installer bundle after publishing
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 12m18s
Phase 0 item 3 of docs/installer/PLAN.md wired up from this side. The installer
does not resolve "latest" at run time — it installs the exact combination named
by a published bundle manifest (PLAN.md §7.1), so until now a new sidecar release
was invisible to operators until the installer repo's nightly cron noticed it.

Adds a final step that POSTs to RunicGateway/installer's bundle workflow-dispatch
endpoint. The bundle job re-reads PROTOCOL_VERSION from sidecar/src/main.rs at the new
release tag and checks it against the overlay's declared protocol before
publishing anything (gate 1), so a bump that lands without its plugin half is
caught at compose time instead of on an operator's shard.

Dispatch, don't wait (PLAN.md §7.3): Gitea's dispatch endpoint returns no run
handle, so there is nothing to poll — a waiting step would have to guess which
run is its own while holding a runner idle. The bundle job runs its own gates
regardless of who started it.

A dispatch failure is a warning, never a failure of this job. By the time this
step runs the release is published and correct, so failing the run would
misreport that; the installer's nightly cron recomposes from whatever the latest
releases actually are, making a dropped dispatch cost latency rather than
correctness. That also means REGISTRY_TOKEN having write on the installer repo
is a nicety, not a new hard requirement — noted in the header.

Verified the workflow still parses and that the new step is last, gated on
release=='true', and contains no path that can exit non-zero.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:13:51 -05:00
654a08add4 Merge pull request 'feat(sidecar): make the sidecar installable — CLI, --print-config, anchored data paths' (#24) from feat/installable-cli into main
All checks were successful
sync-project-tree / sync (push) Successful in 15s
SonarQube / analysis (push) Successful in 58s
Release sidecar / release (push) Successful in 10m13s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 15:48:11 +00:00
81becaf7c8 feat(sidecar): make the sidecar installable — CLI, --print-config, anchored data paths
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 7m52s
Phase 0.2 of the installer plan (docs/installer/PLAN.md §5). The installer has to
drive this binary non-interactively, and today it cannot: the auth token is only
readable by scraping the startup log, the config path can only be named through an
environment variable, and a relative db path follows the process working directory —
which a service manager, not the operator, chooses.

- Add a four-flag CLI (cli.rs): --print-config, --config <PATH>, --version, --help.
  Hand-rolled; an argument-parsing dependency would be larger than the code it
  replaced. An unrecognized flag exits 2 rather than starting a sidecar that is not
  the one that was asked for.
- --print-config resolves the configuration exactly as a normal start does —
  including writing a missing config file and generating a blank auth token — and
  prints it as JSON on stdout: versions, protocol, both bind addresses, ws path,
  resolved db path, and the token. config_created / token_generated let a re-run
  tell "read an existing install" from "provisioned a new one". The log subscriber
  is deliberately not started in this mode, so the document is the whole output.
- Anchor a relative [store].path to the config file's directory instead of the CWD,
  and report resolved absolute paths. A unit pinning UOLINK_CONFIG now keeps its
  database beside its config rather than in %SystemRoot%\System32 or a VirtualStore
  redirect. Development is unaffected: under cargo run the two directories are the
  same. :memory: and file: URIs are left alone.
- Hand the db path to sqlx as a filesystem path instead of formatting it into a
  sqlite:// URL, which percent-decodes it and splits it on '?'. An installed path
  containing %20 previously opened a different file; verified it now does not.
- Create the config's and the database's parent directories when missing, so a
  service can name /var/lib/runicgateway on a host where nothing made it yet.
- 22 unit tests covering argument parsing, path anchoring, token persistence, the
  generated config template, and the --print-config document.

No protocol change: PROTOCOL_VERSION stays 3.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 10:45:12 -05:00
295defb89f Merge pull request 'ci(link): gate pull requests into main on fmt, clippy, and test' (#23) from ci/pr-checks into main
All checks were successful
sync-project-tree / sync (push) Successful in 15s
Release sidecar / release (push) Successful in -18s
SonarQube / analysis (push) Successful in 40s
Reviewed-on: #23
2026-08-01 06:50:16 +00:00
5c4b77d957 Merge branch 'main' into ci/pr-checks
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m14s
2026-08-01 06:47:32 +00:00
f4b71f58fd ci(link): gate pull requests into main on fmt, clippy, and test
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m47s
This repo had no pull_request workflow at all — release.yml runs only
after merge, so its `cargo fmt --check` gate was the first thing to see
new code, and an unformatted commit killed the release job before it
could build, tag, or publish (run for 2301c57).

Add pr-checks.yml, mirroring release.yml's gates in the same order so a
green PR implies the release clears its own gates:

  cargo fmt --check
  cargo clippy --locked --all-targets -- -D warnings
  cargo test --locked

One job, not three: bootstrapping the toolchain costs more than the
checks, so parallel jobs would pay it three times for no wall-clock win.
Cargo registry and target/ are cached on Cargo.lock. The crate is
already clean at `-D warnings`, so clippy starts green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 01:44:23 -05:00
ef639679d1 Merge pull request 'fix(sidecar): apply rustfmt to the protocol 3.0 cutover code' (#22) from fix/rustfmt-release into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
SonarQube / analysis (push) Successful in 42s
Release sidecar / release (push) Successful in 6m22s
Reviewed-on: #22
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:41:46 +00:00
8c4dc0ee93 fix(sidecar): apply rustfmt to the protocol 3.0 cutover code
The release workflow's first gate is `cargo fmt --check`, and the 3.0
cutover merge (2301c57) landed two rustfmt violations in the
`world.ruleset` path, so the run failed before it could build or tag:

- main.rs: the `upsert_ruleset(..)` call fits on one line
- store.rs: the `upsert_ruleset` signature does not

No behavior change — formatting only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 01:40:03 -05:00
2301c57768 Merge pull request 'feat(sidecar)!: Protocol 3.0 cutover — X-UOLink-Version 2 → 3' (#21) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 7s
SonarQube / analysis (push) Successful in 48s
Release sidecar / release (push) Failing after 59s
Reviewed-on: #21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:34:19 +00:00
10 changed files with 977 additions and 40 deletions

View File

@@ -0,0 +1,101 @@
# Gate every pull request into `main` on the same Rust checks the release runs,
# so a formatting slip, a lint regression, or a failing test can't reach the
# deployable branch.
#
# Why this exists: release.yml runs only AFTER merge (on push to `main`) and its
# FIRST Rust step is `cargo fmt --check`. Before this workflow, an unformatted
# commit merged cleanly and then killed the release job before it could build,
# tag, or publish anything — the repo had no pull_request workflow at all. These
# gates are deliberately a mirror of release.yml's, in the same order, so a green
# PR means the release will get past its gates too.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
# • Status check patterns: PR Checks / *
# Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown.
#
# Scope note: this gates PRs into `main` only. Feature work that lands on an
# integration branch first (e.g. `edge`) is still caught on the branch's PR into
# `main`. To gate that earlier hop too, add the branch to the `branches:` list
# below — nothing else needs to change.
#
# Runner: the same self-hosted `ubuntu-latest` runner release.yml uses. Rust is
# not assumed to be preinstalled, so the toolchain step bootstraps it the same
# way release.yml does (minus the MinGW cross-compile deps — PRs build for the
# host only; the Windows cross-build stays a release-time concern).
name: PR Checks
on:
pull_request:
branches: [main]
# A newer push to the same PR cancels the in-flight run.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
env:
WORKDIR: sidecar
jobs:
rust-gates:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# One job runs all three gates on purpose: installing the toolchain costs
# far more than the checks themselves, so splitting fmt/clippy/test into
# parallel jobs would pay that cost three times for no wall-clock win.
- name: Install Rust toolchain (rustfmt + clippy)
run: |
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
build-essential curl ca-certificates git
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable
fi
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt clippy
cargo --version && cargo fmt --version && cargo clippy --version
# Keyed on Cargo.lock: dependency builds are reused until a dep actually
# changes. A cache miss only makes the run slower, never wrong.
- name: Cache cargo registry and build dir
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
sidecar/target
key: ${{ runner.os }}-cargo-${{ hashFiles('sidecar/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
# Cheapest gate first — parses only, no compile, so a formatting slip
# fails in seconds instead of after a full build.
- name: cargo fmt --check
working-directory: sidecar
run: cargo fmt --check
# --all-targets covers tests and examples, not just the binary.
# -D warnings makes a lint a failure; the crate is clean at this bar today,
# so anything new here is a regression introduced by the PR.
- name: cargo clippy
working-directory: sidecar
run: cargo clippy --locked --all-targets -- -D warnings
# --locked matches release.yml: it also proves Cargo.lock is in sync with
# Cargo.toml, rather than letting the build silently update it.
- name: cargo test
working-directory: sidecar
run: cargo test --locked

View File

@@ -28,6 +28,11 @@
# write:package; THIS workflow additionally needs
# `write:repository` so it can push the bump commit + tag
# and create the release. Grant that scope to the token.
# The final step also dispatches RunicGateway/installer's
# bundle workflow, so the token ideally has write there too
# — but that is a nicety, not a requirement: without it the
# step warns and the installer's nightly cron picks the
# release up instead.
# Also: `main` must accept a direct push from that user (disable branch
# protection for it, or add it as an exception) — the bump commit lands on main.
#
@@ -51,6 +56,13 @@ env:
BIN: uo-link-sidecar
LINUX_TARGET: x86_64-unknown-linux-gnu
WINDOWS_TARGET: x86_64-pc-windows-gnu
# Ampere/Graviton instances and Pi-class boxes are a realistic ServUO home,
# and the shard dials the sidecar out on loopback — so wherever the shard
# runs, this binary has to run too (installer PLAN.md §5.2).
ARM64_TARGET: aarch64-unknown-linux-gnu
# Notified after a release so the installer's compat matrix picks up this
# version immediately rather than at its next nightly run (PLAN.md §7.2).
INSTALLER_REPO: RunicGateway/installer
jobs:
release:
@@ -68,6 +80,8 @@ jobs:
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
mkdir -p dist
@@ -105,21 +119,59 @@ jobs:
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is exactly what happened on servuo-plugins' first
# release, where absent REGISTRY_* secrets took the release API call to
# 401 after the tag had already been pushed. Standing down on the tag
# alone makes that state permanent: every later run sees the tag, sets
# RELEASE=false, and the release never appears. Note this deliberately
# OVERRIDES the RELEASE=false decided just above — with the tag in
# place there are no releasable commits after it, so the normal path
# would stand down, which is exactly why it could never self-heal.
REUSE_TAG=false
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Tag v${VERSION} already exists — nothing to release."
RELEASE=false
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token ${CI_TOKEN}" "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
if [ "$REL_HTTP" = "200" ]; then
echo "Tag v${VERSION} already has a release — nothing to do."
RELEASE=false
elif [ "$REL_HTTP" = "404" ]; then
echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true
RELEASE=true
else
# Anything else (000 from a network failure, 401/403 from a bad
# token) is not evidence of absence. Guessing "no release" would
# re-publish over a good one, so refuse instead.
echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess."
exit 1
fi
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.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## ${BIN} v${VERSION}"
echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
echo "$SUBJECTS" | sed 's/^/- /'
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
@@ -129,14 +181,26 @@ jobs:
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
- name: Install Rust toolchain, Windows target, and MinGW linker
- name: Install Rust toolchain, cross targets, and their linkers
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
# The arm64 cross toolchain is not optional for this crate: sqlx's
# sqlite feature pulls libsqlite3-sys, which compiles bundled SQLite
# from C, so a Rust-only cross build fails at the first .c file.
#
# libc6-dev-arm64-cross is named explicitly because gcc-aarch64-linux-gnu
# only *recommends* it, and this install is --no-install-recommends: the
# compiler arrives without arm64 libc headers and SQLite's build dies on
# `bits/libc-header-start.h: No such file or directory`. Verified by
# reproducing both the failure and the fix in a rust:1-slim-bookworm
# container.
$SUDO apt-get install -y --no-install-recommends \
build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq
build-essential gcc-mingw-w64-x86-64 \
gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
curl ca-certificates git jq
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -146,6 +210,7 @@ jobs:
export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt
rustup target add "${WINDOWS_TARGET}"
rustup target add "${ARM64_TARGET}"
- name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
@@ -187,14 +252,30 @@ jobs:
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
# Same shape as the Windows step: a linker for Rust's output and a CC/AR
# pair for the cc-crate build of bundled SQLite.
- name: cargo build --release (Linux arm64, cross via aarch64-linux-gnu)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: cargo build --release --locked --target "${ARM64_TARGET}"
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
- name: Package artifacts and SHA256SUMS
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
cp "${WORKDIR}/target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
# Every artifact must appear here: the installer verifies its download
# against these sums, and `sha256sum -c` passes silently over a file
# this list does not mention.
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" \
"${BIN}-windows-x86_64.exe" > SHA256SUMS )
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
@@ -225,7 +306,16 @@ jobs:
else
echo "Version unchanged (first release) — no bump commit needed."
fi
git tag "${TAG}"
# The tag may already exist when finishing a run that died after tagging
# (see the plan step). `git tag` on an existing name fails under
# `set -e`; pushing an identical existing tag is a harmless no-op. A
# push that fails here means the remote tag points somewhere else,
# which SHOULD stop the run.
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Tag ${TAG} already exists — reusing it."
else
git tag "${TAG}"
fi
git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
@@ -250,9 +340,51 @@ jobs:
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
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}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done
# ── Recompose the installer's bundle manifest ────────────────────────
# The installer does not resolve "latest" at run time — it installs the
# exact combination named by a published bundle (docs/installer/PLAN.md
# §7.1). So a sidecar release that nobody recomposes around is a release
# no operator will ever be offered. This step tells the installer repo to
# rebuild that manifest now, instead of leaving the new version invisible
# until its nightly cron.
#
# DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint
# returns no run handle, so there is nothing to poll: a waiting step would
# have to guess which run is its own and hold a runner idle to do it. The
# bundle job runs its own gates regardless of who started it.
#
# A failure here is a WARNING, never a failure of this job. The release is
# already published and correct by this point; failing the run would
# misreport that. The installer's nightly cron recomposes from whatever
# the latest releases actually are, so a dropped dispatch self-heals — it
# costs latency, not correctness.
#
# `repository_dispatch` is deliberately not used: support for it is
# uncertain on this Gitea version, while dispatching an existing
# workflow_dispatch workflow via the API works today.
- name: Ask the installer repo to recompose its bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}' \
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
case "$HTTP" in
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
403|404)
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
*)
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
esac

View File

@@ -25,6 +25,7 @@ network-facing component, which is what keeps the game unreachable from the inte
| Path | What |
|------|------|
| `sidecar/` | The Rust sidecar crate — terminates the loopback link to the shard, exposes WS + REST to the website. See [`sidecar/README.md`](sidecar/README.md). |
| `.gitea/workflows/pr-checks.yml` | Gates every PR into `main` on `cargo fmt --check`, `cargo clippy -D warnings`, and `cargo test`. |
| `.gitea/workflows/release.yml` | Builds + releases the sidecar binary (Linux + Windows) on every merge to `main`. |
## Build & run
@@ -38,10 +39,30 @@ cp sidecar.toml.example sidecar.toml # then edit
cargo run --release
```
Deploying it rather than developing on it: `--config <PATH>` names the config file (as does
`$UOLINK_CONFIG`), and `--print-config` prints the resolved settings — **including the auth token
the website needs** — as JSON, provisioning the config file on first run. That is the supported way
to read the token back; it is not meant to be scraped from the log.
```bash
uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
```
`.gitea/workflows/release.yml` cross-compiles Linux + Windows binaries and cuts a Gitea release on
every merge to `main` (conventional-commit versioning). See [`sidecar/README.md`](sidecar/README.md)
for configuration and the wire protocol.
Before that, `.gitea/workflows/pr-checks.yml` runs the same gates on every pull request into `main`
`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, then `cargo test --locked`. Run them
locally before pushing and the PR will be green:
```bash
cd sidecar
cargo fmt # or --check to just report
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
```
## Deployment & compatibility
The plugin ([RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins))

View File

@@ -18,9 +18,61 @@ RUST_LOG=debug cargo run # see every event, incl. pong heartbeats
On first run it writes `sidecar.toml` with a generated auth token and logs the path. Binds the shard listener (`127.0.0.1:7788`) and the web server (`127.0.0.1:8080`) from that file, then waits for the shard to connect.
## Command line
Four flags. Everything else is configuration, and configuration lives in the file.
```
uo-link-sidecar [--print-config] [--config <PATH>] [-V|--version] [-h|--help]
```
| Flag | What |
|------|------|
| `--print-config` | Resolve the configuration, print it as JSON on stdout, exit. |
| `--config <PATH>` | Path to `sidecar.toml`. Outranks `$UOLINK_CONFIG`; default `./sidecar.toml`. |
| `-V`, `--version` | `uo-link-sidecar <ver> (protocol <n>)`. |
| `-h`, `--help` | Usage. |
An unrecognized argument is an error (exit `2`), not something to ignore — a typo'd flag would otherwise start a sidecar that is not the one you asked for.
### `--print-config`
The non-interactive way to read the sidecar's own settings back, so an installer or a diagnostic never has to scrape the startup log or parse TOML:
```console
$ uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
{
"component": "uo-link-sidecar",
"config_created": false,
"config_path": "/etc/runicgateway/sidecar.toml",
"protocol": 3,
"shard": { "bind": "127.0.0.1:7788" },
"store": { "path": "/var/lib/runicgateway/uo-link.db" },
"token_generated": false,
"version": "0.1.0",
"web": {
"auth_required": true,
"auth_token": "c0f04ace66a937edff407d9dc25d5d8a967b0300e3306f11",
"bind": "127.0.0.1:8080",
"ws_path": "/ws"
}
}
```
- **It contains the auth token in clear text.** That is the point — those values go straight into Admin → Shard — but it means the output is a secret: don't pipe it into a log or a CI artifact.
- **It performs first-run setup**, exactly as a normal start would: a missing config file is written and a blank token is generated and saved. So `--print-config` on a fresh host provisions the sidecar *and* tells you its token in one step. `config_created` and `token_generated` report whether this run did either, which is how a re-run distinguishes "read an existing install" from "provisioned a new one".
- Paths are the **resolved absolute** ones, not what the file literally says.
- Nothing else is written to stdout — the log subscriber is not started in this mode, so the JSON is the entire output.
## Configuration & auth
All runtime settings live in `sidecar.toml` (path overridable with `$UOLINK_CONFIG`) — **nothing is compiled into the binary**. See `sidecar.toml.example`. Environment variables override the file: `UOLINK_SHARD_BIND`, `UOLINK_WEB_BIND`, `UOLINK_WEB_TOKEN`, `UOLINK_DB_PATH`.
All runtime settings live in `sidecar.toml` (path overridable with `--config` or `$UOLINK_CONFIG`) — **nothing is compiled into the binary**. See `sidecar.toml.example`. Environment variables override the file: `UOLINK_SHARD_BIND`, `UOLINK_WEB_BIND`, `UOLINK_WEB_TOKEN`, `UOLINK_DB_PATH`.
### Where the data goes
A **relative** `[store].path` resolves against the directory holding `sidecar.toml`, not the process's working directory. Under `cargo run` those are the same thing, so nothing changes for development; for an installed service they are emphatically not. A unit that pins `UOLINK_CONFIG=/etc/runicgateway/sidecar.toml` and leaves the default `uo-link.db` gets `/etc/runicgateway/uo-link.db` — beside its config, deterministically — instead of a database wherever the service manager happened to set CWD (`%SystemRoot%\System32`, or a silently redirected VirtualStore copy under `C:\Program Files\`).
Absolute paths are used as written, and the parent directory is created if it does not exist, so a service can name `/var/lib/runicgateway/uo-link.db` on a host where nothing has created that directory yet. Paths are handed to SQLite as filesystem paths rather than being formatted into a `sqlite://` URL, so a `%`, `#`, `?` or space in the path means what it looks like.
The website authenticates to the sidecar with a shared token, presented as:
@@ -56,7 +108,7 @@ Bump `PROTOCOL_VERSION` in `main.rs` whenever an event or endpoint's shape chang
```json
{
"status": "ok", // "ok" when plugin connected and DB reachable, else "degraded"
"protocol": 1,
"protocol": 3,
"plugin_connected": true, // is the shard link up?
"database": "ok",
"uptime": "3d 12h",
@@ -101,7 +153,9 @@ A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request
- **`shard.rs`** — `serve()` binds the listener and accepts shard connections in a loop. Each connection splits into read/write halves: the read half parses newline-JSON into `ShardEvent { kind, value }` and forwards them; the write half drains an mpsc of command lines. `ShardHandle::send` posts a command to whichever shard is currently connected, and **drops with a warning if none is** — a website query during a shard outage should fail fast and retry, not queue behind a reconnect. Live *events* that must survive an outage are buffered by the shard, not here.
- **`web.rs`** — the website-facing HTTP surface (axum). `AppState` holds the `broadcast::Sender<String>`; each `/ws` client subscribes and forwards every event as a text frame. A client that lags past the broadcast buffer is warned and kept live (it just misses events) rather than stalling the others. This side *may* be exposed beyond loopback — it is the gatekeeper, so add auth when you do.
- **`rpc.rs`** — request/reply correlation over the one shard socket. A REST call registers a pending entry under a correlation id, sends the command, and awaits the reply (10 s timeout). The event loop routes any incoming line whose id is pending back to the waiter; everything else flows on as a live event. Recognizes three correlation fields, matching what the plugin echoes: `reqId` (queries), `code` (link), `id` (town-crier).
- **`store.rs`** — SQLite (`sqlx`). Three tables: `events` (the full live stream, append-only), `links` (account ↔ website user, mirrored from `link.ok`), `profiles` (last-known character sheet, cached from `char.profile`). History and economy read here instead of the shard; `pong` is dropped as ephemeral chatter. DB file defaults to `uo-link.db` (`DB_PATH` in `main.rs`), gitignored.
- **`store.rs`** — SQLite (`sqlx`). Three tables: `events` (the full live stream, append-only), `links` (account ↔ website user, mirrored from `link.ok`), `profiles` (last-known character sheet, cached from `char.profile`). History and economy read here instead of the shard; `pong` is dropped as ephemeral chatter. The DB file is `[store].path` (default `uo-link.db` beside the config), gitignored.
- **`config.rs`** — resolves the config file, applies the environment overrides, guarantees an auth token, anchors relative paths, and renders the `--print-config` document.
- **`cli.rs`** — the four flags above. Hand-rolled; no argument-parsing dependency.
- **`main.rs`** — wires it together: the shard event loop first tries to route each line as an RPC reply; if it isn't one, the line is a live event — logged, persisted, and broadcast to WS.
## Wire protocol

View File

@@ -1,12 +1,15 @@
# uo-link sidecar configuration — example.
#
# The sidecar reads `sidecar.toml` (override the path with $UOLINK_CONFIG). If that file
# is absent on first run, one is generated automatically with a random auth_token, so you
# normally do not create this by hand — just start the sidecar and edit the file it writes.
# Nothing here is compiled into the binary.
# The sidecar reads `sidecar.toml` (override the path with --config or $UOLINK_CONFIG).
# If that file is absent on first run, one is generated automatically with a random
# auth_token, so you normally do not create this by hand — just start the sidecar and edit
# the file it writes. Nothing here is compiled into the binary.
#
# Environment variables override the file:
# UOLINK_SHARD_BIND, UOLINK_WEB_BIND, UOLINK_WEB_TOKEN, UOLINK_DB_PATH
#
# Read the resolved settings back without starting the sidecar (JSON, includes the token):
# uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
[shard]
# Loopback address the shard dials out to. Keep this on localhost — the game must not
@@ -27,4 +30,9 @@ bind = "127.0.0.1:8080"
auth_token = "replace-with-a-long-random-secret"
[store]
# A RELATIVE path resolves against the directory holding this file, not the working
# directory of the process — so a service pinned to /etc/runicgateway/sidecar.toml keeps
# its database beside its config no matter what CWD the service manager picked. Give an
# absolute path (or set UOLINK_DB_PATH) to put the data somewhere else, e.g.
# /var/lib/runicgateway/uo-link.db or C:\ProgramData\RunicGateway\uo-link.db.
path = "uo-link.db"

172
sidecar/src/cli.rs Normal file
View File

@@ -0,0 +1,172 @@
//! Command-line surface.
//!
//! The sidecar is configured by file and environment (see [`crate::config`]); this is deliberately
//! not a second configuration mechanism. It exists so the binary can be *driven by an installer*
//! rather than only by a human reading its logs:
//!
//! - `--print-config` resolves the configuration exactly as a normal start would — including
//! generating the auth token on first run — and prints it as JSON on stdout. That is the
//! supported way to obtain the token for the website's Admin → Shard form. Before this existed,
//! the only way to read it back was to scrape the startup log or parse `sidecar.toml`.
//! - `--config <PATH>` names the config file without having to export `UOLINK_CONFIG`, so a
//! diagnostic run can point at an installed config from any working directory.
//!
//! Hand-rolled rather than pulled from a crate: four flags, no subcommands, no completions. A
//! dependency here would be larger than the code it replaced.
/// What this invocation should do. Everything except `Run` prints and exits.
#[derive(Debug, PartialEq, Eq)]
pub enum Mode {
/// Normal operation: bind the shard listener and the web server.
Run,
/// Resolve config, print it as JSON, exit.
PrintConfig,
Help,
Version,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Cli {
pub mode: Mode,
/// `--config <PATH>`, which outranks `$UOLINK_CONFIG`.
pub config: Option<String>,
}
pub const USAGE: &str = "\
uo-link sidecar — bridges a ServUO shard to the Runic Gateway website.
Usage: uo-link-sidecar [OPTIONS]
Options:
--print-config Resolve the configuration, print it as JSON, and exit.
Runs first-run setup like a normal start does: if the
config file is missing it is written, and a blank auth
token is generated and saved. The JSON CONTAINS THE
AUTH TOKEN in clear text.
--config <PATH> Path to sidecar.toml. Overrides $UOLINK_CONFIG;
defaults to ./sidecar.toml.
-V, --version Print the sidecar and protocol versions and exit.
-h, --help Print this help and exit.
Configuration lives in sidecar.toml; environment variables override the file:
UOLINK_CONFIG, UOLINK_SHARD_BIND, UOLINK_WEB_BIND, UOLINK_WEB_TOKEN,
UOLINK_DB_PATH
";
/// Parses arguments **without** the program name.
///
/// Returns the message to print on stderr when the arguments are unusable; the caller exits `2`.
pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
let mut mode = Mode::Run;
let mut config = None;
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--print-config" => mode = Mode::PrintConfig,
"-h" | "--help" => {
return Ok(Cli {
mode: Mode::Help,
config,
})
}
"-V" | "--version" => {
return Ok(Cli {
mode: Mode::Version,
config,
})
}
"--config" => {
// `--config` with nothing after it would otherwise silently fall through and start
// the sidecar against the default config — the opposite of what was asked for.
let path = it
.next()
.ok_or_else(|| "--config requires a path".to_string())?;
config = Some(path);
}
_ => match arg.strip_prefix("--config=") {
Some("") => return Err("--config requires a path".into()),
Some(path) => config = Some(path.to_string()),
None => return Err(format!("unrecognized argument: {arg}")),
},
}
}
Ok(Cli { mode, config })
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_str(args: &[&str]) -> Result<Cli, String> {
parse(args.iter().map(|s| s.to_string()))
}
#[test]
fn no_arguments_runs_the_sidecar() {
let cli = parse_str(&[]).unwrap();
assert_eq!(cli.mode, Mode::Run);
assert_eq!(cli.config, None);
}
#[test]
fn print_config_is_recognized() {
assert_eq!(
parse_str(&["--print-config"]).unwrap().mode,
Mode::PrintConfig
);
}
#[test]
fn config_accepts_both_spellings() {
let spaced = parse_str(&["--config", "/etc/runicgateway/sidecar.toml"]).unwrap();
let equals = parse_str(&["--config=/etc/runicgateway/sidecar.toml"]).unwrap();
assert_eq!(
spaced.config.as_deref(),
Some("/etc/runicgateway/sidecar.toml")
);
assert_eq!(spaced, equals);
}
#[test]
fn config_combines_with_print_config() {
let cli = parse_str(&["--config", "c.toml", "--print-config"]).unwrap();
assert_eq!(cli.mode, Mode::PrintConfig);
assert_eq!(cli.config.as_deref(), Some("c.toml"));
}
#[test]
fn a_path_is_never_swallowed_as_a_flag() {
// `--config --print-config` takes the next token as the path, wrong as that path is. The
// alternative — treating it as a missing value — guesses at intent.
let cli = parse_str(&["--config", "--print-config"]).unwrap();
assert_eq!(cli.mode, Mode::Run);
assert_eq!(cli.config.as_deref(), Some("--print-config"));
}
#[test]
fn config_without_a_value_is_an_error() {
assert!(parse_str(&["--config"]).is_err());
assert!(parse_str(&["--config="]).is_err());
}
#[test]
fn unknown_arguments_are_rejected() {
// Silently ignoring a typo'd flag would start a sidecar that is not what was asked for.
let err = parse_str(&["--pirnt-config"]).unwrap_err();
assert!(err.contains("--pirnt-config"), "{err}");
assert!(parse_str(&["/etc/runicgateway/sidecar.toml"]).is_err());
}
#[test]
fn help_and_version_win_immediately() {
assert_eq!(parse_str(&["--help", "--bogus"]).unwrap().mode, Mode::Help);
assert_eq!(parse_str(&["-h"]).unwrap().mode, Mode::Help);
assert_eq!(
parse_str(&["--version", "--bogus"]).unwrap().mode,
Mode::Version
);
assert_eq!(parse_str(&["-V"]).unwrap().mode, Mode::Version);
}
}

View File

@@ -4,15 +4,26 @@
//! first run, if the file is absent, a default one is written with a freshly generated auth token,
//! so the sidecar is secured out of the box and the operator just copies the token to the website.
//!
//! File path: `$UOLINK_CONFIG`, else `sidecar.toml` in the working directory.
//! File path: `--config <PATH>`, else `$UOLINK_CONFIG`, else `sidecar.toml` in the working
//! directory.
//!
//! **Paths are anchored to the config file, not the working directory.** A relative
//! `[store].path` resolves against the directory holding `sidecar.toml`. A service started with
//! `UOLINK_CONFIG=/etc/runicgateway/sidecar.toml` therefore keeps its database beside its config
//! instead of wherever the service manager happened to set the working directory — which on
//! Windows can be `%SystemRoot%\System32` or, under `C:\Program Files\`, a silently redirected
//! VirtualStore copy. The values reported by `--print-config` are the resolved absolute ones.
use std::env;
use std::fs;
use std::path::Path;
use std::path::{Component, Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use tracing::info;
use crate::PROTOCOL_VERSION;
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
@@ -33,8 +44,8 @@ pub struct ShardCfg {
pub struct WebCfg {
#[serde(default = "default_web_bind")]
pub bind: String,
/// Shared secret the website must present. Empty means the web surface is unauthenticated —
/// only acceptable when `bind` is loopback; refused otherwise (see `Config::validate`).
/// Shared secret the website must present. Never empty in practice — `Config::load` generates
/// and persists one when it finds none, so the web surface is authenticated from first boot.
#[serde(default)]
pub auth_token: String,
}
@@ -45,6 +56,20 @@ pub struct StoreCfg {
pub path: String,
}
/// A loaded configuration plus what loading it *did* — an installer re-running the binary needs to
/// distinguish "read an existing install" from "provisioned a new one", and it cannot tell from the
/// values alone.
#[derive(Debug)]
pub struct Loaded {
pub cfg: Config,
/// Absolute path of the config file that was read or written.
pub path: PathBuf,
/// The config file did not exist and was created by this run.
pub config_created: bool,
/// No usable token was configured, so one was generated and saved.
pub token_generated: bool,
}
fn default_shard_bind() -> String {
"127.0.0.1:7788".into()
}
@@ -79,9 +104,20 @@ impl Default for StoreCfg {
}
impl Config {
pub fn load() -> anyhow::Result<Self> {
let path = env::var("UOLINK_CONFIG").unwrap_or_else(|_| "sidecar.toml".into());
let existed = Path::new(&path).exists();
/// Which config file this invocation will use: `--config`, else `$UOLINK_CONFIG`, else
/// `sidecar.toml` beside the working directory. Always returned absolute, so every later
/// message names a path the operator can act on.
pub fn resolve_path(cli_override: Option<&str>) -> PathBuf {
let raw = cli_override
.map(str::to_string)
.or_else(|| env::var("UOLINK_CONFIG").ok())
.unwrap_or_else(|| "sidecar.toml".into());
absolutize(PathBuf::from(raw))
}
pub fn load(cli_override: Option<&str>) -> anyhow::Result<Loaded> {
let path = Self::resolve_path(cli_override);
let existed = path.exists();
let mut cfg: Config = if existed {
let text = fs::read_to_string(&path)?;
@@ -95,12 +131,16 @@ impl Config {
// Authentication is always on. A blank token is never allowed — if none is set (fresh
// install, or someone cleared it), generate one, save it, and continue. This keeps setup
// effortless while making it impossible to accidentally run with auth off.
if cfg.web.auth_token.trim().is_empty() {
let token_generated = cfg.web.auth_token.trim().is_empty();
if token_generated {
let token = generate_token();
if existed {
persist_token(&path, &token)?;
} else {
// The parent may not exist yet when an installer points at a fresh
// /etc/runicgateway; failing here would mean "run me again after mkdir".
create_parent_dir(&path)?;
fs::write(&path, default_file(&token))?;
}
@@ -108,10 +148,17 @@ impl Config {
info!("No auth token configured.");
info!("Generated new token: {}", token);
info!("Saved to {}. Authentication is on.", path);
info!("Saved to {}. Authentication is on.", path.display());
}
Ok(cfg)
cfg.anchor_store_path(&path);
Ok(Loaded {
cfg,
path,
config_created: !existed,
token_generated,
})
}
/// Environment overrides, so a deployment can set secrets without editing the file.
@@ -130,15 +177,102 @@ impl Config {
}
}
/// Resolves `[store].path` against the config file's directory (see the module docs). Absolute
/// paths and SQLite's non-filesystem spellings are left exactly as written.
fn anchor_store_path(&mut self, config_path: &Path) {
if is_sqlite_special(&self.store.path) {
return;
}
let raw = PathBuf::from(&self.store.path);
let anchored = if raw.is_absolute() {
raw
} else {
config_dir(config_path).join(raw)
};
self.store.path = absolutize(anchored).to_string_lossy().into_owned();
}
pub fn auth_required(&self) -> bool {
// Always true now — load() guarantees a non-empty token.
!self.web.auth_token.is_empty()
}
}
/// The `--print-config` document: everything an installer needs to register this sidecar with a
/// website, in one non-interactive read.
///
/// **This includes the auth token in clear text**, which is the point — §2.4 of the installer plan
/// calls the manual token hunt the largest "I installed it and nothing happened" failure mode. The
/// caller prints it to stdout and starts no log subscriber, so the document is the whole output.
pub fn describe(loaded: &Loaded) -> serde_json::Value {
json!({
"component": "uo-link-sidecar",
"version": env!("CARGO_PKG_VERSION"),
"protocol": PROTOCOL_VERSION,
"config_path": loaded.path.to_string_lossy(),
"config_created": loaded.config_created,
"token_generated": loaded.token_generated,
"shard": { "bind": loaded.cfg.shard.bind },
"web": {
"bind": loaded.cfg.web.bind,
"ws_path": crate::web::WS_PATH,
"auth_required": loaded.cfg.auth_required(),
"auth_token": loaded.cfg.web.auth_token,
},
"store": { "path": loaded.cfg.store.path },
})
}
/// Directory holding the config file. A bare `sidecar.toml` has no parent component, which would
/// join into an empty base — treat it as the current directory.
fn config_dir(config_path: &Path) -> PathBuf {
match config_path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
}
}
/// Prefixes the working directory onto a relative path, then drops the `.` components that
/// joining leaves behind — cosmetic, but these paths are printed and pasted into service units.
fn absolutize(p: PathBuf) -> PathBuf {
let joined = if p.is_absolute() {
p
} else {
match env::current_dir() {
Ok(cwd) => cwd.join(p),
Err(_) => p,
}
};
let cleaned: PathBuf = joined
.components()
.filter(|c| !matches!(c, Component::CurDir))
.collect();
if cleaned.as_os_str().is_empty() {
joined
} else {
cleaned
}
}
/// `:memory:` and `file:` URIs are instructions to SQLite, not paths on disk. Anchoring them to a
/// directory would turn a working in-memory store into an attempt to create a file called
/// `:memory:` — which Windows cannot even name.
fn is_sqlite_special(path: &str) -> bool {
path == ":memory:" || path.starts_with("file:")
}
fn create_parent_dir(path: &Path) -> anyhow::Result<()> {
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() && !dir.exists() {
fs::create_dir_all(dir)?;
}
}
Ok(())
}
/// Rewrites the `auth_token` line in an existing config file, preserving everything else. Falls
/// back to inserting it under `[web]`, or appending a `[web]` section, if the key is absent.
fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
fn persist_token(path: &Path, token: &str) -> anyhow::Result<()> {
let text = fs::read_to_string(path)?;
let line = format!("auth_token = \"{token}\"");
@@ -213,10 +347,269 @@ bind = "127.0.0.1:8080"
# WebSocket: add ?token=<token> to the connect URL
# Authentication is always on: if this is left blank, the sidecar generates a new
# token here on startup. Rotate by changing this value and restarting.
# Read it back without starting the sidecar: uo-link-sidecar --print-config
auth_token = "{token}"
[store]
# Relative paths resolve against the directory holding THIS FILE, not the working
# directory of the process.
path = "uo-link.db"
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
/// A unique scratch directory. `std::env::temp_dir()` plus the test name keeps the cases
/// independent under the default parallel test runner.
fn scratch(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!("uo-link-cfg-test-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
/// `Config::load` consults the process environment, and a developer may have `UOLINK_*` set
/// for a local shard. Mutating shared env state from a test thread is worse than skipping, so
/// the two cases that exercise the full load path bail out instead of failing spuriously.
fn env_overrides_present() -> bool {
[
"UOLINK_SHARD_BIND",
"UOLINK_WEB_BIND",
"UOLINK_WEB_TOKEN",
"UOLINK_DB_PATH",
]
.iter()
.any(|k| env::var_os(k).is_some())
}
fn cfg_with_store(path: &str) -> Config {
Config {
store: StoreCfg { path: path.into() },
..Default::default()
}
}
#[test]
fn relative_store_path_anchors_to_the_config_directory() {
// The working-directory trap: the service pins UOLINK_CONFIG but the service manager
// decides the CWD, so a relative db path must not follow the CWD.
let mut cfg = cfg_with_store("uo-link.db");
let config_path = if cfg!(windows) {
PathBuf::from(r"C:\ProgramData\RunicGateway\sidecar.toml")
} else {
PathBuf::from("/etc/runicgateway/sidecar.toml")
};
cfg.anchor_store_path(&config_path);
let expected = config_path.parent().unwrap().join("uo-link.db");
assert_eq!(Path::new(&cfg.store.path), expected);
}
#[test]
fn absolute_store_path_is_left_alone() {
let absolute = if cfg!(windows) {
r"C:\ProgramData\RunicGateway\uo-link.db"
} else {
"/var/lib/runicgateway/uo-link.db"
};
let mut cfg = cfg_with_store(absolute);
cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml"));
assert_eq!(cfg.store.path, absolute);
}
#[test]
fn a_bare_config_filename_anchors_to_the_working_directory() {
// `cargo run` in the crate root: config dir and CWD are the same, so the historical
// behavior (db beside the binary's CWD) is preserved exactly.
let mut cfg = cfg_with_store("uo-link.db");
cfg.anchor_store_path(Path::new("sidecar.toml"));
assert_eq!(
Path::new(&cfg.store.path),
env::current_dir().unwrap().join("uo-link.db")
);
}
#[test]
fn sqlite_special_paths_are_not_anchored() {
for special in [":memory:", "file:cache?mode=memory"] {
let mut cfg = cfg_with_store(special);
cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml"));
assert_eq!(cfg.store.path, special);
}
}
#[test]
fn resolve_path_prefers_the_cli_override() {
// Absolute in, absolute out — and unchanged, so the operator sees the path they passed.
let explicit = if cfg!(windows) {
r"C:\tmp\custom.toml"
} else {
"/tmp/custom.toml"
};
assert_eq!(
Config::resolve_path(Some(explicit)),
PathBuf::from(explicit)
);
}
#[test]
fn resolve_path_makes_a_relative_override_absolute() {
let resolved = Config::resolve_path(Some("./conf/sidecar.toml"));
assert!(resolved.is_absolute(), "{}", resolved.display());
assert_eq!(
resolved,
env::current_dir()
.unwrap()
.join("conf")
.join("sidecar.toml")
);
}
#[test]
fn persist_token_replaces_an_existing_key() {
let dir = scratch("replace");
let path = dir.join("sidecar.toml");
fs::write(
&path,
"[web]\nbind = \"127.0.0.1:8080\"\nauth_token = \"\"\n\n[store]\npath = \"x.db\"\n",
)
.unwrap();
persist_token(&path, "deadbeef").unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("auth_token = \"deadbeef\""), "{out}");
assert_eq!(out.matches("auth_token").count(), 1, "{out}");
// Everything else survives — the file is the operator's, not ours to rewrite.
assert!(out.contains("bind = \"127.0.0.1:8080\""), "{out}");
assert!(out.contains("path = \"x.db\""), "{out}");
}
#[test]
fn persist_token_inserts_under_an_existing_web_section() {
let dir = scratch("insert");
let path = dir.join("sidecar.toml");
fs::write(&path, "[web]\nbind = \"127.0.0.1:8080\"\n").unwrap();
persist_token(&path, "deadbeef").unwrap();
let out = fs::read_to_string(&path).unwrap();
let web = out.find("[web]").unwrap();
let token = out.find("auth_token").unwrap();
assert!(token > web, "token must land inside [web]: {out}");
assert!(out.contains("auth_token = \"deadbeef\""), "{out}");
}
#[test]
fn persist_token_appends_a_web_section_when_there_is_none() {
let dir = scratch("append");
let path = dir.join("sidecar.toml");
fs::write(&path, "[shard]\nbind = \"127.0.0.1:7788\"\n").unwrap();
persist_token(&path, "deadbeef").unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("[shard]"), "{out}");
assert!(out.contains("[web]\nauth_token = \"deadbeef\""), "{out}");
}
#[test]
fn a_generated_config_round_trips_through_the_parser() {
// The template is a format! string, so a stray brace or a bad key would only ever surface
// on someone's first run.
let cfg: Config = toml::from_str(&default_file("deadbeef")).expect("template parses");
assert_eq!(cfg.web.auth_token, "deadbeef");
assert_eq!(cfg.shard.bind, "127.0.0.1:7788");
assert_eq!(cfg.web.bind, "127.0.0.1:8080");
assert_eq!(cfg.store.path, "uo-link.db");
}
#[test]
fn generated_tokens_are_random_and_hex() {
let (a, b) = (generate_token(), generate_token());
assert_ne!(a, b);
assert_eq!(a.len(), 48);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
}
#[test]
fn describe_reports_the_resolved_configuration() {
let loaded = Loaded {
cfg: Config {
shard: ShardCfg {
bind: "127.0.0.1:7788".into(),
},
web: WebCfg {
bind: "0.0.0.0:8080".into(),
auth_token: "deadbeef".into(),
},
store: StoreCfg {
path: "/var/lib/runicgateway/uo-link.db".into(),
},
},
path: PathBuf::from("/etc/runicgateway/sidecar.toml"),
config_created: true,
token_generated: true,
};
let doc = describe(&loaded);
assert_eq!(doc["component"], "uo-link-sidecar");
assert_eq!(doc["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(doc["protocol"], PROTOCOL_VERSION);
assert_eq!(doc["config_path"], "/etc/runicgateway/sidecar.toml");
assert_eq!(doc["config_created"], true);
assert_eq!(doc["token_generated"], true);
assert_eq!(doc["shard"]["bind"], "127.0.0.1:7788");
assert_eq!(doc["web"]["bind"], "0.0.0.0:8080");
assert_eq!(doc["web"]["ws_path"], "/ws");
assert_eq!(doc["web"]["auth_required"], true);
assert_eq!(doc["web"]["auth_token"], "deadbeef");
assert_eq!(doc["store"]["path"], "/var/lib/runicgateway/uo-link.db");
}
#[test]
fn load_provisions_a_missing_config_and_reports_it() {
if env_overrides_present() {
return;
}
let dir = scratch("provision");
let path = dir.join("sidecar.toml");
let loaded = Config::load(Some(path.to_str().unwrap())).unwrap();
assert!(loaded.config_created);
assert!(loaded.token_generated);
assert!(
path.exists(),
"the config file must be written, not just held in memory"
);
assert!(!loaded.cfg.web.auth_token.is_empty());
// The db lands beside the config, whatever the working directory is.
assert_eq!(Path::new(&loaded.cfg.store.path), dir.join("uo-link.db"));
// Second run: same token, and nothing reported as new.
let again = Config::load(Some(path.to_str().unwrap())).unwrap();
assert!(!again.config_created);
assert!(!again.token_generated);
assert_eq!(again.cfg.web.auth_token, loaded.cfg.web.auth_token);
}
#[test]
fn load_creates_the_config_directory() {
if env_overrides_present() {
return;
}
// An installer pointing at a fresh /etc/runicgateway should not have to mkdir first.
let dir = scratch("mkdir").join("nested").join("deeper");
let path = dir.join("sidecar.toml");
let loaded = Config::load(Some(path.to_str().unwrap())).unwrap();
assert!(path.exists(), "{}", path.display());
assert!(loaded.config_created);
}
}

View File

@@ -3,6 +3,7 @@
//! Terminates the loopback link to the ServUO shard and exposes a website-facing HTTP surface. So
//! far: the shard link (bidirectional) and a WebSocket live feed. REST queries and SQLite come next.
mod cli;
mod config;
mod rpc;
mod shard;
@@ -33,13 +34,48 @@ pub const PROTOCOL_VERSION: u32 = 3;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {
Ok(args) => args,
Err(msg) => {
eprintln!("uo-link-sidecar: {msg}\n\n{}", cli::USAGE);
std::process::exit(2);
}
};
match args.mode {
cli::Mode::Help => {
print!("{}", cli::USAGE);
return Ok(());
}
cli::Mode::Version => {
println!(
"uo-link-sidecar {} (protocol {})",
env!("CARGO_PKG_VERSION"),
PROTOCOL_VERSION
);
return Ok(());
}
// Tracing stays uninitialized here on purpose: the subscriber writes to stdout, and stdout
// is the document. Config::load's messages are dropped rather than interleaved into JSON
// an installer is about to parse — everything they would have said is in the document.
cli::Mode::PrintConfig => {
let loaded = config::Config::load(args.config.as_deref())?;
println!("{:#}", config::describe(&loaded));
return Ok(());
}
cli::Mode::Run => {}
}
init_tracing();
info!("uo-link sidecar starting");
let cfg = config::Config::load()?;
let loaded = config::Config::load(args.config.as_deref())?;
let cfg = loaded.cfg;
info!(
config = %loaded.path.display(),
shard = %cfg.shard.bind,
web = %cfg.web.bind,
db = %cfg.store.path,
auth = cfg.auth_required(),
"configuration loaded"
);
@@ -262,11 +298,7 @@ async fn main() -> anyhow::Result<()> {
// lets a reader tell a re-send from an actual config change.
"world.ruleset" => {
if let Err(e) = event_store
.upsert_ruleset(
ev.value.get("rev").and_then(|r| r.as_str()),
&text,
t,
)
.upsert_ruleset(ev.value.get("rev").and_then(|r| r.as_str()), &text, t)
.await
{
tracing::warn!(error = %e, "failed to upsert ruleset");

View File

@@ -6,7 +6,7 @@
//! instead of round-tripping the shard. Links and profiles are written from the REST reply paths
//! (`link.ok`, `char.profile`), which are RPC replies and never hit the broadcast stream.
use std::str::FromStr;
use std::path::Path;
use serde_json::Value;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
@@ -20,9 +20,24 @@ pub struct Store {
impl Store {
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
///
/// `path` is a filesystem path, handed to sqlx as one. It is deliberately **not** formatted
/// into a `sqlite://` URL first: that spelling is parsed as a URL, so it percent-decodes the
/// path and splits it on `?`. Under an installed layout the path is absolute and chosen by the
/// operator — `C:\ProgramData\RunicGateway\uo-link.db`, or something under a home directory
/// with a `%` or `#` in it — and a URL round-trip silently opens a *different* file.
pub async fn open(path: &str) -> anyhow::Result<Self> {
let opts =
SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true);
// A service unit can name a data directory that does not exist yet; creating it here means
// one less way for a fresh install to fail on first start.
if let Some(dir) = Path::new(path).parent() {
if !dir.as_os_str().is_empty() && !dir.exists() {
std::fs::create_dir_all(dir)?;
}
}
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4)
@@ -299,7 +314,12 @@ impl Store {
/// `world.ruleset` frame per connect describing how it is configured, and only the latest one
/// matters. `rev` is the shard's FNV-1a of the body, kept so a reader can tell "same ruleset,
/// re-sent on reconnect" from "the operator changed something" without diffing the JSON.
pub async fn upsert_ruleset(&self, rev: Option<&str>, json: &str, t: i64) -> anyhow::Result<()> {
pub async fn upsert_ruleset(
&self,
rev: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO ruleset (id, rev, json, updated_t) VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET rev = excluded.rev, json = excluded.json, updated_t = excluded.updated_t",

View File

@@ -43,10 +43,14 @@ pub struct AppState {
pub last_event: Arc<AtomicI64>,
}
/// Path of the live-feed WebSocket. Named because `--print-config` reports it: an installer builds
/// the website's WS URL from `web.bind` plus this, and neither side should be hardcoding it twice.
pub const WS_PATH: &str = "/ws";
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// Everything except /health is behind the auth check.
let protected = Router::new()
.route("/ws", get(ws_upgrade))
.route(WS_PATH, get(ws_upgrade))
// Queries (shard reply correlated by reqId).
.route("/char/:account/:slot", get(char_by_slot))
.route("/char/serial/:serial", get(char_by_serial))