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";
69pub const FEED_SMTP_CA: &str = "secret.smtp_ca";
72
73pub const FEED_MONETIZE: &str = "monetize_pubkey";
75pub const FEED_CLOCK_KEY: &str = "clock_key";
76pub const FEED_CLOCK_PEER: &str = "clock_peer";
77pub const FEED_SSO_PRIVATE_IDP_CIDRS: &str = "sso_private_idp_cidrs";
82pub const FEED_SSO_EGRESS: &str = "sso_egress";
88pub const FEED_SSO_DNS_RESOLVER: &str = "sso_dns_resolver";
93pub const FEED_SELLS_ROOM: &str = "sells_room";
97pub const FEED_FLEET: &str = "fleet";
101pub const FEED_FLEET_INSTANCE: &str = "fleet_instance";
103pub const FEED_FLEET_AT: &str = "fleet_at";
108pub const FEED_FLEET_CA: &str = "fleet_ca";
112
113pub const FEED_BILLING_URL: &str = "refusal_link";
118pub const FEED_REPAIR_AUTO: &str = "repair_auto";
122pub const FEED_REPAIR_KEY: &str = "secret.repair_key";
128
129pub const FEED_MISMATCH: &str = "pairing.feed_mismatch";
131pub const FEED_NOT_APPLIED: &str = "pairing.feed_not_applied";
135
136pub fn offered_by<'a>(
138 feed: &'a std::collections::BTreeMap<String, String>,
139 vm: &str,
140 key: &str,
141) -> Option<&'a str> {
142 feed.get(&format!("offer.{vm}.{key}"))
143 .map(|v| v.trim())
144 .filter(|v| !v.is_empty())
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
149pub struct Fed {
150 pub monetize_pubkey: Option<String>,
151 pub clock_key: Option<String>,
152 pub clock_peer: Option<String>,
153 pub sso_private_idp_cidrs: Option<String>,
155 pub sso_egress: bool,
157 pub sso_dns_resolver: Option<String>,
159 pub sells_room: bool,
161 pub fleet: Option<String>,
163 pub fleet_instance: Option<String>,
165 pub fleet_at: Option<String>,
167 pub fleet_ca: Option<String>,
169 pub smtp_url: Option<Withheld>,
172 pub smtp_from: Option<String>,
174 pub smtp_ca: Option<String>,
176 pub billing_url: Option<String>,
178 pub repair_auto: bool,
180 pub repair_key: Option<Withheld>,
182 pub egress: Option<String>,
184}
185
186#[derive(Clone, Default, PartialEq, Eq)]
188pub struct Withheld(pub String);
189
190impl std::fmt::Debug for Withheld {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.write_str("<withheld>")
193 }
194}
195
196impl Fed {
197 pub fn from_feed(feed: &std::collections::BTreeMap<String, String>) -> Fed {
203 let get = |k: &str| {
204 feed.get(k)
205 .map(|v| v.trim().to_string())
206 .filter(|v| !v.is_empty())
207 .or_else(|| nordisk_pairing::feedbus::offered(feed, k))
208 };
209 Fed {
210 monetize_pubkey: get(FEED_MONETIZE),
211 clock_key: get(FEED_CLOCK_KEY),
212 clock_peer: get(FEED_CLOCK_PEER),
213 sso_private_idp_cidrs: get(FEED_SSO_PRIVATE_IDP_CIDRS),
214 sso_egress: get(FEED_SSO_EGRESS).as_deref() == Some("on"),
215 sso_dns_resolver: get(FEED_SSO_DNS_RESOLVER),
216 sells_room: get(FEED_SELLS_ROOM).as_deref() == Some("true"),
217 fleet: get(FEED_FLEET),
218 fleet_instance: get(FEED_FLEET_INSTANCE),
219 fleet_at: get(FEED_FLEET_AT),
220 fleet_ca: get(FEED_FLEET_CA),
221 smtp_url: get(FEED_SMTP_URL).map(Withheld),
222 smtp_from: get(FEED_SMTP_FROM),
223 smtp_ca: get(FEED_SMTP_CA),
224 billing_url: get(FEED_BILLING_URL),
225 repair_auto: get(FEED_REPAIR_AUTO).as_deref() == Some("true"),
226 repair_key: get(FEED_REPAIR_KEY).map(Withheld),
227 egress: get(FEED_EGRESS).filter(|d| d.starts_with("http://")),
228 }
229 }
230
231 pub fn is_empty(&self) -> bool {
232 self == &Fed::default()
233 }
234
235 pub fn read(root: &Path) -> anyhow::Result<Option<Fed>> {
239 Ok(kept_feed(root)?.as_ref().map(Fed::from_feed))
240 }
241
242 pub fn current() -> Option<Fed> {
245 feedbus::current().map(|feed| Fed::from_feed(&feed))
246 }
247}
248
249pub fn take_egress(feed: &std::collections::BTreeMap<String, String>) {
254 if let Some(door) = Fed::from_feed(feed).egress {
255 if nordisk_pairing::egress::proxy().is_none() {
256 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)");
257 }
258 nordisk_pairing::egress::set_proxy(&door);
259 }
260}
261
262#[cfg(any(feature = "mirror-http", feature = "sso-egress"))]
264pub fn ureq_proxy() -> Option<ureq::Proxy> {
265 nordisk_pairing::egress::proxy().and_then(|d| ureq::Proxy::new(d).ok())
266}
267
268#[cfg(feature = "wg-client")]
270pub fn kept_feed(root: &Path) -> anyhow::Result<Option<Feed>> {
271 let p = root.join(crate::pairing::JOINED_REL);
272 let Some(text) = crate::fed_at_rest::read_checked(root, crate::pairing::JOINED_REL)? else {
273 return Ok(None);
274 };
275 let reply = nordisk_pairing::join::JoinReply::parse(&text)
276 .map_err(|e| anyhow::anyhow!("{}: {e}", p.display()))?;
277 Ok(Some(reply.feed))
278}
279
280#[cfg(not(feature = "wg-client"))]
282pub fn kept_feed(_root: &Path) -> anyhow::Result<Option<Feed>> {
283 Ok(None)
284}
285
286pub fn sso_private_nets(fed: Option<&Fed>) -> Result<gunnar_grpc::sso::PrivateNets, String> {
290 match fed.and_then(|f| f.sso_private_idp_cidrs.as_deref()) {
291 Some(text) => gunnar_grpc::sso::PrivateNets::parse(text)
292 .map_err(|why| format!("`{FEED_SSO_PRIVATE_IDP_CIDRS}`: {why}")),
293 None => Ok(gunnar_grpc::sso::PrivateNets::default()),
294 }
295}
296
297pub fn sso_egress_on(fed: Option<&Fed>, env: Option<&str>) -> bool {
300 fed.is_some_and(|f| f.sso_egress) || env.is_some_and(|v| v.trim() == "on")
301}
302
303pub fn sso_dns_resolver(fed: Option<&Fed>, env: Option<&str>) -> Option<String> {
308 fed.and_then(|f| f.sso_dns_resolver.clone()).or_else(|| {
309 env.map(str::trim)
310 .filter(|v| !v.is_empty())
311 .map(str::to_owned)
312 })
313}
314
315pub fn load_at_start(root: &Path) -> Option<Fed> {
319 match kept_feed(root) {
320 Ok(Some(feed)) => {
321 let fed = Fed::from_feed(&feed);
322 if !fed.is_empty() {
323 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");
324 }
325 feedbus::publish(feed);
326 Some(fed)
327 }
328 Ok(None) => None,
329 Err(e) => {
330 tracing::error!(error = %format!("{e:#}"), "feed: the persisted join reply is unreadable — this start uses the medium's values only");
331 None
332 }
333 }
334}
335
336fn in_force_slot() -> &'static RwLock<Option<Option<[u8; 32]>>> {
342 static IN_FORCE: OnceLock<RwLock<Option<Option<[u8; 32]>>>> = OnceLock::new();
343 IN_FORCE.get_or_init(|| RwLock::new(None))
344}
345
346pub fn record_monetize_in_force(key: Option<[u8; 32]>) {
347 if let Ok(mut s) = in_force_slot().write() {
348 *s = Some(key);
349 }
350}
351
352pub fn monetize_in_force() -> Option<[u8; 32]> {
353 in_force_slot().read().ok().and_then(|s| s.flatten())
354}
355
356pub type MonetizeTaker = Box<dyn Fn(&[u8; 32]) -> Result<(), String> + Send + Sync>;
360
361fn takers() -> &'static Mutex<Vec<(&'static str, MonetizeTaker)>> {
362 static TAKERS: OnceLock<Mutex<Vec<(&'static str, MonetizeTaker)>>> = OnceLock::new();
363 TAKERS.get_or_init(|| Mutex::new(Vec::new()))
364}
365
366pub fn on_monetize_key(name: &'static str, take: MonetizeTaker) {
371 if let Ok(mut t) = takers().lock() {
372 t.push((name, take));
373 }
374}
375
376fn hex(k: &[u8; 32]) -> String {
377 k.iter().map(|b| format!("{b:02x}")).collect()
378}
379
380pub fn resolve_monetize(
385 medium: Option<[u8; 32]>,
386 fed: Option<&str>,
387) -> Result<Option<[u8; 32]>, String> {
388 let fed = match fed {
389 None => return Ok(medium),
390 Some(text) => gunnar_common::monetize_key::parse_hex32(text.trim()).map_err(|why| {
391 format!("REFUSED [pairing-feed-monetize-malformed] the installer fed `{FEED_MONETIZE}` = `{text}`, which is not a monetize key: {why}")
392 })?,
393 };
394 match medium {
395 None => Ok(Some(fed)),
396 Some(m) if m == fed => Ok(Some(m)),
397 Some(m) => Err(format!(
398 "REFUSED [{FEED_MISMATCH}] the medium trusts monetize key {} and the installer's door fed {} — \
399 this box will not pick one silently. Compose the medium without `monetize_pubkey` (the door \
400 feeds it), or with the front's live key",
401 hex(&m),
402 hex(&fed)
403 )),
404 }
405}
406
407#[cfg(feature = "metered")]
416const NO_TAKER_WHY: &str =
417 "the metered start opened no entitlement slot (no --billing-url on a `fed` medium)";
418#[cfg(not(feature = "metered"))]
419const NO_TAKER_WHY: &str =
420 "this build carries no entitlement module, so no start opens a slot to take it";
421
422pub fn monetize_arrived(fed: Option<&str>) -> Option<MonetizeArrival> {
428 let in_force = in_force_slot().read().ok().and_then(|s| *s)?;
429 let fed = fed?;
430 match resolve_monetize(in_force, Some(fed)) {
431 Ok(Some(k)) if Some(k) == in_force => None,
432 Ok(Some(k)) => {
433 let takers = takers().lock().ok()?;
434 if takers.is_empty() {
435 let detail = format!(
436 "the installer fed monetize key {} and nothing on this box can enforce it: {NO_TAKER_WHY}",
437 hex(&k)
438 );
439 raise(FEED_NOT_APPLIED, &detail);
440 return Some(MonetizeArrival::NotApplied(detail));
441 }
442 let failed: Vec<String> = takers
443 .iter()
444 .filter_map(|(name, take)| take(&k).err().map(|e| format!("{name}: {e}")))
445 .collect();
446 if !failed.is_empty() {
447 let detail = format!(
448 "the installer fed monetize key {}, and it could not be applied: {}",
449 hex(&k),
450 failed.join("; ")
451 );
452 raise(FEED_NOT_APPLIED, &detail);
453 return Some(MonetizeArrival::NotApplied(detail));
454 }
455 record_monetize_in_force(Some(k));
456 let detail = format!("the installer fed monetize key {}; it is IN FORCE now (applied in place, no restart)", hex(&k));
457 tracing::info!(event = "pairing.feed_applied", "feed: {detail}");
458 Some(MonetizeArrival::Applied(detail))
459 }
460 Ok(None) => None,
461 Err(why) => {
462 raise(FEED_MISMATCH, &why);
463 Some(MonetizeArrival::Mismatch(why))
464 }
465 }
466}
467
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub enum MonetizeArrival {
470 Applied(String),
472 NotApplied(String),
474 Mismatch(String),
476}
477
478pub use estate_clock::offer::resolve_clock;
485
486pub fn clock_config() -> Result<estate_clock::config::Config, String> {
492 use estate_clock::config::{var, Config, Role, CLOCK_ENV, CLOCK_KEY_ENV};
493 let env_clock = var(CLOCK_ENV);
494 let env_key = var(CLOCK_KEY_ENV);
495 let fed = Fed::current().unwrap_or_default();
496 let (clock, key) = resolve_clock(
497 env_clock.as_deref(),
498 env_key.as_deref(),
499 fed.clock_peer.as_deref(),
500 fed.clock_key.as_deref(),
501 )?;
502 Config::parse(clock.as_deref(), key.as_deref(), Role::Appliance)
503}
504
505pub fn clock_fed() -> bool {
507 Fed::current().is_some_and(|f| f.clock_peer.is_some())
508}
509
510fn alarm_slot() -> &'static Mutex<Vec<gunnar_proto::HealthAlarm>> {
513 static ALARMS: OnceLock<Mutex<Vec<gunnar_proto::HealthAlarm>>> = OnceLock::new();
514 ALARMS.get_or_init(|| Mutex::new(Vec::new()))
515}
516
517pub fn raise(event: &str, detail: &str) {
519 tracing::error!(event, "feed: {detail}");
520 let now = std::time::SystemTime::now()
521 .duration_since(std::time::UNIX_EPOCH)
522 .map(|d| d.as_millis() as i64)
523 .unwrap_or(0);
524 if let Ok(mut alarms) = alarm_slot().lock() {
525 match alarms.iter_mut().find(|a| a.event == event) {
526 Some(a) => {
527 a.count += 1;
528 a.detail = detail.to_string();
529 }
530 None => alarms.push(gunnar_proto::HealthAlarm {
531 event: event.into(),
532 count: 1,
533 since_unix_ms: now,
534 detail: detail.to_string(),
535 }),
536 }
537 }
538}
539
540pub fn alarms() -> Vec<gunnar_proto::HealthAlarm> {
542 alarm_slot().lock().map(|a| a.clone()).unwrap_or_default()
543}
544
545pub fn seam_refusal(at: Option<&str>, ca: bool) -> Option<String> {
551 const WORD: &str = "fleet-seam-bench-only";
552 match at {
553 None if ca => Some(format!(
554 "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",
555 crate::feed::FEED_FLEET_CA,
556 crate::feed::FEED_FLEET_AT
557 )),
558 None => None,
559 Some(at) => match at.trim().parse::<std::net::SocketAddr>() {
560 Ok(a) if bench_address(a.ip()) => None,
561 _ => Some(format!(
562 "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`)",
563 crate::feed::FEED_FLEET_AT
564 )),
565 },
566 }
567}
568
569fn bench_address(ip: std::net::IpAddr) -> bool {
571 match ip {
572 std::net::IpAddr::V4(v) => {
573 v.is_private()
574 || v.is_loopback()
575 || v.is_link_local()
576 || (v.octets()[0] == 100 && (v.octets()[1] & 0xc0) == 64)
577 }
578 std::net::IpAddr::V6(v) => {
579 v.is_loopback()
580 || (v.segments()[0] & 0xfe00) == 0xfc00
581 || (v.segments()[0] & 0xffc0) == 0xfe80
582 }
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
593 fn the_fed_relay_is_read_and_its_url_withheld() {
594 let feed: std::collections::BTreeMap<String, String> = [
595 (FEED_SMTP_URL, "smtp://u:TOKENvalue@smtp.example:587"),
596 (FEED_SMTP_FROM, "u@smtp.example"),
597 ]
598 .iter()
599 .map(|(k, v)| (k.to_string(), v.to_string()))
600 .collect();
601 let f = Fed::from_feed(&feed);
602 assert_eq!(
603 f.smtp_url,
604 Some(Withheld("smtp://u:TOKENvalue@smtp.example:587".into()))
605 );
606 assert_eq!(f.smtp_from.as_deref(), Some("u@smtp.example"));
607 assert!(!format!("{f:?}").contains("TOKENvalue"), "{f:?}");
608 assert!(Fed::from_feed(&Default::default()).smtp_url.is_none());
609 }
610
611 #[test]
614 fn the_fed_egress_door_is_read_and_only_an_http_one() {
615 let feed = |pairs: &[(&str, &str)]| {
616 pairs
617 .iter()
618 .map(|(k, v)| (k.to_string(), v.to_string()))
619 .collect::<std::collections::BTreeMap<_, _>>()
620 };
621 assert_eq!(FEED_EGRESS, nordisk_pairing::egress::FEED_EGRESS);
622 assert_eq!(
623 Fed::from_feed(&feed(&[(FEED_EGRESS, " http://10.44.0.1:3128 ")]))
624 .egress
625 .as_deref(),
626 Some("http://10.44.0.1:3128")
627 );
628 assert_eq!(
629 Fed::from_feed(&feed(&[(FEED_EGRESS, "socks5://10.44.0.1:1080")])).egress,
630 None,
631 "only an http:// door is one"
632 );
633 assert_eq!(
634 Fed::from_feed(&feed(&[])).egress,
635 None,
636 "a box with its own way out is fed none"
637 );
638 }
639
640 const A: &str = "43cb00000000000000000000000000000000000000000000000000000000842a";
641 const B: &str = "43cb00000000000000000000000000000000000000000000000000000000842b";
642
643 fn k(s: &str) -> [u8; 32] {
644 gunnar_common::monetize_key::parse_hex32(s).unwrap()
645 }
646
647 #[test]
650 fn a_value_offered_by_a_vm_is_taken_when_the_installer_feeds_none_itself() {
651 let feed = |pairs: &[(&str, &str)]| {
652 pairs
653 .iter()
654 .map(|(k, v)| (k.to_string(), v.to_string()))
655 .collect::<std::collections::BTreeMap<_, _>>()
656 };
657 let f = Fed::from_feed(&feed(&[
658 ("offer.front.monetize_pubkey", A),
659 ("offer.front.clock_key", "ssh-ed25519 AAAAk"),
660 ("clock_peer", "peer://10.13.8.1:50123"),
661 ("offer.front.xmonetize_pubkey", B),
662 ]));
663 assert_eq!(
664 f,
665 Fed {
666 monetize_pubkey: Some(A.into()),
667 clock_key: Some("ssh-ed25519 AAAAk".into()),
668 clock_peer: Some("peer://10.13.8.1:50123".into()),
669 ..Fed::default()
670 }
671 );
672 assert_eq!(
673 Fed::from_feed(&feed(&[
674 (FEED_MONETIZE, B),
675 ("offer.front.monetize_pubkey", A)
676 ]))
677 .monetize_pubkey
678 .as_deref(),
679 Some(B),
680 "the plain key wins"
681 );
682 assert_eq!(
683 nordisk_pairing::feedbus::offered(
684 &feed(&[
685 ("offer..monetize_pubkey", A),
686 ("offer.a.b.monetize_pubkey", A)
687 ]),
688 FEED_MONETIZE
689 ),
690 None,
691 "not a VM name"
692 );
693 }
694
695 #[test]
698 fn the_fleet_door_and_the_estate_are_taken_from_the_feed_or_the_fronts_offer() {
699 let feed = |pairs: &[(&str, &str)]| {
700 pairs
701 .iter()
702 .map(|(k, v)| (k.to_string(), v.to_string()))
703 .collect::<std::collections::BTreeMap<_, _>>()
704 };
705 let f = Fed::from_feed(&feed(&[
706 ("offer.front.fleet", "https://monetize.ignalina.dk:8443"),
707 ("offer.front.fleet_instance", "acme"),
708 ("offer.front.monetize_pubkey", A),
709 ]));
710 assert_eq!(
711 (
712 f.fleet.as_deref(),
713 f.fleet_instance.as_deref(),
714 f.monetize_pubkey.as_deref()
715 ),
716 (
717 Some("https://monetize.ignalina.dk:8443"),
718 Some("acme"),
719 Some(A)
720 )
721 );
722 let f = Fed::from_feed(&feed(&[
723 (FEED_FLEET, "https://one:8443"),
724 ("offer.front.fleet", "https://other:8443"),
725 (FEED_FLEET_INSTANCE, " "),
726 ("offer.front.fleet_instance", "acme"),
727 ]));
728 assert_eq!(
729 (f.fleet.as_deref(), f.fleet_instance.as_deref()),
730 (Some("https://one:8443"), Some("acme")),
731 "the plain key wins; blank is absent"
732 );
733 assert!(Fed::from_feed(&feed(&[])).fleet.is_none());
734 }
735
736 #[test]
739 fn sells_room_is_fed_as_exactly_true() {
740 let feed = |pairs: &[(&str, &str)]| {
741 pairs
742 .iter()
743 .map(|(k, v)| (k.to_string(), v.to_string()))
744 .collect::<std::collections::BTreeMap<_, _>>()
745 };
746 assert!(Fed::from_feed(&feed(&[(FEED_SELLS_ROOM, "true")])).sells_room);
747 assert!(
748 Fed::from_feed(&feed(&[(FEED_SELLS_ROOM, " true\n")])).sells_room,
749 "trimmed, as fed"
750 );
751 assert!(!Fed::from_feed(&feed(&[(FEED_SELLS_ROOM, "yes")])).sells_room);
752 assert!(
753 !Fed::from_feed(&feed(&[])).sells_room,
754 "a sold estate is fed nothing"
755 );
756 }
757
758 #[test]
761 fn the_sites_pay_page_is_read_from_the_feed() {
762 let feed = |pairs: &[(&str, &str)]| {
763 pairs
764 .iter()
765 .map(|(k, v)| (k.to_string(), v.to_string()))
766 .collect::<std::collections::BTreeMap<_, _>>()
767 };
768 assert_eq!(FEED_BILLING_URL, "refusal_link");
769 assert_eq!(
770 Fed::from_feed(&feed(&[(
771 FEED_BILLING_URL,
772 " https://test2.gunnar.rs/billing/{tenant}\n"
773 )]))
774 .billing_url
775 .as_deref(),
776 Some("https://test2.gunnar.rs/billing/{tenant}")
777 );
778 assert_eq!(
779 Fed::from_feed(&feed(&[(FEED_BILLING_URL, " ")])).billing_url,
780 None
781 );
782 assert_eq!(Fed::from_feed(&feed(&[])).billing_url, None);
783 }
784
785 #[test]
789 fn sso_egress_is_off_unless_the_operator_turns_it_on() {
790 let feed = |pairs: &[(&str, &str)]| {
791 pairs
792 .iter()
793 .map(|(k, v)| (k.to_string(), v.to_string()))
794 .collect::<std::collections::BTreeMap<_, _>>()
795 };
796 let on = Fed::from_feed(&feed(&[(FEED_SSO_EGRESS, " on ")]));
797 assert!(on.sso_egress);
798 assert!(sso_egress_on(Some(&on), None));
799 for word in ["off", "true", "yes", "ON?", ""] {
800 let fed = Fed::from_feed(&feed(&[(FEED_SSO_EGRESS, word)]));
801 assert!(!sso_egress_on(Some(&fed), None), "{word:?} turned it on");
802 }
803 assert!(!sso_egress_on(None, None), "nothing said is airgap");
804 assert!(
805 sso_egress_on(None, Some("on")),
806 "a box fed nothing takes the operator's environment"
807 );
808 assert!(!sso_egress_on(None, Some("1")));
809 let off = Fed::from_feed(&feed(&[]));
810 assert!(
811 sso_egress_on(Some(&off), Some("on")),
812 "the environment holds when the feed says nothing"
813 );
814 }
815
816 #[test]
821 fn the_domain_proofs_resolver_is_fed_or_the_operators_environment() {
822 let feed = |pairs: &[(&str, &str)]| {
823 pairs
824 .iter()
825 .map(|(k, v)| (k.to_string(), v.to_string()))
826 .collect::<std::collections::BTreeMap<_, _>>()
827 };
828 let fed = Fed::from_feed(&feed(&[(
829 FEED_SSO_DNS_RESOLVER,
830 "https://doh.example/resolve",
831 )]));
832 assert_eq!(
833 sso_dns_resolver(Some(&fed), None).as_deref(),
834 Some("https://doh.example/resolve")
835 );
836 assert_eq!(
837 sso_dns_resolver(Some(&fed), Some("http://127.0.0.1:9/resolve")).as_deref(),
838 Some("https://doh.example/resolve"),
839 "the feed's resolver wins over the environment"
840 );
841 let none = Fed::from_feed(&feed(&[]));
842 assert_eq!(
843 sso_dns_resolver(Some(&none), Some(" http://127.0.0.1:9/resolve ")).as_deref(),
844 Some("http://127.0.0.1:9/resolve")
845 );
846 assert_eq!(
847 sso_dns_resolver(None, Some("http://127.0.0.1:9/resolve")).as_deref(),
848 Some("http://127.0.0.1:9/resolve"),
849 "a box fed nothing"
850 );
851 assert_eq!(sso_dns_resolver(None, Some(" ")), None, "blank is none");
852 assert_eq!(
853 sso_dns_resolver(None, None),
854 None,
855 "nothing named is no resolver (RA5-24)"
856 );
857 }
858
859 #[test]
864 fn the_operators_idp_networks_are_fed_and_applied_live() {
865 let feed = |pairs: &[(&str, &str)]| {
866 pairs
867 .iter()
868 .map(|(k, v)| (k.to_string(), v.to_string()))
869 .collect::<std::collections::BTreeMap<_, _>>()
870 };
871 let fed = Fed::from_feed(&feed(&[(FEED_SSO_PRIVATE_IDP_CIDRS, " 10.0.2.100/32 ")]));
872 assert_eq!(fed.sso_private_idp_cidrs.as_deref(), Some("10.0.2.100/32"));
873 assert_eq!(
874 sso_private_nets(Some(&fed))
875 .expect("a private network")
876 .to_string(),
877 "10.0.2.100/32"
878 );
879 assert!(sso_private_nets(None).expect("nothing fed").is_empty());
880 let public = Fed::from_feed(&feed(&[(FEED_SSO_PRIVATE_IDP_CIDRS, "8.8.8.0/24")]));
881 assert!(
882 sso_private_nets(Some(&public)).is_err(),
883 "a public range is refused"
884 );
885 let seen = std::sync::Arc::new(Mutex::new(Vec::<String>::new()));
886 let into = std::sync::Arc::clone(&seen);
887 feedbus::on_published("test: the idp networks", move |f: &Feed| {
888 into.lock().unwrap().push(
889 Fed::from_feed(f)
890 .sso_private_idp_cidrs
891 .clone()
892 .unwrap_or_default(),
893 )
894 });
895 feedbus::publish(feed(&[(FEED_SSO_PRIVATE_IDP_CIDRS, " 10.0.2.100/32 ")]));
896 feedbus::publish(feed(&[]));
897 let got = seen.lock().unwrap().clone();
898 let at = got
899 .iter()
900 .position(|v| v == "10.0.2.100/32")
901 .expect("the list reached the taker");
902 assert!(
903 got[at + 1..].iter().any(String::is_empty),
904 "every publish reaches the taker: {got:?}"
905 );
906 }
907
908 #[test]
911 fn an_offer_is_read_from_the_named_vm_not_the_first_by_name() {
912 let feed: std::collections::BTreeMap<String, String> = [
913 ("offer.vm2.ticket_pubkey", A),
914 ("offer.vm3.ticket_pubkey", B),
915 ("offer.vm4.ticket_pubkey", " "),
916 ]
917 .iter()
918 .map(|(k, v)| (k.to_string(), v.to_string()))
919 .collect();
920 assert_eq!(
921 nordisk_pairing::feedbus::offered(&feed, "ticket_pubkey").as_deref(),
922 Some(A),
923 "the first by name"
924 );
925 assert_eq!(
926 offered_by(&feed, "vm3", "ticket_pubkey"),
927 Some(B),
928 "the primary's own"
929 );
930 assert_eq!(
931 offered_by(&feed, "vm4", "ticket_pubkey"),
932 None,
933 "blank is absent"
934 );
935 assert_eq!(offered_by(&feed, "vm9", "ticket_pubkey"), None);
936 }
937
938 #[test]
939 fn monetize_door_alone_medium_alone_and_equal_are_taken() {
940 assert_eq!(
941 resolve_monetize(None, Some(A)),
942 Ok(Some(k(A))),
943 "one medium, any estate: the door's key"
944 );
945 assert_eq!(
946 resolve_monetize(Some(k(A)), None),
947 Ok(Some(k(A))),
948 "no feed: today's behaviour"
949 );
950 assert_eq!(resolve_monetize(Some(k(A)), Some(A)), Ok(Some(k(A))));
951 assert_eq!(
952 resolve_monetize(None, None),
953 Ok(None),
954 "a metered build with no key from either source: today's behaviour"
955 );
956 }
957
958 #[test]
959 fn monetize_medium_and_a_different_door_key_is_refused_by_name() {
960 let why = resolve_monetize(Some(k(A)), Some(B)).unwrap_err();
961 assert!(
962 why.contains(&format!("[{FEED_MISMATCH}]")) && why.contains(A) && why.contains(B),
963 "{why}"
964 );
965 let why = resolve_monetize(None, Some("not-a-key")).unwrap_err();
966 assert!(why.contains("[pairing-feed-monetize-malformed]"), "{why}");
967 }
968
969 #[test]
970 fn the_feed_table_reads_the_contract_keys_and_blank_is_absent() {
971 let mut feed = std::collections::BTreeMap::new();
972 feed.insert(FEED_MONETIZE.to_string(), A.to_string());
973 feed.insert(
974 FEED_CLOCK_PEER.to_string(),
975 "peer://10.13.8.179:50123".to_string(),
976 );
977 feed.insert(FEED_CLOCK_KEY.to_string(), " ".to_string());
978 feed.insert(
979 "admin_pubkey".to_string(),
980 "ssh-ed25519 AAAAconsole".to_string(),
981 );
982 let fed = Fed::from_feed(&feed);
983 assert_eq!(
984 fed,
985 Fed {
986 monetize_pubkey: Some(A.into()),
987 clock_key: None,
988 clock_peer: Some("peer://10.13.8.179:50123".into()),
989 ..Fed::default()
990 }
991 );
992 }
993
994 #[cfg(feature = "wg-client")]
995 #[test]
996 fn a_persisted_join_reply_is_read_back_and_absent_is_none() {
997 let dir = std::env::temp_dir().join(format!("gunnar-feed-{}", std::process::id()));
998 let _ = std::fs::create_dir_all(dir.join("pairing"));
999 assert_eq!(Fed::read(&dir.join("nowhere")).unwrap(), None);
1000 let mut feed = std::collections::BTreeMap::new();
1001 feed.insert(FEED_MONETIZE.to_string(), A.to_string());
1002 feed.insert(FEED_CLOCK_KEY.to_string(), "ssh-ed25519 AAAAk".to_string());
1003 let reply = nordisk_pairing::join::JoinReply {
1004 estate: "gunnar.rs".into(),
1005 me: nordisk_pairing::join::Assigned {
1006 name: "main".into(),
1007 overlay: nordisk_pairing::OVERLAY_BOX_A,
1008 },
1009 cert_pem: None,
1010 role: None,
1011 primary_overlay: None,
1012 primary_utility: None,
1013 peers: vec![],
1014 feed,
1015 };
1016 std::fs::write(
1017 dir.join(crate::pairing::JOINED_REL),
1018 reply.render().unwrap(),
1019 )
1020 .unwrap();
1021 let fed = Fed::read(&dir).unwrap().unwrap();
1022 assert_eq!(fed.monetize_pubkey.as_deref(), Some(A));
1023 assert_eq!(fed.clock_key.as_deref(), Some("ssh-ed25519 AAAAk"));
1024 let _ = std::fs::remove_dir_all(&dir);
1025 }
1026}