feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
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>
This commit is contained in:
105
src/record.rs
105
src/record.rs
@@ -35,7 +35,12 @@ pub struct InstallRecord {
|
||||
pub servuo: ServUoRef,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overlay: Option<OverlayRecord>,
|
||||
/// Phase 2 (uo-link binary, config and service). Carried through untouched by this build.
|
||||
/// The sidecar: binary, config, database, service (Phase 2).
|
||||
///
|
||||
/// Held as raw JSON rather than as a [`LinkRecord`] so that a record written by a *newer*
|
||||
/// installer — with fields this build has no name for — survives a re-run here intact. Phase 1
|
||||
/// carried this section through without understanding it at all; the same tolerance now applies
|
||||
/// in the other direction. Read it with [`InstallRecord::link_record`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<serde_json::Value>,
|
||||
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
|
||||
@@ -98,6 +103,53 @@ pub struct FileRecord {
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// The sidecar half of a deployment, as `install.json` records it.
|
||||
///
|
||||
/// **The auth token is not here and must never be.** It lives in `sidecar.toml` and is printed once
|
||||
/// to the operator's terminal (PLAN.md §6); `install.json` is a support artifact that gets pasted
|
||||
/// into bug reports.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LinkRecord {
|
||||
pub repo: String,
|
||||
pub tag: String,
|
||||
/// What the installed binary reports, not what the bundle claimed — the two agree, and if they
|
||||
/// ever did not, the binary is the one that will actually run.
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
pub binary: BinaryRef,
|
||||
pub config_path: String,
|
||||
/// Absolute, as the sidecar itself resolved it. On Windows this is anchored to the config's
|
||||
/// directory rather than pinned by the service, which is why it is recorded rather than derived.
|
||||
pub db_path: String,
|
||||
/// `None` when no service was registered — a relocated test run, or a host with no service
|
||||
/// manager the installer can drive. `doctor` reports that as an unfinished install rather than
|
||||
/// as a healthy one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service: Option<ServiceRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BinaryRef {
|
||||
pub path: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ServiceRecord {
|
||||
/// `systemd` or `windows-scm`.
|
||||
pub kind: String,
|
||||
/// `runicgateway-link.service` or `RunicGatewayLink`.
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unit_path: Option<String>,
|
||||
/// The account the service runs as.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
/// The installer created that account. `uninstall` (Phase 4) removes only what it created —
|
||||
/// deleting a user that was already on the host is not this tool's business.
|
||||
pub user_created: bool,
|
||||
}
|
||||
|
||||
impl InstallRecord {
|
||||
/// Whether a re-run would record anything new.
|
||||
///
|
||||
@@ -142,6 +194,15 @@ impl InstallRecord {
|
||||
pub fn overlay_files(&self) -> Option<&BTreeMap<String, FileRecord>> {
|
||||
self.overlay.as_ref().map(|o| &o.files)
|
||||
}
|
||||
|
||||
/// The sidecar section, when it is one this build understands.
|
||||
///
|
||||
/// A section it cannot parse yields `None` rather than an error: the raw value is still carried
|
||||
/// through on save, so the worst case is that this run re-derives what it needs instead of
|
||||
/// reading it — never that an older installer refuses to run on a newer host.
|
||||
pub fn link_record(&self) -> Option<LinkRecord> {
|
||||
serde_json::from_value(self.link.clone()?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_rfc3339() -> String {
|
||||
@@ -256,6 +317,48 @@ mod tests {
|
||||
assert!(!a.same_deployment_as(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_section_round_trips_and_holds_no_secret() {
|
||||
let link = LinkRecord {
|
||||
repo: "RunicGateway/link".into(),
|
||||
tag: "v1.1.0".into(),
|
||||
version: "1.1.0".into(),
|
||||
protocol: 3,
|
||||
binary: BinaryRef {
|
||||
path: "/usr/bin/runicgateway-link".into(),
|
||||
sha256: "27d491ef".repeat(8),
|
||||
},
|
||||
config_path: "/etc/runicgateway/sidecar.toml".into(),
|
||||
db_path: "/var/lib/runicgateway/uo-link.db".into(),
|
||||
service: Some(ServiceRecord {
|
||||
kind: "systemd".into(),
|
||||
name: "runicgateway-link.service".into(),
|
||||
unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()),
|
||||
user: Some("runicgateway".into()),
|
||||
user_created: true,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut record = sample();
|
||||
record.link = Some(serde_json::to_value(&link).unwrap());
|
||||
assert_eq!(record.link_record().unwrap(), link);
|
||||
|
||||
// install.json is pasted into bug reports. The token lives in sidecar.toml and on the
|
||||
// operator's terminal; there is no field here for it to arrive in.
|
||||
let text = serde_json::to_string(&record).unwrap();
|
||||
assert!(!text.contains("auth_token"), "{text}");
|
||||
assert!(!text.contains("token"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_link_section_is_ignored_rather_than_fatal() {
|
||||
// A record written by a future installer must not stop this one from running.
|
||||
let mut record = sample();
|
||||
record.link = Some(serde_json::json!({ "shape": "from a newer installer" }));
|
||||
assert!(record.link_record().is_none());
|
||||
assert!(InstallRecord::load(Path::new("rg-no-such-record.json")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_rfc3339() {
|
||||
let now = now_rfc3339();
|
||||
|
||||
Reference in New Issue
Block a user