zlayer-overlay 0.14.2

Encrypted overlay networking for containers using boringtun userspace WireGuard
Documentation
//! Edge artifact parsing (JSON / base64 / `zledge1.` token / stdin).
//!
//! One entry point, [`load_artifact`], resolves an operator-supplied `input`
//! string into a validated [`EdgeConfig`]. It is deliberately permissive about
//! *where* the artifact comes from and *how* it is encoded, because a minted
//! edge config reaches a provisioner in whatever shape is convenient:
//!
//! **Source resolution** (in order):
//! 1. `input == "-"` → read the whole of `stdin` as the artifact bytes.
//! 2. `input` names an existing file (`Path::is_file`) → read that file.
//! 3. otherwise → treat `input` itself as the inline artifact content.
//!
//! **Content sniffing** of the resolved bytes (after trimming surrounding
//! whitespace/newlines):
//! - starts with the `zledge1.` token prefix → [`EdgeConfig::from_token`]
//!   (which base64-decodes, version-checks, and JSON-parses in one shot);
//! - starts with `{` → inline JSON;
//! - otherwise → a bare URL-safe-base64 blob *without* the prefix, decoded with
//!   the same engine [`EdgeConfig::to_token`] uses, then JSON-parsed.
//!
//! Every path funnels through [`validate`] before returning, so a config that
//! decoded cleanly but is semantically unusable (wrong version, not exactly one
//! peer, an unreachable/wildcard endpoint, an empty or malformed `AllowedIPs`
//! list) is rejected here rather than deep in the connect path.

use std::io::Read;
use std::net::SocketAddr;
use std::path::Path;

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use zlayer_types::overlayd::{EdgeConfig, EDGE_CONFIG_VERSION};

use crate::edge::EdgeError;

/// Prefix identifying an [`EdgeConfig`] token string.
///
/// Mirrors the private `EDGE_TOKEN_PREFIX` in `zlayer_types::overlayd`
/// (not re-exported there), kept in sync by the token FORMAT version. Used
/// only to *sniff* which decoder to run — [`EdgeConfig::from_token`] does the
/// authoritative prefix check.
const EDGE_TOKEN_PREFIX: &str = "zledge1.";

/// Resolve `input` (stdin sentinel / file path / inline content), sniff its
/// encoding, decode it into an [`EdgeConfig`], and [`validate`] it.
///
/// See the module docs for the full source-resolution and sniffing rules.
///
/// # Errors
///
/// - [`EdgeError::Io`] if `stdin` or the named file cannot be read.
/// - [`EdgeError::Token`] if a `zledge1.` token is malformed (bad base64,
///   bad JSON, or an unsupported payload version).
/// - [`EdgeError::Artifact`] if inline JSON / bare-base64 content fails to
///   decode, or if the decoded config fails [`validate`].
pub fn load_artifact(input: &str) -> Result<EdgeConfig, EdgeError> {
    let content = read_source(input)?;
    parse_content(&content)
}

/// Materialize the raw artifact text per the source-resolution order.
///
/// Kept separate from [`parse_content`] so the sniff/decode/validate half is
/// unit-testable without a real `stdin` — tests drive [`parse_content`]
/// directly with in-memory strings.
fn read_source(input: &str) -> Result<String, EdgeError> {
    if input == "-" {
        // Drain all of stdin; artifacts are ASCII text (JSON / base64 / token),
        // so a UTF-8 read is correct and a non-UTF-8 body is a hard error.
        let mut buf = String::new();
        std::io::stdin().lock().read_to_string(&mut buf)?;
        Ok(buf)
    } else if Path::new(input).is_file() {
        Ok(std::fs::read_to_string(input)?)
    } else {
        Ok(input.to_string())
    }
}

