Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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]"));
    }
}