feat(sidecar): a Windows service, the egg and its launcher, and the first release workflow (phase 18) #13

Merged
whitlocktech merged 1 commits from feat/phase-18-release into edge 2026-09-26 05:34:15 +00:00
13 changed files with 1559 additions and 16 deletions
Showing only changes of commit b3b66b1cc2 - Show all commits

View File

@@ -57,7 +57,7 @@ jobs:
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
build-essential curl ca-certificates git jq gcc-mingw-w64-x86-64
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -66,6 +66,7 @@ jobs:
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt clippy
rustup target add x86_64-pc-windows-gnu
cargo --version && cargo fmt --version && cargo clippy --version
# Keyed on Cargo.lock: dependency builds are reused until a dep actually
@@ -97,3 +98,20 @@ jobs:
- name: cargo test
run: cargo test --locked
# The Windows service entry point (src/windows.rs) compiles only for
# Windows, so the Linux clippy above never sees it. Linted against the
# release's own target here, so a service-only fault fails a PR instead
# of waiting for a release to show up (docs/modules/rust/PLAN.md §34.2.5).
# MinGW is the C compiler for the bundled SQLite, as in release.yml.
- name: cargo clippy (Windows target)
env:
CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo clippy --locked --target x86_64-pc-windows-gnu --all-targets -- -D warnings
# The egg the release publishes, assembled exactly as release.yml does:
# the install script and the launcher parse, and the egg carries the
# shape the panel's importer needs and every variable it exists to add.
- name: Build the egg
working-directory: .
run: bash egg/build.sh /tmp/egg-rust-runicgateway.json

View File

