diff --git a/deploy/50-yukart-gw-poweroff.pkla b/deploy/50-yukart-gw-poweroff.pkla new file mode 100644 index 0000000..a0fb85c --- /dev/null +++ b/deploy/50-yukart-gw-poweroff.pkla @@ -0,0 +1,12 @@ +# polkit (0.105, local authority): lets the svc-gw user power the host off through +# logind, with no sudo. svc-gw runs `systemctl poweroff` for the display's +# `poweroff` command (contracts BOUNDARY.md). Nothing else is granted. +# Installed to /etc/polkit-1/localauthority/50-local.d/ by scripts/deploy.sh. +# multiple-sessions: an ssh login is another session; ignore-inhibit: no inhibitor lock +# (none are taken on this host today) should leave the button silently doing nothing. +[yukart-gw: power off] +Identity=unix-user:mikkeli +Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.power-off-ignore-inhibit +ResultAny=yes +ResultInactive=yes +ResultActive=yes diff --git a/deploy/yukart-gw.service b/deploy/yukart-gw.service index ab9c238..b6e189c 100644 --- a/deploy/yukart-gw.service +++ b/deploy/yukart-gw.service @@ -8,7 +8,8 @@ StartLimitIntervalSec=0 [Service] User=mikkeli # Add --mock only on a bench unit: every message then says so, and displays must show it. -ExecStart=/usr/local/bin/yukart-gw --listen 0.0.0.0:7700 +# --allow-poweroff: the display may shut this host down (contracts BOUNDARY.md, poweroff). Only on the Orin. +ExecStart=/usr/local/bin/yukart-gw --listen 0.0.0.0:7700 --allow-poweroff Restart=always RestartSec=2 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index ff8f8c3..0525707 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,29 +1,43 @@ #!/usr/bin/env bash -# Build svc-gw for aarch64, check it against the car's glibc, and install it -# on an Orin as the yukart-gw service. +# Build svc-gw natively on the development Orin, check it against the car's +# glibc, and install it on an Orin as the yukart-gw service. # # usage: scripts/deploy.sh [host] (default: mikkeli-orin-nano) +# env: BUILD_HOST (default: mikkeli-orin-nano-2) # -# Cross-compiles from the workstation (the documented fallback): the development -# Orin is the preferred native build host. Either way check-glibc.sh gates it. +# Builds what is pushed, not the working tree: the build host pulls master of +# svc-gw and contracts under ~/dev/yukart/. Native on purpose: the poweroff command +# runs systemctl, and std::process::Command cross-compiled against the workstation's +# newer glibc links GLIBC_2.39 symbols (pidfd_spawnp) the car cannot load. set -euo pipefail HOST="${1:-mikkeli-orin-nano}" +BUILD_HOST="${BUILD_HOST:-mikkeli-orin-nano-2}" cd "$(dirname "$0")/.." -TARGET=aarch64-unknown-linux-gnu -BIN="target/$TARGET/release/svc-gw" +BIN="$(mktemp -d)/svc-gw" -cargo build --release --target "$TARGET" +ssh "$BUILD_HOST" 'set -e + mkdir -p ~/dev/yukart && cd ~/dev/yukart + for r in svc-gw contracts; do + [ -d $r ] || git clone -q http://192.168.2.199:3005/yukart/$r.git + git -C $r pull -q --ff-only + done + cd svc-gw && ~/.cargo/bin/cargo build --release 2>&1 | tail -1 + git log --oneline -1' +scp -q "$BUILD_HOST:dev/yukart/svc-gw/target/release/svc-gw" "$BIN" scripts/check-glibc.sh "$BIN" # Binary lives under ~/infra per the host repo's guidelines; /usr/local/bin gets a symlink. ssh "$HOST" 'mkdir -p ~/infra/svc-gw' scp -q "$BIN" "$HOST:infra/svc-gw/svc-gw.new" scp -q deploy/yukart-gw.service "$HOST:infra/svc-gw/yukart-gw.service" +scp -q deploy/50-yukart-gw-poweroff.pkla "$HOST:infra/svc-gw/50-yukart-gw-poweroff.pkla" ssh "$HOST" 'set -e mv ~/infra/svc-gw/svc-gw.new ~/infra/svc-gw/svc-gw sudo ln -sfn "$HOME/infra/svc-gw/svc-gw" /usr/local/bin/yukart-gw sudo install -m 0644 ~/infra/svc-gw/yukart-gw.service /etc/systemd/system/yukart-gw.service + # polkit, not sudo, lets the service power the host off (the poweroff command). + sudo install -m 0644 ~/infra/svc-gw/50-yukart-gw-poweroff.pkla /etc/polkit-1/localauthority/50-local.d/50-yukart-gw-poweroff.pkla sudo systemctl daemon-reload sudo systemctl enable yukart-gw.service >/dev/null sudo systemctl restart yukart-gw.service diff --git a/src/client.rs b/src/client.rs index b27ffa8..4fe4a16 100644 --- a/src/client.rs +++ b/src/client.rs @@ -9,7 +9,9 @@ use std::collections::VecDeque; use std::io::{Read, Write}; use std::net::TcpStream; use std::os::unix::net::UnixStream; +use std::process::Command; use std::sync::{Arc, Mutex}; +use std::thread; use std::time::{Duration, Instant}; use serde_json::{json, Value}; @@ -24,6 +26,8 @@ pub struct Ctx { pub hub: Hub, pub hello: Value, pub recorder_ctl: String, + /// `--allow-poweroff`: off by default, so a gateway on a workstation can never shut it down. + pub allow_poweroff: bool, /// Acks by command id, shared across connections so a retry after reconnect is answered, not re-run. pub acks: Mutex>, } @@ -126,10 +130,41 @@ fn run(ctx: &Ctx, msg: &Value) -> (bool, String) { Err(e) => (false, format!("recorder unreachable: {e}")), } } + "poweroff" => poweroff(ctx, &msg["args"]), other => (false, format!("unknown command '{other}'")), } } +/// The host's boot id: a poweroff command must carry the current one, so a resend +/// from before a reboot cannot shut the next boot down. +pub fn boot_id() -> String { + std::fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap_or_default().trim().to_string() +} + +fn poweroff(ctx: &Ctx, args: &Value) -> (bool, String) { + if !ctx.allow_poweroff { + return (false, "poweroff not enabled on this gateway (--allow-poweroff)".into()); + } + if args["confirm"].as_str() != Some("poweroff") { + return (false, "poweroff needs args.confirm = \"poweroff\"".into()); + } + if args["boot_id"].as_str() != Some(boot_id().as_str()) { + return (false, "poweroff refused: boot_id is not this boot's".into()); + } + eprintln!("svc-gw: poweroff requested by a client"); + // Ack first: the reply has to reach the client before the services stop. + thread::spawn(|| { + thread::sleep(Duration::from_secs(2)); + // Unprivileged: polkit grants this user logind's power-off (deploy/50-yukart-gw-poweroff.pkla). + match Command::new("systemctl").args(["poweroff", "--no-ask-password"]).status() { + Ok(s) if s.success() => {} + Ok(s) => eprintln!("svc-gw: systemctl poweroff exited {s}"), + Err(e) => eprintln!("svc-gw: systemctl poweroff: {e}"), + } + }); + (true, "shutting down in 2 s".into()) +} + fn recorder(path: &str, line: &str) -> std::io::Result { let mut s = UnixStream::connect(path)?; s.set_read_timeout(Some(Duration::from_secs(3)))?; diff --git a/src/main.rs b/src/main.rs index 798ad63..0489f5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,8 @@ usage: svc-gw [options] --listen ADDR WebSocket listen address (0.0.0.0:7700) --mock synthesise vehicle state, flagged as mock in every message (no bus) --broker HOST:PORT MQTT broker; '' for none (127.0.0.1:1883) - --recorder-ctl PATH recorder control socket, for 'mark' (/run/yukart-recorder/ctl.sock)"; + --recorder-ctl PATH recorder control socket, for 'mark' (/run/yukart-recorder/ctl.sock) + --allow-poweroff accept the 'poweroff' command (the Orin's unit only; never on a workstation)"; pub fn now_us() -> i64 { let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); @@ -53,11 +54,13 @@ fn main() -> ExitCode { let mut listen = "0.0.0.0:7700".to_string(); let mut mock = false; let mut recorder_ctl = "/run/yukart-recorder/ctl.sock".to_string(); + let mut allow_poweroff = false; let mut broker = "127.0.0.1:1883".to_string(); let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { "--mock" => mock = true, + "--allow-poweroff" => allow_poweroff = true, "--listen" => listen = args.next().unwrap_or_default(), "--recorder-ctl" => recorder_ctl = args.next().unwrap_or_default(), "--broker" => broker = args.next().unwrap_or_default(), @@ -89,12 +92,14 @@ fn main() -> ExitCode { "proto": 0, "server": concat!("svc-gw ", env!("CARGO_PKG_VERSION")), "host": std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap_or_default().trim(), + "boot_id": client::boot_id(), "source": source, "mock": mock, "hb_ms": client::HEARTBEAT.as_millis() as u64, "timeout_ms": client::TIMEOUT.as_millis() as u64, }), recorder_ctl, + allow_poweroff, acks: Mutex::new(Default::default()), });