zlayer-types 0.14.0

Shared wire types for the ZLayer platform — API DTOs, OCI image references, and related serde types.
Documentation
//! Wire-safe NAT-traversal DTOs shared across the daemon / overlayd boundary.
//!
//! The live NAT-traversal types (`NatConfig`, `Candidate`, `RelayServerConfig`,
//! …) live in `zlayer-overlay` behind its `nat` cargo feature. They cannot be
//! referenced from `zlayer-types` (a leaf crate that must not depend on
//! `zlayer-overlay`, and must compile with NAT disabled), so this module
//! mirrors the subset that crosses the overlayd IPC channel as plain,
//! always-available, `serde`-friendly structs.
//!
//! The conversion `NatConfigSpec` ⇄ `zlayer_overlay::NatConfig` is performed in
//! the crates that already depend on `zlayer-overlay` (the agent shim and the
//! overlayd server) — never here — so the leaf-crate dependency rule holds.
//!
//! Every field is `#[serde(default)]` so an older daemon/overlayd that omits the
//! `nat` object (or any sub-field) deserializes cleanly: a missing object means
//! "no explicit NAT config supplied" and overlayd falls back to its built-in
//! `NatConfig::default()`.

use serde::{Deserialize, Serialize};

/// Wire form of `zlayer_overlay::nat::NatConfig`.
///
/// Carried in [`crate::overlayd::OverlaydRequest::SetupGlobalOverlay`] so the
/// operator-supplied `--stun-server` / `--turn-server` / `--relay-server-bind`
/// flags (parsed by the main daemon) actually reach overlayd, which owns the
/// live NAT orchestrator. Previously only an `enabled: bool` crossed the wire
/// and overlayd reconstructed a *default* `NatConfig`, silently dropping every
/// operator override.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct NatConfigSpec {
    /// Whether NAT traversal is enabled.
    #[serde(default)]
    pub enabled: bool,
    /// STUN servers (`host:port`) for reflexive-address discovery.
    #[serde(default)]
    pub stun_servers: Vec<String>,
    /// TURN/relay servers used as the last-resort fallback.
    #[serde(default)]
    pub turn_servers: Vec<TurnServerSpec>,
    /// Seconds to attempt hole-punching before falling back to relay.
    #[serde(default)]
    pub hole_punch_timeout_secs: u64,
    /// Seconds between STUN reflexive-address refreshes.
    #[serde(default)]
    pub stun_refresh_interval_secs: u64,
    /// Maximum candidate pairs to test per peer.
    #[serde(default)]
    pub max_candidate_pairs: usize,
    /// When `Some`, this node should run the built-in relay server.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relay_server: Option<RelayServerSpec>,
}

/// Wire form of `zlayer_overlay::nat::TurnServerConfig`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TurnServerSpec {
    /// TURN server address (`host:port`).
    #[serde(default)]
    pub addr: String,
    /// Auth username.
    #[serde(default)]
    pub username: String,
    /// Auth credential.
    #[serde(default)]
    pub credential: String,
}

/// Wire form of `zlayer_overlay::nat::RelayServerConfig`, plus the cluster-wide
/// shared `auth_credential` clients use to derive the relay's `BLAKE2b` auth
/// key.
///
/// The credential MUST be identical on every node (a client's
/// `derive_auth_key(credential)` must match the server's), so it cannot be the
/// node's per-node `WireGuard` key. The main daemon populates it with the
/// cluster-shared HS256 secret (the same value behind `cluster_jwt_secret`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RelayServerSpec {
    /// Port the relay server listens on (`0` = OS-assigned ephemeral).
    #[serde(default)]
    pub listen_port: u16,
    /// External address other nodes use to reach this relay (`host:port`).
    #[serde(default)]
    pub external_addr: String,
    /// Maximum concurrent relay sessions.
    #[serde(default)]
    pub max_sessions: usize,
    /// Cluster-shared secret used to derive the relay auth key. `None` when the
    /// daemon could not supply one (the relay still starts but rejects clients
    /// that don't share the same — empty — credential).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_credential: Option<String>,
}

/// Wire form of a single `zlayer_overlay::nat::Candidate`.
///
/// Exchanged peer-to-peer so a joining node can hand the cluster the addresses
/// it can be reached on; overlayd feeds the remote node's candidates into
/// `NatTraversal::connect_to_peer`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct NatCandidateWire {
    /// `"host"` / `"server-reflexive"` / `"relay"`.
    pub candidate_type: String,
    /// The candidate endpoint as a `host:port` string.
    pub address: String,
    /// Priority (higher = preferred).
    pub priority: u32,
}

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

    #[test]
    fn nat_config_spec_round_trips() {
        let spec = NatConfigSpec {
            enabled: true,
            stun_servers: vec!["stun.l.google.com:19302".into()],
            turn_servers: vec![TurnServerSpec {
                addr: "turn.example.com:3478".into(),
                username: "u".into(),
                credential: "p".into(),
            }],
            hole_punch_timeout_secs: 15,
            stun_refresh_interval_secs: 60,
            max_candidate_pairs: 10,
            relay_server: Some(RelayServerSpec {
                listen_port: 3478,
                external_addr: "1.2.3.4:3478".into(),
                max_sessions: 100,
                auth_credential: Some("cluster-secret".into()),
            }),
        };
        let json = serde_json::to_string(&spec).expect("serialize");
        let back: NatConfigSpec = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(spec, back);
    }

    /// A payload omitting every field (or the whole object) deserializes to the
    /// all-default spec — the back-compat guarantee older senders rely on.
    #[test]
    fn nat_config_spec_empty_object_is_all_default() {
        let spec: NatConfigSpec = serde_json::from_str("{}").expect("deserialize");
        assert!(!spec.enabled);
        assert!(spec.stun_servers.is_empty());
        assert!(spec.turn_servers.is_empty());
        assert_eq!(spec.hole_punch_timeout_secs, 0);
        assert!(spec.relay_server.is_none());
        assert_eq!(spec, NatConfigSpec::default());
    }

    #[test]
    fn relay_server_spec_omits_none_credential() {
        let spec = RelayServerSpec {
            listen_port: 3478,
            external_addr: "1.2.3.4:3478".into(),
            max_sessions: 50,
            auth_credential: None,
        };
        let json = serde_json::to_string(&spec).expect("serialize");
        assert!(
            !json.contains("auth_credential"),
            "None credential must be skipped: {json}"
        );
        let back: RelayServerSpec = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(spec, back);
    }

    #[test]
    fn nat_candidate_wire_round_trips() {
        let c = NatCandidateWire {
            candidate_type: "server-reflexive".into(),
            address: "203.0.113.5:51820".into(),
            priority: 50,
        };
        let json = serde_json::to_string(&c).expect("serialize");
        let back: NatCandidateWire = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(c, back);
    }
}