24 Commits

Author SHA1 Message Date
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
cfe9ec9017 Merge pull request 'feat(sidecar)!: bump PROTOCOL_VERSION to 3' (#20) from chore/protocol-3-cutover into edge
Reviewed-on: #20
2026-07-30 03:03:32 +00:00
5f50b881ca feat(sidecar)!: bump PROTOCOL_VERSION to 3
Protocol 3.0 is feature-complete on `edge` -- world.ruleset, points.board and
vendor.listing / vendor.listing.remove all landed there while the sidecar kept
declaring 2, because a bump is an operator-visible hard break (409 on every
protected route via web.rs::gate, and the website closes the WS on the ws.hello
mismatch). Doing it per phase would have broken the site four times; this is the
one time it happens.

Nothing that existed in v2 changed shape, so the version constant and its doc
comment are the whole change here. The README's worked example moves with it --
it still claimed "currently 1", two bumps stale.

Verified against the release binary: /health reports "protocol": 3, every
response carries `X-UOLink-Version: 3`, an authenticated request declaring 2 is
refused 409 {"sidecar_protocol":3,"client_protocol":"2"}, and one declaring 3
gets 200 off /ruleset. cargo build --release + cargo clippy --all-targets clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:03:31 -05:00
05e192ca70 Merge pull request 'feat(sidecar): store and serve the player-vendor market index' (#19) from feat/vendor-listing into edge
Reviewed-on: #19
2026-07-29 20:03:40 +00:00
480423090a feat(sidecar): store and serve the player-vendor market index
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors`
table and serves GET /market.

The frame is authoritative for one vendor, so the upsert is a whole-row
overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed,
expires, or its owner switches off the in-game Vendor Search flag — the last of
those is a privacy control, so dropping the row promptly is the point.

Items ride inside the stored blob and are deliberately not normalized into a
vendor_items table. The sidecar's job for the market is outage resilience
(PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side,
where the query surface, the indexes and the cliloc-resolved names already are.

/market is the only PAGED read the sidecar serves, because it is the only board
that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and
`total` comes back so a caller knows when to stop rather than paging until it
sees a short page, which would race a concurrent sweep. Ordering is by SERIAL,
not shop name: a serial is stable while a shop name is renameable, so a rename
mid-walk cannot make a vendor skip or repeat a page.

The route is /market and not /vendors: /vendors/:account next door is the
per-account RPC, and two routes a prefix apart meaning "this player's shops" and
"every shop on the shard" is a readability trap.

Frames are served verbatim, owner names and coordinates included — the sidecar
defines no audiences (v3.md §3.2).

Verified against the live shard: 27 vendors / 1,040 listings ingested from the
plugin, plus a synthetic insert-then-remove confirming the delete path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:14 -05:00
2e386a9d5c Merge pull request 'feat(store): persist points.board and serve it from GET /points' (#18) from feat/points-board into edge
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:53:17 +00:00
21a1462e62 feat(store): persist points.board and serve it from GET /points
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards as one points.board frame per system; the sidecar folds each into a
projection table and serves them back, so the site's leaderboards page renders
during a shard outage.

  - points_boards(system PK, name, json, updated_t), keyed by the shard's own
    PointsType name. `name` is hoisted only for the ORDER BY.
  - main.rs gains a points.board arm keyed on `system`, alongside the existing
    champ/guild/governor/house/ruleset projections. There is deliberately no
    delete counterpart: the shard's set of point systems is fixed at startup, so
    it emits no points.remove — the same shape the governor board already has.
  - GET /points returns every board ordered by display name; GET /points/:system
    returns one, or 404 when the shard has never published that system. 404 and
    "a published board nobody has scored in yet" (200, empty top) are different
    answers, and the website renders them differently.

Store-backed rather than an RPC for the same reason as the other boards, and it
matters more here: these are standings accumulated over months, so blanking them
during a shard restart reads as data loss rather than as staleness.

PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover
in v3.md §4, not a per-phase change.

cargo build and cargo clippy --all-targets are clean. Smoke-tested against a
driver on the loopback link: two boards stored and served, a re-emitted system
overwriting rather than accumulating, 404 for an unknown system, 401
unauthenticated. Also verified against the real ServUO shard, which fed five
live boards through this path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:23 -05:00
41811d40af Merge pull request 'feat(store): persist world.ruleset and serve it from GET /ruleset' (#17) from feat/ruleset-endpoint into edge
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:35:31 +00:00
8e018a01e9 feat(store): persist world.ruleset and serve it from GET /ruleset
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:19:10 -05:00
7e4177c6c0 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#16) from chore/sync-project-tree-ci into main
All checks were successful
Release sidecar / release (push) Successful in 6s
sync-project-tree / sync (push) Successful in 13s
SonarQube / analysis (push) Successful in 47s
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:20:43 +00:00
ecdaf9171b ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:19:43 -05:00
f23b9030ed Merge pull request 'ci(sonarqube): correct project key to Runic-Gateway-link' (#15) from ci/sonarqube-fix-project-key into main
All checks were successful
Release sidecar / release (push) Successful in 8s
SonarQube / analysis (push) Successful in 2m9s
Reviewed-on: #15
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:23:45 +00:00
31497c38b2 ci(sonarqube): correct project key to Runic-Gateway-link
The SonarQube server already has a project keyed Runic-Gateway-link and
refuses to create a case-variant duplicate, so the scan (added in #13)
failed to auto-create runic-gateway-link. Match the existing key.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:22:58 -05:00
21b9920f80 Merge pull request 'ci(sonarqube): add non-blocking SonarQube analysis on push to main' (#13) from ci/sonarqube-analysis into main
Some checks failed
Release sidecar / release (push) Successful in 15s
SonarQube / analysis (push) Failing after 55s
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:15:14 +00:00
7224b9b834 ci(sonarqube): add non-blocking SonarQube analysis on push to main
Mirrors the website repo's setup: a source-based scan of sidecar/src that
reports to the self-hosted SonarQube server after merge, never gating PRs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:28 -05:00
14 changed files with 1516 additions and 27 deletions

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

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,9 @@ env:
BIN: uo-link-sidecar
LINUX_TARGET: x86_64-unknown-linux-gnu
WINDOWS_TARGET: x86_64-pc-windows-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:
@@ -256,3 +264,45 @@ jobs:
-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

@@ -0,0 +1,52 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements release.yml
# (which builds + cuts releases) — this one only feeds the dashboard.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# Runic-Gateway-link project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as release.yml) must be able to
# reach SONAR_HOST_URL on your network. Nothing here waits on the SonarQube
# Quality Gate, so a failing gate does not fail this job — check the dashboard
# when you want to.
#
# Scope: this analyses the Rust source directly (the Sonar scanner reads
# sonar-project.properties). It does NOT build the crate or run Clippy — see the
# "Optional enrichment" note in sonar-project.properties for wiring in a Clippy
# report if your SonarQube edition supports Rust lint import.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

View File

@@ -0,0 +1,111 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/link/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/link
DOCS_PATH: link/PROJECT_TREE.md
TREE_TITLE: uo-link
ROOT_LABEL: link
PR_BRANCH: chore/sync-link-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

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:
@@ -41,10 +93,10 @@ So you can never accidentally run without auth. Rotate by editing the token and
## Protocol version
The wire protocol has a version (`PROTOCOL_VERSION`, currently **1**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes.
The wire protocol has a version (`PROTOCOL_VERSION`, currently **3**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes.
- Every response carries an `X-UOLink-Version: 1` header.
- `/health` and the WebSocket `ws.hello` include `"protocol": 1`.
- Every response carries an `X-UOLink-Version: 3` header.
- `/health` and the WebSocket `ws.hello` include `"protocol": 3`.
- If a request sends `X-UOLink-Version` and it disagrees with the sidecar, the request is rejected **409 Conflict** with `{sidecar_protocol, client_protocol}` so the mismatch is obvious.
Bump `PROTOCOL_VERSION` in `main.rs` whenever an event or endpoint's shape changes.
@@ -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;
@@ -24,17 +25,57 @@ use tracing_subscriber::EnvFilter;
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
pub const PROTOCOL_VERSION: u32 = 2;
///
/// v3 (Protocol 3.0): adds `world.ruleset`, `points.board` and `vendor.listing` /
/// `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them
/// from the store. Same shape as the v2 bump — the kinds are additive, the endpoints are not — and
/// there is deliberately no feature-negotiation array: v3 implies all three kinds.
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"
);
@@ -194,6 +235,75 @@ async fn main() -> anyhow::Result<()> {
}
}
}
// Points/loyalty boards (Protocol 3.0): one row per point system, keyed by
// the shard's own PointsType name. The plugin only emits a system whose top N
// actually moved, so this is a sparse stream of overwrites — and there is no
// `points.remove` to handle, because the shard's set of systems is fixed at
// startup and cannot shrink.
"points.board" => {
if let Some(system) = ev.value.get("system").and_then(|s| s.as_str()) {
if let Err(e) = event_store
.upsert_points_board(
system,
ev.value.get("nameString").and_then(|n| n.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert points board");
}
}
}
// Player-vendor market index (Protocol 3.0). Each frame is authoritative for
// one vendor — the shard's round-robin sweep only emits a shop whose contents,
// prices or location actually moved — so this is a whole-row overwrite.
//
// Unlike the boards above there IS a remove: a vendor is dismissed, expires, or
// its owner switches off the in-game Vendor Search flag, and any of those must
// take the shop off the site. The last of the three is a privacy control, so
// dropping the row promptly is the point rather than housekeeping.
"vendor.listing" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
let loc = ev.value.get("location");
let field = |k: &str| loc.and_then(|l| l.get(k));
if let Err(e) = event_store
.upsert_vendor(
serial,
ev.value.get("shopName").and_then(|v| v.as_str()),
ev.value.get("ownerName").and_then(|v| v.as_str()),
field("map").and_then(|v| v.as_str()),
field("x").and_then(|v| v.as_i64()),
field("y").and_then(|v| v.as_i64()),
field("region").and_then(|v| v.as_str()),
ev.value.get("count").and_then(|v| v.as_i64()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert vendor listing");
}
}
}
"vendor.listing.remove" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store.delete_vendor(serial).await {
tracing::warn!(error = %e, "failed to remove vendor listing");
}
}
}
// Shard ruleset (Protocol 3.0): a singleton projection. The shard re-emits
// world.ruleset on every connect, so this row is simply overwritten; `rev`
// 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)
.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)
@@ -293,6 +308,174 @@ impl Store {
Ok(parse_json_column(rows))
}
// ---- shard ruleset (Protocol 3.0) ----
/// Stores the shard's published ruleset. A singleton (`id = 1`): the shard emits one
/// `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<()> {
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",
)
.bind(rev)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// The stored ruleset, or `None` if the shard has never published one. Returning `None` rather
/// than an empty object is deliberate: "not published yet" and "published, everything off" are
/// different answers and the website renders them differently.
pub async fn ruleset(&self) -> anyhow::Result<Option<Value>> {
let row = sqlx::query("SELECT json FROM ruleset WHERE id = 1")
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
// ---- points / loyalty boards (Protocol 3.0) ----
/// Upserts one system's leaderboard, keyed by its `PointsType` name (`QueensLoyalty`,
/// `CleanUpBritannia`, …). Fed from `points.board`; one row per system, always the most recent
/// top-N snapshot.
///
/// There is no matching delete, and that is deliberate rather than an omission: the shard's set
/// of point systems is fixed at startup by `PointsSystem.Configure`, so a system cannot vanish
/// at runtime and the plugin emits no `points.remove`. Same argument the governor board makes.
pub async fn upsert_points_board(
&self,
system: &str,
name: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO points_boards (system, name, json, updated_t) VALUES (?, ?, ?, ?)
ON CONFLICT(system) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(system)
.bind(name)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Every system's latest board, ordered by display name then system key. Systems the shard has
/// never published are simply absent — the website renders the set it is given.
pub async fn points_boards_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM points_boards ORDER BY name, system")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
/// One system's board, or `None` when that system has never published one. `None` is a real
/// answer (an unknown system name, or one the operator excluded via `Bridge.cfg PointsSystems`),
/// which the website turns into a 404 rather than an empty board.
pub async fn points_board(&self, system: &str) -> anyhow::Result<Option<Value>> {
let row = sqlx::query("SELECT json FROM points_boards WHERE system = ?")
.bind(system)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
// ---- player-vendor market index (Protocol 3.0) ----
/// Upserts one vendor's whole listing, keyed by serial. Fed from `vendor.listing`, which the
/// shard emits as an authoritative per-vendor frame — so this replaces the row outright rather
/// than merging anything.
///
/// The items ride inside `json` and are deliberately NOT normalized into a `vendor_items`
/// table. The sidecar's job for the market is outage resilience (`PROTOCOL_2.md` §12.2) — hand
/// the website back what the shard last said — not search. Search lives in MariaDB on the
/// website side, where the query surface, the indexes and the cliloc-resolved display names
/// already are; a second search implementation here would be one more thing to keep in step
/// with it for no reader.
#[allow(clippy::too_many_arguments)]
pub async fn upsert_vendor(
&self,
serial: &str,
shop_name: Option<&str>,
owner_name: Option<&str>,
map: Option<&str>,
x: Option<i64>,
y: Option<i64>,
region: Option<&str>,
count: Option<i64>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO vendors (serial, shop_name, owner_name, map, x, y, region, count, json, updated_t)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(serial) DO UPDATE SET shop_name = excluded.shop_name,
owner_name = excluded.owner_name, map = excluded.map, x = excluded.x, y = excluded.y,
region = excluded.region, count = excluded.count, json = excluded.json,
updated_t = excluded.updated_t",
)
.bind(serial)
.bind(shop_name)
.bind(owner_name)
.bind(map)
.bind(x)
.bind(y)
.bind(region)
.bind(count)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Drops one vendor from the index. Fed from `vendor.listing.remove` — a vendor dismissed,
/// expired, or whose owner switched off its in-game Vendor Search flag.
pub async fn delete_vendor(&self, serial: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM vendors WHERE serial = ?")
.bind(serial)
.execute(&self.pool)
.await?;
Ok(())
}
/// One page of the index, ordered by serial.
///
/// Paged where the other boards are not, and the ordering is why it can be: a whole-world
/// market is the one board that does not fit in a response. Ordering by SERIAL rather than by
/// shop name is deliberate — the page is a snapshot cursor for the website's reconnect
/// backfill, and a serial is stable while a shop name is renameable, so a rename mid-backfill
/// cannot make a vendor skip or repeat a page.
pub async fn vendors_page(&self, limit: i64, offset: i64) -> anyhow::Result<Vec<Value>> {
let limit = limit.clamp(1, 1000);
let offset = offset.max(0);
let rows = sqlx::query("SELECT json FROM vendors ORDER BY serial LIMIT ? OFFSET ?")
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
/// How many vendors the index holds, so a paging caller knows when to stop.
pub async fn vendors_count(&self) -> anyhow::Result<i64> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM vendors")
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("n"))
}
// ---- Town Cryer news (Protocol 2.1) ----
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
@@ -392,4 +575,39 @@ CREATE TABLE IF NOT EXISTS news (
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
-- Points/loyalty leaderboards (Protocol 3.0). One row per point system, keyed by the shard's
-- own PointsType name; `name` is the resolved display name, hoisted only for the ORDER BY.
CREATE TABLE IF NOT EXISTS points_boards (
system TEXT PRIMARY KEY,
name TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
-- Player-vendor market index (Protocol 3.0). One row per vendor, holding the whole authoritative
-- `vendor.listing` frame including its items. The hoisted columns exist for the ORDER BY and for
-- an operator eyeballing the table; nothing here is searched, because search is the website's job
-- (see upsert_vendor). Rows are dropped on `vendor.listing.remove`.
CREATE TABLE IF NOT EXISTS vendors (
serial TEXT PRIMARY KEY,
shop_name TEXT,
owner_name TEXT,
map TEXT,
x INTEGER,
y INTEGER,
region TEXT,
count INTEGER,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
-- The shard's published ruleset (Protocol 3.0). Singleton: the CHECK is what makes it one,
-- so an upsert can target id = 1 unconditionally and no second row can ever appear.
CREATE TABLE IF NOT EXISTS ruleset (
id INTEGER PRIMARY KEY CHECK (id = 1),
rev TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
"#;

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))
@@ -82,6 +86,20 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/governors", get(governors))
.route("/online", get(online))
.route("/houses", get(houses))
// The shard ruleset (Protocol 3.0), likewise store-backed: the shard publishes it once per
// connect, so serving it from the store is what lets the site's rules page render while the
// shard is down.
.route("/ruleset", get(ruleset))
// Points/loyalty leaderboards (Protocol 3.0), store-backed like the other boards: the
// whole set, or one system by its PointsType name.
.route("/points", get(points))
.route("/points/:system", get(points_system))
// The player-vendor market index (Protocol 3.0). `/market`, NOT `/vendors`: axum would
// route the latter fine, but `/vendors/:account` next door is the per-account RPC, and two
// routes a prefix apart that mean "this player's shops" and "every shop on the shard" is a
// readability trap nobody wins. The only PAGED read the sidecar serves — a whole-world
// market does not fit in one response.
.route("/market", get(market))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -790,6 +808,60 @@ async fn houses(State(st): State<AppState>) -> impl IntoResponse {
}
}
/// The shard's published ruleset: expansion, which optional systems are on, skill/stat caps,
/// account and house limits, champion scroll rules, the save/restart schedule. Served from the
/// store, so it answers during a shard outage with the last-known ruleset — which is the whole
/// point, since a rules page that goes blank when the shard restarts is worse than a stale one.
///
/// `{"ruleset": null}` means the shard has never published one (an old plugin, or
/// `Bridge.RulesetEnabled=false`), which the website renders differently from a published ruleset.
async fn ruleset(State(st): State<AppState>) -> impl IntoResponse {
match st.store.ruleset().await {
Ok(r) => (StatusCode::OK, Json(json!({ "ruleset": r }))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// Every points/loyalty leaderboard the shard publishes: one entry per point system, each with its
/// display name (literal and/or cliloc), max points, participant count and top N. Store-backed like
/// the other boards, so the site's leaderboards page renders during a shard outage — which matters
/// more here than elsewhere, since these are month-scale standings that a restart must not blank.
async fn points(State(st): State<AppState>) -> impl IntoResponse {
match st.store.points_boards_all().await {
Ok(boards) => (StatusCode::OK, Json(json!({"boards": boards}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// One system's board by its `PointsType` name (`QueensLoyalty`, `CleanUpBritannia`, …).
///
/// 404 rather than an empty board when the system is unknown: the shard publishes only the systems
/// it shows on the loyalty gump (or the explicit `Bridge.cfg PointsSystems` list), so "no such
/// board" and "a board with nobody on it" are different answers and the website renders them
/// differently.
async fn points_system(
State(st): State<AppState>,
Path(system): Path<String>,
) -> impl IntoResponse {
match st.store.points_board(&system).await {
Ok(Some(board)) => (StatusCode::OK, Json(board)),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown points system", "system": system})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// The current online population: total plus per-facet and per-region counts. This is the most
/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
@@ -810,6 +882,54 @@ async fn online(State(st): State<AppState>) -> impl IntoResponse {
}
}
#[derive(Deserialize)]
struct PageQuery {
limit: Option<i64>,
offset: Option<i64>,
}
/// The player-vendor market index: every vendor's shop name, owner, location and priced inventory,
/// as the shard last published it. Store-backed like the other boards, which is what lets the
/// website's market page render (labelled stale) while the shard is down.
///
/// Paged — `?limit=&offset=`, limit clamped to 1..1000, default 200 — because this is the one board
/// that can be a whole world's inventory. `total` is returned alongside so the caller knows when to
/// stop rather than paging until it sees a short page, which would race a concurrent sweep.
///
/// The frames are served VERBATIM, including owner names and coordinates. That is not an oversight:
/// the sidecar defines no audiences (docs/link/v3.md §3.2). Deciding who may see a vendor's owner
/// or whereabouts is the website's job and is admin-configurable there.
async fn market(State(st): State<AppState>, Query(q): Query<PageQuery>) -> impl IntoResponse {
let limit = q.limit.unwrap_or(200);
let offset = q.offset.unwrap_or(0);
let total = match st.store.vendors_count().await {
Ok(n) => n,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
}
};
match st.store.vendors_page(limit, offset).await {
Ok(vendors) => (
StatusCode::OK,
Json(json!({
"vendors": vendors,
"total": total,
"limit": limit.clamp(1, 1000),
"offset": offset.max(0),
})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
// ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {

25
sonar-project.properties Normal file
View File

@@ -0,0 +1,25 @@
# SonarQube analysis config for the link (uo-link sidecar) repo.
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
# The project key must match the one created in SonarQube (dashboard URL
# ?id=Runic-Gateway-link).
sonar.projectKey=Runic-Gateway-link
sonar.projectName=runic gateway link
# Analysed application code. The Rust sidecar crate lives under sidecar/src.
# Rust unit tests live inline (#[cfg(test)] modules) rather than in a separate
# tree, so there is no distinct sonar.tests path to declare.
sonar.sources=sidecar/src
# Never analyse build output, the vendored lockfile, or generated config.
sonar.exclusions=**/target/**,**/*.lock
sonar.sourceEncoding=UTF-8
# ── Optional enrichment (enable if your SonarQube edition/version supports it) ──
# SonarQube imports Clippy findings when given a JSON report. To turn this on:
# 1. In sonarqube.yml, add a step before the scan that runs:
# cargo clippy --message-format=json > sidecar/clippy-report.json
# (needs the Rust toolchain + `rustup component add clippy` on the runner).
# 2. Uncomment the line below.
# sonar.rust.clippy.reportPaths=sidecar/clippy-report.json