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}