@@ -0,0 +1,517 @@
# Automated build + release for the rust-link sidecar, its launcher and the egg.
#
# Trigger: every push to `main` (i.e. every merged PR — in practice the
# edge→main cutover), and by hand.
#
# Why this exists: the Runic Gateway installer (`--game rust`) and the
# Pterodactyl egg both install the sidecar from a release, never from git. Until
# module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.1, D145) this repository
# had never released.
#
# Flow — the same two halves as link's and servuo-plugins' release.yml:
#
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
# │ reads: latest v* git tag + conventional-commit subjects │
# │ produces: next version, changelog, and (at the end) the release │
# └───────────────────────────────────────────────────────────────────┘
# ┌── RUST ADAPTER (the only repo-specific part) ─────────────────────┐
# │ consumes: the version │
# │ produces: rust-link-sidecar-linux-x86_64 (static, musl) │
# │ rust-link-sidecar-windows-x86_64.exe │
# │ with-sidecar.sh, egg-rust-runicgateway.json │
# │ SHA256SUMS │
# └───────────────────────────────────────────────────────────────────┘
#
# The engine is servuo-plugins', the most complete copy: tag-only (no bump
# commit, so `main` is never pushed to), secrets preflighted before anything is
# tagged, an existing tag checked against the release API rather than trusted,
# every tag swept for a missing release, and 5xx retried.
#
# ── The adapter, and how it differs from link's ─────────────────────────────
#
# * LINUX IS STATIC (musl). The same binary runs on a host under systemd and
# inside the egg's game container, whose image is not ours and whose glibc is
# not a contract (docs/rust-link/INSTALL_RIG.md).
# * NO linux-aarch64. RustDedicated has no arm64 build, so there is no host to
# run it on (D149).
# * WINDOWS IS A SERVICE. src/windows.rs speaks the SCM handshake; without it the
# installer's service would die with error 1053, as link's once did (§34.2.5).
# * THE LAUNCHER AND THE EGG ship here. The launcher is fetched by the egg at
# install time, so a fix to it reaches a server at its next reinstall with no
# re-import (§34.4). The egg is assembled by egg/build.sh from egg/egg.json and
# egg/install.sh.
#
# The version is never the protocol. PROTOCOL_VERSION lives in
# sidecar/src/main.rs and moves only when a message shape does.
#
# Version bump (conventional commits since the last v* tag):
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases SEED_VERSION below (§34.4: the first
# release takes what the engine derives)
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Rust-Link):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
# tag and create the release. The final step also dispatches
# RunicGateway/installer's bundle workflow, so the token
# ideally has write there too — without it the step warns
# and that repo's nightly cron picks the release up instead.
# REGISTRY_USER — the Gitea username that token belongs to.
name: Release sidecar
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: release-sidecar
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/Rust-Link
# The engine's changelog heading.
ARTIFACT: rust-link-sidecar
WORKDIR: sidecar
BIN: rust-link-sidecar
LINUX_TARGET: x86_64-unknown-linux-musl
WINDOWS_TARGET: x86_64-pc-windows-gnu
SEED_VERSION: "0.1.0"
INSTALLER_REPO: RunicGateway/installer
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out full history (need tags + commit log for the bump)
uses: actions/checkout@v4
with:
fetch-depth: 0
# ── 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
git fetch --tags --force >/dev/null 2>&1 || true
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
RELEASE=true
if [ -z "$LAST_TAG" ]; then
VERSION="$SEED_VERSION" # first release: seed
elif [ "$BUMP" = none ]; then
RELEASE=false # no feat/fix/breaking since last tag
VERSION="${LAST_TAG#v}"
else
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
# run, when missing REGISTRY_* secrets took the release API call to 401
# after the tag had already been pushed. Standing down on the tag alone
# would make that state permanent: every later run would see the tag,
# set RELEASE=false, and the release would never appear. So distinguish
# the two cases and finish the job the earlier run started.
# Note this OVERRIDES the RELEASE=false decided just above. With the tag
# already in place there are no releasable commits after it, so the
# normal path stands down — which is precisely why the stuck state
# could never clear itself. Recovery has to be able to say "yes,
# publish" for a version the bump logic considers already done.
REUSE_TAG=false
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/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" here
# 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
# ── Orphan sweep ────────────────────────────────────────────────
#
# The check above is VERSION-SCOPED: it only ever asks about the one
# version this run computed. That is enough to recover an orphan on
# the very next run, and useless afterwards — once any releasable
# commit lands, the next run computes a NEW version, never looks at
# the old tag again, and the orphan becomes permanent and silent.
#
# servuo-plugins v0.1.0 is the proof: the commit that ADDED the
# recovery above was itself a `fix:`, so it bumped to v0.1.1 and the
# run that introduced the recovery stepped straight past the tag it
# was written to rescue.
#
# So every v* tag is checked, and anything missing a release is
# WARNED about. Deliberately not recovered: publishing an old version
# would mean building today's tree and shipping it under a tag whose
# tree it is not, which is worse than the inconsistency it fixes.
# A human decides whether to recover or drop it.
#
# Never fails the run. A sweep that can break a good release is a
# sweep someone will delete.
ORPHANS=""
for T in $(git tag -l 'v*' --sort=-v:refname); do
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
done
if [ -n "${ORPHANS}" ]; then
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
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 "## ${ARTIFACT} v${VERSION}"
echo
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 "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-<none>}"
# ── Credential preflight ─────────────────────────────────────────────
# Runs BEFORE anything is built or pushed, and only when this run intends
# to publish, so a docs:/chore:-only merge stays green on a repo that has
# no secrets.
#
# This exists because of how servuo-plugins' first run failed. REGISTRY_USER and
# REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway:
# actions/checkout leaves an `http.<host>.extraheader` credential in the
# local git config, so `git remote set-url` to a URL with empty
# credentials still authenticated through that leftover header. The
# release API call had no such fallback and returned 401 — so the run
# tagged the repo and then failed, which is the worst of both outcomes.
# Checking the secrets up front turns that into an immediate, legible
# failure instead of a half-published release.
- name: Verify release credentials are configured
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
MISSING=""
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
if [ -n "$MISSING" ]; then
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the tag and create the release."
exit 1
fi
echo "Release credentials present."
- name: Install jq
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
command -v jq >/dev/null 2>&1 && exit 0
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
# musl-tools gives the cc crate a musl-gcc for the bundled SQLite that
# sqlx's sqlite feature compiles from C; mingw does the same for Windows.
# A Rust-only cross build fails at the first .c file.
- name: Install Rust toolchain, targets, and their C compilers
if: ${{ steps.plan.outputs.release == 'true' }}
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 musl-tools gcc-mingw-w64-x86-64 file \
curl ca-certificates git jq
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
rustup target add "${LINUX_TARGET}"
rustup target add "${WINDOWS_TARGET}"
- name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
# Replace only the [package] version (the first `version = "..."`), so
# `--version` on a released binary names its release. Not committed:
# the tag is the version.
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml"
grep -m1 '^version' "${WORKDIR}/Cargo.toml"
# Re-sync this crate's own entry in Cargo.lock, or every `--locked`
# step below fails. Dependency pins are untouched.
cargo update --manifest-path "${WORKDIR}/Cargo.toml" --workspace
# ── RUST ADAPTER: gates ──────────────────────────────────────────────
- name: cargo fmt --check
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo fmt --check
- name: cargo test
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo test --locked
# ── RUST ADAPTER: build both targets ─────────────────────────────────
- name: cargo build --release (Linux, static musl)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo build --release --locked --target "${LINUX_TARGET}"
- name: cargo build --release (Windows, cross via MinGW)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
- name: Package artifacts and SHA256SUMS
id: package
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/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
install -m 755 egg/with-sidecar.sh dist/with-sidecar.sh
bash egg/build.sh dist/egg-rust-runicgateway.json
# A dynamically linked "static" binary fails only inside the game
# container, on somebody else's glibc. Refuse it here.
if file "dist/${BIN}-linux-x86_64" | grep -q 'dynamically linked'; then
echo "::error::the Linux sidecar is dynamically linked; the egg needs it static (musl)"; exit 1
fi
ASSETS="${BIN}-linux-x86_64 ${BIN}-windows-x86_64.exe with-sidecar.sh egg-rust-runicgateway.json"
# Every artifact must appear here: the installer, the egg and the
# bundle CI verify against these sums, and `sha256sum -c` passes
# silently over a file this list does not mention.
( cd dist && sha256sum ${ASSETS} > SHA256SUMS )
echo "assets=${ASSETS} SHA256SUMS" >> "$GITHUB_OUTPUT"
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: tag ──────────────────────────────────────────────
# Tag only — no bump commit, so `main` is never pushed to (see header).
- name: Push the release tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline (depending on how they were
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
# be parsed"). Strip line breaks before building the URL.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "rust-link-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
# The tag may already exist when we are finishing a run that died after
# tagging (see the plan step). `git tag` on an existing name fails under
# `set -e`, and pushing an identical existing tag is a harmless no-op —
# so create it only if it is new, then push either way. 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 ─────────
- name: Create Gitea release and upload assets
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
BODY="$(cat dist/CHANGELOG.md)"
# Same newline hygiene as the tag step: a stray CR/LF in the token would
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# installer#22's release run failed exactly here: it landed one second
# after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running published the same artifacts
# untouched, so it was a race, not a bad request — but the tag sat
# orphaned until a human noticed.
#
# Two things made that worse than it needed to be.
#
# 1. `curl -sSf` prints NO response body on an error status, so all the
# log carried was "curl: (22) ... error: 500" and the cause had to be
# inferred from timestamps. Capture the body and print it.
# 2. Nothing retried, so a transient 5xx became a permanent orphan.
#
# 4xx is deliberately NOT retried: a bad token or a malformed body does
# not improve by being sent again, and retrying only turns a clear
# failure into a slow one.
REL_ID=""
for attempt in 1 2 3 4 5; do
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" || echo 000)"
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
REL_ID="$(jq -r '.id' /tmp/rel.json)"
break
fi
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
echo "--- response body ---"
cat /tmp/rel.json || true
echo
echo "---------------------"
case "$HTTP" in
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
esac
if [ "$attempt" = 5 ]; then
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
exit 1
fi
sleep $(( attempt * 5 ))
done
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
echo "::error::Release created but no id came back; refusing to upload assets blind."
exit 1
fi
echo "Created release ${TAG} (id=${REL_ID})"
for f in ${{ steps.package.outputs.assets }}; do
# Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every artifact it advertises, which is
# worse than no release at all -- that file is the trust anchor.
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" || echo 000)"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::uploading ${f} returned HTTP ${HTTP}"
cat /tmp/asset.json || true
exit 1
fi
echo " uploaded ${f}"
done
# ── Recompose the installer's bundle manifest ────────────────────────
# Neither the installer nor the egg resolves "latest" at run time — both
# install the exact sidecar named by a published bundle
# (docs/modules/rust/PLAN.md §34.2.2). A sidecar release that nobody
# recomposes around is therefore a release no operator will ever be
# offered. This tells the installer repo to
# rebuild that manifest now rather than leaving the new version invisible
# until its nightly cron.
#
# That job reads PROTOCOL_VERSION from sidecar/src/main.rs at this tag
# and checks it against the released plugin's declared protocol before
# publishing anything (gate 1).
#
# 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.
#
# A failure here is a WARNING, never a failure of this job. The release is
# already published and correct by this point, and failing the run would
# misreport that. The installer's nightly cron recomposes from whatever the
# latest releases actually are, so a dropped dispatch costs latency, not
# correctness.
- 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

@@ -45,6 +45,27 @@ as JSON, which is how an installer reads the token back without scraping a log.
See [`sidecar/README.md`](sidecar/README.md) for the configuration reference and the endpoint list.
## Releases, the launcher and the egg
Every merge to `main` carrying a `feat`, `fix` or `perf` commit cuts a release
(`.gitea/workflows/release.yml`):
| Asset | What it is |
|---|---|
| `rust-link-sidecar-linux-x86_64` | Static (musl): one binary for a systemd host and for the egg's game container |
| `rust-link-sidecar-windows-x86_64.exe` | Runs as a console program or as a Windows service |
| `with-sidecar.sh` | The egg's launcher: starts the sidecar, prints the URL (and a new token, once), then `exec`s the game |
| `egg-rust-runicgateway.json` | The Pterodactyl egg, for a panel admin to import |
| `SHA256SUMS` | The trust anchor for all of the above |
There is no `linux-aarch64`: RustDedicated has no arm64 build. A release then asks the installer
repo to recompose its Rust bundle, which is what the installer (`--game rust`) and the egg install
from.
[`egg/`](egg/) holds the egg's sources: `egg.json`, `install.sh` (egg 18 "Rust Autowipe"'s script
with a wipe guard around its `rm -rf ${REMOVE_FILES}` and the bridge fetched from a bundle) and
`with-sidecar.sh`. `bash egg/build.sh` assembles them, as PR Checks and the release do.
## The protocol is a contract
The loopback JSON protocol (plugin ↔ sidecar) and this sidecar's HTTP/WS API (sidecar ↔ website)

