Skip to main content

zlayer_types/
overlayd.rs

1//! IPC wire protocol between the main `zlayer` daemon and `zlayer-overlayd`.
2//!
3//! `zlayer-overlayd` is a standalone, long-lived daemon that owns every
4//! mechanism touching the overlay/network plane (the `WireGuard` device +
5//! adapter, peers, `AllowedIPs`/service subnets, IP allocation, DNS, NAT,
6//! Linux bridges + veth/netns attach, and the Windows HCN Internal network +
7//! endpoints). The main `zlayer` daemon keeps the cluster brain (Raft, the
8//! scheduler, the service registry, container/HCS process lifecycle) and
9//! drives overlayd over a length-prefixed-JSON IPC channel (a Unix domain
10//! socket on Unix, a named pipe on Windows).
11//!
12//! This module is that channel's **wire contract** — the only thing both
13//! sides must agree on. It lives in `zlayer-types` (a leaf crate) so the
14//! daemon, the overlayd server, and the overlayd client can all depend on it
15//! without a dependency cycle.
16//!
17//! ## Framing
18//!
19//! One connection multiplexes request/response and server→client event push.
20//! Each frame is a [`OverlaydFrame`] serialized as JSON and written with a
21//! `u32` little-endian length prefix (the framing itself lives in
22//! `zlayer-overlayd`'s transport module, not here). The main daemon sends
23//! [`OverlaydFrame::Request`]s each carrying a client-chosen `id`; overlayd
24//! replies with a [`OverlaydFrame::Response`] echoing that `id`, and may at
25//! any time push an unsolicited [`OverlaydFrame::Event`].
26//!
27//! ## Wire-type conventions
28//!
29//! - Windows HCN GUIDs cross the wire as **bare lowercase strings**
30//!   (`aabbccdd-eeff-...`, no braces) — `windows::core::GUID` is not
31//!   `serde`-serializable and `zlayer-types` must not depend on `windows`.
32//! - CIDRs cross as `String` (e.g. `"10.200.0.0/28"`); endpoints as `String`
33//!   (`"host:port"`, kept textual so an unresolved/hostname endpoint survives).
34//! - Addresses use [`std::net::IpAddr`] (serde-serializable via `std`).
35
36use std::net::IpAddr;
37
38use base64::engine::general_purpose::URL_SAFE_NO_PAD;
39use base64::Engine as _;
40use serde::{Deserialize, Serialize};
41
42pub use crate::nat_wire::{NatCandidateWire, NatConfigSpec};
43pub use crate::overlay::{OverlayConfig, OverlayMode};
44
45/// Wire-protocol version. Bump on any breaking change to the frame/request/
46/// response/event shapes so a version-skewed daemon/overlayd pair can detect
47/// the mismatch instead of silently misparsing.
48pub const PROTOCOL_VERSION: u32 = 1;
49
50/// A multiplexed frame on the overlayd IPC connection.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(tag = "frame", rename_all = "snake_case")]
53pub enum OverlaydFrame {
54    /// Main daemon → overlayd. `id` is echoed back on the matching response.
55    Request { id: u64, request: OverlaydRequest },
56    /// overlayd → main daemon, answering the request with the same `id`.
57    Response { id: u64, response: OverlaydResponse },
58    /// overlayd → main daemon, unsolicited (no `id`).
59    Event(OverlaydEvent),
60}
61
62/// Identifies the container overlayd must wire into the overlay. The agent
63/// owns the container's process/compute-system lifecycle and hands overlayd
64/// just enough to attach it.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(tag = "platform", rename_all = "snake_case")]
67pub enum AttachHandle {
68    /// Linux: the container's PID. overlayd opens `/proc/<pid>/ns/net` and
69    /// creates the veth pair into that network namespace.
70    LinuxPid { pid: u32 },
71    /// Windows: the HCS container id (+ the IP the agent reserved, if any).
72    /// overlayd creates the HCN endpoint + per-container namespace on its HCN
73    /// Internal network and returns the bare-lowercase namespace GUID
74    /// ([`AttachResult::namespace_guid`]) for the agent to embed in the
75    /// compute-system document's `Container.Networking.Namespace`.
76    WindowsContainer {
77        container_id: String,
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        ip: Option<IpAddr>,
80    },
81    /// A guest that manages its own overlay interface (the macOS VZ-Linux VM
82    /// runtime). overlayd cannot enter the guest's netns (it is a VM, not a host
83    /// process), so instead of building a veth it **allocates the overlay
84    /// identity** — keypair, address, and the current peer set — registers the
85    /// generated public key in the mesh, and returns it as
86    /// [`OverlaydResponse::GuestConfig`]. The caller ships that config into the
87    /// guest (over vsock) where a kernel `WireGuard` device is brought up. `id` is
88    /// the opaque container id used to scope the allocation + the registered
89    /// peer so `DetachContainer` can release it.
90    GuestManaged { id: String },
91    /// Host-shared native macOS container (Seatbelt, native-VZ, libkrun): no
92    /// per-container netns/PID and no in-guest `WireGuard`. overlayd allocates a
93    /// distinct overlay `/32` from the node slice and adds it as a `utun` alias
94    /// so it is locally deliverable; the agent then forwards
95    /// `<overlay_ip>:port` to the container's local delivery address. Detach +
96    /// bookkeeping are keyed by `id` (like `GuestManaged`).
97    HostShared { id: String },
98}
99
100/// A request from the main daemon to overlayd.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(tag = "op", rename_all = "snake_case")]
103pub enum OverlaydRequest {
104    /// Push this node's Raft id (cluster-brain context overlayd scopes by).
105    SetLocalNodeId { node_id: u64 },
106    /// Push this node's `WireGuard` public key (base64).
107    SetLocalWgPubkey { pubkey: String },
108
109    /// Bring up (or reuse) this node's base/global overlay. Idempotent: if the
110    /// overlay network already exists (recorded in overlayd's marker), it is
111    /// reused rather than recreated. This is the only place the base overlay
112    /// is created; it is torn down only on a full uninstall.
113    SetupGlobalOverlay {
114        deployment: String,
115        instance_id: String,
116        /// Full cluster CIDR, e.g. `"10.200.0.0/16"`.
117        cluster_cidr: String,
118        /// This node's per-node slice, e.g. `"10.200.0.0/28"`. `None` until the
119        /// leader assigns one.
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        slice_cidr: Option<String>,
122        wg_port: u16,
123        /// When true, a host-adapter (utun/Wintun) bringup failure is FATAL
124        /// instead of degrading to a VM-only overlay. Set by the daemon when the
125        /// node runs a host-shared runtime (macOS Seatbelt/native-VZ/libkrun)
126        /// where the host adapter IS the container data path. `#[serde(default)]`
127        /// keeps a pre-field daemon's payload decoding (false = old behavior).
128        #[serde(default)]
129        host_adapter_mandatory: bool,
130        /// Full NAT-traversal configuration for this node's overlay.
131        ///
132        /// `None` (or any omitted sub-field) means "no explicit NAT config" and
133        /// overlayd falls back to its built-in `NatConfig::default()`. This
134        /// replaced the previous `nat_enabled: bool`, which silently dropped the
135        /// operator's `--stun-server`/`--turn-server`/`--relay-server-bind`
136        /// flags (overlayd only ever saw the enabled toggle). `#[serde(default)]`
137        /// keeps a pre-`nat` daemon's payload (no `nat` field) decoding cleanly.
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        nat: Option<NatConfigSpec>,
140    },
141    /// Tear down the node's base overlay (e.g. on full uninstall).
142    TeardownGlobalOverlay,
143
144    /// Create the per-service overlay segment (Linux bridge / Windows HCN
145    /// Internal network) for `service`. Returns [`OverlaydResponse::BridgeName`].
146    SetupServiceOverlay { service: String, mode: OverlayMode },
147    /// Remove the per-service overlay segment.
148    TeardownServiceOverlay { service: String },
149
150    /// Allocate (or, with `ip` set on a later attach, validate) an overlay IP
151    /// from the node slice for a container on `service`.
152    AllocateIp { service: String, join_global: bool },
153    /// Return an overlay IP to the allocator.
154    ReleaseIp { ip: IpAddr },
155
156    /// Wire a container into the overlay. Returns [`OverlaydResponse::Attached`].
157    AttachContainer {
158        handle: AttachHandle,
159        service: String,
160        join_global: bool,
161        /// When true, overlayd reclaims the per-service bridge once the LAST
162        /// container detaches (ephemeral/per-job networks). When false, the bridge
163        /// persists across scale-to-0 (managed services). Defaults false for
164        /// back-compat with older clients.
165        #[serde(default)]
166        ephemeral: bool,
167        /// When `Some(network)`, this attach joins the named **isolated** network:
168        /// overlayd records the member in that network's membership set and
169        /// enforces Docker-style L3 isolation (the member reaches its own
170        /// network's members + the daemon node IP + egress, but NOT other
171        /// networks' members or arbitrary cluster overlay IPs). `None` = the flat
172        /// cluster mesh (today's behavior). Defaults `None` for older clients.
173        #[serde(default, skip_serializing_if = "Option::is_none")]
174        isolation_network: Option<String>,
175        #[serde(default, skip_serializing_if = "Option::is_none")]
176        dns_server: Option<IpAddr>,
177        #[serde(default, skip_serializing_if = "Option::is_none")]
178        dns_domain: Option<String>,
179    },
180    /// Tear down a container's overlay attachment and release its IP.
181    DetachContainer { handle: AttachHandle },
182
183    /// Add a `WireGuard` peer to the base overlay.
184    AddPeer {
185        #[serde(flatten)]
186        peer: PeerSpec,
187        #[serde(default)]
188        scope: PeerScope,
189    },
190    /// Remove a peer by its base64 public key.
191    RemovePeer {
192        pubkey: String,
193        #[serde(default)]
194        scope: PeerScope,
195    },
196    /// Plumb a service subnet into a peer's `AllowedIPs`.
197    AddAllowedIp {
198        pubkey: String,
199        cidr: String,
200        #[serde(default)]
201        scope: PeerScope,
202    },
203    /// Remove a service subnet from a peer's `AllowedIPs`.
204    RemoveAllowedIp {
205        pubkey: String,
206        cidr: String,
207        #[serde(default)]
208        scope: PeerScope,
209    },
210
211    /// Mint a short-lived unprivileged `WireGuard` edge peer: allocate an
212    /// overlay identity keyed by `name`, register it in the mesh, and return
213    /// it as [`OverlaydResponse::EdgeConfig`]. Minted peers die with overlayd
214    /// — provisioners re-mint rather than persist a config.
215    MintEdgePeer {
216        /// Caller-chosen handle the peer is minted (and later revoked) under.
217        name: String,
218        /// Seconds until the peer expires and is swept from the mesh.
219        ttl_secs: u64,
220        /// Overlay CIDRs the edge peer may reach. Empty for a sender that
221        /// omits the field; `#[serde(default)]` keeps such payloads decoding.
222        #[serde(default, skip_serializing_if = "Vec::is_empty")]
223        allow: Vec<String>,
224        /// The EXTERNAL `advertise_addr:overlay_port` endpoint the edge peer
225        /// dials, supplied by the daemon — never the local overlay IP (the
226        /// edge peer sits outside the overlay until its first handshake).
227        node_endpoint: String,
228    },
229    /// Revoke a minted edge peer by name. Idempotent: revoking an unknown or
230    /// already-expired name succeeds.
231    RevokeEdgePeer { name: String },
232    /// List currently minted edge peers. Returns [`OverlaydResponse::EdgePeers`].
233    ListEdgePeers,
234
235    /// Register an overlay DNS A/AAAA record.
236    RegisterDns { name: String, ip: IpAddr },
237    /// Remove an overlay DNS record.
238    UnregisterDns { name: String },
239
240    /// Write a macOS `/etc/resolver/<zone>` scoped-resolver file pointing at the
241    /// node's overlay DNS. Privileged (root-only path); the rootless daemon asks
242    /// the ROOT overlayd to perform it. macOS-only handler; no-op elsewhere.
243    WriteScopedResolver {
244        zone: String,
245        node_ip: IpAddr,
246        #[serde(default, skip_serializing_if = "Option::is_none")]
247        port: Option<u16>,
248    },
249    /// Remove a macOS `/etc/resolver/<zone>` scoped-resolver file.
250    RemoveScopedResolver { zone: String },
251
252    /// Reclaim orphaned per-service host bridges (and their stale device/
253    /// container veths) that no live deployment still owns. The daemon computes
254    /// `live_bridge_names` from storage — the full set of `zl-…-b` bridge names
255    /// every currently-restored service SHOULD own — and overlayd deletes every
256    /// `zl-…-b` bridge link NOT in that set (plus releases its subnet/AllowedIPs
257    /// when recoverable), so a bridge left behind by a crashed/forgotten
258    /// deployment is swept on the next daemon startup. Names are passed (rather
259    /// than overlayd reaching into storage) to keep overlayd storage-free.
260    /// Returns [`OverlaydResponse::PrunedBridges`].
261    PruneOrphanBridges { live_bridge_names: Vec<String> },
262
263    /// Snapshot overlay state for diagnostics. Returns [`OverlaydResponse::Status`].
264    Status,
265    /// Run one NAT-traversal maintenance tick (probe/refresh endpoints).
266    NatTick,
267    /// Snapshot the live NAT-traversal state (local candidates, per-peer
268    /// connection types, last refresh). Returns [`OverlaydResponse::NatStatus`].
269    NatStatus,
270    /// Ask overlayd to shut down gracefully (drops the adapter).
271    Shutdown,
272}
273
274/// overlayd's answer to an [`OverlaydRequest`].
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(tag = "result", rename_all = "snake_case")]
277pub enum OverlaydResponse {
278    /// Generic success with no payload.
279    Ok,
280    /// An allocated/validated overlay IP (`AllocateIp`).
281    Ip { ip: IpAddr },
282    /// A completed container attach.
283    Attached(AttachResult),
284    /// The overlay identity for a guest-managed attach
285    /// ([`AttachHandle::GuestManaged`]): the keypair, allocated address, and the
286    /// peer set the guest should configure on its own `WireGuard` device.
287    GuestConfig(GuestOverlayConfig),
288    /// The interface/bridge/network name created (`SetupServiceOverlay`,
289    /// `SetupGlobalOverlay`).
290    BridgeName { name: String },
291    /// A diagnostics snapshot (`Status`).
292    Status(StatusSnapshot),
293    /// A live NAT-traversal snapshot (`NatStatus`).
294    NatStatus(NatStatusWire),
295    /// The orphaned bridges/veths reclaimed by `PruneOrphanBridges`.
296    PrunedBridges { reclaimed: Vec<String> },
297    /// A dedicated per-service overlay device's identity (`SetupServiceOverlay`
298    /// in Dedicated mode). Not yet produced by the server — the server still
299    /// returns [`OverlaydResponse::BridgeName`] for now; this variant is the
300    /// wire contract for a later task that switches Dedicated setup over.
301    ServiceOverlay(ServiceOverlayInfo),
302    /// The minted edge peer's portable config (`MintEdgePeer`).
303    EdgeConfig(EdgeConfig),
304    /// The currently minted edge peers (`ListEdgePeers`).
305    EdgePeers { peers: Vec<EdgePeerStatus> },
306    /// The request failed; `message` is a human-readable reason.
307    Err { message: String },
308}
309
310/// Identity of a dedicated per-service overlay device, reported by
311/// `SetupServiceOverlay` once Dedicated mode is wired up. Shared-mode setups
312/// leave the `wg_*`/`overlay_ip`/`subnet` fields `None`.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ServiceOverlayInfo {
315    pub name: String,
316    pub mode: crate::overlay::OverlayMode,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub wg_public_key: Option<String>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub wg_port: Option<u16>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub overlay_ip: Option<std::net::IpAddr>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub subnet: Option<String>,
325}
326
327/// Result of [`OverlaydRequest::AttachContainer`].
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct AttachResult {
330    /// The container's overlay IP.
331    pub ip: IpAddr,
332    /// Windows only: the bare-lowercase HCN namespace GUID the agent embeds in
333    /// the compute-system document. `None` on Linux (no HCN namespace).
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub namespace_guid: Option<String>,
336}
337
338/// Overlay identity returned for a guest-managed attach
339/// ([`AttachHandle::GuestManaged`] → [`OverlaydResponse::GuestConfig`]).
340///
341/// The host allocated the address from the node slice, generated the keypair,
342/// and registered `public_key` in the mesh (so peers route to the guest). The
343/// caller ships everything except `public_key` into the guest; `public_key` is
344/// echoed back so the caller can record/deregister the peer it represents.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct GuestOverlayConfig {
347    /// The guest's allocated overlay address.
348    pub overlay_ip: IpAddr,
349    /// Prefix length of the overlay network (interface address + on-link route).
350    pub prefix_len: u8,
351    /// Base64 `WireGuard` private key for the guest's overlay endpoint.
352    pub private_key: String,
353    /// Base64 `WireGuard` public key matching `private_key` (registered in the
354    /// mesh by overlayd; echoed for the caller's bookkeeping).
355    pub public_key: String,
356    /// UDP port the guest's `WireGuard` device should listen on.
357    pub listen_port: u16,
358    /// The peers the guest should configure (other nodes/containers).
359    pub peers: Vec<PeerSpec>,
360    /// Overlay DNS resolver IP for the container, if any.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub dns_server: Option<IpAddr>,
363    /// Overlay DNS search domain, if any.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub dns_domain: Option<String>,
366}
367
368/// Shape version stamped into every [`EdgeConfig`] (and its token form). Bump
369/// on any breaking change to the `EdgeConfig` shape so a version-skewed
370/// decoder rejects the token instead of silently misparsing it.
371pub const EDGE_CONFIG_VERSION: u32 = 1;
372
373/// Prefix identifying an [`EdgeConfig`] token string. The `1` is the token
374/// FORMAT version (prefix + base64 compact JSON); the payload's
375/// [`EDGE_CONFIG_VERSION`] is checked separately after decoding.
376const EDGE_TOKEN_PREFIX: &str = "zledge1.";
377
378/// Portable artifact for a minted edge peer
379/// ([`OverlaydRequest::MintEdgePeer`] → [`OverlaydResponse::EdgeConfig`]):
380/// everything a short-lived unprivileged `WireGuard` edge process needs to
381/// join the overlay. Minted peers die with overlayd — provisioners re-mint
382/// rather than persist and reuse a config.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
384pub struct EdgeConfig {
385    /// Always [`EDGE_CONFIG_VERSION`] for configs minted by this build;
386    /// [`EdgeConfig::from_token`] rejects anything else.
387    pub version: u32,
388    /// The caller-chosen handle the peer was minted under.
389    pub name: String,
390    /// The edge peer's allocated overlay address.
391    #[schema(value_type = String, example = "10.200.0.9")]
392    pub overlay_ip: IpAddr,
393    /// Prefix length of the overlay network (interface address + on-link route).
394    pub prefix_len: u8,
395    /// Base64 `WireGuard` private key for the edge peer's device.
396    pub private_key: String,
397    /// Base64 `WireGuard` public key matching `private_key` (registered in the
398    /// mesh by overlayd; echoed for the caller's bookkeeping).
399    pub public_key: String,
400    /// The peers the edge should configure (the minting node, at least). Each
401    /// [`PeerSpec::allowed_ips`] is a comma-separated CIDR list by the
402    /// established convention — vzagent splits on `','`.
403    pub peers: Vec<PeerSpec>,
404    /// Overlay DNS resolver IP for the edge peer, if any.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    #[schema(value_type = Option<String>, example = "10.200.0.1")]
407    pub dns_server: Option<IpAddr>,
408    /// Overlay DNS search domain, if any.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub dns_domain: Option<String>,
411    /// Unix-seconds when the peer expires and is swept from the mesh.
412    pub expires_at_unix: u64,
413}
414
415impl EdgeConfig {
416    /// Encode the config as a single `zledge1.`-prefixed URL-safe-base64
417    /// (no padding) string of its compact-JSON form, suitable for CLI flags /
418    /// env vars (the prefix-versioned CLI-string idiom, cf.
419    /// `WorkerBootstrapToken::to_cli_string` in `zlayer-secrets`).
420    ///
421    /// # Panics
422    ///
423    /// Never in practice: `EdgeConfig` JSON-serializes infallibly (no
424    /// non-string map keys, no fallible `Serialize` impls).
425    #[must_use]
426    pub fn to_token(&self) -> String {
427        let json = serde_json::to_string(self)
428            .expect("EdgeConfig JSON serialization is infallible (no non-string map keys)");
429        format!("{EDGE_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(json))
430    }
431
432    /// Decode a token previously produced by [`Self::to_token`].
433    ///
434    /// # Errors
435    ///
436    /// Returns [`EdgeTokenError::BadPrefix`] when the `zledge1.` prefix is
437    /// missing, [`EdgeTokenError::Decode`] / [`EdgeTokenError::Parse`] on
438    /// base64 / JSON failure, and [`EdgeTokenError::UnsupportedVersion`] when
439    /// the payload's `version` is not [`EDGE_CONFIG_VERSION`].
440    pub fn from_token(token: &str) -> Result<Self, EdgeTokenError> {
441        let encoded = token
442            .strip_prefix(EDGE_TOKEN_PREFIX)
443            .ok_or(EdgeTokenError::BadPrefix)?;
444        let bytes = URL_SAFE_NO_PAD.decode(encoded)?;
445        // Probe the version before the full parse so a future-versioned token
446        // whose shape drifted reports the version mismatch, not a parse error.
447        let probe: EdgeConfigVersionProbe = serde_json::from_slice(&bytes)?;
448        if probe.version != EDGE_CONFIG_VERSION {
449            return Err(EdgeTokenError::UnsupportedVersion {
450                found: probe.version,
451            });
452        }
453        Ok(serde_json::from_slice(&bytes)?)
454    }
455}
456
457/// Minimal deserialization target for the version pre-check in
458/// [`EdgeConfig::from_token`] (unknown fields are ignored, so a
459/// future-versioned payload still yields its `version`).
460#[derive(Deserialize)]
461struct EdgeConfigVersionProbe {
462    version: u32,
463}
464
465/// Errors from [`EdgeConfig::from_token`].
466#[derive(Debug, thiserror::Error)]
467pub enum EdgeTokenError {
468    /// The token does not start with the `zledge1.` prefix.
469    #[error("edge token missing `{EDGE_TOKEN_PREFIX}` prefix")]
470    BadPrefix,
471    /// The portion after the prefix is not valid URL-safe base64.
472    #[error("edge token base64 decode failed: {0}")]
473    Decode(#[from] base64::DecodeError),
474    /// The decoded payload is not valid `EdgeConfig` JSON.
475    #[error("edge token JSON parse failed: {0}")]
476    Parse(#[from] serde_json::Error),
477    /// The payload's `version` differs from this build's [`EDGE_CONFIG_VERSION`].
478    #[error("unsupported edge config version {found} (expected {EDGE_CONFIG_VERSION})")]
479    UnsupportedVersion {
480        /// The version the token carried.
481        found: u32,
482    },
483}
484
485/// Live status of one minted edge peer, reported by
486/// [`OverlaydRequest::ListEdgePeers`] → [`OverlaydResponse::EdgePeers`].
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
488pub struct EdgePeerStatus {
489    /// The handle the peer was minted under.
490    pub name: String,
491    /// The peer's allocated overlay address.
492    #[schema(value_type = String, example = "10.200.0.9")]
493    pub overlay_ip: IpAddr,
494    /// Base64 `WireGuard` public key registered for the peer.
495    pub public_key: String,
496    /// Unix-seconds when the peer was minted.
497    pub minted_at_unix: u64,
498    /// Unix-seconds when the peer expires and is swept from the mesh.
499    pub expires_at_unix: u64,
500    /// Last successful handshake, Unix seconds; `None` if never.
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub last_handshake_unix_secs: Option<i64>,
503    /// The overlay CIDRs granted at mint time (the accepted `allow` list).
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub allowed: Vec<String>,
506}
507
508/// Which overlay device a peer / `AllowedIP` op targets. `Global` (default, and
509/// the only value pre-Dedicated senders emit) = the single cluster transport.
510/// `Service` = that service's dedicated per-service transport.
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
512#[serde(tag = "scope", rename_all = "snake_case")]
513pub enum PeerScope {
514    #[default]
515    Global,
516    Service {
517        service: String,
518    },
519}
520
521/// A `WireGuard` peer to add to the base overlay. Mirrors
522/// `zlayer_overlay::PeerInfo` but with wire-safe field types.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
524pub struct PeerSpec {
525    /// base64 `WireGuard` public key.
526    pub public_key: String,
527    /// `host:port` (textual so an unresolved/hostname endpoint survives).
528    pub endpoint: String,
529    /// Comma-separated CIDR list (e.g. `"10.200.0.5/32,10.200.1.0/24"`).
530    pub allowed_ips: String,
531    /// Persistent-keepalive interval, in seconds (0 = disabled).
532    pub persistent_keepalive_secs: u64,
533    /// NAT-traversal candidates the peer advertised at join time (host /
534    /// server-reflexive / relay addresses it can be reached on). overlayd feeds
535    /// these into `NatTraversal::connect_to_peer` to hole-punch / relay toward
536    /// the peer when a direct endpoint doesn't establish a handshake. Empty for
537    /// a pre-NAT sender; `#[serde(default)]` keeps such payloads decoding.
538    #[serde(default, skip_serializing_if = "Vec::is_empty")]
539    pub candidates: Vec<NatCandidateWire>,
540}
541
542/// An unsolicited notification pushed from overlayd to the main daemon.
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(tag = "event", rename_all = "snake_case")]
545pub enum OverlaydEvent {
546    /// A peer's liveness changed (handshake seen / lost).
547    PeerHealthChanged { pubkey: String, healthy: bool },
548    /// NAT traversal moved a peer to a new endpoint.
549    NatEndpointChanged { pubkey: String, endpoint: String },
550}
551
552/// Diagnostics snapshot returned by [`OverlaydRequest::Status`].
553#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
554pub struct StatusSnapshot {
555    /// Base overlay interface name (e.g. `"zl-overlay0"`), if up.
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub interface: Option<String>,
558    /// This node's overlay IP, if assigned.
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub node_ip: Option<IpAddr>,
561    /// This node's `WireGuard` public key (base64), if up.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub public_key: Option<String>,
564    /// Full cluster CIDR.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub overlay_cidr: Option<String>,
567    /// This node's per-node slice CIDR.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub slice_cidr: Option<String>,
570    /// Number of base-overlay peers.
571    pub peer_count: u32,
572    /// Number of per-service overlays set up on this node.
573    pub service_count: u32,
574    /// Per-peer status.
575    #[serde(default, skip_serializing_if = "Vec::is_empty")]
576    pub peers: Vec<PeerStatus>,
577    /// Per dedicated per-service overlay device status. Empty unless one or
578    /// more services run in `OverlayMode::Dedicated` on this node.
579    #[serde(default, skip_serializing_if = "Vec::is_empty")]
580    pub dedicated_services: Vec<DedicatedServiceStatus>,
581}
582
583/// Status of a single dedicated per-service overlay device within a
584/// [`StatusSnapshot`].
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
586pub struct DedicatedServiceStatus {
587    pub service: String,
588    pub interface: String,
589    pub public_key: String,
590    pub listen_port: u16,
591    pub overlay_ip: std::net::IpAddr,
592    pub subnet: String,
593    pub peer_count: u32,
594}
595
596/// Per-peer status within a [`StatusSnapshot`].
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598pub struct PeerStatus {
599    pub public_key: String,
600    pub endpoint: String,
601    pub allowed_ips: String,
602    /// Last successful handshake, Unix seconds; `0` if never.
603    pub last_handshake_unix_secs: i64,
604}
605
606/// Live NAT-traversal snapshot returned by [`OverlaydRequest::NatStatus`].
607///
608/// Wire mirror of `zlayer_overlay::NatStatusSnapshot` (which `zlayer-types`
609/// cannot reference — it lives behind the `nat` feature). The agent shim
610/// converts this back into `NatStatusSnapshot` for the API layer.
611#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
612pub struct NatStatusWire {
613    /// Locally gathered ICE candidates.
614    #[serde(default, skip_serializing_if = "Vec::is_empty")]
615    pub candidates: Vec<NatCandidateWire>,
616    /// Per-peer NAT connectivity entries.
617    #[serde(default, skip_serializing_if = "Vec::is_empty")]
618    pub peers: Vec<NatPeerWire>,
619    /// Unix-epoch seconds of the last successful candidate gather / STUN refresh.
620    #[serde(default)]
621    pub last_refresh: u64,
622}
623
624/// Per-peer NAT connectivity entry within a [`NatStatusWire`].
625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626pub struct NatPeerWire {
627    /// Peer node id, or the base64 public key when no node id is available.
628    pub node_id: String,
629    /// Connection type as a lowercase string
630    /// (`"direct"` / `"hole-punched"` / `"relayed"` / `"unreachable"`).
631    pub connection_type: String,
632    /// Selected remote endpoint (`host:port`), if one has been negotiated.
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub remote_endpoint: Option<String>,
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::nat_wire::{RelayServerSpec, TurnServerSpec};
641
642    /// A frame round-trips through JSON unchanged (the core wire guarantee).
643    fn roundtrip(frame: &OverlaydFrame) {
644        let json = serde_json::to_string(frame).expect("serialize");
645        let back: OverlaydFrame = serde_json::from_str(&json).expect("deserialize");
646        assert_eq!(frame, &back, "frame must round-trip; json was {json}");
647    }
648
649    #[test]
650    fn request_frames_round_trip() {
651        roundtrip(&OverlaydFrame::Request {
652            id: 1,
653            request: OverlaydRequest::SetupGlobalOverlay {
654                deployment: "prod".into(),
655                instance_id: "42".into(),
656                cluster_cidr: "10.200.0.0/16".into(),
657                slice_cidr: Some("10.200.0.0/28".into()),
658                wg_port: 51820,
659                host_adapter_mandatory: true,
660                nat: Some(NatConfigSpec {
661                    enabled: true,
662                    stun_servers: vec!["stun.l.google.com:19302".into()],
663                    turn_servers: vec![TurnServerSpec {
664                        addr: "turn.example.com:3478".into(),
665                        username: "u".into(),
666                        credential: "p".into(),
667                    }],
668                    hole_punch_timeout_secs: 15,
669                    stun_refresh_interval_secs: 60,
670                    max_candidate_pairs: 10,
671                    relay_server: Some(RelayServerSpec {
672                        listen_port: 3478,
673                        external_addr: "1.2.3.4:3478".into(),
674                        max_sessions: 100,
675                        auth_credential: Some("cluster-secret".into()),
676                    }),
677                }),
678            },
679        });
680        roundtrip(&OverlaydFrame::Request {
681            id: 2,
682            request: OverlaydRequest::AttachContainer {
683                handle: AttachHandle::WindowsContainer {
684                    container_id: "ctr-abc".into(),
685                    ip: Some("10.200.0.5".parse().unwrap()),
686                },
687                service: "web".into(),
688                join_global: false,
689                ephemeral: false,
690                isolation_network: None,
691                dns_server: Some("10.200.0.1".parse().unwrap()),
692                dns_domain: Some("overlay".into()),
693            },
694        });
695        roundtrip(&OverlaydFrame::Request {
696            id: 3,
697            request: OverlaydRequest::AttachContainer {
698                handle: AttachHandle::LinuxPid { pid: 4242 },
699                service: "web".into(),
700                join_global: true,
701                ephemeral: false,
702                isolation_network: Some("job-net".into()),
703                dns_server: None,
704                dns_domain: None,
705            },
706        });
707    }
708
709    #[test]
710    fn response_and_event_frames_round_trip() {
711        roundtrip(&OverlaydFrame::Response {
712            id: 2,
713            response: OverlaydResponse::Attached(AttachResult {
714                ip: "10.200.0.5".parse().unwrap(),
715                namespace_guid: Some("aabbccdd-eeff-0011-2233-445566778899".into()),
716            }),
717        });
718        roundtrip(&OverlaydFrame::Response {
719            id: 9,
720            response: OverlaydResponse::Err {
721                message: "no slice assigned".into(),
722            },
723        });
724        roundtrip(&OverlaydFrame::Event(OverlaydEvent::PeerHealthChanged {
725            pubkey: "base64key".into(),
726            healthy: false,
727        }));
728    }
729
730    #[test]
731    fn prune_orphan_bridges_round_trips() {
732        roundtrip(&OverlaydFrame::Request {
733            id: 30,
734            request: OverlaydRequest::PruneOrphanBridges {
735                live_bridge_names: vec!["zl-prod-0-web-b".into(), "zl-prod-0-api-b".into()],
736            },
737        });
738        roundtrip(&OverlaydFrame::Response {
739            id: 30,
740            response: OverlaydResponse::PrunedBridges {
741                reclaimed: vec!["zl-1ca4568944-b".into(), "zl-81c6bc17c7-b".into()],
742            },
743        });
744    }
745
746    #[test]
747    fn status_snapshot_round_trips_and_defaults() {
748        roundtrip(&OverlaydFrame::Response {
749            id: 7,
750            response: OverlaydResponse::Status(StatusSnapshot {
751                interface: Some("zl-overlay0".into()),
752                node_ip: Some("10.200.0.1".parse().unwrap()),
753                peer_count: 2,
754                service_count: 1,
755                peers: vec![PeerStatus {
756                    public_key: "k".into(),
757                    endpoint: "1.2.3.4:51820".into(),
758                    allowed_ips: "10.200.0.2/32".into(),
759                    last_handshake_unix_secs: 0,
760                }],
761                ..StatusSnapshot::default()
762            }),
763        });
764    }
765
766    fn sample_peer() -> PeerSpec {
767        PeerSpec {
768            public_key: "base64key".into(),
769            endpoint: "1.2.3.4:51820".into(),
770            allowed_ips: "10.200.0.2/32".into(),
771            persistent_keepalive_secs: 25,
772            candidates: vec![
773                NatCandidateWire {
774                    candidate_type: "host".into(),
775                    address: "192.168.1.5:51820".into(),
776                    priority: 100,
777                },
778                NatCandidateWire {
779                    candidate_type: "server-reflexive".into(),
780                    address: "203.0.113.5:51820".into(),
781                    priority: 50,
782                },
783            ],
784        }
785    }
786
787    #[test]
788    fn peer_ops_round_trip_both_scopes() {
789        // AddPeer, global (default) + service scope.
790        roundtrip(&OverlaydFrame::Request {
791            id: 1,
792            request: OverlaydRequest::AddPeer {
793                peer: sample_peer(),
794                scope: PeerScope::Global,
795            },
796        });
797        roundtrip(&OverlaydFrame::Request {
798            id: 2,
799            request: OverlaydRequest::AddPeer {
800                peer: sample_peer(),
801                scope: PeerScope::Service {
802                    service: "web".into(),
803                },
804            },
805        });
806        // RemovePeer.
807        roundtrip(&OverlaydFrame::Request {
808            id: 3,
809            request: OverlaydRequest::RemovePeer {
810                pubkey: "k".into(),
811                scope: PeerScope::Global,
812            },
813        });
814        roundtrip(&OverlaydFrame::Request {
815            id: 4,
816            request: OverlaydRequest::RemovePeer {
817                pubkey: "k".into(),
818                scope: PeerScope::Service {
819                    service: "web".into(),
820                },
821            },
822        });
823        // AddAllowedIp.
824        roundtrip(&OverlaydFrame::Request {
825            id: 5,
826            request: OverlaydRequest::AddAllowedIp {
827                pubkey: "k".into(),
828                cidr: "10.200.1.0/24".into(),
829                scope: PeerScope::Global,
830            },
831        });
832        roundtrip(&OverlaydFrame::Request {
833            id: 6,
834            request: OverlaydRequest::AddAllowedIp {
835                pubkey: "k".into(),
836                cidr: "10.200.1.0/24".into(),
837                scope: PeerScope::Service {
838                    service: "web".into(),
839                },
840            },
841        });
842        // RemoveAllowedIp.
843        roundtrip(&OverlaydFrame::Request {
844            id: 7,
845            request: OverlaydRequest::RemoveAllowedIp {
846                pubkey: "k".into(),
847                cidr: "10.200.1.0/24".into(),
848                scope: PeerScope::Global,
849            },
850        });
851        roundtrip(&OverlaydFrame::Request {
852            id: 8,
853            request: OverlaydRequest::RemoveAllowedIp {
854                pubkey: "k".into(),
855                cidr: "10.200.1.0/24".into(),
856                scope: PeerScope::Service {
857                    service: "web".into(),
858                },
859            },
860        });
861    }
862
863    #[test]
864    fn add_peer_without_scope_defaults_to_global() {
865        // A pre-Dedicated sender emits no `scope` field. The frame is tagged
866        // `frame: "request"`, the request `op: "add_peer"`, and `PeerSpec` is
867        // flattened so its fields sit at the request level.
868        let json = r#"{
869            "frame": "request",
870            "id": 11,
871            "request": {
872                "op": "add_peer",
873                "public_key": "base64key",
874                "endpoint": "1.2.3.4:51820",
875                "allowed_ips": "10.200.0.2/32",
876                "persistent_keepalive_secs": 25
877            }
878        }"#;
879        let frame: OverlaydFrame = serde_json::from_str(json).expect("deserialize");
880        match frame {
881            OverlaydFrame::Request {
882                request: OverlaydRequest::AddPeer { scope, peer },
883                ..
884            } => {
885                assert_eq!(scope, PeerScope::Global);
886                assert_eq!(peer.public_key, "base64key");
887                // A pre-NAT sender omits `candidates`; it must default to empty.
888                assert!(peer.candidates.is_empty());
889            }
890            other => panic!("expected AddPeer request, got {other:?}"),
891        }
892    }
893
894    /// `SetupGlobalOverlay` from a pre-`nat` daemon omits the `nat` field
895    /// entirely; it must deserialize to `nat: None` (overlayd then uses its
896    /// built-in `NatConfig::default()`), proving the wire change is backward
897    /// compatible.
898    #[test]
899    fn setup_global_overlay_without_nat_field_defaults_to_none() {
900        let json = r#"{
901            "frame": "request",
902            "id": 12,
903            "request": {
904                "op": "setup_global_overlay",
905                "deployment": "prod",
906                "instance_id": "1",
907                "cluster_cidr": "10.200.0.0/16",
908                "wg_port": 51820
909            }
910        }"#;
911        let frame: OverlaydFrame = serde_json::from_str(json).expect("deserialize");
912        match frame {
913            OverlaydFrame::Request {
914                request: OverlaydRequest::SetupGlobalOverlay { nat, .. },
915                ..
916            } => assert!(nat.is_none(), "missing nat field must default to None"),
917            other => panic!("expected SetupGlobalOverlay, got {other:?}"),
918        }
919    }
920
921    /// An `AddPeer` carrying NAT candidates round-trips with the candidate list
922    /// intact (the join-time candidate exchange the connect-half relies on).
923    #[test]
924    fn add_peer_with_candidates_round_trips() {
925        roundtrip(&OverlaydFrame::Request {
926            id: 40,
927            request: OverlaydRequest::AddPeer {
928                peer: sample_peer(),
929                scope: PeerScope::Global,
930            },
931        });
932    }
933
934    /// The `NatStatus` request and its `NatStatus` response round-trip, including
935    /// candidates + per-peer connection types.
936    #[test]
937    fn nat_status_request_and_response_round_trip() {
938        roundtrip(&OverlaydFrame::Request {
939            id: 41,
940            request: OverlaydRequest::NatStatus,
941        });
942        roundtrip(&OverlaydFrame::Response {
943            id: 41,
944            response: OverlaydResponse::NatStatus(NatStatusWire {
945                candidates: vec![NatCandidateWire {
946                    candidate_type: "host".into(),
947                    address: "192.168.1.5:51820".into(),
948                    priority: 100,
949                }],
950                peers: vec![NatPeerWire {
951                    node_id: "base64peerkey".into(),
952                    connection_type: "hole-punched".into(),
953                    remote_endpoint: Some("203.0.113.9:51820".into()),
954                }],
955                last_refresh: 1_700_000_000,
956            }),
957        });
958        // Empty snapshot round-trips too (default shape).
959        roundtrip(&OverlaydFrame::Response {
960            id: 42,
961            response: OverlaydResponse::NatStatus(NatStatusWire::default()),
962        });
963    }
964
965    #[test]
966    fn service_overlay_response_round_trips_both_shapes() {
967        // Shared shape: identity fields are None.
968        roundtrip(&OverlaydFrame::Response {
969            id: 20,
970            response: OverlaydResponse::ServiceOverlay(ServiceOverlayInfo {
971                name: "web".into(),
972                mode: crate::overlay::OverlayMode::Shared,
973                wg_public_key: None,
974                wg_port: None,
975                overlay_ip: None,
976                subnet: None,
977            }),
978        });
979        // Dedicated shape: all identity fields populated.
980        roundtrip(&OverlaydFrame::Response {
981            id: 21,
982            response: OverlaydResponse::ServiceOverlay(ServiceOverlayInfo {
983                name: "web".into(),
984                mode: crate::overlay::OverlayMode::Dedicated,
985                wg_public_key: Some("svc-key".into()),
986                wg_port: Some(51821),
987                overlay_ip: Some("10.201.0.1".parse().unwrap()),
988                subnet: Some("10.201.0.0/24".into()),
989            }),
990        });
991    }
992
993    #[test]
994    fn status_snapshot_with_dedicated_service_round_trips() {
995        roundtrip(&OverlaydFrame::Response {
996            id: 22,
997            response: OverlaydResponse::Status(StatusSnapshot {
998                interface: Some("zl-overlay0".into()),
999                node_ip: Some("10.200.0.1".parse().unwrap()),
1000                peer_count: 1,
1001                service_count: 1,
1002                dedicated_services: vec![DedicatedServiceStatus {
1003                    service: "web".into(),
1004                    interface: "zl-svc-web0".into(),
1005                    public_key: "svc-key".into(),
1006                    listen_port: 51821,
1007                    overlay_ip: "10.201.0.1".parse().unwrap(),
1008                    subnet: "10.201.0.0/24".into(),
1009                    peer_count: 3,
1010                }],
1011                ..StatusSnapshot::default()
1012            }),
1013        });
1014    }
1015
1016    fn sample_edge_config() -> EdgeConfig {
1017        EdgeConfig {
1018            version: EDGE_CONFIG_VERSION,
1019            name: "ci-runner-7".into(),
1020            overlay_ip: "10.200.0.9".parse().unwrap(),
1021            prefix_len: 16,
1022            private_key: "edge-priv-b64".into(),
1023            public_key: "edge-pub-b64".into(),
1024            peers: vec![sample_peer()],
1025            dns_server: Some("10.200.0.1".parse().unwrap()),
1026            dns_domain: Some("overlay".into()),
1027            expires_at_unix: 1_700_000_600,
1028        }
1029    }
1030
1031    #[test]
1032    fn edge_peer_requests_round_trip() {
1033        roundtrip(&OverlaydFrame::Request {
1034            id: 50,
1035            request: OverlaydRequest::MintEdgePeer {
1036                name: "ci-runner-7".into(),
1037                ttl_secs: 600,
1038                allow: vec!["10.200.0.0/16".into(), "10.201.0.0/24".into()],
1039                node_endpoint: "203.0.113.7:51820".into(),
1040            },
1041        });
1042        roundtrip(&OverlaydFrame::Request {
1043            id: 51,
1044            request: OverlaydRequest::RevokeEdgePeer {
1045                name: "ci-runner-7".into(),
1046            },
1047        });
1048        roundtrip(&OverlaydFrame::Request {
1049            id: 52,
1050            request: OverlaydRequest::ListEdgePeers,
1051        });
1052    }
1053
1054    #[test]
1055    fn edge_peer_responses_round_trip() {
1056        roundtrip(&OverlaydFrame::Response {
1057            id: 50,
1058            response: OverlaydResponse::EdgeConfig(sample_edge_config()),
1059        });
1060        roundtrip(&OverlaydFrame::Response {
1061            id: 52,
1062            response: OverlaydResponse::EdgePeers {
1063                peers: vec![EdgePeerStatus {
1064                    name: "ci-runner-7".into(),
1065                    overlay_ip: "10.200.0.9".parse().unwrap(),
1066                    public_key: "edge-pub-b64".into(),
1067                    minted_at_unix: 1_700_000_000,
1068                    expires_at_unix: 1_700_000_600,
1069                    last_handshake_unix_secs: Some(1_700_000_030),
1070                    allowed: vec!["10.200.0.0/16".into()],
1071                }],
1072            },
1073        });
1074        // A never-handshaked, no-grant peer round-trips too (both optional
1075        // fields skipped on the wire).
1076        roundtrip(&OverlaydFrame::Response {
1077            id: 53,
1078            response: OverlaydResponse::EdgePeers {
1079                peers: vec![EdgePeerStatus {
1080                    name: "fresh".into(),
1081                    overlay_ip: "10.200.0.10".parse().unwrap(),
1082                    public_key: "k".into(),
1083                    minted_at_unix: 1_700_000_000,
1084                    expires_at_unix: 1_700_000_600,
1085                    last_handshake_unix_secs: None,
1086                    allowed: vec![],
1087                }],
1088            },
1089        });
1090    }
1091
1092    /// A sender that omits `allow` entirely must decode to an empty grant
1093    /// list (`#[serde(default)]`), proving the field is optional on the wire.
1094    #[test]
1095    fn mint_edge_peer_without_allow_defaults_to_empty() {
1096        let json = r#"{
1097            "frame": "request",
1098            "id": 54,
1099            "request": {
1100                "op": "mint_edge_peer",
1101                "name": "ci-runner-7",
1102                "ttl_secs": 600,
1103                "node_endpoint": "203.0.113.7:51820"
1104            }
1105        }"#;
1106        let frame: OverlaydFrame = serde_json::from_str(json).expect("deserialize");
1107        match frame {
1108            OverlaydFrame::Request {
1109                request:
1110                    OverlaydRequest::MintEdgePeer {
1111                        name,
1112                        ttl_secs,
1113                        allow,
1114                        node_endpoint,
1115                    },
1116                ..
1117            } => {
1118                assert_eq!(name, "ci-runner-7");
1119                assert_eq!(ttl_secs, 600);
1120                assert!(
1121                    allow.is_empty(),
1122                    "missing allow field must default to empty"
1123                );
1124                assert_eq!(node_endpoint, "203.0.113.7:51820");
1125            }
1126            other => panic!("expected MintEdgePeer request, got {other:?}"),
1127        }
1128    }
1129
1130    #[test]
1131    fn edge_config_token_round_trips() {
1132        let config = sample_edge_config();
1133        let token = config.to_token();
1134        assert!(token.starts_with("zledge1."), "token was {token}");
1135        let back = EdgeConfig::from_token(&token).expect("decode");
1136        assert_eq!(config, back);
1137    }
1138
1139    #[test]
1140    fn edge_config_token_rejects_bad_prefix() {
1141        let err = EdgeConfig::from_token("zledge2.abc").expect_err("must reject");
1142        assert!(matches!(err, EdgeTokenError::BadPrefix), "got {err:?}");
1143    }
1144
1145    #[test]
1146    fn edge_config_token_rejects_bad_base64() {
1147        let err = EdgeConfig::from_token("zledge1.!!!not-base64!!!").expect_err("must reject");
1148        assert!(matches!(err, EdgeTokenError::Decode(_)), "got {err:?}");
1149    }
1150
1151    #[test]
1152    fn edge_config_token_rejects_non_json_payload() {
1153        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1154        use base64::Engine as _;
1155
1156        let token = format!("zledge1.{}", URL_SAFE_NO_PAD.encode("not json"));
1157        let err = EdgeConfig::from_token(&token).expect_err("must reject");
1158        assert!(matches!(err, EdgeTokenError::Parse(_)), "got {err:?}");
1159    }
1160
1161    #[test]
1162    fn edge_config_token_rejects_wrong_version() {
1163        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1164        use base64::Engine as _;
1165
1166        // A future-versioned payload: valid base64 + JSON, but version 2. The
1167        // version probe must report the mismatch, not a parse error.
1168        let mut value = serde_json::to_value(sample_edge_config()).expect("to_value");
1169        value["version"] = serde_json::json!(EDGE_CONFIG_VERSION + 1);
1170        let token = format!("zledge1.{}", URL_SAFE_NO_PAD.encode(value.to_string()));
1171        let err = EdgeConfig::from_token(&token).expect_err("must reject");
1172        assert!(
1173            matches!(
1174                err,
1175                EdgeTokenError::UnsupportedVersion { found } if found == EDGE_CONFIG_VERSION + 1
1176            ),
1177            "got {err:?}"
1178        );
1179    }
1180}