/// Sniff the (already source-resolved) artifact text, decode it, and validate.
///
/// This is the content-only half of [`load_artifact`]; it never touches the
/// filesystem or `stdin`.
fn parse_content(raw: &str) -> Result<EdgeConfig, EdgeError> {
    let trimmed = raw.trim();
    let cfg = if trimmed.starts_with(EDGE_TOKEN_PREFIX) {
        // `zledge1.` token: `from_token` base64-decodes, checks the payload
        // version, and JSON-parses. Its `EdgeTokenError` maps into
        // `EdgeError::Token` via `?`.
        EdgeConfig::from_token(trimmed)?
    } else if trimmed.starts_with('{') {
        // Inline JSON object.
        serde_json::from_str(trimmed)
            .map_err(|e| EdgeError::Artifact(format!("edge artifact JSON parse failed: {e}")))?
    } else {
        // Bare URL-safe-base64 blob WITHOUT the `zledge1.` prefix — decode with
        // the same engine `EdgeConfig::to_token` uses, then JSON-parse the bytes.
        let bytes = URL_SAFE_NO_PAD
            .decode(trimmed)
            .map_err(|e| EdgeError::Artifact(format!("edge artifact base64 decode failed: {e}")))?;
        serde_json::from_slice(&bytes)
            .map_err(|e| EdgeError::Artifact(format!("edge artifact JSON parse failed: {e}")))?
    };
    validate(&cfg)?;
    Ok(cfg)
}

/// Semantic checks every decoded [`EdgeConfig`] must pass before use.
///
/// `from_token` already version-checks the token path, but the inline-JSON and
/// bare-base64 paths do NOT — so the version check is repeated here to cover
/// them. The remaining checks (peer count, endpoint, `AllowedIPs`) apply to
/// every path.
///
/// # Errors
///
/// [`EdgeError::Artifact`] on: a version mismatch; a peer count other than
/// exactly one; an endpoint that is portless, zero-port, wildcard, or otherwise
/// unusable; or an empty / malformed `AllowedIPs` list.
fn validate(cfg: &EdgeConfig) -> Result<(), EdgeError> {
    if cfg.version != EDGE_CONFIG_VERSION {
        return Err(EdgeError::Artifact(format!(
            "unsupported edge config version {} (expected {EDGE_CONFIG_VERSION})",
            cfg.version
        )));
    }
    // A minted edge config carries exactly one peer: the node that minted it.
    if cfg.peers.len() != 1 {
        return Err(EdgeError::Artifact(format!(
            "edge config must carry exactly one peer (the minting node), found {}",
            cfg.peers.len()
        )));
    }
    let peer = &cfg.peers[0];
    parse_endpoint_host_port(&peer.endpoint)?;
    // `AllowedIPs` must name at least one routable CIDR, and every entry must
    // parse. `parse_allowed_ips` tolerates empty tokens (trailing commas), so a
    // wholly-empty list surfaces as an empty vec here.
    let nets = parse_allowed_ips(&peer.allowed_ips)?;
    if nets.is_empty() {
        return Err(EdgeError::Artifact(
            "edge peer allowed_ips is empty (no routable CIDR)".to_string(),
        ));
    }
    Ok(())
}

