1use std::path::Path;
56use std::sync::{Mutex, OnceLock, RwLock};
57
58use nordisk_pairing::feedbus::{self, Feed};
59
60pub const FEED_SMTP_URL: &str = "secret.smtp_url";
63pub const FEED_EGRESS: &str = nordisk_pairing::egress::FEED_EGRESS;
68pub const FEED_SMTP_FROM: &str = "secret.smtp_from";
69
70pub const FEED_MONETIZE: &str = "monetize_pubkey";
72pub const FEED_CLOCK_KEY: &str = "clock_key";
73pub const FEED_CLOCK_PEER: &str = "clock_peer";
74pub const FEED_SSO_PRIVATE_IDP_CIDRS: &str = "sso_private_idp_cidrs";
79pub const FEED_SELLS_ROOM: &str = "sells_room";
83pub const FEED_FLEET: &str = "fleet";
87pub const FEED_FLEET_INSTANCE: &str = "fleet_instance";
89pub const FEED_FLEET_AT: &str = "fleet_at";
94pub const FEED_FLEET_CA: &str = "fleet_ca";
98
99pub const FEED_BILLING_URL: &str = "refusal_link";
104pub const FEED_REPAIR_AUTO: &str = "repair_auto";
108pub const FEED_REPAIR_KEY: &str = "secret.repair_key";
114
115pub const FEED_MISMATCH: &str = "pairing.feed_mismatch";
117pub const FEED_NOT_APPLIED: &str = "pairing.feed_not_applied";
121
122pub 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#[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 pub sso_private_idp_cidrs: Option<String>,
135 pub sells_room: bool,
137 pub fleet: Option<String>,
139 pub fleet_instance: Option<String>,
141 pub fleet_at: Option<String>,
143 pub fleet_ca: Option<String>,
145 pub smtp_url: Option<Withheld>,
148 pub smtp_from: Option<String>,
150 pub billing_url: Option<String>,
152 pub repair_auto: bool,
154 pub repair_key: Option<Withheld>,
156 pub egress: Option<String>,
158}
159
160#[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 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 pub fn read(root: &Path) -> anyhow::Result<Option<Fed>> {
205 Ok(kept_feed(root)?.as_ref().map(Fed::from_feed))
206 }
207
208 pub fn current() -> Option<Fed> {
211 feedbus::current().map(|feed| Fed::from_feed(&feed))
212 }
213}
214
215pub 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#[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#[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#[cfg(not(feature = "wg-client"))]
245pub fn kept_feed(_root: &Path) -> anyhow::Result<Option<Feed>> {
246 Ok(None)
247}
248
249pub 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
259pub 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
280fn 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
300pub 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
310pub 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
324pub 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#[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
361pub 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 Applied(String),
404 NotApplied(String),
406 Mismatch(String),
408}
409
410pub use estate_clock::offer::resolve_clock;
417
418pub 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
432pub fn clock_fed() -> bool {
434 Fed::current().is_some_and(|f| f.clock_peer.is_some())
435}
436
437fn 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
444pub 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
459pub fn alarms() -> Vec<gunnar_proto::HealthAlarm> {
461 alarm_slot().lock().map(|a| a.clone()).unwrap_or_default()
462}
463
464pub 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
488fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}