31
egg/build.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Assemble the importable egg: egg.json with install.sh inserted as its install script.
#
# egg/build.sh [OUT] default OUT: dist/egg-rust-runicgateway.json
#
# The install script is kept as a real file so it can be read, diffed and shellchecked; an install
# script edited inside a JSON string is one nobody reviews. PR Checks runs this, so a broken egg
# fails a pull request, and release.yml runs it to produce the asset a panel admin imports.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="${1:-dist/egg-rust-runicgateway.json}"
mkdir -p "$(dirname "$OUT")"
bash -n "$HERE/install.sh"
sh -n "$HERE/with-sidecar.sh"
jq --rawfile script "$HERE/install.sh" \
'.scripts.installation.script = $script' "$HERE/egg.json" > "$OUT"
# The shape Pterodactyl's importer requires, and the variables this egg exists to add.
jq -e '
.meta.version == "PTDL_v2"
and (.scripts.installation.script | startswith("#!/bin/bash"))
and (.startup | startswith("$( [ -x ./rust-link/with-sidecar.sh ]"))
and ([.variables[].env_variable] as $v
| ["RUSTLINK_SERVER_ID","RUSTLINK_WEB_PORT","RUSTLINK_WEB_TOKEN","RUNICGATEWAY_BUNDLE",
"RUSTLINK_RETAIN_DAYS","FRAMEWORK","REGEN_SERVER","REMOVE_FILES"]
| all(. as $k | $v | index($k)))
' "$OUT" >/dev/null || { echo "egg/build.sh: $OUT is missing something the egg must have" >&2; exit 1; }
echo "egg: $OUT"

272
egg/egg.json Normal file
View File

