reboot command beside poweroff, one polkit grant; forward yukart/gnss/+; mock uses current names
/ crosscheck (push) Successful in 0s
Rust (L4T) / Clippy, test, build, glibc (push) Successful in 4s

--allow-poweroff becomes --allow-power. deploy/50-yukart-gw-power.pkla adds logind's
reboot actions and the deploy removes the old poweroff-only file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-13 22:50:04 +09:00
parent 133adeb5a5
commit aa72100148
8 changed files with 48 additions and 43 deletions
@@ -1,12 +1,13 @@
# polkit (0.105, local authority): lets the svc-gw user power the host off through # polkit (0.105, local authority): lets the svc-gw user power the host off or reboot it
# logind, with no sudo. svc-gw runs `systemctl poweroff` for the display's # through logind, with no sudo. svc-gw runs `systemctl poweroff` / `systemctl reboot`
# `poweroff` command (contracts BOUNDARY.md). Nothing else is granted. # for the display's `poweroff` and `reboot` commands (contracts BOUNDARY.md). Nothing
# else is granted.
# Installed to /etc/polkit-1/localauthority/50-local.d/ by scripts/deploy.sh. # 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 # 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. # (none are taken on this host today) should leave the button silently doing nothing.
[yukart-gw: power off] [yukart-gw: power off and reboot]
Identity=unix-user:mikkeli Identity=unix-user:mikkeli
Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.power-off-ignore-inhibit Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.power-off-ignore-inhibit;org.freedesktop.login1.reboot;org.freedesktop.login1.reboot-multiple-sessions;org.freedesktop.login1.reboot-ignore-inhibit
ResultAny=yes ResultAny=yes
ResultInactive=yes ResultInactive=yes
ResultActive=yes ResultActive=yes
+2 -2
View File
@@ -8,8 +8,8 @@ StartLimitIntervalSec=0
[Service] [Service]
User=mikkeli User=mikkeli
# Add --mock only on a bench unit: every message then says so, and displays must show it. # Add --mock only on a bench unit: every message then says so, and displays must show it.
# --allow-poweroff: the display may shut this host down (contracts BOUNDARY.md, poweroff). Only on the Orin. # --allow-power: the display may power this host off or reboot it (contracts BOUNDARY.md). Only on the Orin.
ExecStart=/usr/local/bin/yukart-gw --listen 0.0.0.0:7700 --allow-poweroff ExecStart=/usr/local/bin/yukart-gw --listen 0.0.0.0:7700 --allow-power
Restart=always Restart=always
RestartSec=2 RestartSec=2
+2 -2
View File
@@ -23,8 +23,8 @@ directly.
0. **Boundary v0** — in place: WebSocket on `:7700`, hello, heartbeats both ways, 0. **Boundary v0** — in place: WebSocket on `:7700`, hello, heartbeats both ways,
`mark` forwarded to the recorder's control socket, mock state behind `--mock`. `mark` forwarded to the recorder's control socket, mock state behind `--mock`.
Protocol in contracts [`docs/BOUNDARY.md`](gitea:yukart/contracts). Protocol in contracts [`docs/BOUNDARY.md`](gitea:yukart/contracts).
**Bus input** — in place (2026-10-12): subscribes to `yukart/vehicle/+` and **Bus input** — in place (2026-10-12): subscribes to `yukart/vehicle/+`,
`yukart/host/+`, keeps the newest envelope per signal, marks stale after 5 s `yukart/host/+` and `yukart/gnss/+`, keeps the newest envelope per signal, marks stale after 5 s
or when known only from a retained copy. Bus or mock, never both. or when known only from a retained copy. Bus or mock, never both.
1. Static file serving for the basemap — smallest useful thing, unblocks the map. 1. Static file serving for the basemap — smallest useful thing, unblocks the map.
2. Nav HTTP proxy to [svc-nav](gitea:yukart/svc-nav). 2. Nav HTTP proxy to [svc-nav](gitea:yukart/svc-nav).
+7 -5
View File
@@ -6,8 +6,8 @@
# env: BUILD_HOST (default: mikkeli-orin-nano-2) # env: BUILD_HOST (default: mikkeli-orin-nano-2)
# #
# Builds what is pushed, not the working tree: the build host pulls master of # 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 # svc-gw and contracts under ~/dev/yukart/. Native on purpose: the poweroff and reboot
# runs systemctl, and std::process::Command cross-compiled against the workstation's # commands run systemctl, and std::process::Command cross-compiled against the workstation's
# newer glibc links GLIBC_2.39 symbols (pidfd_spawnp) the car cannot load. # newer glibc links GLIBC_2.39 symbols (pidfd_spawnp) the car cannot load.
set -euo pipefail set -euo pipefail
@@ -31,13 +31,15 @@ scripts/check-glibc.sh "$BIN"
ssh "$HOST" 'mkdir -p ~/infra/svc-gw' ssh "$HOST" 'mkdir -p ~/infra/svc-gw'
scp -q "$BIN" "$HOST:infra/svc-gw/svc-gw.new" 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/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" scp -q deploy/50-yukart-gw-power.pkla "$HOST:infra/svc-gw/50-yukart-gw-power.pkla"
ssh "$HOST" 'set -e ssh "$HOST" 'set -e
mv ~/infra/svc-gw/svc-gw.new ~/infra/svc-gw/svc-gw 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 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 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). # polkit, not sudo, lets the service power the host off or reboot it (the poweroff and
sudo install -m 0644 ~/infra/svc-gw/50-yukart-gw-poweroff.pkla /etc/polkit-1/localauthority/50-local.d/50-yukart-gw-poweroff.pkla # reboot commands). The file was named 50-yukart-gw-poweroff.pkla before reboot joined.
sudo install -m 0644 ~/infra/svc-gw/50-yukart-gw-power.pkla /etc/polkit-1/localauthority/50-local.d/50-yukart-gw-power.pkla
sudo rm -f /etc/polkit-1/localauthority/50-local.d/50-yukart-gw-poweroff.pkla ~/infra/svc-gw/50-yukart-gw-poweroff.pkla
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable yukart-gw.service >/dev/null sudo systemctl enable yukart-gw.service >/dev/null
sudo systemctl restart yukart-gw.service sudo systemctl restart yukart-gw.service
+1 -1
View File
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
use rumqttc::{Client, Event, MqttOptions, Packet, QoS}; use rumqttc::{Client, Event, MqttOptions, Packet, QoS};
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
const TOPICS: [&str; 2] = ["yukart/vehicle/+", "yukart/host/+"]; const TOPICS: [&str; 3] = ["yukart/vehicle/+", "yukart/host/+", "yukart/gnss/+"];
const STALE: Duration = Duration::from_secs(5); const STALE: Duration = Duration::from_secs(5);
struct Entry { struct Entry {
+19 -17
View File
@@ -26,8 +26,8 @@ pub struct Ctx {
pub hub: Hub, pub hub: Hub,
pub hello: Value, pub hello: Value,
pub recorder_ctl: String, pub recorder_ctl: String,
/// `--allow-poweroff`: off by default, so a gateway on a workstation can never shut it down. /// `--allow-power`: off by default, so a gateway on a workstation can never shut it down or reboot it.
pub allow_poweroff: bool, pub allow_power: bool,
/// Acks by command id, shared across connections so a retry after reconnect is answered, not re-run. /// Acks by command id, shared across connections so a retry after reconnect is answered, not re-run.
pub acks: Mutex<VecDeque<(String, Value)>>, pub acks: Mutex<VecDeque<(String, Value)>>,
} }
@@ -130,39 +130,41 @@ fn run(ctx: &Ctx, msg: &Value) -> (bool, String) {
Err(e) => (false, format!("recorder unreachable: {e}")), Err(e) => (false, format!("recorder unreachable: {e}")),
} }
} }
"poweroff" => poweroff(ctx, &msg["args"]), "poweroff" => power(ctx, &msg["args"], "poweroff"),
"reboot" => power(ctx, &msg["args"], "reboot"),
other => (false, format!("unknown command '{other}'")), other => (false, format!("unknown command '{other}'")),
} }
} }
/// The host's boot id: a poweroff command must carry the current one, so a resend /// The host's boot id: a poweroff or reboot command must carry the current one, so a resend
/// from before a reboot cannot shut the next boot down. /// from before a reboot cannot shut the next boot down.
pub fn boot_id() -> String { pub fn boot_id() -> String {
std::fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap_or_default().trim().to_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) { /// `poweroff` or `reboot`: the same guards, the same grant, a different systemctl verb.
if !ctx.allow_poweroff { fn power(ctx: &Ctx, args: &Value, verb: &'static str) -> (bool, String) {
return (false, "poweroff not enabled on this gateway (--allow-poweroff)".into()); if !ctx.allow_power {
return (false, format!("{verb} not enabled on this gateway (--allow-power)"));
} }
if args["confirm"].as_str() != Some("poweroff") { if args["confirm"].as_str() != Some(verb) {
return (false, "poweroff needs args.confirm = \"poweroff\"".into()); return (false, format!("{verb} needs args.confirm = \"{verb}\""));
} }
if args["boot_id"].as_str() != Some(boot_id().as_str()) { if args["boot_id"].as_str() != Some(boot_id().as_str()) {
return (false, "poweroff refused: boot_id is not this boot's".into()); return (false, format!("{verb} refused: boot_id is not this boot's"));
} }
eprintln!("svc-gw: poweroff requested by a client"); eprintln!("svc-gw: {verb} requested by a client");
// Ack first: the reply has to reach the client before the services stop. // Ack first: the reply has to reach the client before the services stop.
thread::spawn(|| { thread::spawn(move || {
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_secs(2));
// Unprivileged: polkit grants this user logind's power-off (deploy/50-yukart-gw-poweroff.pkla). // Unprivileged: polkit grants this user logind's power-off and reboot (deploy/50-yukart-gw-power.pkla).
match Command::new("systemctl").args(["poweroff", "--no-ask-password"]).status() { match Command::new("systemctl").args([verb, "--no-ask-password"]).status() {
Ok(s) if s.success() => {} Ok(s) if s.success() => {}
Ok(s) => eprintln!("svc-gw: systemctl poweroff exited {s}"), Ok(s) => eprintln!("svc-gw: systemctl {verb} exited {s}"),
Err(e) => eprintln!("svc-gw: systemctl poweroff: {e}"), Err(e) => eprintln!("svc-gw: systemctl {verb}: {e}"),
} }
}); });
(true, "shutting down in 2 s".into()) (true, if verb == "reboot" { "rebooting in 2 s" } else { "shutting down in 2 s" }.into())
} }
fn recorder(path: &str, line: &str) -> std::io::Result<String> { fn recorder(path: &str, line: &str) -> std::io::Result<String> {
+4 -4
View File
@@ -25,7 +25,7 @@ usage: svc-gw [options]
--mock synthesise vehicle state, flagged as mock in every message (no bus) --mock synthesise vehicle state, flagged as mock in every message (no bus)
--broker HOST:PORT MQTT broker; '' for none (127.0.0.1:1883) --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)"; --allow-power accept the 'poweroff' and 'reboot' commands (the Orin's unit only; never on a workstation)";
pub fn now_us() -> i64 { pub fn now_us() -> i64 {
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
@@ -54,13 +54,13 @@ fn main() -> ExitCode {
let mut listen = "0.0.0.0:7700".to_string(); let mut listen = "0.0.0.0:7700".to_string();
let mut mock = false; let mut mock = false;
let mut recorder_ctl = "/run/yukart-recorder/ctl.sock".to_string(); let mut recorder_ctl = "/run/yukart-recorder/ctl.sock".to_string();
let mut allow_poweroff = false; let mut allow_power = false;
let mut broker = "127.0.0.1:1883".to_string(); let mut broker = "127.0.0.1:1883".to_string();
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(a) = args.next() { while let Some(a) = args.next() {
match a.as_str() { match a.as_str() {
"--mock" => mock = true, "--mock" => mock = true,
"--allow-poweroff" => allow_poweroff = true, "--allow-power" => allow_power = true,
"--listen" => listen = args.next().unwrap_or_default(), "--listen" => listen = args.next().unwrap_or_default(),
"--recorder-ctl" => recorder_ctl = args.next().unwrap_or_default(), "--recorder-ctl" => recorder_ctl = args.next().unwrap_or_default(),
"--broker" => broker = args.next().unwrap_or_default(), "--broker" => broker = args.next().unwrap_or_default(),
@@ -99,7 +99,7 @@ fn main() -> ExitCode {
"timeout_ms": client::TIMEOUT.as_millis() as u64, "timeout_ms": client::TIMEOUT.as_millis() as u64,
}), }),
recorder_ctl, recorder_ctl,
allow_poweroff, allow_power,
acks: Mutex::new(Default::default()), acks: Mutex::new(Default::default()),
}); });
+7 -7
View File
@@ -40,20 +40,20 @@ impl Mock {
s.insert(name.to_string(), reg.envelope(name, v, q, true)); s.insert(name.to_string(), reg.envelope(name, v, q, true));
}; };
put("engine_rpm", json!(rpm.round()), None); put("engine_rpm", json!(rpm.round()), None);
put("vehicle_speed_kph", json!((speed * 10.0_f64).round() / 10.0), None); put("gnss_speed_kph", json!((speed * 10.0_f64).round() / 10.0), None);
put("coolant_temp_c", json!(self.coolant.round()), None); put("obd_coolant_temp_c", json!(self.coolant.round()), None);
// Throttle drops out for 5 s in every 40: a signal that disappears. // The pedal drops out for 5 s in every 40: a signal that disappears.
if t % 40.0 >= 5.0 { if t % 40.0 >= 5.0 {
put("throttle_pct", json!(throttle), None); put("accel_pedal_pct", json!(throttle), None);
} }
// Intake temperature is stuck at its last value and marked stale for 10 s in every 30. // Intake temperature is stuck at its last value and marked stale for 10 s in every 30.
if t % 30.0 < 10.0 { if t % 30.0 < 10.0 {
put("intake_air_temp_c", json!(31.0), Some("stale")); put("obd_intake_air_temp_c", json!(31.0), Some("stale"));
} else { } else {
put("intake_air_temp_c", json!((31.0 + (t * 0.05).sin()).round()), None); put("obd_intake_air_temp_c", json!((31.0 + (t * 0.05).sin()).round()), None);
} }
// Fuel level is known but has no value: the registry has it, the car never sent it. // Fuel level is known but has no value: the registry has it, the car never sent it.
put("fuel_level_pct", Value::Null, None); put("obd_fuel_level_pct", Value::Null, None);
s s
} }
} }