/// Validate a `host:port` endpoint string, rejecting portless / zero-port /
/// wildcard forms.
///
/// Two accepted shapes:
/// - an IP-literal endpoint (`1.2.3.4:51820`, `[::1]:51820`) parses as a
///   [`SocketAddr`]; the port must be non-zero and the address must not be the
///   unspecified/wildcard address (`0.0.0.0` or `[::]`).
/// - a DNS `hostname:port` endpoint (`node.example.com:51820`) never parses as
///   a [`SocketAddr`] (the host is not an IP literal), so it is split on the
///   LAST `:` (keeping any host-internal colons off the port), requiring a
///   non-empty host and a numeric port greater than zero.
///
/// # Errors
///
/// [`EdgeError::Artifact`] when the endpoint has no port, a non-numeric port, a
/// zero port, an empty host, or is the wildcard address.
fn parse_endpoint_host_port(endpoint: &str) -> Result<(), EdgeError> {
    if let Ok(addr) = endpoint.parse::<SocketAddr>() {
        if addr.port() == 0 {
            return Err(EdgeError::Artifact(format!(
                "edge peer endpoint '{endpoint}' has a zero port"
            )));
        }
        if addr.ip().is_unspecified() {
            return Err(EdgeError::Artifact(format!(
                "edge peer endpoint '{endpoint}' uses the wildcard address"
            )));
        }
        return Ok(());
    }
    // Hostname path: split on the LAST ':' so an unbracketed host keeps its
    // colons out of the port field.
    let (host, port) = endpoint.rsplit_once(':').ok_or_else(|| {
        EdgeError::Artifact(format!(
            "edge peer endpoint '{endpoint}' is missing a ':port'"
        ))
    })?;
    if host.is_empty() {
        return Err(EdgeError::Artifact(format!(
            "edge peer endpoint '{endpoint}' has an empty host"
        )));
    }
    let port: u16 = port.parse().map_err(|_| {
        EdgeError::Artifact(format!(
            "edge peer endpoint '{endpoint}' has a non-numeric port"
        ))
    })?;
    if port == 0 {
        return Err(EdgeError::Artifact(format!(
            "edge peer endpoint '{endpoint}' has a zero port"
        )));
    }
    Ok(())
}

