Skip to main content

planarian_backend_linux/
hold.rs

1//! §12d *Network security fails closed*: the hold, as lane-nix's
2//! `planarian-hold@<unit>.service` (nix/posture.nix) builds it. This module
3//! only starts and stops that unit, reads which holds are in place, and
4//! writes the release marker the hold script honours:
5//!
6//! - apply: `systemctl start planarian-hold@<unit>.service` (idempotent;
7//!   systemd's `OnFailure=` starts the same unit on a crash or kill);
8//! - lift: `systemctl stop` of it;
9//! - reapply: `systemctl reload` of it (`ExecReload`: rebuild the table,
10//!   never lift);
11//! - state: `/run/planarian/hold/<unit>` exists while a hold is in place;
12//! - release: `/run/planarian/hold-released/<unit>` holds `boot` (until the
13//!   next boot), or `<unix> <boottime>`: the expiry in Unix seconds and the
14//!   same deadline in `CLOCK_BOOTTIME` seconds. The script takes whichever
15//!   passes first, so a stepped wall clock cannot lengthen a release; an
16//!   unparsable or past one is expired (the script holds).
17//!
18//! `<unit>` is the systemd unit name without `.service`. A failure posture
19//! response, not a transition (docs/bypasses.md): no `Prepared`, no lease,
20//! no op tag. It only ever restricts traffic, except `lift`, which the
21//! daemon calls only once the unit is Running and its Readiness probes pass,
22//! or on an attested release.
23
24use crate::cmd::{Output, run};
25use crate::systemd::unit_name;
26use planarian_schema::{Outcome, Timestamp, UnitId};
27use std::collections::BTreeSet;
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31pub const HOLD_DIR: &str = "/run/planarian/hold";
32pub const RELEASED_DIR: &str = "/run/planarian/hold-released";
33const TIMEOUT: Duration = Duration::from_secs(30);
34
35/// The hold's instance name: the unit's systemd name without `.service`.
36pub fn instance(unit: &UnitId) -> String {
37    let n = unit_name(unit);
38    n.strip_suffix(".service").map(str::to_string).unwrap_or(n)
39}
40
41fn hold_unit(unit: &UnitId) -> String {
42    format!("planarian-hold@{}.service", instance(unit))
43}
44
45fn outcome(verb: &str, unit: &UnitId, o: Output) -> Outcome {
46    if o.timed_out {
47        Outcome::Unknown(format!("systemctl {verb} {} did not finish", hold_unit(unit)))
48    } else if o.ok() {
49        Outcome::Succeeded
50    } else {
51        Outcome::Failed(o.tail())
52    }
53}
54
55/// Start the unit's hold, waiting at most `wait` (and never past TIMEOUT).
56/// Past it the `systemctl` client is killed and the result is `Unknown`;
57/// systemd's start job is the manager's, not the client's, and goes on, so
58/// the hold may still land.
59pub fn apply(unit: &UnitId, wait: Duration) -> Outcome {
60    outcome("start", unit, run(&["systemctl", "start", &hold_unit(unit)], wait.min(TIMEOUT)))
61}
62
63/// Rebuild the hold's table in place (`ExecReload` runs the script's
64/// `reapply`). A reload never lifts the hold, where a restart would run
65/// `ExecStop` (`lift`) first and let traffic through for a moment.
66///
67/// `systemctl reload` fails on an inactive unit, so one not active (the hold
68/// was stopped, or never started on this boot) is started instead.
69pub fn reapply(unit: &UnitId, wait: Duration) -> Outcome {
70    reapply_with(unit, |argv| run(argv, wait.min(TIMEOUT)))
71}
72
73fn reapply_with(unit: &UnitId, run: impl Fn(&[&str]) -> Output) -> Outcome {
74    let name = hold_unit(unit);
75    let active = run(&["systemctl", "is-active", "--quiet", &name]);
76    if active.timed_out {
77        return Outcome::Unknown(format!("systemctl is-active {name} did not finish"));
78    }
79    let verb = if active.ok() { "reload" } else { "start" };
80    outcome(verb, unit, run(&["systemctl", verb, &name]))
81}
82
83/// The hold script's table (`table inet planarian-hold`).
84pub fn table_present(wait: Duration) -> Result<bool, Outcome> {
85    let o = run(&["nft", "list", "table", "inet", "planarian-hold"], wait.min(TIMEOUT));
86    if o.ok() {
87        Ok(true)
88    } else if !o.timed_out && o.stderr.contains("No such file or directory") {
89        Ok(false)
90    } else {
91        Err(Outcome::Failed(o.tail()))
92    }
93}
94
95pub fn lift(unit: &UnitId, wait: Duration) -> Outcome {
96    outcome("stop", unit, run(&["systemctl", "stop", &hold_unit(unit)], wait.min(TIMEOUT)))
97}
98
99/// The units held now, from the hold script's state directory. A directory
100/// that cannot be read (other than absent: no hold was ever applied) is an
101/// error, never "none" (rule 21).
102pub fn held_in(dir: &Path) -> Result<BTreeSet<UnitId>, Outcome> {
103    match std::fs::read_dir(dir) {
104        Ok(entries) => Ok(entries
105            .flatten()
106            .filter_map(|e| e.file_name().to_str().map(str::to_string))
107            .map(|n| UnitId::service(&n))
108            .collect()),
109        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()),
110        Err(e) => Err(Outcome::Failed(format!("{}: {e}", dir.display()))),
111    }
112}
113
114/// The marker's content: `boot`, or the expiry in Unix seconds and the same
115/// deadline on the boot clock, `boottime_now + (until - wall_now)`, both
116/// truncated to seconds. A deadline already past saturates to `boottime_now`
117/// or below: expired.
118pub fn marker(until: Option<Timestamp>, wall_now: Timestamp, boottime_now_s: u64) -> String {
119    match until {
120        Some(t) => {
121            let left = (t.0 / 1000).saturating_sub(wall_now.0 / 1000);
122            format!("{} {}\n", t.0 / 1000, boottime_now_s.saturating_add(left))
123        }
124        None => "boot\n".into(),
125    }
126}
127
128fn boottime_s() -> u64 {
129    rustix::time::clock_gettime(rustix::time::ClockId::Boottime).tv_sec as u64
130}
131
132fn wall_now() -> Timestamp {
133    let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
134    Timestamp(d.as_millis() as u64)
135}
136
137fn marker_path(dir: &Path, unit: &UnitId) -> PathBuf {
138    dir.join(instance(unit))
139}
140
141/// Write the release marker, then lift the hold. The marker comes first so
142/// that a failure racing the lift does not re-apply the hold.
143pub fn release_in(dir: &Path, unit: &UnitId, until: Option<Timestamp>, wait: Duration) -> Outcome {
144    if let Err(e) = std::fs::create_dir_all(dir)
145        .and_then(|_| std::fs::write(marker_path(dir, unit), marker(until, wall_now(), boottime_s())))
146    {
147        return Outcome::Failed(format!("{}: {e}", dir.display()));
148    }
149    lift(unit, wait)
150}
151
152pub fn end_release_in(dir: &Path, unit: &UnitId) -> Outcome {
153    match std::fs::remove_file(marker_path(dir, unit)) {
154        Ok(()) => Outcome::Succeeded,
155        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Succeeded,
156        Err(e) => Outcome::Failed(format!("{}: {e}", dir.display())),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn instance_names_drop_the_service_suffix() {
166        assert_eq!(instance(&UnitId::service("wireguard-gym")), "wireguard-gym");
167        assert_eq!(hold_unit(&UnitId::service("wg-quick-wg0")), "planarian-hold@wg-quick-wg0.service");
168        // A template instance keeps its `@`, as systemd's `%N` gives it to `OnFailure=`.
169        assert_eq!(hold_unit(&UnitId::service("wg-quick@wg0")), "planarian-hold@wg-quick@wg0.service");
170    }
171
172    #[test]
173    fn the_marker_is_boot_or_wall_and_boot_deadlines() {
174        let now = Timestamp(1_799_999_000_900);
175        // 1000 s from now on both clocks, truncated.
176        assert_eq!(marker(Some(Timestamp(1_800_000_000_123)), now, 5_000), "1800000000 6000\n");
177        // Already past: the boot deadline is now, so the script holds.
178        assert_eq!(marker(Some(Timestamp(1_000)), now, 5_000), "1 5000\n");
179        assert_eq!(marker(None, now, 5_000), "boot\n");
180    }
181
182    /// §12d: re-applying reloads an active hold unit (never lifting it) and
183    /// starts an inactive one, since `systemctl reload` fails on that.
184    #[test]
185    fn reapply_reloads_an_active_hold_and_starts_an_inactive_one() {
186        let u = UnitId::service("wireguard-gym");
187        for (active, verb) in [(true, "reload"), (false, "start")] {
188            let calls = std::cell::RefCell::new(Vec::new());
189            let o = reapply_with(&u, |argv| {
190                calls.borrow_mut().push(argv.join(" "));
191                let status = if argv[1] == "is-active" && !active { 3 } else { 0 };
192                Output {
193                    status: Some(status),
194                    stdout: String::new(),
195                    stderr: String::new(),
196                    timed_out: false,
197                    truncated: false,
198                }
199            });
200            assert_eq!(o, Outcome::Succeeded);
201            assert_eq!(
202                calls.into_inner(),
203                [
204                    "systemctl is-active --quiet planarian-hold@wireguard-gym.service".to_string(),
205                    format!("systemctl {verb} planarian-hold@wireguard-gym.service"),
206                ]
207            );
208        }
209    }
210
211    #[test]
212    fn held_reads_the_state_directory_and_absent_is_none() {
213        let d = std::env::temp_dir().join(format!("planarian-hold-{}", std::process::id()));
214        assert_eq!(held_in(&d), Ok(BTreeSet::new()), "never applied: none");
215        std::fs::create_dir_all(&d).unwrap();
216        std::fs::write(d.join("wireguard-gym"), "").unwrap();
217        assert_eq!(held_in(&d), Ok([UnitId::service("wireguard-gym")].into()));
218        // An unreadable directory is an error, not "none".
219        let f = d.join("not-a-dir");
220        std::fs::write(&f, "").unwrap();
221        assert!(held_in(&f).is_err());
222        std::fs::remove_dir_all(&d).unwrap();
223    }
224
225    #[test]
226    fn a_release_writes_its_marker_and_ending_it_removes_it() {
227        let d = std::env::temp_dir().join(format!("planarian-hold-released-{}", std::process::id()));
228        let u = UnitId::service("wireguard-gym");
229        // `lift` runs systemctl, absent here: the marker is written first all the same.
230        let _ = release_in(&d, &u, None, Duration::from_secs(1));
231        assert_eq!(std::fs::read_to_string(d.join("wireguard-gym")).unwrap(), "boot\n");
232        assert_eq!(end_release_in(&d, &u), Outcome::Succeeded);
233        assert!(!d.join("wireguard-gym").exists());
234        assert_eq!(end_release_in(&d, &u), Outcome::Succeeded, "idempotent");
235        std::fs::remove_dir_all(&d).unwrap();
236    }
237}