server/lib/src/egress.rs
//! **Offline mode: a holger that makes no network call.**
//!
//! The secure side of an airgap must not reach out. Building the zero-egress
//! tiers without an HTTP client (`--no-default-features`) is one half of that;
//! this module is the other half, for a binary that does carry the clients:
//!
//! ```ron
//! network: Offline,
//! ```
//!
//! With it, two things hold.
//!
//! 1. **The boot refuses a config that would need the network**, by name
//! (`holger-offline-2`): a `*-remote` repository, a `mirror:` block, a twin
//! role, a recharge source. A config that reads as offline must not quietly
//! carry a remote.
//! 2. **Every outbound dial is refused at its source** (`holger-offline-1`):
//! the pull-through HTTP client, the gRPC channel to another holger, the twin
//! follower, the estate wire, the peer probe. [`check`] runs BEFORE a socket
//! is opened, so nothing leaves the box, not even a SYN.
//!
//! The switch is process-wide and one-way: [`set_offline`] is called once at
//! boot from the config and nothing turns it off again.
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
use crate::lock::RwLockOrRecover as _;
/// Whether this holger may make network calls of its own.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NetworkMode {
/// **The default: the airgap unless the config names an upstream** (Holger v2.0 B2).
/// No `network:` key resolves at boot (`Holger::resolved_network`): `Offline` when
/// the config names nothing that dials, `Online` when it names an upstream (a
/// pull-through source with a url, a `*-remote` repository, a mirror, a twin's
/// primary, a sync upstream, the intel feeds) or the build is an estate box (it dials
/// its front, its twin and the ONE box). The boot says which, and why.
#[default]
Auto,
/// Upstreams, mirrors and twins may dial out.
Online,
/// No outbound network call at all. Doors still listen; nothing dials.
Offline,
}
static OFFLINE: AtomicBool = AtomicBool::new(false);
/// **A downstream's one line out** (Holger v2.0 H3): with it set, the process is offline
/// EXCEPT for these origins (`scheme://host:port`, its upstream Holger). One-way, like
/// [`set_offline`].
static PINNED: std::sync::RwLock<Option<Vec<String>>> = std::sync::RwLock::new(None);
/// Put this process into the downstream's mode: no outbound call but to `origins`
/// (normalised by `holger_downstream_rules::origin_of`; empty = none at all).
pub fn pin_to(origins: Vec<String>) {
*PINNED.write_or_recover() = Some(origins);
set_offline();
}
/// The origins a pinned (downstream) process may still dial; `None` when not pinned.
pub fn pinned() -> Option<Vec<String>> {
PINNED.read_or_recover().clone()
}
/// The scheme a WireGuard endpoint is pinned and checked under (`wireguard://ip:port`): a
/// UDP line, never an HTTP origin, so the same `host:port` by any other scheme stays refused.
pub const WIREGUARD: &str = "wireguard://";
/// The target a WireGuard tunnel's dial to `endpoint` (`ip:port`, as the invite signs it) asks
/// [`check`] for.
pub fn wireguard_target(endpoint: &str) -> String {
format!("{WIREGUARD}{endpoint}")
}
/// ★ **A downstream's tunnel to its upstream** (lane HOL-EGRESS-WG): a signed invite names the
/// front's WireGuard `endpoint` that carries the upstream's `door`. When the process is pinned
/// and `door` is its upstream (one of the pinned origins), the endpoint is pinned beside it,
/// exactly as signed; the tunnel ([`wireguard_target`]) may then dial it and nothing else.
/// Not pinned, a door that is not the upstream, or no endpoint (a direct invite): nothing
/// changes. Returns whether the endpoint is pinned now.
pub fn pin_invite_endpoint(door: &str, endpoint: &str) -> bool {
let mut g = PINNED.write_or_recover();
let Some(allowed) = g.as_mut() else {
return false;
};
if endpoint.is_empty() || !origin(door).is_some_and(|o| allowed.contains(&o)) {
return false;
}
let line = wireguard_target(endpoint);
if !allowed.contains(&line) {
log::info!("downstream: its upstream's door {door} is reached through the WireGuard endpoint {endpoint} its signed invite names: pinned beside it");
allowed.push(line);
}
true
}
/// The `scheme://host:port` of a target, the downstream plan's own normalisation
/// (`https://h` is `https://h:443`). Only a downstream build pins, so only it needs one.
fn origin(target: &str) -> Option<String> {
#[cfg(feature = "gateway")]
return holger_downstream_rules::origin_of(target);
#[cfg(not(feature = "gateway"))]
{
let _ = target;
None
}
}
/// Put this process into offline mode. One-way: there is no way back.
pub fn set_offline() {
OFFLINE.store(true, Ordering::SeqCst);
}
/// Is this process in offline mode?
pub fn is_offline() -> bool {
OFFLINE.load(Ordering::SeqCst)
}
/// The refusal an outbound dial gets in offline mode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EgressRefused {
/// What wanted to dial (`pull-through`, `remote holger`, …).
pub what: String,
/// Where it wanted to go.
pub target: String,
/// Refused because the box is a downstream pinned to its upstream (not plain offline).
pub pinned: bool,
}
impl EgressRefused {
pub fn code(&self) -> &'static holger_errcode::ErrCode {
if self.pinned {
&holger_errcode::DOWNSTREAM_EGRESS_REFUSED
} else {
&holger_errcode::OFFLINE_EGRESS_REFUSED
}
}
}
impl std::fmt::Display for EgressRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.pinned {
return write!(
f,
"{}",
holger_errcode::DOWNSTREAM_EGRESS_REFUSED.tag(format_args!(
"{} to {} refused: this Holger is a downstream and dials only its upstream Holger",
self.what, self.target
))
);
}
write!(
f,
"{}",
holger_errcode::OFFLINE_EGRESS_REFUSED.tag(format_args!(
"{} to {} refused: this holger runs `network: Offline` and makes no network call",
self.what, self.target
))
)
}
}
impl std::error::Error for EgressRefused {}
/// ★ **Ask before every outbound dial.** `Err` in offline mode, before any
/// socket exists.
pub fn check(what: &str, target: &str) -> Result<(), EgressRefused> {
let refused = if let Some(allowed) = pinned() {
if origin(target).is_some_and(|o| allowed.contains(&o))
|| (target.starts_with(WIREGUARD) && allowed.iter().any(|a| a == target))
{
return Ok(());
}
log::warn!("downstream: refused {what} to {target} (its one line out is {allowed:?})");
EgressRefused {
what: what.to_string(),
target: target.to_string(),
pinned: true,
}
} else if is_offline() {
log::warn!("offline: refused {what} to {target}");
EgressRefused {
what: what.to_string(),
target: target.to_string(),
pinned: false,
}
} else {
return Ok(());
};
// HOL-NCSC: every blocked dial leaves an audit row. The SIEM's own refusal is not written
// (it would be exported again, and refused again).
if what != "siem" {
crate::refusals::record("egress", "", "", &refused.to_string());
}
Err(refused)
}
/// **The one dial an offline box still makes: to a collector on its own site** (lane
/// HOL-NCSC). `network: Offline` implies the SIEM's `local_only`, so its sinks are a file, a
/// loopback or a private, link-local or unique-local IP literal; a socket to one of those
/// passes, every other dial is [`check`]ed. A name is never on site (nothing is resolved).
pub fn check_site_local(what: &str, target: &str) -> Result<(), EgressRefused> {
if (is_offline() || pinned().is_some()) && site_local(target) {
return Ok(());
}
check(what, target)
}
/// Pure: `target` (`host:port`, `[v6]:port`, a URL) names an on-site IP literal.
fn site_local(target: &str) -> bool {
let t = target.trim();
let rest = t.split_once("://").map_or(t, |(_, r)| r);
let rest = rest.split('/').next().unwrap_or_default();
let host = match rest.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => match rest.rsplit_once(':') {
Some((h, _)) if !h.contains(':') => h,
_ => rest,
},
};
host.parse::<std::net::IpAddr>()
.is_ok_and(|ip| crate::audit::siem::is_local_ip(&ip))
}
/// The boot refusal for a config that cannot run offline.
pub fn config_refusal(why: impl std::fmt::Display) -> anyhow::Error {
anyhow::anyhow!(
"{}",
holger_errcode::OFFLINE_CONFIG_REFUSED.tag(format_args!(
"config `network: Offline`: {why}; an offline holger makes no network call, so a \
config that needs one is refused rather than run without it (write `network: Online` \
to let it dial, or leave `network:` out: a config that names an upstream is then Online)"
))
)
}
// ── the lean estate: leaving through the front (owner 2026-10-23) ─────────
/// The feed key that names the front's egress door (Skidbladnir nordisk-pairing
/// `egress::FEED_EGRESS`): the shop feeds it to a box laid with no public IPv4.
pub const FEED_EGRESS: &str = "egress";
type FeedNow = Box<dyn Fn() -> std::collections::BTreeMap<String, String> + Send + Sync>;
static FED: std::sync::RwLock<Option<FeedNow>> = std::sync::RwLock::new(None);
/// **Where this box reads its feed in force** (the pull channel's start), so the door it is fed
/// later is the one its next client takes.
pub fn read_feed_from(
now: impl Fn() -> std::collections::BTreeMap<String, String> + Send + Sync + 'static,
) {
*FED.write_or_recover() = Some(Box::new(now));
}
/// **The door this box leaves through**: its front's egress door when the feed in force names
/// one (a box behind its front, with no public IPv4); `None`, it has its own way out.
pub fn front_door() -> Option<String> {
let g = FED.read_or_recover();
let fed = (g.as_ref()?)();
fed.get(FEED_EGRESS)
.map(|v| v.trim().to_string())
.filter(|v| v.starts_with("http://"))
}
/// The proxy an outbound HTTPS client of this box is built with ([`front_door`]): ureq tunnels
/// through it with `CONNECT`, end to end TLS, the front resolving the name.
#[cfg(feature = "online")]
pub fn ureq_proxy() -> Option<ureq::Proxy> {
front_door().and_then(|u| ureq::Proxy::new(&u).ok())
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **A box behind its front leaves through the door it is fed** (the lean estate): read
/// from the feed in force at each ask; a box fed none has its own way out; only an
/// `http://` door is one.
#[test]
fn a_box_fed_the_front_door_leaves_through_it() {
assert_eq!(front_door(), None, "nothing read yet");
let fed = std::sync::Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new()));
let f = fed.clone();
read_feed_from(move || f.lock().unwrap().clone());
assert_eq!(front_door(), None, "a feed with no door");
fed.lock().unwrap().insert(
FEED_EGRESS.to_string(),
" http://10.44.0.1:3128 ".to_string(),
);
assert_eq!(
front_door().as_deref(),
Some("http://10.44.0.1:3128"),
"a door fed later is taken"
);
#[cfg(feature = "online")]
assert!(ureq_proxy().is_some());
fed.lock()
.unwrap()
.insert(FEED_EGRESS.to_string(), "socks5://elsewhere:1".to_string());
assert_eq!(front_door(), None);
}
#[test]
fn the_refusal_is_coded() {
let e = EgressRefused {
what: "pull-through".into(),
target: "https://pypi.org".into(),
pinned: false,
};
let s = e.to_string();
assert!(s.contains("[holger-offline-1]"), "{s}");
assert!(s.contains("https://pypi.org"), "{s}");
assert!(config_refusal("x")
.to_string()
.contains("[holger-offline-2]"));
}
}