@@ -0,0 +1,272 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2026-09-26T00:00:00+00:00",
"name": "Rust (Runic Gateway)",
"author": "ci@whitlocktech.com",
"description": "Egg 18 \"Rust Autowipe\" with the Runic Gateway bridge: the rust-link sidecar runs beside the game and the plugin is placed for Oxide or Carbon, both from a published, checksum-verified bundle at install time. FRAMEWORK=vanilla installs no bridge. Built from RunicGateway/Rust-Link egg/; see docs/rust-link/INSTALL.md.",
"features": null,
"docker_images": {
"ghcr.io/pterodactyl/games:rust": "ghcr.io/pterodactyl/games:rust"
},
"file_denylist": [],
"startup": "$( [ -x ./rust-link/with-sidecar.sh ] && printf %s ./rust-link/with-sidecar.sh ) \"./RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.queryport {{QUERY_PORT}} +server.identity \"rust\" +rcon.ip 0.0.0.0 +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +app.port {{APP_PORT}} +server.saveinterval {{SAVEINTERVAL}} $( [ -z ${MAP_URL} ] && printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"$( if [ -f seed.txt ] && [[ ${WORLD_SEED} == \"0\" ]]; then printf %s $(cat seed.txt); else printf %s ${WORLD_SEED}; fi )\\\"\"|| printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}\"",
"config": {
"files": "{}",
"startup": "{\n \"done\": \"Server startup complete\"\n}",
"logs": "{}",
"stop": "quit"
},
"scripts": {
"installation": {
"script": "@@ egg/install.sh, inserted by egg/build.sh @@",
"container": "ghcr.io/ptero-eggs/installers:debian",
"entrypoint": "bash"
}
},
"variables": [
{
"name": "SRCDS_APPID",
"description": "",
"env_variable": "SRCDS_APPID",
"default_value": "258550",
"user_viewable": false,
"user_editable": false,
"rules": "required|string|max:20",
"field_type": "text"
},
{
"name": "Max Players",
"description": "The maximum amount of players allowed in the server at once.",
"env_variable": "MAX_PLAYERS",
"default_value": "40",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "Server Name",
"description": "The name of your server in the public server list.",
"env_variable": "HOSTNAME",
"default_value": "A Rust Server",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:40",
"field_type": "text"
},
{
"name": "Level",
"description": "The world file for Rust to use.",
"env_variable": "LEVEL",
"default_value": "Procedural Map",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:20",
"field_type": "text"
},
{
"name": "Description",
"description": "The description under your server title. Commonly used for rules & info. Use \\n for newlines.",
"env_variable": "DESCRIPTION",
"default_value": "Powered by Pterodactyl",
"user_viewable": true,
"user_editable": true,
"rules": "required|string",
"field_type": "text"
},
{
"name": "URL",
"description": "The URL for your server. This is what comes up when clicking the \"Visit Website\" button.",
"env_variable": "SERVER_URL",
"default_value": "http://pterodactyl.io",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
},
{
"name": "World Size",
"description": "The world size for a procedural map.",
"env_variable": "WORLD_SIZE",
"default_value": "3000",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "World Seed",
"description": "The seed for a procedural map.",
"env_variable": "WORLD_SEED",
"default_value": "0",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string",
"field_type": "text"
},
{
"name": "Server Image",
"description": "The header image for the top of your server listing.",
"env_variable": "SERVER_IMG",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
},
{
"name": "RCON Port",
"description": "Port for RCON connections.",
"env_variable": "RCON_PORT",
"default_value": "28016",
"user_viewable": true,
"user_editable": false,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "RCON Password",
"description": "RCON access password.",
"env_variable": "RCON_PASS",
"default_value": "CHANGEME",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:/^[\\w.-]*$/|max:64",
"field_type": "text"
},
{
"name": "Save Interval",
"description": "Sets the server’s auto-save interval in seconds.",
"env_variable": "SAVEINTERVAL",
"default_value": "60",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "Additional Arguments",
"description": "Add additional startup parameters to the server.",
"env_variable": "ADDITIONAL_ARGS",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string",
"field_type": "text"
},
{
"name": "Regen Server",
"description": "If the server should have its files removed and regenerate the server seed on reinstall.",
"env_variable": "REGEN_SERVER",
"default_value": "0",
"user_viewable": true,
"user_editable": true,
"rules": "required|boolean",
"field_type": "text"
},
{
"name": "Files to remove",
"description": "A space-separated list of files to remove when regenerating the server on reinstall.",
"env_variable": "REMOVE_FILES",
"default_value": "server/rust/player.deaths.*.db server/rust/player.identities.*.db server/rust/player.states.*.db server/rust/player.tokens.db proceduralmap.*.*.*.map server/rust/proceduralmap.*.*.*.sav oxide/data/Kits_Data.json oxide/data/NTeleportationHome.json oxide/data/ServerRewards/player_data.json oxide/data/PTTracker/playtime_data.json",
"user_viewable": true,
"user_editable": true,
"rules": "required|string",
"field_type": "text"
},
{
"name": "QUERY PORT",
"description": "Port for QUERY connections.",
"env_variable": "QUERY_PORT",
"default_value": "28017",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "APP PORT",
"description": "Port for Rust+ applications. -1 to disable.",
"env_variable": "APP_PORT",
"default_value": "28082",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "Custom Map URL",
"description": "Overwrites the map with the one from the direct download URL. Invalid URLs will cause the server to crash.",
"env_variable": "MAP_URL",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
},
{
"name": "Modding Framework",
"description": "The modding framework to be used: carbon, oxide, vanilla.\nDefaults to \"vanilla\" for a non-modded server installation.",
"env_variable": "FRAMEWORK",
"default_value": "vanilla",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:vanilla,carbon,oxide",
"field_type": "text"
},
{
"name": "Runic Gateway: server id",
"description": "This server's id on the website: lowercase letters, digits and '-', up to 64. It is read ONCE, when the bridge plugin writes its first config; after that the config file holds it and the website locks it, so changing this later changes nothing. Type the same id at Admin -> Rust -> Servers.",
"env_variable": "RUSTLINK_SERVER_ID",
"default_value": "main",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|regex:/^[a-z0-9][a-z0-9-]{0,63}$/",
"field_type": "text"
},
{
"name": "Runic Gateway: sidecar port",
"description": "The port the website reaches this server's bridge on. It MUST be one of this server's allocations: the panel does not tell the server which ports it holds, and a port that is not allocated binds but is never reachable. The console prints the URL on every boot.",
"env_variable": "RUSTLINK_WEB_PORT",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer|between:1024,65535",
"field_type": "text"
},
{
"name": "Runic Gateway: sidecar token",
"description": "Leave blank: the bridge generates a token on its first boot, keeps it in rust-link/sidecar.toml and prints it to the console once. Set it only to choose your own. Anyone who can see this server's startup variables in the panel can read a token typed here.",
"env_variable": "RUSTLINK_WEB_TOKEN",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:128",
"field_type": "text"
},
{
"name": "Runic Gateway: bundle",
"description": "Pin the bridge to a published bundle (e.g. 2026.09.27). Blank takes the current one. Either way it is fetched only when the server is (re)installed, never on a restart, so a restart cannot change the protocol under your website.",
"env_variable": "RUNICGATEWAY_BUNDLE",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|regex:/^\\d{4}\\.\\d{2}\\.\\d{2}(\\.\\d+)?$/",
"field_type": "text"
},
{
"name": "Runic Gateway: history days",
"description": "How many days of raw events the bridge keeps before rolling them up. Blank keeps its default.",
"env_variable": "RUSTLINK_RETAIN_DAYS",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|integer|min:0",
"field_type": "text"
}
]
}

173
egg/install.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/bin/bash
# Rust + the Runic Gateway bridge — the egg's install script.
#
# Egg 18 "Rust Autowipe"'s script, unchanged down to its wipe, plus two things
# (docs/modules/rust/PLAN.md §34.2.6):
#
# 1. THE WIPE GUARD. `rm -rf ${REMOVE_FILES}` runs with rust-link/ moved out of
# the server root, so no list an operator types — wildcards included — can
# reach the bridge's store or its token.
# 2. THE BRIDGE. The sidecar, its launcher and the plugin, from a published
# bundle, each checked against the bundle's sha256 before anything is
# placed. RUNICGATEWAY_BUNDLE pins a bundle; blank takes the current one
# (D151). This runs at install and reinstall only — never at boot, so a
# restart cannot change the protocol under a website that has not moved.
#
# Server Files: /mnt/server
# Image to install with is 'ghcr.io/ptero-eggs/installers:debian' (jq, curl,
# sha256sum and tar; no python3).
##
#
# Variables
# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.
# WINDOWS_INSTALL - if it's a windows server you want to install set to 1
# SRCDS_APPID - steam app id found here - https://developer.valvesoftware.com/wiki/Dedicated_Servers_List
# SRCDS_BETAID - beta branch of a steam app. Leave blank to install normal branch
# SRCDS_BETAPASS - password for a beta branch should one be required during private or closed testing phases.. Leave blank for no password.
# INSTALL_FLAGS - Any additional SteamCMD flags to pass during install.. Keep in mind that steamcmd auto update process in the docker image might overwrite or ignore these when it performs update on server boot.
# AUTO_UPDATE - Adding this variable to the egg allows disabling or enabling automated updates on boot. Boolean value. 0 to disable and 1 to enable.
#
##
## just in case someone removed the defaults.
if [[ "${STEAM_USER}" == "" ]] || [[ "${STEAM_PASS}" == "" ]]; then
echo -e "steam user is not set.\n"
echo -e "Using anonymous user.\n"
STEAM_USER=anonymous
STEAM_PASS=""
STEAM_AUTH=""
else
echo -e "user set to ${STEAM_USER}"
fi
## download and install steamcmd
cd /tmp
mkdir -p /mnt/server/steamcmd
curl -sSL -o steamcmd.tar.gz https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz
tar -xzvf steamcmd.tar.gz -C /mnt/server/steamcmd
mkdir -p /mnt/server/steamapps # Fix steamcmd disk write error when this folder is missing
cd /mnt/server/steamcmd
# SteamCMD fails otherwise for some reason, even running as root.
# This is changed at the end of the install process anyways.
chown -R root:root /mnt
export HOME=/mnt/server
## install game using steamcmd
./steamcmd.sh +force_install_dir /mnt/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ "${WINDOWS_INSTALL}" == "1" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} $( [[ -z ${SRCDS_BETAID} ]] || printf %s "-beta ${SRCDS_BETAID}" ) $( [[ -z ${SRCDS_BETAPASS} ]] || printf %s "-betapassword ${SRCDS_BETAPASS}" ) ${INSTALL_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6
## set up 32 bit libraries
mkdir -p /mnt/server/.steam/sdk32
cp -v linux32/steamclient.so ../.steam/sdk32/steamclient.so
## set up 64 bit libraries
mkdir -p /mnt/server/.steam/sdk64
cp -v linux64/steamclient.so ../.steam/sdk64/steamclient.so
## ── The wipe, with rust-link/ held outside the server root ─────────────────
# The store keeps all-time rollups across wipes (R12) and the token is what the
# website holds; a swept store is the failure that looks like success. Moved to
# /tmp — outside /mnt/server, so no path in REMOVE_FILES can name it — and put
# back straight after, before anything else can fail.
if [ "${REGEN_SERVER}" == "1" ]; then
cd /mnt/server/
RG_KEEP=/tmp/runicgateway-rust-link.keep
rm -rf "${RG_KEEP}"
if [ -d rust-link ]; then mv rust-link "${RG_KEEP}"; fi
rm -rf ${REMOVE_FILES}
if [ -d "${RG_KEEP}" ]; then rm -rf rust-link; mv "${RG_KEEP}" rust-link; fi
fi
if [ $WORLD_SEED == "0" ]; then
if [ ! -f /mnt/server/seed.txt ]; then
rm -sf /mnt/server/seed.txt
fi
cat /dev/urandom | tr -dc '1-9' | fold -w 5 | head -n 1 > /mnt/server/seed.txt
fi
## ── The Runic Gateway bridge ─────────────────────────────────────────────────
# After the wipe, so REMOVE_FILES can never delete the plugin this just placed.
rg_install() {
set -euo pipefail
# Overridable only for testing against a mock: the panel passes a container
# just the variables an egg declares, and this one is not declared.
local api="${RUNICGATEWAY_BUNDLE_API:-https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents/v2/rust}"
local work=/tmp/runicgateway
local plugins
case "${FRAMEWORK:-vanilla}" in
oxide) plugins=/mnt/server/oxide/plugins ;;
carbon) plugins=/mnt/server/carbon/plugins ;;
*)
# Not a failure (§34.4): failing would leave the operator without a game
# server over a bridge they may not want yet. The startup skips the
# launcher when it is absent, so the server boots exactly as egg 18's.
echo "Runic Gateway: FRAMEWORK=${FRAMEWORK:-vanilla} - the bridge needs Oxide or Carbon, so nothing of it was installed."
return 0
;;
esac
rm -rf "${work}"; mkdir -p "${work}"
local doc="current.json"
if [ -n "${RUNICGATEWAY_BUNDLE:-}" ]; then doc="bundle-${RUNICGATEWAY_BUNDLE}.json"; fi
echo "Runic Gateway: resolving bundle ${doc}"
# The contents API, not /raw/: raw reads are CDN-cached for hours, which would
# hand a reinstall right after a release the bundle from before it.
curl -fsSL "${api}/${doc}?ref=bundles" | jq -r '.content' | base64 -d > "${work}/bundle.json" \
|| { echo "Runic Gateway: could not fetch ${doc} - is RUNICGATEWAY_BUNDLE a published bundle?"; return 1; }
jq -e '.schema == 2 and .game == "rust"' "${work}/bundle.json" >/dev/null \
|| { echo "Runic Gateway: ${doc} is not a schema-2 Rust bundle"; return 1; }
local tag protocol
tag="$(jq -r '.bundle' "${work}/bundle.json")"
protocol="$(jq -r '.protocol' "${work}/bundle.json")"
echo "Runic Gateway: bundle ${tag}, protocol ${protocol}"
# Everything is fetched and checked BEFORE anything is placed: a half-updated
# pair is a sidecar and a plugin speaking two protocols.
fetch() { # <jq path to an asset> <local name>
local name url sha
name="$(jq -r "$1.name" "${work}/bundle.json")"
url="$(jq -r "$1.url" "${work}/bundle.json")"
sha="$(jq -r "$1.sha256" "${work}/bundle.json")"
curl -fsSL -o "${work}/$2" "${url}" || { echo "Runic Gateway: could not download ${name}"; return 1; }
echo "${sha} ${work}/$2" | sha256sum -c --quiet - \
|| { echo "Runic Gateway: ${name} does not match the bundle's sha256 - refusing it"; return 1; }
}
fetch '.sidecar.assets["linux-x86_64"]' rust-link-sidecar
fetch '.sidecar.launcher' with-sidecar.sh
fetch '.payload.asset' plugin.tar.gz
tar -xzf "${work}/plugin.tar.gz" -C "${work}"
local manifest="${work}/runicgateway-rust-plugin/manifest.json"
[ -f "${manifest}" ] || { echo "Runic Gateway: the plugin tarball has no manifest.json"; return 1; }
[ "$(jq -r '.protocol' "${manifest}")" = "${protocol}" ] \
|| { echo "Runic Gateway: the plugin declares protocol $(jq -r '.protocol' "${manifest}"), the bundle ${protocol} - refusing the pair"; return 1; }
mkdir -p /mnt/server/rust-link "${plugins}"
install -m 755 "${work}/rust-link-sidecar" /mnt/server/rust-link/rust-link-sidecar
install -m 755 "${work}/with-sidecar.sh" /mnt/server/rust-link/with-sidecar.sh
install -m 644 "${work}/runicgateway-rust-plugin/RunicGateway.cs" "${plugins}/RunicGateway.cs"
# What is installed, readable from the panel's file manager.
jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ bundle, protocol, framework: $framework, installed: $installed,
sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } }' \
"${work}/bundle.json" > /mnt/server/rust-link/bundle.json
echo "Runic Gateway: installed sidecar $(jq -r '.sidecar.tag' "${work}/bundle.json") and plugin $(jq -r '.payload.tag' "${work}/bundle.json") (${FRAMEWORK})"
echo "Runic Gateway: add this server under Admin -> Rust -> Servers; the console prints its URL and, on first boot, its token."
}
# In a subshell so `set -e` inside cannot leak into the rest of this script, and
# so a failure fails the install with its reason rather than leaving a half pair.
# NOT `( rg_install ) || …`: a subshell in a condition runs with errexit OFF, and
# an unchecked failed step inside it would sail on to "installed".
( rg_install )
if [ $? -ne 0 ]; then
echo "Runic Gateway: the bridge was NOT installed (see above)."
exit 1
fi
## install end
echo "-----------------------------------------"
echo "Installation completed..."
echo "-----------------------------------------"

