//! 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 `. 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 { 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 { 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 { 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 (protocol )`. /// /// 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 { 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://".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::(&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:///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, } } }