Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
Adds the sidecar half of a deployment to the same `install` run: download and verify the bundle's binary, provision its config, register and start a service, and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the binary and the config document; `src/service.rs` owns systemd and the Windows SCM. The order is fixed by PLAN.md §5 and matters: stop anything running the old binary, replace it, then `--print-config` (which writes the config the service will be pointed at), then register. Registering first points a service at a file that does not exist yet. Decisions worth a reviewer's attention: - Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets the `runicgateway` system user the plan already specified; Windows gets a virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`, which the SCM creates itself and which has no password. Plain `sc create` runs as LocalSystem — the most privileged local identity there is, for a process listening on two TCP ports while its Linux twin deliberately does not run as root. - `sidecar.toml` holds the auth token and neither default location protects it: /etc is world-readable and %ProgramData% grants Users read by inheritance, so a stock install would leave the shard's token readable by any local account. The lockdown straddles registration because it has to — on Windows the service account does not exist until `sc create` creates it, so the file is first cut down to SYSTEM + Administrators, and the account's read grant comes after. - Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory and the sidecar anchors a relative [store] path to its config's directory, so the pin is redundant — and `sc.exe` has no per-service environment, only a machine-wide one that every process inherits and that outlives an uninstall. The config path rides in the service's own binPath instead. - `--verify` runs no part of the sidecar half. `--print-config` provisions: it writes the config and mints a token, so a dry run that called it would create the state it claims not to. It also carries an existing `link` section of install.json through untouched, so a dry run cannot make a service disappear from the record. - The installed binary's protocol version is checked against the bundle before the service is registered. Gate 1 read that number from source at the release tag; this is the same check applied to the binary that will actually answer the website. - RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses service registration and the file-permission hardening. There is no such thing as a relocated systemd unit, and hardening a scratch config against the only account that will ever read it just breaks the next test run. - A host with no systemd, or where the service user cannot be created, still gets a working binary and config plus the exact unit and commands. There is no fallback to User=root or LocalSystem: a service quietly running with more privilege than its documentation promises is worse than one that was not registered. - install.json never records the token. The `link` section carries versions, the binary's hash, the config and database paths, and the service's name, unit path and account. Docs half: docs#91. Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end on Windows against a relocated layout — bundle sidecar downloaded and verified, config provisioned, handoff printed with URLs composed from the host rather than the bind address, second run reporting unchanged with install.json byte-identical, --verify over an installed host writing nothing and preserving the link section, and a tampered binary detected by hash and replaced with no staging file left. Co-Authored-By: Claude <noreply@anthropic.com>
466 lines
18 KiB
Rust
466 lines
18 KiB
Rust
//! The uo-link sidecar: install the binary, provision its config, read the token back.
|
|
//!
|
|
//! This is the half of the deployment that makes the website work at all. The overlay puts code in
|
|
//! the ServUO tree; nothing reaches a website until a sidecar is listening on `127.0.0.1:7788` for
|
|
//! the shard to dial out to, and until the website has been given its address, protocol version and
|
|
//! token (PLAN.md §2.4 calls that missing handoff the largest "I installed it and nothing happened"
|
|
//! failure mode).
|
|
//!
|
|
//! Three rules govern this module:
|
|
//!
|
|
//! - **Every value in the handoff comes from asking the installed binary**, via
|
|
//! `--print-config --config <the pinned path>`. Not from the log, not from re-reading the TOML,
|
|
//! and not from the installer's own idea of what it wrote. That single call also *provisions* —
|
|
//! it writes the config file if absent and generates the token if blank — which is why PLAN.md
|
|
//! §5 requires it to run **before** the service is registered: the service must never start
|
|
//! against a config that does not exist yet.
|
|
//! - **The token is printed and never stored.** It goes to the operator's terminal and into
|
|
//! `sidecar.toml`, and nowhere else — not into `install.json`, not into an error message, not
|
|
//! into the output of a failed command (PLAN.md §6). That is why `--print-config` is run through
|
|
//! [`crate::util::run`] and handled here rather than through `run_ok`, which quotes what a
|
|
//! command printed.
|
|
//! - **A binary in place is not a working sidecar.** Nothing here claims more than "the file is
|
|
//! installed and it answered `--print-config`"; whether the shard ever dials in is `doctor`'s
|
|
//! question (Phase 4).
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use serde::Deserialize;
|
|
|
|
use crate::bundle::Asset;
|
|
use crate::util::{run, sha256_file};
|
|
|
|
/// The document `uo-link-sidecar --print-config` prints (`link/sidecar/src/config.rs::describe`).
|
|
///
|
|
/// Unknown fields are ignored on purpose: a newer sidecar that adds a key must not break an
|
|
/// installer that does not know about it, and every field read here has been in the document since
|
|
/// the CLI was introduced in link v1.1.0.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ConfigDoc {
|
|
pub component: String,
|
|
pub version: String,
|
|
pub protocol: u32,
|
|
pub config_path: String,
|
|
/// This run created the config file. False on every re-run — which is how the installer knows
|
|
/// not to report a token as newly minted when it is simply being read back.
|
|
pub config_created: bool,
|
|
pub token_generated: bool,
|
|
pub shard: ShardDoc,
|
|
pub web: WebDoc,
|
|
pub store: StoreDoc,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ShardDoc {
|
|
pub bind: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct WebDoc {
|
|
pub bind: String,
|
|
pub ws_path: String,
|
|
pub auth_required: bool,
|
|
/// **A secret.** Printed in the handoff block and never recorded anywhere else.
|
|
pub auth_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct StoreDoc {
|
|
/// Absolute, already resolved by the sidecar against its config file's directory.
|
|
pub path: String,
|
|
}
|
|
|
|
/// What installing the binary would do, decided by hash before anything is downloaded.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BinaryAction {
|
|
/// Nothing is installed at the target path yet.
|
|
Install,
|
|
/// Something is, and it is not what the bundle names.
|
|
Replace,
|
|
/// The bundle's binary is already in place, byte for byte.
|
|
Unchanged,
|
|
}
|
|
|
|
impl BinaryAction {
|
|
pub fn writes(self) -> bool {
|
|
!matches!(self, Self::Unchanged)
|
|
}
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Install => "install",
|
|
Self::Replace => "replace",
|
|
Self::Unchanged => "unchanged",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compares what is installed against what the bundle names.
|
|
///
|
|
/// Hash rather than version string: the version an installed binary reports costs a process launch
|
|
/// to obtain and would still not distinguish two builds of the same version. The bundle records the
|
|
/// SHA256 CI computed from the asset it verified (PLAN.md §7.1 gate 2), so this comparison is
|
|
/// against the same value the download will be checked against.
|
|
pub fn decide(asset: &Asset, dest: &Path) -> Result<BinaryAction> {
|
|
if !dest.exists() {
|
|
return Ok(BinaryAction::Install);
|
|
}
|
|
let installed = sha256_file(dest)?;
|
|
if installed.eq_ignore_ascii_case(asset.sha256.trim()) {
|
|
Ok(BinaryAction::Unchanged)
|
|
} else {
|
|
Ok(BinaryAction::Replace)
|
|
}
|
|
}
|
|
|
|
/// Downloads the sidecar binary and puts it at `dest`, executable.
|
|
///
|
|
/// The download lands in the scratch directory and is checksum-verified there, then copied to a
|
|
/// `.new` sibling of the target and renamed over it. The two-step matters on both platforms for
|
|
/// different reasons: on Windows the target is locked while the service runs (the caller stops it
|
|
/// first), and on either, a copy interrupted halfway would otherwise leave a truncated binary at
|
|
/// exactly the path a service is about to execute.
|
|
pub fn place(asset: &Asset, dest: &Path, scratch: &Path) -> Result<String> {
|
|
let staged = scratch.join(&asset.name);
|
|
crate::net::download_verified(&asset.url, &staged, &asset.sha256)?;
|
|
|
|
let parent = dest
|
|
.parent()
|
|
.ok_or_else(|| anyhow::anyhow!("{} has no parent directory", dest.display()))?;
|
|
fs::create_dir_all(parent).with_context(|| {
|
|
format!(
|
|
"cannot create {} — run as root/Administrator",
|
|
parent.display()
|
|
)
|
|
})?;
|
|
|
|
let pending = pending_path(dest);
|
|
fs::copy(&staged, &pending).with_context(|| format!("cannot write {}", pending.display()))?;
|
|
set_executable(&pending)?;
|
|
|
|
// Windows will not rename onto an existing file. The old binary goes first; the replacement is
|
|
// already complete on disk by this point, so the window is a rename wide.
|
|
if dest.exists() {
|
|
fs::remove_file(dest).with_context(|| {
|
|
format!(
|
|
"cannot replace {} — if a service is running it, stop it first",
|
|
dest.display()
|
|
)
|
|
})?;
|
|
}
|
|
fs::rename(&pending, dest)
|
|
.with_context(|| format!("cannot move {} into place", pending.display()))?;
|
|
|
|
sha256_file(dest)
|
|
}
|
|
|
|
/// `uo-link-sidecar.exe` → `uo-link-sidecar.new`, in the same directory as the target.
|
|
///
|
|
/// Same directory so the final step is a rename rather than a cross-filesystem copy: `/tmp` and
|
|
/// `/usr/bin` are routinely different mounts, and a rename between them fails.
|
|
fn pending_path(dest: &Path) -> PathBuf {
|
|
let mut name = dest.file_name().unwrap_or_default().to_os_string();
|
|
name.push(".new");
|
|
dest.with_file_name(name)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_executable(path: &Path) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
|
.with_context(|| format!("cannot make {} executable", path.display()))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_executable(_path: &Path) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Runs the installed binary's `--print-config`, provisioning the config and returning the token.
|
|
///
|
|
/// `db_path` is passed as `UOLINK_DB_PATH` **only when it is not already where the sidecar would
|
|
/// put it** — that is, on Linux, where the config lives in `/etc` and the database in `/var/lib`.
|
|
/// On Windows both are `%ProgramData%\RunicGateway`, the sidecar anchors a relative `[store].path`
|
|
/// to its config's directory, and passing the variable would buy nothing while implying the service
|
|
/// needs a machine-wide environment variable it does not (see [`crate::paths`]).
|
|
///
|
|
/// **This function must not print, log or attach the child's stdout to an error.** It is the one
|
|
/// place in the installer where a secret crosses a process boundary.
|
|
pub fn print_config(binary: &Path, config: &Path, db_path: Option<&Path>) -> Result<ConfigDoc> {
|
|
let mut command = std::process::Command::new(binary);
|
|
command.arg("--print-config").arg("--config").arg(config);
|
|
if let Some(db) = db_path {
|
|
command.env("UOLINK_DB_PATH", db);
|
|
}
|
|
|
|
let output = command.output().with_context(|| {
|
|
format!(
|
|
"cannot run {} --print-config. The binary was just installed, so this usually means it \
|
|
cannot execute here — a 32/64-bit or libc mismatch, or a filesystem mounted noexec.",
|
|
binary.display()
|
|
)
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
// stderr only. stdout is the document, and the document contains the auth token.
|
|
let reason = String::from_utf8_lossy(&output.stderr)
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|l| !l.is_empty())
|
|
.unwrap_or("(nothing on stderr)")
|
|
.to_string();
|
|
bail!(
|
|
"{} --print-config --config {} failed with {}: {reason}",
|
|
binary.display(),
|
|
config.display(),
|
|
match output.status.code() {
|
|
Some(code) => format!("exit code {code}"),
|
|
None => "no exit code".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
|
|
"the sidecar's --print-config output is not the document this installer expects. \
|
|
Its contents are not shown here because they would contain the auth token; run the same \
|
|
command by hand to see it (INSTALL.md Appendix A3).",
|
|
)?;
|
|
|
|
if doc.component != "uo-link-sidecar" {
|
|
bail!(
|
|
"the binary at {} identifies itself as {:?}, not uo-link-sidecar",
|
|
binary.display(),
|
|
doc.component
|
|
);
|
|
}
|
|
if doc.web.auth_token.trim().is_empty() {
|
|
// Authentication is always on in the sidecar, so this cannot happen against a real one —
|
|
// and if it ever did, an unauthenticated web surface must not be reported as a success.
|
|
bail!(
|
|
"the sidecar reported an empty auth token from {}. Authentication is always on; refusing \
|
|
to continue with a config that would leave its web surface unauthenticated.",
|
|
doc.config_path
|
|
);
|
|
}
|
|
Ok(doc)
|
|
}
|
|
|
|
/// Asks an installed binary for its version line, `uo-link-sidecar <ver> (protocol <n>)`.
|
|
///
|
|
/// Used to report what is already installed on a run that installs nothing. It is deliberately
|
|
/// tolerant — a binary that cannot answer is described as unknown rather than failing a run whose
|
|
/// real work has already succeeded.
|
|
pub fn version_line(binary: &Path) -> Option<String> {
|
|
let output = run(&binary.to_string_lossy(), &["--version"]).ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.find(|l| !l.trim().is_empty())
|
|
.map(|l| l.trim().to_string())
|
|
}
|
|
|
|
/// The two URLs the website needs, composed from the sidecar's own answers plus a host.
|
|
///
|
|
/// The bind address is **not** echoed: `[web] bind` is `127.0.0.1` by default and frequently
|
|
/// `0.0.0.0`, and neither is something to hand to a website (PLAN.md §6). Only the port is taken
|
|
/// from it; the host is the one the operator named or the installer detected.
|
|
pub fn website_urls(doc: &ConfigDoc, host: &str) -> (String, String) {
|
|
let port = port_of(&doc.web.bind);
|
|
let ws_path = if doc.web.ws_path.starts_with('/') {
|
|
doc.web.ws_path.clone()
|
|
} else {
|
|
format!("/{}", doc.web.ws_path)
|
|
};
|
|
(
|
|
format!("http://{host}:{port}"),
|
|
format!("ws://{host}:{port}{ws_path}"),
|
|
)
|
|
}
|
|
|
|
/// The port half of a bind address.
|
|
///
|
|
/// Split on the **last** colon so an IPv6 bind (`[::]:8080`) yields `8080` rather than a fragment
|
|
/// of the address. A bind with no port at all is not something the sidecar produces, so the whole
|
|
/// string is handed back rather than guessing a default that would then be wrong everywhere it was
|
|
/// printed.
|
|
fn port_of(bind: &str) -> &str {
|
|
match bind.rsplit_once(':') {
|
|
Some((_, port)) if !port.is_empty() => port,
|
|
_ => bind,
|
|
}
|
|
}
|
|
|
|
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
|
|
///
|
|
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
|
|
/// goes. It goes to stdout. It never goes to a file.
|
|
pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
|
|
let (base_url, ws_url) = website_urls(doc, host);
|
|
let site = site_url
|
|
.map(|s| s.trim_end_matches('/').to_string())
|
|
.unwrap_or_else(|| "https://<your-site>".to_string());
|
|
|
|
format!(
|
|
"\nRunic Gateway is installed.\n\n\
|
|
One manual step remains — connect the website to this sidecar:\n\n \
|
|
Base URL {base_url}\n \
|
|
WebSocket URL {ws_url}\n \
|
|
Protocol version {protocol}\n \
|
|
Auth token {token}\n \
|
|
(also in {config})\n\n\
|
|
Paste these into Admin → Shard on your Runic Gateway site:\n \
|
|
{site}/admin/shard\n\n\
|
|
The token is write-only once saved — the site will never show it back to you.\n",
|
|
protocol = doc.protocol,
|
|
token = doc.web.auth_token,
|
|
config = doc.config_path,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::util::TempDir;
|
|
|
|
/// The document link v1.1.0 actually prints, copied from INSTALL.md Appendix A3.
|
|
const PRINT_CONFIG: &str = r#"{
|
|
"component": "uo-link-sidecar",
|
|
"version": "1.1.0",
|
|
"protocol": 3,
|
|
"config_path": "/etc/runicgateway/sidecar.toml",
|
|
"config_created": true,
|
|
"token_generated": true,
|
|
"shard": { "bind": "127.0.0.1:7788" },
|
|
"web": {
|
|
"bind": "127.0.0.1:8080",
|
|
"ws_path": "/ws",
|
|
"auth_required": true,
|
|
"auth_token": "4f9c00112233445566778899aabbccdd"
|
|
},
|
|
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
|
|
}"#;
|
|
|
|
fn doc() -> ConfigDoc {
|
|
serde_json::from_str(PRINT_CONFIG).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn the_documented_print_config_output_parses() {
|
|
let doc = doc();
|
|
assert_eq!(doc.version, "1.1.0");
|
|
assert_eq!(doc.protocol, 3);
|
|
assert!(doc.web.auth_required);
|
|
assert_eq!(doc.store.path, "/var/lib/runicgateway/uo-link.db");
|
|
}
|
|
|
|
#[test]
|
|
fn a_newer_sidecar_adding_fields_still_parses() {
|
|
// The sidecar and the installer version independently; a key added to the document must not
|
|
// strand an installed installer.
|
|
let body = PRINT_CONFIG.replace(
|
|
"\"protocol\": 3,",
|
|
"\"protocol\": 3, \"something_new\": { \"nested\": true },",
|
|
);
|
|
assert!(serde_json::from_str::<ConfigDoc>(&body).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_website_urls_use_the_host_not_the_bind_address() {
|
|
// The whole point of asking for a host: 127.0.0.1 and 0.0.0.0 are both useless to a website.
|
|
let mut d = doc();
|
|
let (base, ws) = website_urls(&d, "shard.example.com");
|
|
assert_eq!(base, "http://shard.example.com:8080");
|
|
assert_eq!(ws, "ws://shard.example.com:8080/ws");
|
|
|
|
d.web.bind = "0.0.0.0:9001".into();
|
|
let (base, ws) = website_urls(&d, "shard.example.com");
|
|
assert_eq!(base, "http://shard.example.com:9001");
|
|
assert_eq!(ws, "ws://shard.example.com:9001/ws");
|
|
}
|
|
|
|
#[test]
|
|
fn an_ipv6_bind_yields_its_port() {
|
|
// Splitting on the first colon would produce "http://host::" from "[::]:8080".
|
|
assert_eq!(port_of("[::]:8080"), "8080");
|
|
assert_eq!(port_of("[::1]:7788"), "7788");
|
|
assert_eq!(port_of("127.0.0.1:8080"), "8080");
|
|
}
|
|
|
|
#[test]
|
|
fn the_handoff_carries_every_value_the_admin_form_asks_for() {
|
|
// INSTALL.md §5 maps four fields; all four must be in the block, plus where to paste them.
|
|
let doc = doc();
|
|
let block = handoff(&doc, "shard.example.com", Some("https://my-site.example/"));
|
|
assert!(block.contains("http://shard.example.com:8080"), "{block}");
|
|
assert!(block.contains("ws://shard.example.com:8080/ws"), "{block}");
|
|
assert!(block.contains("Protocol version 3"), "{block}");
|
|
assert!(block.contains(&doc.web.auth_token), "{block}");
|
|
// The trailing slash on the site URL must not produce a double slash in the link.
|
|
assert!(
|
|
block.contains("https://my-site.example/admin/shard"),
|
|
"{block}"
|
|
);
|
|
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_handoff_still_works_without_a_site_url() {
|
|
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
|
|
// link the operator does not need.
|
|
let block = handoff(&doc(), "shard", None);
|
|
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
|
|
assert!(block.contains("4f9c"), "{block}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_installed_binary_matching_the_bundle_is_left_alone() {
|
|
let dir = TempDir::new("rg-test-sidecar").unwrap();
|
|
let dest = dir.path().join("uo-link-sidecar");
|
|
assert_eq!(
|
|
decide(&asset("0".repeat(64)), &dest).unwrap(),
|
|
BinaryAction::Install
|
|
);
|
|
|
|
fs::write(&dest, b"pretend binary").unwrap();
|
|
let installed = sha256_file(&dest).unwrap();
|
|
assert_eq!(
|
|
decide(&asset(installed.clone()), &dest).unwrap(),
|
|
BinaryAction::Unchanged
|
|
);
|
|
// Hex case must not decide whether a host reinstalls its sidecar on every run.
|
|
assert_eq!(
|
|
decide(&asset(installed.to_uppercase()), &dest).unwrap(),
|
|
BinaryAction::Unchanged
|
|
);
|
|
assert_eq!(
|
|
decide(&asset("a".repeat(64)), &dest).unwrap(),
|
|
BinaryAction::Replace
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_staging_file_sits_beside_its_target() {
|
|
// /tmp and /usr/bin are routinely different filesystems, and rename across them fails.
|
|
let dest = Path::new("/usr/bin/runicgateway-link");
|
|
assert_eq!(pending_path(dest).parent(), dest.parent());
|
|
assert_eq!(
|
|
pending_path(Path::new("/usr/bin/x.exe"))
|
|
.file_name()
|
|
.unwrap(),
|
|
"x.exe.new"
|
|
);
|
|
}
|
|
|
|
fn asset(sha256: String) -> Asset {
|
|
Asset {
|
|
name: "uo-link-sidecar-linux-x86_64".into(),
|
|
url: "https://example/uo-link-sidecar".into(),
|
|
sha256,
|
|
}
|
|
}
|
|
}
|