Skip to main content

planarian_backend_netwatch/
contract.rs

1//! The files the hold reads (see the crate docs, the single source).
2
3use crate::model::{Endpoint, Footprint, FootprintView, Proto, WatchState};
4use planarian_schema::{Scope, UnitId, UnitKind};
5use std::io::{self, Write};
6use std::path::Path;
7
8/// Where the footprints live.
9pub const DIR: &str = "/run/planarian/footprint";
10/// The clock every `<t>` is on: seconds since boot, suspend included (the
11/// first field of `/proc/uptime`), so a wall-clock step cannot make a stale
12/// file look fresh.
13pub const CLOCK: &str = "CLOCK_BOOTTIME";
14/// A file confirmed longer ago than this is stale.
15pub const FRESH_SECS: u64 = 30;
16/// A stamp further ahead than this is not trusted either (the hold's script).
17pub const AHEAD_SECS: u64 = 5;
18/// How often netwatch rewrites every file.
19pub const TICK_SECS: u64 = 5;
20
21const WATCH: &str = ".watch";
22/// netwatch's own record of who claimed what, across its restarts (a
23/// dot-file: the hold reads only `.watch` and `.unattributed`).
24const STATE: &str = ".state";
25const UNATTRIBUTED: &str = ".unattributed";
26/// The hold's condition on a failure, as lane-nix's script implements it
27/// (crate docs, "The hold's order"): it may release only when the unit's
28/// footprint describes the invocation that failed (`invocation`, systemd's
29/// `InvocationID`), that invocation has `ended` (its cgroup is gone and the
30/// ring drained after, so nothing it did is still to come), and the
31/// footprint reads `protecting no` at `now`, with nothing unattributed.
32/// Anything else holds. The reference the property tests check.
33pub fn hold_may_release(view: &FootprintView, unit: &UnitId, invocation: Option<&str>, now: u64) -> bool {
34    let name = file_name(unit);
35    let Some(f) = view.units.get(&name) else { return false };
36    invocation.is_some()
37        && f.invocation.as_deref() == invocation
38        && f.ended
39        && !f.unknown
40        && !view.protecting_now(&name, now)
41}
42
43/// The file name of a unit's footprint: systemd's `%N` (the unit name with
44/// any `@instance`, without `.service`). Network tools are system services.
45pub fn file_name(u: &UnitId) -> String {
46    debug_assert!(u.kind == UnitKind::Service && u.scope == Scope::System, "{u}");
47    u.name.trim_end_matches(".service").to_string()
48}
49
50fn proto(p: Proto) -> &'static str {
51    match p {
52        Proto::Tcp => "tcp",
53        Proto::Udp => "udp",
54    }
55}
56
57pub(crate) fn render(f: &Footprint) -> String {
58    let protecting = if f.unknown {
59        "unknown"
60    } else if f.protecting {
61        "yes"
62    } else {
63        "no"
64    };
65    let mut s = format!("protecting {protecting}\nat {}\n", f.at);
66    if let Some(i) = &f.invocation {
67        s.push_str(&format!("invocation {i}\n"));
68    }
69    if f.ended {
70        s.push_str("ended\n");
71    }
72    for i in &f.ifaces {
73        s.push_str(&format!("iface {i}\n"));
74    }
75    for e in &f.endpoints {
76        s.push_str(&format!("endpoint {} {} {}\n", proto(e.proto), e.addr, e.port));
77    }
78    if f.unattributed {
79        s.push_str("unattributed\n");
80    }
81    s
82}
83
84/// Write `name` in `dir` atomically: a temporary file, synced, renamed over it.
85fn put(dir: &Path, name: &str, body: &str) -> io::Result<()> {
86    let tmp = dir.join(format!(".{name}.tmp"));
87    let mut f = std::fs::File::create(&tmp)?;
88    f.write_all(body.as_bytes())?;
89    f.sync_all()?;
90    std::fs::rename(&tmp, dir.join(name))
91}
92
93/// Write `view` to `dir`: `.watch`, `.unattributed` (present or removed),
94/// and one file per unit; a unit's file that is no longer in the view is
95/// removed, so a reader never trusts a footprint netwatch no longer keeps.
96pub fn write(dir: &Path, view: &FootprintView) -> io::Result<()> {
97    std::fs::create_dir_all(dir)?;
98    for (name, f) in &view.units {
99        put(dir, name, &render(f))?;
100    }
101    for e in std::fs::read_dir(dir)? {
102        let name = e?.file_name().to_string_lossy().into_owned();
103        if !name.starts_with('.') && !view.units.contains_key(&name) {
104            std::fs::remove_file(dir.join(&name))?;
105        }
106    }
107    if !view.unattributed.is_empty() {
108        let why: String = view.unattributed.iter().map(|r| format!("{}\n", one_line(r))).collect();
109        put(dir, UNATTRIBUTED, &why)?;
110    } else {
111        match std::fs::remove_file(dir.join(UNATTRIBUTED)) {
112            Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
113            _ => {}
114        }
115    }
116    // Last: the heartbeat confirms everything written before it.
117    let watch = match &view.watch {
118        WatchState::Attached { at } => format!("attached {at}\n"),
119        WatchState::Failed { reason } => format!("failed {}\n", one_line(reason)),
120        WatchState::Unknown => "unknown\n".to_string(),
121    };
122    put(dir, WATCH, &watch)
123}
124
125fn one_line(s: &str) -> String {
126    let s: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
127    if s.trim().is_empty() { "unknown".into() } else { s.trim().to_string() }
128}
129
130pub(crate) fn parse_footprint(text: &str) -> Option<Footprint> {
131    let mut f = Footprint::default();
132    let mut at = None;
133    for line in text.lines() {
134        let w: Vec<&str> = line.split_whitespace().collect();
135        match w.as_slice() {
136            ["at", t] => at = Some(t.parse().ok()?),
137            ["invocation", i] if i.len() == 32 && i.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) => {
138                f.invocation = Some((*i).to_string());
139            }
140            ["protecting", "yes"] => f.protecting = true,
141            ["protecting", "no"] => f.protecting = false,
142            // Unknown counts as protecting (fail closed).
143            ["protecting", "unknown"] => {
144                f.protecting = true;
145                f.unknown = true;
146            }
147            ["ended"] => f.ended = true,
148            ["iface", i] => {
149                f.ifaces.insert((*i).to_string());
150            }
151            ["endpoint", p, a, port] => {
152                let proto = match *p {
153                    "tcp" => Proto::Tcp,
154                    "udp" => Proto::Udp,
155                    _ => return None,
156                };
157                f.endpoints.insert(Endpoint { proto, addr: a.parse().ok()?, port: port.parse().ok()? });
158            }
159            ["unattributed"] => f.unattributed = true,
160            [] => {}
161            // A line this reader does not know: not a footprint it can trust.
162            _ => return None,
163        }
164    }
165    f.at = at?;
166    Some(f)
167}
168
169/// Read what `dir` holds. Fail closed: a footprint that does not parse is
170/// left out (so it reads as missing, which counts as protecting), and an
171/// unreadable or unknown `.watch` is [`WatchState::Unknown`].
172pub fn read(dir: &Path) -> FootprintView {
173    let mut view = FootprintView::default();
174    let Ok(entries) = std::fs::read_dir(dir) else { return view };
175    view.watch = match std::fs::read_to_string(dir.join(WATCH)) {
176        Ok(s) => match s.split_whitespace().collect::<Vec<_>>().as_slice() {
177            ["attached", t] => t.parse().map(|at| WatchState::Attached { at }).unwrap_or(WatchState::Unknown),
178            ["failed", why @ ..] => WatchState::Failed { reason: why.join(" ") },
179            _ => WatchState::Unknown,
180        },
181        Err(_) => WatchState::Unknown,
182    };
183    view.unattributed = match std::fs::read_to_string(dir.join(UNATTRIBUTED)) {
184        // Present: it stands, with its reasons (or one saying none was given).
185        Ok(s) => {
186            let why: Vec<String> = s.lines().filter(|l| !l.trim().is_empty()).map(str::to_string).collect();
187            if why.is_empty() { vec!["unattributed".into()] } else { why }
188        }
189        Err(e) if e.kind() == io::ErrorKind::NotFound => vec![],
190        // Unreadable: fail closed, as if it stood.
191        Err(e) => vec![format!("unreadable: {e}")],
192    };
193    for e in entries.flatten() {
194        let name = e.file_name().to_string_lossy().into_owned();
195        if name.starts_with('.') {
196            continue;
197        }
198        if let Some(f) = std::fs::read_to_string(e.path()).ok().and_then(|t| parse_footprint(&t)) {
199            view.units.insert(name, f);
200        }
201    }
202    view
203}
204
205/// Keep the tracker's claims in `dir/.state`, atomically.
206pub fn write_state(dir: &Path, claims: &crate::Claims) -> io::Result<()> {
207    let json = serde_json::to_string(claims).map_err(io::Error::other)?;
208    put(dir, STATE, &json)
209}
210
211/// The claims kept before a restart: `.state` if it reads, else the
212/// footprint files' `iface` lines (`Tracker::claims_from_files`).
213pub fn read_claims(dir: &Path) -> crate::Claims {
214    std::fs::read_to_string(dir.join(STATE))
215        .ok()
216        .and_then(|s| serde_json::from_str(&s).ok())
217        .unwrap_or_else(|| crate::Tracker::claims_from_files(&read(dir)))
218}