86
egg/with-sidecar.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/sh
# with-sidecar.sh — start the rust-link sidecar beside a Rust server, then become the server.
#
# The egg's startup is this script followed by the game's own command line:
#
# ./rust-link/with-sidecar.sh ./RustDedicated -batchmode …
#
# It ships in Rust-Link's release rather than inside the egg, so a fix here reaches a server at its
# next reinstall without anybody re-importing the egg (docs/modules/rust/PLAN.md §34.2.6, §34.4).
# The shape is docs/rust-link/INSTALL_RIG.md's, proven on the rigs; see that file for why each line
# that looks optional is not.
#
# POSIX sh and no jq: the game image (ghcr.io/pterodactyl/games:rust) has grep and sed, not jq.
#
# Deliberately NOT `set -e`. Every step before the last line is the bridge's, and the last line is
# the game's: nothing the bridge gets wrong — an unwritable log, a sidecar that will not start — may
# keep the server from booting. Each step reports its own failure and the script goes on to `exec`.
RL=/home/container/rust-link
mkdir -p "$RL" 2>/dev/null
export RUSTLINK_CONFIG="$RL/sidecar.toml"
# Fixed, and never a panel variable: the install script moves this directory aside around its own
# `rm -rf ${REMOVE_FILES}`, which is what keeps a wipe from reaching the store (§34.1).
export RUSTLINK_DB_PATH="$RL/rust-link.db"
# An EMPTY panel variable is exported as `VAR=""`. The sidecar treats that as unset, and so does
# this script, so a blank field always means "the default" (or, for the token, "the saved one").
for v in RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS RUSTLINK_WEB_PORT RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND; do
eval "val=\${$v-}"
if [ -z "$val" ]; then unset "$v"; fi
done
# The website-facing bind. Pterodactyl tells a container nothing about its extra allocations, so
# the port is typed into the egg, and it must be one of this server's allocations — a port that is
# not fails as a bind the website never reaches, which the lines below make visible (§34.1).
if [ -n "${RUSTLINK_WEB_PORT-}" ]; then
export RUSTLINK_WEB_BIND="0.0.0.0:${RUSTLINK_WEB_PORT}"
fi
SIDECAR="$RL/rust-link-sidecar"
if [ ! -x "$SIDECAR" ]; then
echo "[rust-link] $SIDECAR is missing - reinstall the server to fetch the bridge. Starting the game without it."
exec "$@"
fi
# Provision first, so the token can be shown. `--print-config` resolves the configuration exactly as
# a start does, writing sidecar.toml with a generated token when there is none; the JSON it prints
# says whether it generated one on THIS call, which is what makes "print once" true (D152).
# Tracing is off on that path, so stdout is only the document. LD_PRELOAD is dropped for the
# sidecar in both calls: Carbon's entrypoint puts its Mono preloader in front of the whole startup.
if CFG="$(env -u LD_PRELOAD "$SIDECAR" --print-config 2>&1)"; then
# The Nth `"key": "value"` line of the pretty-printed JSON. `bind` appears twice — game, then web.
field() { printf '%s\n' "$CFG" | sed -n "s/^ *\"$1\": \"\([^\"]*\)\",\{0,1\}\$/\1/p" | sed -n "${2:-1}p"; }
WEB_BIND="$(field bind 2)"
SERVER_ID="$(field server_id)"
if printf '%s\n' "$CFG" | grep -q '"token_generated": true'; then
TOKEN="$(field auth_token)"
echo "[rust-link] ================================================================"
echo "[rust-link] A new sidecar token was generated. It is shown ONCE, here:"
echo "[rust-link] ${TOKEN}"
echo "[rust-link] It is kept in rust-link/sidecar.toml; read it there if you lose it."
echo "[rust-link] ================================================================"
fi
PORT="${WEB_BIND##*:}"
HOST="${SERVER_IP-}"
case "$HOST" in ""|0.0.0.0) HOST="<this node's address>" ;; esac
echo "[rust-link] Admin -> Rust -> Servers: server id '${SERVER_ID:-main}', sidecar URL http://${HOST}:${PORT} (listening on ${WEB_BIND})"
else
echo "[rust-link] the sidecar could not read its configuration:"
printf '%s\n' "$CFG" | sed 's/^/[rust-link] /'
fi
# A background job's redirection fails in the child, invisibly, so writability is asked first. A
# directory the log cannot be written to is one the store and the token cannot be written to either.
if touch "$RL/sidecar.log" 2>/dev/null; then
env -u LD_PRELOAD "$SIDECAR" >> "$RL/sidecar.log" 2>&1 &
else
echo "[rust-link] $RL is not writable - the game starts without the sidecar."
fi
# The game BECOMES this process: the panel console keeps its stdin and stdout, and stop still
# stops the server, which takes the sidecar down with the container.
exec "$@"

