1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
13use planarian_schema::{GenerationId, Incident, IncidentState, SystemState, Timestamp, UnitDefs, UnitStatus};
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18const DESIRED_TAG: &[u8] = b"planarian desired-state v1\0";
19const HEALTH_TAG: &[u8] = b"planarian health-report v1\0";
20
21pub fn hex(bytes: &[u8]) -> String {
22 bytes.iter().map(|b| format!("{b:02x}")).collect()
23}
24
25pub fn unhex(s: &str) -> Option<Vec<u8>> {
26 let s = s.trim();
27 if !s.len().is_multiple_of(2) {
28 return None;
29 }
30 (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
31}
32
33fn tagged(tag: &[u8], doc: &[u8]) -> Vec<u8> {
34 [tag, doc].concat()
35}
36
37fn sig_path(p: &Path) -> PathBuf {
38 let mut s = p.as_os_str().to_owned();
39 s.push(".sig");
40 s.into()
41}
42
43#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct ExternalSourceConfig {
50 pub public_key: String,
52 pub path: PathBuf,
54}
55
56#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct DesiredDoc {
60 pub seq: u64,
62 pub issued: Timestamp,
63 #[serde(default)]
65 pub issuer: String,
66 #[serde(default)]
67 pub units: UnitDefs,
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct Verified {
73 pub doc: DesiredDoc,
74 pub bytes: Vec<u8>,
75 pub sig: Vec<u8>,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum Poll {
80 Unchanged,
82 Accepted(Verified),
84 Refused(String),
87 Stale(String),
89 Integrity(String),
91}
92
93pub struct ExternalSource {
94 key: VerifyingKey,
95 path: PathBuf,
96 cache: PathBuf,
97 pub current: Option<Verified>,
98}
99
100impl ExternalSource {
101 pub fn new(cfg: &ExternalSourceConfig, cache_dir: &Path) -> Result<ExternalSource, String> {
103 let raw = unhex(&cfg.public_key).ok_or("externalSource.publicKey: not hex")?;
104 let raw: [u8; 32] = raw.try_into().map_err(|_| "externalSource.publicKey: not 32 bytes")?;
105 let key = VerifyingKey::from_bytes(&raw).map_err(|e| format!("externalSource.publicKey: {e}"))?;
106 Ok(ExternalSource {
107 key,
108 path: cfg.path.clone(),
109 cache: cache_dir.join("external-desired.json"),
110 current: None,
111 })
112 }
113
114 fn verify(&self, bytes: &[u8], sig_hex: &str) -> Result<(), String> {
115 let sig = unhex(sig_hex).ok_or("signature is not hex")?;
116 let sig = Signature::from_slice(&sig).map_err(|e| format!("signature: {e}"))?;
117 self.key.verify(&tagged(DESIRED_TAG, bytes), &sig).map_err(|_| "signature does not verify".to_string())
118 }
119
120 fn read(&self, path: &Path) -> Result<(Vec<u8>, String), String> {
121 let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
122 let sig = std::fs::read_to_string(sig_path(path)).map_err(|e| format!("{}: {e}", sig_path(path).display()))?;
123 Ok((bytes, sig))
124 }
125
126 fn check(&self, bytes: Vec<u8>, sig: &str) -> Result<Result<Verified, String>, String> {
127 self.verify(&bytes, sig)?;
128 let parsed = serde_json::from_slice::<DesiredDoc>(&bytes).map_err(|e| format!("signed but unreadable: {e}"));
129 Ok(parsed.map(|doc| Verified { doc, bytes, sig: unhex(sig).unwrap_or_default() }))
130 }
131
132 pub fn load_cache(&mut self) -> Result<(), String> {
135 if !self.cache.exists() {
136 return Ok(());
137 }
138 let raw = std::fs::read(&self.cache).map_err(|e| format!("external source cache: {e}"))?;
139 let Some(nl) = raw.iter().position(|b| *b == b'\n') else {
141 return Err("external source cache: no signature line".into());
142 };
143 let sig = String::from_utf8_lossy(&raw[..nl]).into_owned();
144 match self.check(raw[nl + 1..].to_vec(), &sig) {
145 Ok(Ok(v)) => {
146 self.current = Some(v);
147 Ok(())
148 }
149 Ok(Err(e)) | Err(e) => Err(format!("external source cache: {e}")),
150 }
151 }
152
153 pub fn poll(&mut self) -> Poll {
154 self.poll_against(None)
155 }
156
157 pub fn replayed(doc: &Verified, journal: Option<(u64, planarian_schema::Hash)>) -> Option<String> {
160 let (seq, digest) = journal?;
161 if doc.doc.seq < seq {
162 return Some(format!(
163 "sequence {} is older than the {seq} the journal accepted (a replayed document)",
164 doc.doc.seq
165 ));
166 }
167 (doc.doc.seq == seq && planarian_schema::Hash::of(&doc.bytes) != digest)
168 .then(|| format!("a different document with sequence {seq}, which the journal accepted"))
169 }
170
171 pub fn poll_against(&mut self, journal: Option<(u64, planarian_schema::Hash)>) -> Poll {
174 if !self.path.exists() {
175 return Poll::Unchanged;
176 }
177 let (bytes, sig) = match self.read(&self.path) {
178 Ok(r) => r,
179 Err(e) => return Poll::Stale(e),
180 };
181 if self.current.as_ref().is_some_and(|c| c.bytes == bytes && hex(&c.sig) == sig.trim()) {
182 return Poll::Unchanged;
183 }
184 let v = match self.check(bytes.clone(), &sig) {
185 Err(e) => {
186 std::thread::sleep(std::time::Duration::from_millis(300));
190 return match self.read(&self.path) {
191 Ok((b2, s2)) if b2 == bytes && s2 == sig => {
192 Poll::Integrity(format!("external desired-state source {}: {e}", self.path.display()))
193 }
194 Ok(_) => Poll::Stale("the document changed while it was read".into()),
195 Err(e) => Poll::Stale(e),
196 };
197 }
198 Ok(Err(e)) => return Poll::Refused(e),
199 Ok(Ok(v)) => v,
200 };
201 if let Some(why) = Self::replayed(&v, journal) {
202 return Poll::Refused(why);
203 }
204 if let Some(c) = &self.current {
205 if v.doc.seq < c.doc.seq {
206 return Poll::Refused(format!(
207 "sequence {} is older than the {} in force (a replayed document)",
208 v.doc.seq, c.doc.seq
209 ));
210 }
211 if v.doc.seq == c.doc.seq {
212 if v.bytes == c.bytes {
213 return Poll::Unchanged;
214 }
215 return Poll::Refused(format!("a second, different document with sequence {}", v.doc.seq));
216 }
217 }
218 if let Err(e) = self.store(&v) {
219 return Poll::Stale(format!("could not cache it: {e}"));
220 }
221 self.current = Some(v.clone());
222 Poll::Accepted(v)
223 }
224
225 fn store(&self, v: &Verified) -> std::io::Result<()> {
228 use std::io::Write;
229 let dir = self.cache.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
230 std::fs::create_dir_all(&dir)?;
231 let tmp = self.cache.with_extension("tmp");
232 let mut f = std::fs::File::create(&tmp)?;
233 f.write_all(hex(&v.sig).as_bytes())?;
234 f.write_all(b"\n")?;
235 f.write_all(&v.bytes)?;
236 f.sync_all()?;
237 std::fs::rename(&tmp, &self.cache)?;
238 std::fs::File::open(&dir)?.sync_all()
239 }
240}
241
242pub fn sanitized(def: &planarian_schema::UnitDef) -> planarian_schema::UnitDef {
248 use planarian_schema::{ProbeKind, Sensitivities, Sensitivity};
249 let mut d = def.clone();
250 d.probes.retain(|p| !matches!(p.kind, ProbeKind::Exec { .. }));
251 d.sensitivity = Sensitivities::of(&[Sensitivity::Security]);
252 d.edges.clear();
253 d.runtime_repair = None;
254 d
255}
256
257pub fn sign_desired(key: &SigningKey, bytes: &[u8]) -> String {
259 hex(&key.sign(&tagged(DESIRED_TAG, bytes)).to_bytes())
260}
261
262#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
268pub enum DisclosureProfile {
269 #[default]
272 Minimal,
273}
274
275#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
276pub struct IncidentSummary {
277 pub kind: String,
278 pub state: String,
279}
280
281#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
282pub struct HealthReport {
283 pub format: u32,
284 pub profile: DisclosureProfile,
285 pub at: Timestamp,
286 pub generation: GenerationId,
288 pub known_good: GenerationId,
289 pub units: BTreeMap<String, String>,
291 pub incidents: Vec<IncidentSummary>,
292}
293
294pub fn status_kind(s: &UnitStatus) -> &'static str {
296 match s {
297 UnitStatus::Running => "Running",
298 UnitStatus::Degraded(_) => "Degraded",
299 UnitStatus::Stopped => "Stopped",
300 UnitStatus::Overridden(_) => "Overridden",
301 UnitStatus::Unknown => "Unknown",
302 UnitStatus::Failed(_) => "Failed",
303 UnitStatus::Absent => "Absent",
304 UnitStatus::Detected => "Detected",
305 UnitStatus::Bound => "Bound",
306 UnitStatus::Transitioning => "Transitioning",
307 UnitStatus::NotExpected => "NotExpected",
308 }
309}
310
311fn incident_state(s: &IncidentState) -> &'static str {
312 match s {
313 IncidentState::Open => "Open",
314 IncidentState::Cooldown { .. } => "Cooldown",
315 IncidentState::Escalated { .. } => "Escalated",
316 IncidentState::Closed => "Closed",
317 IncidentState::Regressed { .. } => "Regressed",
318 }
319}
320
321pub fn report<'a>(
322 profile: DisclosureProfile,
323 s: &SystemState,
324 defs: &UnitDefs,
325 incidents: impl IntoIterator<Item = &'a Incident>,
326) -> HealthReport {
327 match profile {
328 DisclosureProfile::Minimal => HealthReport {
329 format: 1,
330 profile,
331 at: s.observed_at,
332 generation: s.generation,
333 known_good: s.known_good,
334 units: s.units.keys().map(|id| (id.to_string(), status_kind(&s.status(id, defs)).to_string())).collect(),
335 incidents: incidents
336 .into_iter()
337 .filter(|i| !matches!(i.state, IncidentState::Closed))
338 .map(|i| IncidentSummary { kind: format!("{:?}", i.kind), state: incident_state(&i.state).into() })
339 .collect(),
340 },
341 }
342}
343
344pub fn health_key(dir: &Path) -> std::io::Result<SigningKey> {
347 use std::os::unix::fs::OpenOptionsExt;
348 let p = dir.join("health.key");
349 if let Ok(b) = std::fs::read(&p)
350 && let Ok(seed) = <[u8; 32]>::try_from(b.as_slice())
351 {
352 return Ok(SigningKey::from_bytes(&seed));
353 }
354 let key = SigningKey::generate(&mut rand_core::OsRng);
355 std::fs::create_dir_all(dir)?;
356 let tmp = dir.join("health.key.tmp");
357 let _ = std::fs::remove_file(&tmp);
358 let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&tmp)?;
359 std::io::Write::write_all(&mut f, &key.to_bytes())?;
360 f.sync_all()?;
361 std::fs::rename(&tmp, &p)?;
362 std::fs::write(dir.join("health.pub"), hex(key.verifying_key().as_bytes()))?;
363 Ok(key)
364}
365
366pub fn emit(dir: &Path, key: &SigningKey, r: &HealthReport) -> std::io::Result<PathBuf> {
368 let bytes = serde_json::to_vec_pretty(r).map_err(std::io::Error::other)?;
369 let p = dir.join("health-report.json");
370 let tmp = dir.join("health-report.json.tmp");
371 std::fs::write(sig_path(&tmp), hex(&key.sign(&tagged(HEALTH_TAG, &bytes)).to_bytes()))?;
372 std::fs::write(&tmp, &bytes)?;
373 std::fs::rename(sig_path(&tmp), sig_path(&p))?;
374 std::fs::rename(&tmp, &p)?;
375 Ok(p)
376}
377
378pub fn verify_report(public: &VerifyingKey, bytes: &[u8], sig_hex: &str) -> bool {
380 let Some(sig) = unhex(sig_hex).and_then(|s| Signature::from_slice(&s).ok()) else { return false };
381 public.verify(&tagged(HEALTH_TAG, bytes), &sig).is_ok()
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 fn key(n: u8) -> SigningKey {
389 SigningKey::from_bytes(&[n; 32])
390 }
391
392 fn dir(name: &str) -> PathBuf {
393 let d = std::env::temp_dir().join(format!("planarian-hooks-{name}-{}", std::process::id()));
394 let _ = std::fs::remove_dir_all(&d);
395 std::fs::create_dir_all(&d).unwrap();
396 d
397 }
398
399 fn put(path: &Path, k: &SigningKey, doc: &DesiredDoc) {
400 let bytes = serde_json::to_vec(doc).unwrap();
401 std::fs::write(sig_path(path), sign_desired(k, &bytes)).unwrap();
402 std::fs::write(path, bytes).unwrap();
403 }
404
405 fn source(d: &Path, k: &SigningKey) -> ExternalSource {
406 let cfg = ExternalSourceConfig { public_key: hex(k.verifying_key().as_bytes()), path: d.join("in/doc.json") };
407 std::fs::create_dir_all(d.join("in")).unwrap();
408 ExternalSource::new(&cfg, &d.join("cache")).unwrap()
409 }
410
411 fn doc(seq: u64) -> DesiredDoc {
412 DesiredDoc { seq, issued: Timestamp(seq * 1000), issuer: "test".into(), units: Default::default() }
413 }
414
415 #[test]
419 fn the_journal_not_the_cache_decides_what_is_a_replay() {
420 let d = dir("journal-floor");
421 let k = key(1);
422 let mut s = source(&d, &k);
423 put(&d.join("in/doc.json"), &k, &doc(3));
424 let Poll::Accepted(v3) = s.poll() else { panic!() };
425 let journal = Some((3, planarian_schema::Hash::of(&v3.bytes)));
426 std::fs::remove_dir_all(d.join("cache")).ok();
428 let mut fresh = source(&d, &k);
429 put(&d.join("in/doc.json"), &k, &doc(2));
430 assert!(matches!(fresh.poll_against(journal), Poll::Refused(m) if m.contains("journal")));
431 let mut other = doc(3);
433 other.issuer = "someone else".into();
434 put(&d.join("in/doc.json"), &k, &other);
435 assert!(matches!(fresh.poll_against(journal), Poll::Refused(m) if m.contains("journal")));
436 put(&d.join("in/doc.json"), &k, &doc(3));
438 assert!(matches!(fresh.poll_against(journal), Poll::Accepted(v) if v.doc.seq == 3));
439 put(&d.join("in/doc.json"), &k, &doc(4));
440 assert!(matches!(fresh.poll_against(journal), Poll::Accepted(v) if v.doc.seq == 4));
441 }
442
443 #[test]
444 fn accepts_caches_and_keeps_the_newest() {
445 let d = dir("accept");
446 let k = key(1);
447 let mut s = source(&d, &k);
448 assert_eq!(s.poll(), Poll::Unchanged, "no document yet");
449 put(&d.join("in/doc.json"), &k, &doc(2));
450 assert!(matches!(s.poll(), Poll::Accepted(v) if v.doc.seq == 2));
451 assert_eq!(s.poll(), Poll::Unchanged);
452 put(&d.join("in/doc.json"), &k, &doc(1));
454 assert!(matches!(s.poll(), Poll::Refused(m) if m.contains("older")));
455 assert_eq!(s.current.as_ref().unwrap().doc.seq, 2);
456 let mut other = doc(2);
458 other.issuer = "someone else".into();
459 put(&d.join("in/doc.json"), &k, &other);
460 assert!(matches!(s.poll(), Poll::Refused(m) if m.contains("second")));
461 std::fs::remove_file(d.join("in/doc.json")).unwrap();
463 assert_eq!(s.poll(), Poll::Unchanged);
464 let mut again = source(&d, &k);
465 again.load_cache().unwrap();
466 assert_eq!(again.current.unwrap().doc.seq, 2);
467 }
468
469 #[test]
470 fn an_invalid_signature_is_an_integrity_failure() {
471 let d = dir("invalid");
472 let k = key(1);
473 let mut s = source(&d, &k);
474 put(&d.join("in/doc.json"), &key(2), &doc(5));
476 assert!(matches!(s.poll(), Poll::Integrity(_)));
477 assert!(s.current.is_none());
478 put(&d.join("in/doc.json"), &k, &doc(5));
480 assert!(matches!(s.poll(), Poll::Accepted(_)));
481 let mut bytes = std::fs::read(d.join("in/doc.json")).unwrap();
482 bytes.extend_from_slice(b" ");
483 std::fs::write(d.join("in/doc.json"), bytes).unwrap();
484 assert!(matches!(s.poll(), Poll::Integrity(_)));
485 let bytes = serde_json::to_vec(&doc(9)).unwrap();
487 std::fs::write(d.join("in/doc.json"), &bytes).unwrap();
488 std::fs::write(sig_path(&d.join("in/doc.json")), hex(&k.sign(&tagged(HEALTH_TAG, &bytes)).to_bytes())).unwrap();
489 assert!(matches!(s.poll(), Poll::Integrity(_)));
490 }
491
492 #[test]
493 fn an_edited_cache_is_an_integrity_failure() {
494 let d = dir("cache");
495 let k = key(1);
496 let mut s = source(&d, &k);
497 put(&d.join("in/doc.json"), &k, &doc(3));
498 assert!(matches!(s.poll(), Poll::Accepted(_)));
499 let c = d.join("cache/external-desired.json");
500 let text = std::fs::read_to_string(&c).unwrap().replace("\"seq\":3", "\"seq\":4");
501 std::fs::write(&c, text).unwrap();
502 assert!(source(&d, &k).load_cache().is_err());
503 }
504
505 #[test]
506 fn a_bad_key_is_refused() {
507 let cfg = ExternalSourceConfig { public_key: "abcd".into(), path: "/nonexistent".into() };
508 assert!(ExternalSource::new(&cfg, Path::new("/nonexistent")).is_err());
509 }
510
511 #[test]
512 fn the_minimal_report_discloses_kinds_only_and_verifies() {
513 use planarian_schema::{Observed, Reason, UnitId};
514 let mut s = planarian_schema::fixtures::empty_state(1000);
515 s.units.insert(
516 UnitId::service("gym-web"),
517 (Observed::Failed(Reason::DriverError("/secret/path v1.2".into())), vec![]),
518 );
519 let r = report(DisclosureProfile::Minimal, &s, &UnitDefs::new(), []);
520 let text = serde_json::to_string(&r).unwrap();
521 assert!(!text.contains("secret") && !text.contains("1.2"), "{text}");
522 assert!(r.units.values().all(|v| !v.contains('(')));
523
524 let d = dir("report");
525 let k = health_key(&d).unwrap();
526 assert_eq!(health_key(&d).unwrap().to_bytes(), k.to_bytes(), "the key persists");
527 use std::os::unix::fs::PermissionsExt;
528 assert_eq!(std::fs::metadata(d.join("health.key")).unwrap().permissions().mode() & 0o777, 0o600);
529 let p = emit(&d, &k, &r).unwrap();
530 let bytes = std::fs::read(&p).unwrap();
531 let sig = std::fs::read_to_string(sig_path(&p)).unwrap();
532 let public = VerifyingKey::from_bytes(
533 &unhex(&std::fs::read_to_string(d.join("health.pub")).unwrap()).unwrap().try_into().unwrap(),
534 )
535 .unwrap();
536 assert!(verify_report(&public, &bytes, &sig));
537 assert!(!verify_report(&public, &[bytes.as_slice(), b" "].concat(), &sig));
538 }
539}