server/lib/src/front_report.rs
//! ★ **The front's report, kept by main** (lane HOL-FLOW-FRONT, Henrik's Q1 of HOL-FLOW-SMART).
//!
//! The front tells main its doors, the names it serves and where each goes
//! (`holger_downstream_rules::front`): `POST /-/front/report` on main's HTTP door, with a proof
//! of its own. Main opens it under the key its feed gave it for its front ([`keep_key`]: the
//! paired box derives it from the front's offered console key and its own, the pair the
//! console's bearer comes from, under the report's own context), keeps the last one
//! ([`heard`] → `<data>/holger-front/report.ron`), and the flow unfolds the front to it.
//! Nothing that is not reported is drawn: a front that never reported (an older front, a box
//! fed no key) stays one closed box.
//!
//! An OLDER main has no such door: it answers `404`, and the front says so and carries on.
//! The door names no secret and needs no bearer (the repository route carries main's HTTP door
//! to the internet, so anybody may knock): the proof is the credential, as a downstream's report
//! proof is.
use std::path::{Path, PathBuf};
pub use holger_downstream_rules::front;
use holger_downstream_rules::front::{FrontReport, Sent};
use serde::{Deserialize, Serialize};
/// The front's folder under the data root.
pub const DIR: &str = "holger-front";
/// The proof's key, hex, 0600 (written by the pairing loop from the feed).
pub const KEY_FILE: &str = "report.key";
/// The last report heard.
pub const REPORT_FILE: &str = "report.ron";
/// The largest body the door reads.
pub const MAX_BODY: usize = 64 * 1024;
/// **What main keeps of its front's last report.**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Kept {
pub report: FrontReport,
/// When main heard it (Unix ms).
pub heard_ms: i64,
}
fn dir(data_root: &Path) -> PathBuf {
data_root.join(DIR)
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// Write `bytes` to `path` 0600, staged and renamed.
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
use std::io::Write as _;
if let Some(d) = path.parent() {
std::fs::create_dir_all(d).map_err(|e| format!("create {}: {e}", d.display()))?;
}
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
o.mode(0o600);
}
let mut f = o
.open(&tmp)
.map_err(|e| format!("write {}: {e}", tmp.display()))?;
f.write_all(bytes)
.and_then(|()| f.sync_all())
.and_then(|()| std::fs::rename(&tmp, path))
.map_err(|e| format!("write {}: {e}", path.display()))
}
/// ★ **Keep the key this box opens its front's reports under** (the pairing loop, every feed;
/// unchanged is a no-op): `Ok(true)` when it was written.
pub fn keep_key(data_root: &Path, key: &[u8; 32]) -> Result<bool, String> {
let path = dir(data_root).join(KEY_FILE);
let want = hex(key);
if std::fs::read_to_string(&path)
.ok()
.as_deref()
.map(str::trim)
== Some(want.as_str())
{
return Ok(false);
}
write_private(&path, want.as_bytes())?;
Ok(true)
}
fn key(data_root: &Path) -> Option<[u8; 32]> {
let t = std::fs::read_to_string(dir(data_root).join(KEY_FILE)).ok()?;
let t = t.trim();
if t.len() != 64 {
return None;
}
let mut k = [0u8; 32];
for (i, b) in k.iter_mut().enumerate() {
*b = u8::from_str_radix(t.get(i * 2..i * 2 + 2)?, 16).ok()?;
}
Some(k)
}
/// ★ **The front's report, heard**: the body parsed, its proof checked under the kept key,
/// near now; when the pairing names fronts, the report names one of them. A console address
/// that is no plain URL is dropped (the rest is kept). Kept, and answered with what was kept;
/// refused as `(status, words)` otherwise, nothing kept.
pub fn heard(data_root: &Path, body: &[u8], now_ms: i64) -> Result<Kept, (u16, String)> {
let refused = |status: u16, why: String| {
(
status,
holger_errcode::FRONT_REPORT_REFUSED
.tag(format_args!("the front's report is not kept: {why}")),
)
};
let sent: Sent = serde_json::from_slice(body)
.map_err(|e| refused(400, format!("it is not a front report: {e}")))?;
let Some(k) = key(data_root) else {
return Err(refused(
409,
"this box was fed no front console key, so it has no key to open a front's report under".into(),
));
};
front::check(&k, &sent.report, &sent.proof, now_ms.div_euclid(1000))
.map_err(|why| refused(403, why))?;
if let Some(p) = crate::topology::pairing() {
let fronts: Vec<&str> = p
.boxes
.iter()
.filter(|b| b.role == crate::topology::ROLE_FRONT)
.map(|b| b.name.as_str())
.collect();
if !fronts.is_empty() && !fronts.contains(&sent.report.front.as_str()) {
return Err(refused(
403,
format!(
"it names the front '{}', and the pairing names {}",
sent.report.front,
fronts.join(", ")
),
));
}
}
let mut report = sent.report;
report.console_url = front::console_url(&report.console_url).unwrap_or_default();
let kept = Kept {
report,
heard_ms: now_ms,
};
let text = ron::ser::to_string_pretty(&kept, ron::ser::PrettyConfig::default())
.map_err(|e| (500, format!("the front's report could not be written: {e}")))?;
let path = dir(data_root).join(REPORT_FILE);
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
std::fs::create_dir_all(dir(data_root))
.and_then(|()| std::fs::write(&tmp, text))
.and_then(|()| std::fs::rename(&tmp, &path))
.map_err(|e| (500, format!("the front's report could not be kept: {e}")))?;
Ok(kept)
}
/// The front's last report, as kept; `None` when it never reported.
pub fn kept(data_root: &Path) -> Option<Kept> {
let t = std::fs::read_to_string(dir(data_root).join(REPORT_FILE)).ok()?;
ron::from_str(&t).ok()
}
/// **This estate's console address**, as its front last reported it; empty when none.
pub fn console_url(data_root: &Path) -> String {
kept(data_root)
.map(|k| k.report.console_url)
.unwrap_or_default()
}
/// Pure: **the kept report as the flow draws it**, as of `now_ms`.
pub fn view(k: &Kept, now_ms: i64) -> crate::flow::FrontView {
use crate::flow::{FrontDoorView, FrontRouteView, FrontView};
FrontView {
front: k.report.front.clone(),
last_seen_unix_ms: k.heard_ms,
seen_ago_ms: now_ms.saturating_sub(k.heard_ms).max(0) as u64,
doors: k
.report
.doors
.iter()
.map(|d| FrontDoorView {
name: d.name.clone(),
listen: d.listen.clone(),
what: d.what.clone(),
})
.collect(),
routes: k
.report
.routes
.iter()
.map(|r| FrontRouteView {
host: r.host.clone(),
door: r.door.clone(),
to: r.to.clone(),
what: r.what.clone(),
})
.collect(),
console_url: k.report.console_url.clone(),
}
}