95
sidecar/Cargo.lock generated
View File

@@ -233,6 +233,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -275,6 +284,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
@@ -912,6 +927,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
@@ -1039,6 +1060,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1165,7 +1192,9 @@ dependencies = [
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-service",
]
[[package]]
@@ -1573,6 +1602,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.119"
@@ -1661,6 +1696,36 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -1808,6 +1873,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.20",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -2018,6 +2096,12 @@ dependencies = [
"wasite",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -2068,6 +2152,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-service"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
dependencies = [
"bitflags",
"widestring",
"windows-sys 0.61.2",
]
[[package]]
name = "windows-strings"
version = "0.5.1"

View File

@@ -18,5 +18,10 @@ toml = "0.8"
getrandom = "0.2"
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
# The SCM handshake (src/windows.rs). Windows only: systemd supervises a console program as-is.
[target.'cfg(windows)'.dependencies]
windows-service = "0.8"
tracing-appender = "0.2"
[profile.release]
opt-level = 2

View File

@@ -6,7 +6,9 @@ Configuration reference and endpoint list. For what this component *is*, see the
## Configuration
`sidecar.toml`, resolved in this order: `--config <PATH>`, else `$RUSTLINK_CONFIG`, else
`./sidecar.toml`. Environment variables override the file; the file overrides the defaults.
`./sidecar.toml`. Environment variables override the file; the file overrides the defaults. **An
empty or blank variable counts as unset**: a Pterodactyl egg exports every variable it declares, so a
field left blank arrives as `VAR=""`, and honouring that would erase the saved token on every boot.
| Key | Env | Default | What it is |
|---|---|---|---|
@@ -42,6 +44,15 @@ Resolves the configuration exactly as a normal start would — writing the file
token if they are missing — and prints it as JSON on stdout, **including the token in clear text**.
That is the supported way for an installer to obtain it; the alternative is scraping a log.
## As a Windows service
The same `.exe` runs from a shell or under the Service Control Manager. It tries the SCM handshake
first and falls through to an ordinary console run when a human started it, so there is no
`--service` flag to forget. Under the SCM it reports `Running` only once the game listener and the
store are up, turns a stop request into a clean shutdown, and logs to a daily-rolled
`rust-link-sidecar.YYYY-MM-DD.log` beside its config (a service has no console). One service per
game server, each with its own `--config`; the installer names them `RunicGatewayRust-<server id>`.
## Endpoints
Everything except `/health` requires the token, as `Authorization: Bearer <t>`, `X-Api-Key: <t>`,