/// Parse a comma-separated `AllowedIPs` CIDR list into [`ipnet::IpNet`]s.
///
/// This is the ONE shared `AllowedIPs` parser for the edge modules (DRY): each
/// comma-split token is trimmed, empty tokens are skipped (so a trailing comma
/// or surrounding whitespace is tolerated), and every remaining token must
/// parse as an [`ipnet::IpNet`]. Callers that require a non-empty result (e.g.
/// [`validate`]) check the returned vec's length themselves.
///
/// # Errors
///
/// [`EdgeError::Artifact`] if any non-empty token is not a valid CIDR.
pub fn parse_allowed_ips(csv: &str) -> Result<Vec<ipnet::IpNet>, EdgeError> {
    csv.split(',')
        .map(str::trim)
        .filter(|tok| !tok.is_empty())
        .map(|tok| {
            tok.parse::<ipnet::IpNet>()
                .map_err(|e| EdgeError::Artifact(format!("invalid allowed_ips CIDR '{tok}': {e}")))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use zlayer_types::overlayd::PeerSpec;

    /// A peer with a concrete, valid endpoint + `AllowedIPs`.
    fn sample_peer() -> PeerSpec {
        PeerSpec {
            public_key: "edge-pub-b64".into(),
            endpoint: "203.0.113.7:51820".into(),
            allowed_ips: "10.200.0.0/16,10.201.0.0/24".into(),
            persistent_keepalive_secs: 25,
            candidates: Vec::new(),
        }
    }

    /// A minimal valid edge config (exactly one peer).
    fn sample_config() -> EdgeConfig {
        EdgeConfig {
            version: EDGE_CONFIG_VERSION,
            name: "ci-runner-7".into(),
            overlay_ip: "10.200.0.9".parse().unwrap(),
            prefix_len: 16,
            private_key: "edge-priv-b64".into(),
            public_key: "edge-pub-b64".into(),
            peers: vec![sample_peer()],
            dns_server: Some("10.200.0.1".parse().unwrap()),
            dns_domain: Some("overlay".into()),
            expires_at_unix: 1_700_000_600,
        }
    }

    // --- happy paths: each encoding round-trips back to the exact config ---

    #[test]
    fn token_path_round_trips() {
        let cfg = sample_config();
        let token = cfg.to_token();
        assert!(token.starts_with(EDGE_TOKEN_PREFIX), "token was {token}");
        assert_eq!(parse_content(&token).expect("token loads"), cfg);
    }

    #[test]
    fn json_inline_path_round_trips() {
        let cfg = sample_config();
        let json = serde_json::to_string(&cfg).expect("serialize");
        assert!(json.trim_start().starts_with('{'), "json was {json}");
        assert_eq!(parse_content(&json).expect("json loads"), cfg);
    }

    #[test]
    fn bare_base64_path_round_trips() {
        let cfg = sample_config();
        let json = serde_json::to_string(&cfg).expect("serialize");
        // No `zledge1.` prefix — a raw URL-safe-base64 blob.
        let blob = URL_SAFE_NO_PAD.encode(json);
        assert!(!blob.starts_with(EDGE_TOKEN_PREFIX));
        assert!(!blob.starts_with('{'));
        assert_eq!(parse_content(&blob).expect("base64 loads"), cfg);
    }

    #[test]
    fn surrounding_whitespace_is_trimmed() {
        let cfg = sample_config();
        let token = format!("  \n{}\n  ", cfg.to_token());
        assert_eq!(parse_content(&token).expect("trims and loads"), cfg);
    }

    #[test]
    fn hostname_and_ipv6_endpoints_accepted() {
        // DNS hostname endpoint (never parses as SocketAddr → hostname path).
        let mut cfg = sample_config();
        cfg.peers[0].endpoint = "node.example.com:51820".into();
        assert!(parse_content(&cfg.to_token()).is_ok());

        // Bracketed IPv6 literal endpoint (parses as SocketAddr).
        let mut cfg = sample_config();
        cfg.peers[0].endpoint = "[2001:db8::1]:51820".into();
        assert!(parse_content(&cfg.to_token()).is_ok());
    }

    // --- rejection paths ---

    #[test]
    fn rejects_wrong_version_on_json_path() {
        // JSON/base64 paths don't version-check on decode, so `validate` must.
        let mut cfg = sample_config();
        cfg.version = EDGE_CONFIG_VERSION + 1;
        let json = serde_json::to_string(&cfg).expect("serialize");
        let err = parse_content(&json).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    #[test]
    fn rejects_two_peers() {
        let mut cfg = sample_config();
        cfg.peers.push(sample_peer());
        let err = parse_content(&cfg.to_token()).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    #[test]
    fn rejects_zero_peers() {
        let mut cfg = sample_config();
        cfg.peers.clear();
        let err = parse_content(&cfg.to_token()).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    #[test]
    fn rejects_portless_endpoint() {
        let mut cfg = sample_config();
        cfg.peers[0].endpoint = "1.2.3.4".into();
        let err = parse_content(&cfg.to_token()).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    #[test]
    fn rejects_wildcard_zero_port_endpoint() {
        let mut cfg = sample_config();
        cfg.peers[0].endpoint = "0.0.0.0:0".into();
        let err = parse_content(&cfg.to_token()).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    #[test]
    fn rejects_wildcard_address_even_with_valid_port() {
        // Isolates the wildcard check from the zero-port check.
        let mut cfg = sample_config();
        cfg.peers[0].endpoint = "0.0.0.0:51820".into();
        let err = parse_content(&cfg.to_token()).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    #[test]
    fn rejects_empty_allowed_ips() {
        let mut cfg = sample_config();
        cfg.peers[0].allowed_ips = String::new();
        let err = parse_content(&cfg.to_token()).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    #[test]
    fn rejects_non_cidr_allowed_ips() {
        let mut cfg = sample_config();
        cfg.peers[0].allowed_ips = "not-a-cidr".into();
        let err = parse_content(&cfg.to_token()).expect_err("must reject");
        assert!(matches!(err, EdgeError::Artifact(_)), "got {err:?}");
    }

    // --- the shared AllowedIPs parser in isolation ---

    #[test]
    fn parse_allowed_ips_handles_multi_trailing_and_bad() {
        let nets = parse_allowed_ips("10.200.0.0/16, 10.201.0.0/24 ,").expect("valid list");
        assert_eq!(nets.len(), 2, "trailing comma + whitespace tolerated");

        assert!(parse_allowed_ips("")
            .expect("empty is Ok-but-empty")
            .is_empty());

        assert!(matches!(
            parse_allowed_ips("10.0.0.0/8,garbage").expect_err("bad token"),
            EdgeError::Artifact(_)
        ));
    }
}