Skip to main content

gunnar_server/
feed.rs

1//! **The estate values the installer's door FEEDS**, taken instead of the
2//! medium's — so one medium serves any estate.
3//!
4//! The join reply the pairing loop receives carries a `feed` table
5//! (`nordisk_pairing::join::JoinReply::feed`); the loop persists the whole
6//! reply to `<data>/pairing/joined.toml` ([`crate::pairing::JOINED_REL`]).
7//! Three of its keys configure this box:
8//!
9//! | key               | what it replaces on the medium                       | applied            |
10//! |-------------------|------------------------------------------------------|--------------------|
11//! | `monetize_pubkey` | `GUNNAR_MONETIZE_PUBKEY` / `--monetize-pubkey`        | at start, or LIVE when it arrives |
12//! | `clock_key`       | `ESTATE_CLOCK_KEY` / `GUNNAR_CLOCK_KEY` (`korp.env.` / `clock_key=` param) | on every clock sync|
13//! | `clock_peer`      | `ESTATE_CLOCK` / `GUNNAR_CLOCK` (`korp.env.` / `clock=` param)         | on every clock sync|
14//!
15//! (`admin_pubkey` is the fourth feed key; the pairing loop installs it itself.)
16//!
17//! # Precedence: there is none — a disagreement is REFUSED by name
18//!
19//! Medium alone → the medium's. Door alone → the door's. Both and EQUAL →
20//! that value. Both and DIFFERENT → [`FEED_MISMATCH`], never a silent pick:
21//! the medium and the installer disagree about which estate this box is in,
22//! and choosing either one is choosing whose money or whose time to trust
23//! without saying so (the reasoning of ops' `monetize-pubkey-mismatch`
24//! preflight, which refuses a bring-up over the same disagreement).
25//!
26//! * The monetize key at start: a refusal to start ([`resolve_monetize`]).
27//! * The monetize key arriving while running (first boot): the key in force
28//!   stays, the `Admin.Health` alarm [`FEED_MISMATCH`] is raised.
29//! * The clock: every sync reads the effective configuration
30//!   ([`clock_config`]), and a disagreement is the sync's `misconfigured`
31//!   report naming [`FEED_MISMATCH`] — the clock is left alone.
32//!
33//! # The monetize key applies LIVE (owner 2026-09-25: avoid restarts)
34//!
35//! A metered box on a `fed` medium starts KEYLESS: its entitlement slot and
36//! its growth go-ahead policy are built with no key (every namespace Free,
37//! `Entitlement.Set` refused, a `monetize` go-ahead refused) and registered
38//! as takers ([`on_monetize_key`]). When the installer feeds the key (or vm1
39//! offers it), [`monetize_arrived`] hands it to each taker, which swaps it in
40//! place — the slot re-verifies every stored fact under it — and the key is
41//! persisted in `joined.toml` for the next start as before. Until 2026-09-25
42//! this was a restart of the machine. The clock (`clock_key`, `clock_peer`)
43//! and the fed principals were already live: the clock reads the feed on
44//! every sync, and the primary installs principals on every feed.
45//!
46//! # The bus is the shared one (L10, 2026-10-20)
47//!
48//! Every feed this process takes is published on nordisk-pairing's process-wide
49//! feed bus ([`feedbus::publish`], Skidbladnir 0.5.15, A7 row 8): a part that
50//! applies a fed value LIVE subscribes with [`feedbus::on_published`] and reads
51//! the typed [`Fed::from_feed`]; [`Fed::current`] is the typed view of the
52//! value in force. Gunnar's own bus (this module's `publish`, `on_published`,
53//! `current`) is gone; this module is the typed view over the shared one.
54
55use std::path::Path;
56use std::sync::{Mutex, OnceLock, RwLock};
57
58use nordisk_pairing::feedbus::{self, Feed};
59
60/// The relay a sold estate's main mails through, as the shop feeds it (monetize-server
61/// `estate_order::FEED_SMTP_*`, P1).
62pub const FEED_SMTP_URL: &str = "secret.smtp_url";
63/// **The front's egress door** (Skidbladnir nordisk-pairing `egress::FEED_EGRESS`, the lean
64/// estate, A19): fed by the shop to a box laid with no public IPv4. The box's clients that
65/// reach the internet (the identity provider's, the webhooks', the mirrors', the mail relay's)
66/// leave through it ([`take_egress`], `nordisk_pairing::egress::proxy`).
67pub const FEED_EGRESS: &str = nordisk_pairing::egress::FEED_EGRESS;
68pub const FEED_SMTP_FROM: &str = "secret.smtp_from";
69
70/// The feed keys this module reads (the contract's names; never renamed).
71pub const FEED_MONETIZE: &str = "monetize_pubkey";
72pub const FEED_CLOCK_KEY: &str = "clock_key";
73pub const FEED_CLOCK_PEER: &str = "clock_peer";
74/// **The operator's internal networks a company IdP may live on** (owner
75/// 2026-10-06, a private gunnar): a list of CIDRs, estate config the
76/// installer feeds; never an account admin's. Empty or absent: today's guard
77/// (`gunnar_grpc::sso::PrivateNets`). Applied LIVE on every feed.
78pub const FEED_SSO_PRIVATE_IDP_CIDRS: &str = "sso_private_idp_cidrs";
79/// **This estate SELLS room** (2026-10-08): `true` on a selling shop's boxes (gunnar.rs,
80/// test2), absent everywhere else — a SOLD estate and the open build keep "no fact is
81/// uncapped". Applied LIVE ([`Fed::sells_room`], gunnar-grpc `selling`).
82pub const FEED_SELLS_ROOM: &str = "sells_room";
83/// **The ONE monetize's `Fleet` door** (5.4, ONE monetize, M-D35): what `--fleet` names on
84/// the medium. shop-pairing feeds it to the front, the front offers it to every box; the
85/// box's pull channel reports there ([`crate::repair::resolve_fleet`]).
86pub const FEED_FLEET: &str = "fleet";
87/// **The estate this box reports for** (`--fleet-instance`), fed beside [`FEED_FLEET`].
88pub const FEED_FLEET_INSTANCE: &str = "fleet_instance";
89/// **Where the pull channel connects** (`host:port`), fed beside [`FEED_FLEET`] when the
90/// door's name is not what this box should dial (a test bench whose guests resolve no
91/// names). TLS still verifies the certificate under [`FEED_FLEET`]'s host. Absent: the
92/// URL's own host, resolved. A BENCH address only (ST-19, [`crate::repair::seam_refusal`]).
93pub const FEED_FLEET_AT: &str = "fleet_at";
94/// **The PEM the pull channel's door is checked against**, fed beside [`FEED_FLEET`] when
95/// its certificate is not under the public roots. Absent: the public roots. Only beside a
96/// bench [`FEED_FLEET_AT`] (ST-19).
97pub const FEED_FLEET_CA: &str = "fleet_ca";
98
99/// **The SITE's pay page** (UW-6, feed key `refusal_link`: the open build names no money): a `{tenant}` template, `https://<site>/billing/{tenant}`
100/// — a page the site's own console serves. The medium is one for every estate, so the
101/// page it bakes (`--billing-url`) is a placeholder this replaces, LIVE
102/// ([`crate::entitlement::EntitlementSlot::set_billing_url`]).
103pub const FEED_BILLING_URL: &str = "refusal_link";
104/// **Auto-fix, fed** (main rebuild, 2026-10-12): exactly `true` turns the box's auto-fix on,
105/// as `--repair-auto` does. The estate's opt-in, carried by its installer; nothing else
106/// changes what the guard allows (`gunnar_repair::rebuild`).
107pub const FEED_REPAIR_AUTO: &str = "repair_auto";
108/// **The repair key, fed** (main rebuild, 2026-10-12): an OpenSSH private key this box signs
109/// its repair calls with when no `--repair-key` is given — a re-laid main holds no twin key,
110/// and without one every pulled order and the auto recharge were refused. The installer
111/// feeds its public half as a principal, so the twin reads its log to it and a fresh main
112/// takes its `Bootstrap.Restore`.
113pub const FEED_REPAIR_KEY: &str = "secret.repair_key";
114
115/// The alarm/refusal word for a medium value and a DIFFERENT fed value.
116pub const FEED_MISMATCH: &str = "pairing.feed_mismatch";
117/// The alarm for a fed monetize key this running process could not apply
118/// (no entitlement slot to apply it to, or a taker refused it). It used to be
119/// `pairing.feed_restart` and a reboot; the key is applied live now.
120pub const FEED_NOT_APPLIED: &str = "pairing.feed_not_applied";
121
122/// **`vm`'s offer of `key`** (`offer.<vm>.<key>`), trimmed; blank is absent.
123pub fn offered_by<'a>(feed: &'a std::collections::BTreeMap<String, String>, vm: &str, key: &str) -> Option<&'a str> {
124    feed.get(&format!("offer.{vm}.{key}")).map(|v| v.trim()).filter(|v| !v.is_empty())
125}
126
127/// What the door fed, as far as this box's configuration goes.
128#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub struct Fed {
130    pub monetize_pubkey: Option<String>,
131    pub clock_key: Option<String>,
132    pub clock_peer: Option<String>,
133    /// [`FEED_SSO_PRIVATE_IDP_CIDRS`], as fed.
134    pub sso_private_idp_cidrs: Option<String>,
135    /// [`FEED_SELLS_ROOM`]: exactly `true`, as fed; anything else is false.
136    pub sells_room: bool,
137    /// [`FEED_FLEET`], as fed.
138    pub fleet: Option<String>,
139    /// [`FEED_FLEET_INSTANCE`], as fed.
140    pub fleet_instance: Option<String>,
141    /// [`FEED_FLEET_AT`], as fed.
142    pub fleet_at: Option<String>,
143    /// [`FEED_FLEET_CA`], as fed.
144    pub fleet_ca: Option<String>,
145    /// [`FEED_SMTP_URL`], as fed: the relay a SOLD estate's main mails through (P1). Its
146    /// `Debug` withholds it (it carries the submission token).
147    pub smtp_url: Option<Withheld>,
148    /// [`FEED_SMTP_FROM`], as fed.
149    pub smtp_from: Option<String>,
150    /// [`FEED_BILLING_URL`], as fed.
151    pub billing_url: Option<String>,
152    /// [`FEED_REPAIR_AUTO`]: exactly `true`, as fed; anything else is false.
153    pub repair_auto: bool,
154    /// [`FEED_REPAIR_KEY`], as fed (withheld from `Debug`).
155    pub repair_key: Option<Withheld>,
156    /// [`FEED_EGRESS`], as fed: an `http://` door only.
157    pub egress: Option<String>,
158}
159
160/// A fed value that is a credential: kept, compared, never shown.
161#[derive(Clone, Default, PartialEq, Eq)]
162pub struct Withheld(pub String);
163
164impl std::fmt::Debug for Withheld {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.write_str("<withheld>")
167    }
168}
169
170impl Fed {
171    /// From the reply's `feed` table. Blank values are absent. A value the
172    /// installer does not feed itself may reach this box as a VM's OFFER,
173    /// fed onward as `offer.<vm>.<key>` (edda nordisk-pairing 0.3.1; since
174    /// 0.4 the front's keys — monetize's verifying key, the clock key — come
175    /// that way): the plain key wins, then the first offer by VM name.
176    pub fn from_feed(feed: &std::collections::BTreeMap<String, String>) -> Fed {
177        let get = |k: &str| feed.get(k).map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).or_else(|| nordisk_pairing::feedbus::offered(feed, k));
178        Fed {
179            monetize_pubkey: get(FEED_MONETIZE),
180            clock_key: get(FEED_CLOCK_KEY),
181            clock_peer: get(FEED_CLOCK_PEER),
182            sso_private_idp_cidrs: get(FEED_SSO_PRIVATE_IDP_CIDRS),
183            sells_room: get(FEED_SELLS_ROOM).as_deref() == Some("true"),
184            fleet: get(FEED_FLEET),
185            fleet_instance: get(FEED_FLEET_INSTANCE),
186            fleet_at: get(FEED_FLEET_AT),
187            fleet_ca: get(FEED_FLEET_CA),
188            smtp_url: get(FEED_SMTP_URL).map(Withheld),
189            smtp_from: get(FEED_SMTP_FROM),
190            billing_url: get(FEED_BILLING_URL),
191            repair_auto: get(FEED_REPAIR_AUTO).as_deref() == Some("true"),
192            repair_key: get(FEED_REPAIR_KEY).map(Withheld),
193            egress: get(FEED_EGRESS).filter(|d| d.starts_with("http://")),
194        }
195    }
196
197    pub fn is_empty(&self) -> bool {
198        self == &Fed::default()
199    }
200
201    /// `<data>/pairing/joined.toml`, if the loop has persisted one. Absent is
202    /// `None`; present and unreadable is an error — a persisted answer that
203    /// lies must not be quietly ignored.
204    pub fn read(root: &Path) -> anyhow::Result<Option<Fed>> {
205        Ok(kept_feed(root)?.as_ref().map(Fed::from_feed))
206    }
207
208    /// **The typed view of the feed in force** on the shared bus
209    /// (nordisk-pairing [`feedbus::current`]); `None` before anything was published.
210    pub fn current() -> Option<Fed> {
211        feedbus::current().map(|feed| Fed::from_feed(&feed))
212    }
213}
214
215/// **This box leaves through the door it was fed** ([`FEED_EGRESS`]): kept process-wide
216/// (`nordisk_pairing::egress::set_proxy`, the first wins: the door is the front's overlay
217/// address, the same for the life of the estate) for every client that reaches the internet.
218/// Called with each feed published (`serve`); a feed with no door changes nothing.
219pub fn take_egress(feed: &std::collections::BTreeMap<String, String>) {
220    if let Some(door) = Fed::from_feed(feed).egress {
221        if nordisk_pairing::egress::proxy().is_none() {
222            tracing::info!(target: "gunnar::audit", event = "egress_door", door = %door, "this box leaves through its front's egress door (no public IPv4 of its own)");
223        }
224        nordisk_pairing::egress::set_proxy(&door);
225    }
226}
227
228/// The proxy a ureq client of this box is built with: the fed door ([`take_egress`]), or none.
229#[cfg(any(feature = "mirror-http", feature = "sso-egress"))]
230pub fn ureq_proxy() -> Option<ureq::Proxy> {
231    nordisk_pairing::egress::proxy().and_then(|d| ureq::Proxy::new(d).ok())
232}
233
234/// The feed of `<data>/pairing/joined.toml`, as the door fed it ([`Fed::read`]).
235#[cfg(feature = "wg-client")]
236pub fn kept_feed(root: &Path) -> anyhow::Result<Option<Feed>> {
237    let p = root.join(crate::pairing::JOINED_REL);
238    let Some(text) = crate::fed_at_rest::read_checked(root, crate::pairing::JOINED_REL)? else { return Ok(None) };
239    let reply = nordisk_pairing::join::JoinReply::parse(&text).map_err(|e| anyhow::anyhow!("{}: {e}", p.display()))?;
240    Ok(Some(reply.feed))
241}
242
243/// A build without the pairing loop has no door to be fed by.
244#[cfg(not(feature = "wg-client"))]
245pub fn kept_feed(_root: &Path) -> anyhow::Result<Option<Feed>> {
246    Ok(None)
247}
248
249/// **The operator's internal networks a company IdP may live on**, as `fed`
250/// names them: none when nothing is fed. A list gunnar refuses (a public or
251/// loopback range) is an error, said by the caller and applied as none.
252pub fn sso_private_nets(fed: Option<&Fed>) -> Result<gunnar_grpc::sso::PrivateNets, String> {
253    match fed.and_then(|f| f.sso_private_idp_cidrs.as_deref()) {
254        Some(text) => gunnar_grpc::sso::PrivateNets::parse(text).map_err(|why| format!("`{FEED_SSO_PRIVATE_IDP_CIDRS}`: {why}")),
255        None => Ok(gunnar_grpc::sso::PrivateNets::default()),
256    }
257}
258
259/// Read `joined.toml` under `root` and publish its feed on the shared bus
260/// ([`feedbus::publish`]). Called once at start; an unreadable file is logged
261/// and treated as no feed (the loop re-joins and rewrites it).
262pub fn load_at_start(root: &Path) -> Option<Fed> {
263    match kept_feed(root) {
264        Ok(Some(feed)) => {
265            let fed = Fed::from_feed(&feed);
266            if !fed.is_empty() {
267                tracing::info!(monetize = fed.monetize_pubkey.is_some(), clock_peer = ?fed.clock_peer, clock_key = fed.clock_key.is_some(), "feed: the installer's values from {}", "<data>/pairing/joined.toml");
268            }
269            feedbus::publish(feed);
270            Some(fed)
271        }
272        Ok(None) => None,
273        Err(e) => {
274            tracing::error!(error = %format!("{e:#}"), "feed: the persisted join reply is unreadable — this start uses the medium's values only");
275            None
276        }
277    }
278}
279
280// ── the monetize key ────────────────────────────────────────────────────────
281
282/// The key the running server enforces with: recorded at start (outer `None`
283/// = nothing recorded, e.g. a test), and moved ONCE more when an awaited key
284/// arrives and is applied live ([`monetize_arrived`]).
285fn in_force_slot() -> &'static RwLock<Option<Option<[u8; 32]>>> {
286    static IN_FORCE: OnceLock<RwLock<Option<Option<[u8; 32]>>>> = OnceLock::new();
287    IN_FORCE.get_or_init(|| RwLock::new(None))
288}
289
290pub fn record_monetize_in_force(key: Option<[u8; 32]>) {
291    if let Ok(mut s) = in_force_slot().write() {
292        *s = Some(key);
293    }
294}
295
296pub fn monetize_in_force() -> Option<[u8; 32]> {
297    in_force_slot().read().ok().and_then(|s| s.flatten())
298}
299
300/// Something that takes the monetize key LIVE: the entitlement slot (both
301/// front doors' plan gate and `Entitlement.Set` read it) and the growth
302/// go-ahead policy. Registered by `serve` once each is built.
303pub type MonetizeTaker = Box<dyn Fn(&[u8; 32]) -> Result<(), String> + Send + Sync>;
304
305fn takers() -> &'static Mutex<Vec<(&'static str, MonetizeTaker)>> {
306    static TAKERS: OnceLock<Mutex<Vec<(&'static str, MonetizeTaker)>>> = OnceLock::new();
307    TAKERS.get_or_init(|| Mutex::new(Vec::new()))
308}
309
310/// **Register a taker of the monetize key** (owner 2026-09-25: avoid
311/// restarts) — called by `serve` for the entitlement slot and the go-ahead
312/// policy it built keyless, so a key the installer feeds later is applied in
313/// place by [`monetize_arrived`].
314pub fn on_monetize_key(name: &'static str, take: MonetizeTaker) {
315    if let Ok(mut t) = takers().lock() {
316        t.push((name, take));
317    }
318}
319
320fn hex(k: &[u8; 32]) -> String {
321    k.iter().map(|b| format!("{b:02x}")).collect()
322}
323
324/// **The monetize key at start.** `medium` is the resolved key off
325/// `--monetize-pubkey`/`GUNNAR_MONETIZE_PUBKEY`; `fed` is the door's spelling
326/// (64 hex). A fed value that is not a key, or that differs from the
327/// medium's, is refused by name.
328pub fn resolve_monetize(medium: Option<[u8; 32]>, fed: Option<&str>) -> Result<Option<[u8; 32]>, String> {
329    let fed = match fed {
330        None => return Ok(medium),
331        Some(text) => gunnar_common::monetize_key::parse_hex32(text.trim()).map_err(|why| {
332            format!("REFUSED [pairing-feed-monetize-malformed] the installer fed `{FEED_MONETIZE}` = `{text}`, which is not a monetize key: {why}")
333        })?,
334    };
335    match medium {
336        None => Ok(Some(fed)),
337        Some(m) if m == fed => Ok(Some(m)),
338        Some(m) => Err(format!(
339            "REFUSED [{FEED_MISMATCH}] the medium trusts monetize key {} and the installer's door fed {} — \
340             this box will not pick one silently. Compose the medium without `monetize_pubkey` (the door \
341             feeds it), or with the front's live key",
342            hex(&m),
343            hex(&fed)
344        )),
345    }
346}
347
348/// Why a fed monetize key found no taker, in the words THIS build can say.
349///
350/// The metered build names the flag that opens the slot. The open build has no
351/// such flag (`--billing-url` is declared in `entitlement.rs`, which is
352/// `metered`-only) and must not compile the word into anything an operator can
353/// read — `tests/open_build_speaks_no_money.rs::the_open_sources_say_no_money`,
354/// the owner's ruling of 2026-09-15. Before this split the open build carried
355/// the metered sentence verbatim and the guard was red on it (feed.rs:243).
356#[cfg(feature = "metered")]
357const NO_TAKER_WHY: &str = "the metered start opened no entitlement slot (no --billing-url on a `fed` medium)";
358#[cfg(not(feature = "metered"))]
359const NO_TAKER_WHY: &str = "this build carries no entitlement module, so no start opens a slot to take it";
360
361/// **The fed monetize key arriving while running** (the pairing loop's
362/// answer, or vm1's offer carried by the installer): APPLIED IN PLACE by every
363/// registered taker ([`on_monetize_key`]), with no restart (owner 2026-09-25).
364/// `None` = nothing to do (no key fed, the same key already in force, or a
365/// process that recorded none — a community build or a test).
366pub fn monetize_arrived(fed: Option<&str>) -> Option<MonetizeArrival> {
367    let in_force = in_force_slot().read().ok().and_then(|s| *s)?;
368    let fed = fed?;
369    match resolve_monetize(in_force, Some(fed)) {
370        Ok(Some(k)) if Some(k) == in_force => None,
371        Ok(Some(k)) => {
372            let takers = takers().lock().ok()?;
373            if takers.is_empty() {
374                let detail = format!(
375                    "the installer fed monetize key {} and nothing on this box can enforce it: {NO_TAKER_WHY}",
376                    hex(&k)
377                );
378                raise(FEED_NOT_APPLIED, &detail);
379                return Some(MonetizeArrival::NotApplied(detail));
380            }
381            let failed: Vec<String> = takers.iter().filter_map(|(name, take)| take(&k).err().map(|e| format!("{name}: {e}"))).collect();
382            if !failed.is_empty() {
383                let detail = format!("the installer fed monetize key {}, and it could not be applied: {}", hex(&k), failed.join("; "));
384                raise(FEED_NOT_APPLIED, &detail);
385                return Some(MonetizeArrival::NotApplied(detail));
386            }
387            record_monetize_in_force(Some(k));
388            let detail = format!("the installer fed monetize key {}; it is IN FORCE now (applied in place, no restart)", hex(&k));
389            tracing::info!(event = "pairing.feed_applied", "feed: {detail}");
390            Some(MonetizeArrival::Applied(detail))
391        }
392        Ok(None) => None,
393        Err(why) => {
394            raise(FEED_MISMATCH, &why);
395            Some(MonetizeArrival::Mismatch(why))
396        }
397    }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub enum MonetizeArrival {
402    /// The server started with no key; the fed one is enforced from now on.
403    Applied(String),
404    /// The server started with no key and cannot take the fed one: alarmed.
405    NotApplied(String),
406    /// The medium's key is in force and the door fed another: refused, alarmed.
407    Mismatch(String),
408}
409
410// ── the clock ───────────────────────────────────────────────────────────────
411
412/// **The clock's two values**, medium against door — edda estate-clock's
413/// (`offer::resolve_clock`, moved there from here AS IS; the refusal names
414/// [`FEED_MISMATCH`], the same word). `Ok((clock, key))` is what
415/// [`estate_clock::config::Config::parse`] is handed.
416pub use estate_clock::offer::resolve_clock;
417
418/// **The clock configuration in force now**: the process environment (the
419/// medium's `korp.env.ESTATE_CLOCK*`, or Gunnar's `korp.env.GUNNAR_CLOCK*`
420/// read as its alias, `estate_clock::config::var`) merged with the current
421/// feed. Read on EVERY sync, so a feed that arrives while running applies to
422/// the next one.
423pub fn clock_config() -> Result<estate_clock::config::Config, String> {
424    use estate_clock::config::{var, Config, Role, CLOCK_ENV, CLOCK_KEY_ENV};
425    let env_clock = var(CLOCK_ENV);
426    let env_key = var(CLOCK_KEY_ENV);
427    let fed = Fed::current().unwrap_or_default();
428    let (clock, key) = resolve_clock(env_clock.as_deref(), env_key.as_deref(), fed.clock_peer.as_deref(), fed.clock_key.as_deref())?;
429    Config::parse(clock.as_deref(), key.as_deref(), Role::Appliance)
430}
431
432/// Whether the door fed a clock source.
433pub fn clock_fed() -> bool {
434    Fed::current().is_some_and(|f| f.clock_peer.is_some())
435}
436
437// ── alarms ──────────────────────────────────────────────────────────────────
438
439fn alarm_slot() -> &'static Mutex<Vec<gunnar_proto::HealthAlarm>> {
440    static ALARMS: OnceLock<Mutex<Vec<gunnar_proto::HealthAlarm>>> = OnceLock::new();
441    ALARMS.get_or_init(|| Mutex::new(Vec::new()))
442}
443
444/// Raise (or re-raise, counting) a feed alarm; said on the log too.
445pub fn raise(event: &str, detail: &str) {
446    tracing::error!(event, "feed: {detail}");
447    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
448    if let Ok(mut alarms) = alarm_slot().lock() {
449        match alarms.iter_mut().find(|a| a.event == event) {
450            Some(a) => {
451                a.count += 1;
452                a.detail = detail.to_string();
453            }
454            None => alarms.push(gunnar_proto::HealthAlarm { event: event.into(), count: 1, since_unix_ms: now, detail: detail.to_string() }),
455        }
456    }
457}
458
459/// The feed alarms, for `Admin.Health`.
460pub fn alarms() -> Vec<gunnar_proto::HealthAlarm> {
461    alarm_slot().lock().map(|a| a.clone()).unwrap_or_default()
462}
463
464/// **The mock's seam, honoured only as the mock feeds it** (ST-19): a fed `fleet_at` is a
465/// bench forward — an address literal `ip:port` on a private, loopback, link-local or
466/// shared (100.64/10) network — and a fed `fleet_ca` comes only beside one. Anything else
467/// (a public address, a name, a CA with no bench address) is refused by name: a release
468/// box never dials elsewhere, nor re-roots its trust, on a feed's word.
469pub fn seam_refusal(at: Option<&str>, ca: bool) -> Option<String> {
470    const WORD: &str = "fleet-seam-bench-only";
471    match at {
472        None if ca => Some(format!(
473            "REFUSED [{WORD}] the installer fed `{}` with no `{}`: a door's own CA is a bench's, beside a bench address only — a public door is checked under the public roots",
474            crate::feed::FEED_FLEET_CA,
475            crate::feed::FEED_FLEET_AT
476        )),
477        None => None,
478        Some(at) => match at.trim().parse::<std::net::SocketAddr>() {
479            Ok(a) if bench_address(a.ip()) => None,
480            _ => Some(format!(
481                "REFUSED [{WORD}] the installer fed `{}` = {at}: the pull channel is dialled elsewhere than its door's name only on a bench (a private, loopback or link-local `ip:port`)",
482                crate::feed::FEED_FLEET_AT
483            )),
484        },
485    }
486}
487
488/// A bench guest's network: private, loopback, link-local, or shared address space.
489fn bench_address(ip: std::net::IpAddr) -> bool {
490    match ip {
491        std::net::IpAddr::V4(v) => v.is_private() || v.is_loopback() || v.is_link_local() || (v.octets()[0] == 100 && (v.octets()[1] & 0xc0) == 64),
492        std::net::IpAddr::V6(v) => v.is_loopback() || (v.segments()[0] & 0xfe00) == 0xfc00 || (v.segments()[0] & 0xffc0) == 0xfe80,
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    /// ★ **P1: the relay a sold estate's main mails through comes in the feed** — read from
501    /// `secret.smtp_url` / `secret.smtp_from`; the URL (it carries the token) never shows.
502    #[test]
503    fn the_fed_relay_is_read_and_its_url_withheld() {
504        let feed: std::collections::BTreeMap<String, String> = [(FEED_SMTP_URL, "smtp://u:TOKENvalue@smtp.example:587"), (FEED_SMTP_FROM, "u@smtp.example")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
505        let f = Fed::from_feed(&feed);
506        assert_eq!(f.smtp_url, Some(Withheld("smtp://u:TOKENvalue@smtp.example:587".into())));
507        assert_eq!(f.smtp_from.as_deref(), Some("u@smtp.example"));
508        assert!(!format!("{f:?}").contains("TOKENvalue"), "{f:?}");
509        assert!(Fed::from_feed(&Default::default()).smtp_url.is_none());
510    }
511
512    /// ★ **The front's egress door a box is fed** (the lean estate, A19 rows 10-14): read from
513    /// `egress` (Skidbladnir nordisk-pairing's key), an `http://` door only.
514    #[test]
515    fn the_fed_egress_door_is_read_and_only_an_http_one() {
516        let feed = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<std::collections::BTreeMap<_, _>>();
517        assert_eq!(FEED_EGRESS, nordisk_pairing::egress::FEED_EGRESS);
518        assert_eq!(Fed::from_feed(&feed(&[(FEED_EGRESS, " http://10.44.0.1:3128 ")])).egress.as_deref(), Some("http://10.44.0.1:3128"));
519        assert_eq!(Fed::from_feed(&feed(&[(FEED_EGRESS, "socks5://10.44.0.1:1080")])).egress, None, "only an http:// door is one");
520        assert_eq!(Fed::from_feed(&feed(&[])).egress, None, "a box with its own way out is fed none");
521    }
522
523    const A: &str = "43cb00000000000000000000000000000000000000000000000000000000842a";
524    const B: &str = "43cb00000000000000000000000000000000000000000000000000000000842b";
525
526    fn k(s: &str) -> [u8; 32] {
527        gunnar_common::monetize_key::parse_hex32(s).unwrap()
528    }
529
530    /// Since 0.4 the front's keys reach the boxes as vm1's offer; the plain
531    /// key, when the installer feeds one, still wins.
532    #[test]
533    fn a_value_offered_by_a_vm_is_taken_when_the_installer_feeds_none_itself() {
534        let feed = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<std::collections::BTreeMap<_, _>>();
535        let f = Fed::from_feed(&feed(&[("offer.front.monetize_pubkey", A), ("offer.front.clock_key", "ssh-ed25519 AAAAk"), ("clock_peer", "peer://10.13.8.1:50123"), ("offer.front.xmonetize_pubkey", B)]));
536        assert_eq!(f, Fed { monetize_pubkey: Some(A.into()), clock_key: Some("ssh-ed25519 AAAAk".into()), clock_peer: Some("peer://10.13.8.1:50123".into()), ..Fed::default() });
537        assert_eq!(Fed::from_feed(&feed(&[(FEED_MONETIZE, B), ("offer.front.monetize_pubkey", A)])).monetize_pubkey.as_deref(), Some(B), "the plain key wins");
538        assert_eq!(nordisk_pairing::feedbus::offered(&feed(&[("offer..monetize_pubkey", A), ("offer.a.b.monetize_pubkey", A)]), FEED_MONETIZE), None, "not a VM name");
539    }
540
541    /// ONE monetize (M-D35): shop-pairing feeds the front `fleet` and `fleet_instance`; the
542    /// front offers them, and every box takes them — the plain key first, then the offer.
543    #[test]
544    fn the_fleet_door_and_the_estate_are_taken_from_the_feed_or_the_fronts_offer() {
545        let feed = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<std::collections::BTreeMap<_, _>>();
546        let f = Fed::from_feed(&feed(&[("offer.front.fleet", "https://monetize.ignalina.dk:8443"), ("offer.front.fleet_instance", "acme"), ("offer.front.monetize_pubkey", A)]));
547        assert_eq!((f.fleet.as_deref(), f.fleet_instance.as_deref(), f.monetize_pubkey.as_deref()), (Some("https://monetize.ignalina.dk:8443"), Some("acme"), Some(A)));
548        let f = Fed::from_feed(&feed(&[(FEED_FLEET, "https://one:8443"), ("offer.front.fleet", "https://other:8443"), (FEED_FLEET_INSTANCE, " "), ("offer.front.fleet_instance", "acme")]));
549        assert_eq!((f.fleet.as_deref(), f.fleet_instance.as_deref()), (Some("https://one:8443"), Some("acme")), "the plain key wins; blank is absent");
550        assert!(Fed::from_feed(&feed(&[])).fleet.is_none());
551    }
552
553    /// ★ **`sells_room` is the installer's word, exactly `true`** (2026-10-08): fed on a
554    /// selling shop's boxes, absent on a sold estate; anything else is false.
555    #[test]
556    fn sells_room_is_fed_as_exactly_true() {
557        let feed = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<std::collections::BTreeMap<_, _>>();
558        assert!(Fed::from_feed(&feed(&[(FEED_SELLS_ROOM, "true")])).sells_room);
559        assert!(Fed::from_feed(&feed(&[(FEED_SELLS_ROOM, " true\n")])).sells_room, "trimmed, as fed");
560        assert!(!Fed::from_feed(&feed(&[(FEED_SELLS_ROOM, "yes")])).sells_room);
561        assert!(!Fed::from_feed(&feed(&[])).sells_room, "a sold estate is fed nothing");
562    }
563
564    /// ★ UW-6: **the site's pay page is the installer's word** (`refusal_link`), trimmed;
565    /// blank or absent is none (the medium's page stays).
566    #[test]
567    fn the_sites_pay_page_is_read_from_the_feed() {
568        let feed = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<std::collections::BTreeMap<_, _>>();
569        assert_eq!(FEED_BILLING_URL, "refusal_link");
570        assert_eq!(Fed::from_feed(&feed(&[(FEED_BILLING_URL, " https://test2.gunnar.rs/billing/{tenant}\n")])).billing_url.as_deref(), Some("https://test2.gunnar.rs/billing/{tenant}"));
571        assert_eq!(Fed::from_feed(&feed(&[(FEED_BILLING_URL, " ")])).billing_url, None);
572        assert_eq!(Fed::from_feed(&feed(&[])).billing_url, None);
573    }
574
575    /// ★ **The operator's internal IdP networks are the installer's feed**
576    /// (owner 2026-10-06): read from the feed, applied to every taker on
577    /// every publish (live), parsed by gunnar's own rule — a public or
578    /// loopback range is refused, nothing fed is none.
579    #[test]
580    fn the_operators_idp_networks_are_fed_and_applied_live() {
581        let feed = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<std::collections::BTreeMap<_, _>>();
582        let fed = Fed::from_feed(&feed(&[(FEED_SSO_PRIVATE_IDP_CIDRS, " 10.0.2.100/32 ")]));
583        assert_eq!(fed.sso_private_idp_cidrs.as_deref(), Some("10.0.2.100/32"));
584        assert_eq!(sso_private_nets(Some(&fed)).expect("a private network").to_string(), "10.0.2.100/32");
585        assert!(sso_private_nets(None).expect("nothing fed").is_empty());
586        let public = Fed::from_feed(&feed(&[(FEED_SSO_PRIVATE_IDP_CIDRS, "8.8.8.0/24")]));
587        assert!(sso_private_nets(Some(&public)).is_err(), "a public range is refused");
588        let seen = std::sync::Arc::new(Mutex::new(Vec::<String>::new()));
589        let into = std::sync::Arc::clone(&seen);
590        feedbus::on_published("test: the idp networks", move |f: &Feed| into.lock().unwrap().push(Fed::from_feed(f).sso_private_idp_cidrs.clone().unwrap_or_default()));
591        feedbus::publish(feed(&[(FEED_SSO_PRIVATE_IDP_CIDRS, " 10.0.2.100/32 ")]));
592        feedbus::publish(feed(&[]));
593        let got = seen.lock().unwrap().clone();
594        let at = got.iter().position(|v| v == "10.0.2.100/32").expect("the list reached the taker");
595        assert!(got[at + 1..].iter().any(String::is_empty), "every publish reaches the taker: {got:?}");
596    }
597
598    /// **A role-bound offer is read from the VM that holds the role**: with the
599    /// twin sorting first, "the first by name" is the twin's stale key.
600    #[test]
601    fn an_offer_is_read_from_the_named_vm_not_the_first_by_name() {
602        let feed: std::collections::BTreeMap<String, String> = [("offer.vm2.ticket_pubkey", A), ("offer.vm3.ticket_pubkey", B), ("offer.vm4.ticket_pubkey", " ")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
603        assert_eq!(nordisk_pairing::feedbus::offered(&feed, "ticket_pubkey").as_deref(), Some(A), "the first by name");
604        assert_eq!(offered_by(&feed, "vm3", "ticket_pubkey"), Some(B), "the primary's own");
605        assert_eq!(offered_by(&feed, "vm4", "ticket_pubkey"), None, "blank is absent");
606        assert_eq!(offered_by(&feed, "vm9", "ticket_pubkey"), None);
607    }
608
609    #[test]
610    fn monetize_door_alone_medium_alone_and_equal_are_taken() {
611        assert_eq!(resolve_monetize(None, Some(A)), Ok(Some(k(A))), "one medium, any estate: the door's key");
612        assert_eq!(resolve_monetize(Some(k(A)), None), Ok(Some(k(A))), "no feed: today's behaviour");
613        assert_eq!(resolve_monetize(Some(k(A)), Some(A)), Ok(Some(k(A))));
614        assert_eq!(resolve_monetize(None, None), Ok(None), "a metered build with no key from either source: today's behaviour");
615    }
616
617    #[test]
618    fn monetize_medium_and_a_different_door_key_is_refused_by_name() {
619        let why = resolve_monetize(Some(k(A)), Some(B)).unwrap_err();
620        assert!(why.contains(&format!("[{FEED_MISMATCH}]")) && why.contains(A) && why.contains(B), "{why}");
621        let why = resolve_monetize(None, Some("not-a-key")).unwrap_err();
622        assert!(why.contains("[pairing-feed-monetize-malformed]"), "{why}");
623    }
624
625    #[test]
626    fn the_feed_table_reads_the_contract_keys_and_blank_is_absent() {
627        let mut feed = std::collections::BTreeMap::new();
628        feed.insert(FEED_MONETIZE.to_string(), A.to_string());
629        feed.insert(FEED_CLOCK_PEER.to_string(), "peer://10.13.8.179:50123".to_string());
630        feed.insert(FEED_CLOCK_KEY.to_string(), "  ".to_string());
631        feed.insert("admin_pubkey".to_string(), "ssh-ed25519 AAAAconsole".to_string());
632        let fed = Fed::from_feed(&feed);
633        assert_eq!(fed, Fed { monetize_pubkey: Some(A.into()), clock_key: None, clock_peer: Some("peer://10.13.8.179:50123".into()), ..Fed::default() });
634    }
635
636    #[cfg(feature = "wg-client")]
637    #[test]
638    fn a_persisted_join_reply_is_read_back_and_absent_is_none() {
639        let dir = std::env::temp_dir().join(format!("gunnar-feed-{}", std::process::id()));
640        let _ = std::fs::create_dir_all(dir.join("pairing"));
641        assert_eq!(Fed::read(&dir.join("nowhere")).unwrap(), None);
642        let mut feed = std::collections::BTreeMap::new();
643        feed.insert(FEED_MONETIZE.to_string(), A.to_string());
644        feed.insert(FEED_CLOCK_KEY.to_string(), "ssh-ed25519 AAAAk".to_string());
645        let reply = nordisk_pairing::join::JoinReply {
646            estate: "gunnar.rs".into(),
647            me: nordisk_pairing::join::Assigned { name: "main".into(), overlay: nordisk_pairing::OVERLAY_BOX_A },
648            cert_pem: None,
649            role: None,
650            primary_overlay: None,
651            primary_utility: None,
652            peers: vec![],
653            feed,
654        };
655        std::fs::write(dir.join(crate::pairing::JOINED_REL), reply.render().unwrap()).unwrap();
656        let fed = Fed::read(&dir).unwrap().unwrap();
657        assert_eq!(fed.monetize_pubkey.as_deref(), Some(A));
658        assert_eq!(fed.clock_key.as_deref(), Some("ssh-ed25519 AAAAk"));
659        let _ = std::fs::remove_dir_all(&dir);
660    }
661}