View File

@@ -187,22 +187,36 @@ impl Config {
/// Environment overrides, so a deployment can set secrets without editing the file.
fn apply_env(&mut self) {
if let Ok(v) = env::var("RUSTLINK_GAME_BIND") {
self.apply_env_from(|key| env::var(key).ok());
}
/// [`Self::apply_env`] against any lookup, so the rules below are testable without mutating the
/// process environment (which the test harness shares across threads).
///
/// **An empty or blank value is the same as an unset one.** A Pterodactyl egg exports every
/// variable it declares, so an operator who leaves `RUSTLINK_WEB_TOKEN` blank arrives here as
/// `RUSTLINK_WEB_TOKEN=""`. Honouring that as an override would blank the token saved in
/// `sidecar.toml` on every boot, and a fresh one would be generated and persisted each time:
/// the website's copy would go stale at every restart (PLAN.md §34, D152). No variable here has
/// a meaningful empty value — an empty bind or database path can only fail later, less clearly.
fn apply_env_from(&mut self, get: impl Fn(&str) -> Option<String>) {
let get = |key: &str| get(key).filter(|v| !v.trim().is_empty());
if let Some(v) = get("RUSTLINK_GAME_BIND") {
self.game.bind = v;
}
if let Ok(v) = env::var("RUSTLINK_SERVER_ID") {
if let Some(v) = get("RUSTLINK_SERVER_ID") {
self.game.server_id = v;
}
if let Ok(v) = env::var("RUSTLINK_WEB_BIND") {
if let Some(v) = get("RUSTLINK_WEB_BIND") {
self.web.bind = v;
}
if let Ok(v) = env::var("RUSTLINK_WEB_TOKEN") {
if let Some(v) = get("RUSTLINK_WEB_TOKEN") {
self.web.auth_token = v;
}
if let Ok(v) = env::var("RUSTLINK_DB_PATH") {
if let Some(v) = get("RUSTLINK_DB_PATH") {
self.store.path = v;
}
if let Ok(v) = env::var("RUSTLINK_RETAIN_DAYS") {
if let Some(v) = get("RUSTLINK_RETAIN_DAYS") {
// A malformed value is ignored rather than fatal: this reaches the process as a panel
// variable somebody typed (R22), and refusing to start over a stray character would
// take the bridge down for a setting that has a perfectly good default.
@@ -449,4 +463,34 @@ mod tests {
assert_eq!(cfg.store.path, default_db_path());
assert_eq!(cfg.game.server_id, "");
}
/// The egg's case (D152): a blank panel variable must not erase the token already saved in the
/// file, or a new one would be generated on every boot.
#[test]
fn an_empty_variable_does_not_override_the_file() {
let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap();
cfg.apply_env_from(|key| match key {
"RUSTLINK_WEB_TOKEN" => Some(String::new()),
"RUSTLINK_WEB_BIND" => Some(" ".into()),
"RUSTLINK_SERVER_ID" => Some(String::new()),
_ => None,
});
assert_eq!(cfg.web.auth_token, "saved-token");
assert_eq!(cfg.web.bind, default_web_bind());
assert_eq!(cfg.game.server_id, "");
}
#[test]
fn a_set_variable_still_overrides_the_file() {
let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap();
cfg.apply_env_from(|key| match key {
"RUSTLINK_WEB_TOKEN" => Some("from-env".into()),
"RUSTLINK_WEB_BIND" => Some("0.0.0.0:21009".into()),
"RUSTLINK_SERVER_ID" => Some("alpha".into()),
_ => None,
});
assert_eq!(cfg.web.auth_token, "from-env");
assert_eq!(cfg.web.bind, "0.0.0.0:21009");
assert_eq!(cfg.game.server_id, "alpha");
}
}

View File

@@ -20,8 +20,10 @@
//! # Layout
//!
//! `main` does argument handling and nothing else; the sidecar proper lives in [`app`], which is
//! parameterised on `ready`/`shutdown` so that a future service wrapper (the installer's phase)
//! can supply the host's own start and stop without restructuring anything.
//! parameterised on `ready`/`shutdown` so a service wrapper can supply the host's own start and
//! stop. On Windows that wrapper is [`windows`] — the SCM handshake, without which a registered
//! service dies with error 1053 (PLAN.md §34.2.5). On Linux there is none: systemd supervises a
//! console program as it is, and stops it with `SIGTERM`, which [`shutdown_signal`] already hears.
mod app;
mod cli;
@@ -30,6 +32,8 @@ mod game;
mod rpc;
mod store;
mod web;
#[cfg(windows)]
mod windows;
use tracing_subscriber::EnvFilter;
@@ -198,6 +202,12 @@ fn main() -> anyhow::Result<()> {
cli::Mode::Run => {}
}
// The SCM's way in. It falls through to a console run when a human started the process.
#[cfg(windows)]
return windows::run(args.config.as_deref());
#[cfg(not(windows))]
{
init_console_tracing();
let runtime = tokio::runtime::Builder::new_multi_thread()
@@ -206,6 +216,7 @@ fn main() -> anyhow::Result<()> {
runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal()))
}
}
/// Logging for a foreground run: human-readable, on stdout.
pub fn init_console_tracing() {
@@ -217,7 +228,7 @@ pub fn init_console_tracing() {
}
/// Resolves on Ctrl-C, and on `SIGTERM` where there is one.
async fn shutdown_signal() {
pub(crate) async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};

259
sidecar/src/windows.rs Normal file
View File

@@ -0,0 +1,259 @@
//! Windows startup and shutdown: the SCM handshake.
//!
//! Ported from `link`'s `windows.rs`, which learned it the hard way (docs/modules/rust/PLAN.md
//! §34.2.5, D149). The Windows Service Control Manager cannot supervise an arbitrary console
//! program. A binary registered with `sc.exe create` has ~30 seconds to call
//! `StartServiceCtrlDispatcher` and connect back to the SCM; one that never does is killed with
//! **error 1053, "the service did not respond to the start request in a timely fashion"** — even
//! though the process itself started perfectly and is sitting there serving traffic. That is the
//! entire reason this module exists.
//!
//! ## One binary, two ways in
//!
//! The dispatcher is tried first and *failing is expected*: when the process was started from a
//! shell rather than by the SCM, the connect fails with `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT`
//! (1063), and that — and only that — falls through to a normal foreground run. So
//! `rust-link-sidecar.exe --config ...` stays an ordinary console app you can Ctrl-C, `cargo run`
//! still works, and the same binary can be registered as a service with no `--service` flag for an
//! operator to forget. Any other dispatcher error is a real failure and is reported.
//!
//! ## One binary, many services
//!
//! A Rust host runs one sidecar per game server (R8), so the installer registers one service per
//! instance — `RunicGatewayRust-<server id>` (D148) — all pointing at this one executable with a
//! different `--config`. That works without this module knowing the instance's name: for an
//! **own-process** service the SCM ignores the name handed to the dispatcher and to the control
//! handler, because the process can only ever host the one service it was started as.
//!
//! ## Logging goes to a file, because a service has no stdout
//!
//! Under the SCM there is no console attached, so the normal stdout subscriber writes into the
//! void. In service mode the sidecar logs to a daily-rolled file next to its config instead
//! (`rust-link-sidecar.YYYY-MM-DD.log`, seven kept). Instances keep their configs apart, so their
//! logs are apart too. A service whose start fails leaves a reason behind rather than only an SCM
//! error code.
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::sync::Notify;
use tracing_subscriber::EnvFilter;
use windows_service::service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::{define_windows_service, service_dispatcher};
/// The prefix of every instance's service name (`RunicGatewayRust-<server id>`, installed by
/// `installer/src/service.rs`). Passed to the dispatcher and the control handler, which ignore it
/// for an own-process service — see the module docs. It is a literal on both sides; the two repos
/// are released independently and share no crate.
pub const SERVICE_NAME_PREFIX: &str = "RunicGatewayRust";
const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
/// `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` — "this process was not started by the SCM", which is
/// the normal answer when a human runs the binary.
const ERROR_FAILED_SERVICE_CONTROLLER_CONNECT: i32 = 1063;
/// `service_main` is called through an `extern "system"` trampoline and so can capture nothing.
/// The parsed `--config` is handed over here instead of being re-parsed, so the service and a
/// console run resolve their configuration through exactly the same code path.
static CONFIG_PATH: OnceLock<Option<String>> = OnceLock::new();
pub fn run(config_path: Option<&str>) -> anyhow::Result<()> {
let _ = CONFIG_PATH.set(config_path.map(str::to_string));
match service_dispatcher::start(SERVICE_NAME_PREFIX, ffi_service_main) {
Ok(()) => Ok(()),
// Not started by the SCM: this is a foreground run, which is not an error.
Err(windows_service::Error::Winapi(e))
if e.raw_os_error() == Some(ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) =>
{
console_run(config_path)
}
Err(e) => Err(anyhow::Error::new(e)
.context("could not connect to the Windows service control manager")),
}
}
/// A normal foreground run: stdout logging, Ctrl-C to stop. Exactly what `main` does elsewhere.
fn console_run(config_path: Option<&str>) -> anyhow::Result<()> {
crate::init_console_tracing();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(crate::app::run(
config_path,
|| {},
crate::shutdown_signal(),
))
}
define_windows_service!(ffi_service_main, service_main);
fn service_main(_arguments: Vec<OsString>) {
// Arguments are deliberately ignored: for an own-process service the `binPath=` arguments
// arrive on the process command line and have already been parsed in `main`. What lands here
// is whatever was typed after `sc start`, which nothing in this deployment uses.
if let Err(e) = serve() {
// Nowhere left to report to but the log: the status handle is gone or was never obtained.
tracing::error!(error = %e, "service exited with an error");
}
}
fn serve() -> anyhow::Result<()> {
let config_path = CONFIG_PATH.get().cloned().flatten();
// Held for the life of the service: dropping the guard stops the background log writer.
let _log_guard = init_service_tracing(config_path.as_deref());
// The SCM calls the control handler on its own thread, so the stop signal crosses a thread
// boundary into the async world. `notify_one` stores a permit if nothing is waiting yet, so a
// stop that arrives during startup is not lost.
let stop = Arc::new(Notify::new());
let handler_stop = stop.clone();
let status_handle =
service_control_handler::register(SERVICE_NAME_PREFIX, move |control| match control {
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
ServiceControl::Stop | ServiceControl::Shutdown => {
handler_stop.notify_one();
ServiceControlHandlerResult::NoError
}
_ => ServiceControlHandlerResult::NotImplemented,
})?;
// Registering the handler is the handshake 1053 was about. Everything after this point gets to
// take as long as it credibly needs, as long as the state keeps being reported.
status_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::StartPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(30),
process_id: None,
})?;
let ready_handle = status_handle;
let result = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(crate::app::run(
config_path.as_deref(),
// Reported only once the game listener is bound and the store is open, so a bad config
// or a taken port fails the *start* instead of flapping Running → Stopped a moment later.
move || {
let _ = ready_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
});
},
async move { stop.notified().await },
));
// A failed run must leave a nonzero SERVICE_EXIT_CODE behind: `sc query` reporting STOPPED with
// exit code 0 is what made `link`'s original failure look like a clean stop.
let exit_code = match &result {
Ok(()) => ServiceExitCode::Win32(0),
Err(e) => {
tracing::error!(error = %e, "sidecar failed");
ServiceExitCode::ServiceSpecific(1)
}
};
status_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code,
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
result
}
/// Where the service writes its log: beside the config it was pointed at, which is the directory
/// the installer already provisions and grants the service account write access to.
fn log_dir(config_path: Option<&str>) -> PathBuf {
if let Some(parent) = config_path
.map(PathBuf::from)
.as_deref()
.and_then(|p| p.parent())
.filter(|p| !p.as_os_str().is_empty())
{
return parent.to_path_buf();
}
match std::env::var_os("ProgramData") {
Some(program_data) => PathBuf::from(program_data)
.join("RunicGateway")
.join("rust"),
None => std::env::temp_dir(),
}
}
/// Returns `None` if the log file could not be opened — a service that cannot write a log is still
/// a service worth running, and the SCM start must not fail over it.
fn init_service_tracing(
config_path: Option<&str>,
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let appender = tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("rust-link-sidecar")
.filename_suffix("log")
.max_log_files(7)
.build(log_dir(config_path))
.ok()?;
let (writer, guard) = tracing_appender::non_blocking(appender);
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_ansi(false) // a log file is not a terminal
.with_writer(writer)
.init();
Some(guard)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_dir_follows_the_config_file() {
assert_eq!(
log_dir(Some(r"C:\ProgramData\RunicGateway\rust\alpha.toml")),
PathBuf::from(r"C:\ProgramData\RunicGateway\rust")
);
}
#[test]
fn a_bare_filename_does_not_become_the_filesystem_root() {
// `--config sidecar.toml` has a parent of "", which as a path means the root of the current
// drive — somewhere a service account cannot write. Fall back instead.
let dir = log_dir(Some("sidecar.toml"));
assert_ne!(dir, PathBuf::from(""));
assert!(dir.is_absolute(), "{}", dir.display());
}
#[test]
fn no_config_falls_back_to_program_data() {
let dir = log_dir(None);
assert!(dir.is_absolute(), "{}", dir.display());
}
#[test]
fn service_name_prefix_matches_the_installer() {
// installer/src/service.rs names each instance `RunicGatewayRust-<server id>`.
assert_eq!(SERVICE_NAME_PREFIX, "RunicGatewayRust");
}
}