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 serde::{Deserialize, Serialize};
39
40pub use crate::overlay::{OverlayConfig, OverlayMode};
41
42/// Wire-protocol version. Bump on any breaking change to the frame/request/
43/// response/event shapes so a version-skewed daemon/overlayd pair can detect
44/// the mismatch instead of silently misparsing.
45pub const PROTOCOL_VERSION: u32 = 1;
46
47/// A multiplexed frame on the overlayd IPC connection.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(tag = "frame", rename_all = "snake_case")]
50pub enum OverlaydFrame {
51    /// Main daemon → overlayd. `id` is echoed back on the matching response.
52    Request { id: u64, request: OverlaydRequest },
53    /// overlayd → main daemon, answering the request with the same `id`.
54    Response { id: u64, response: OverlaydResponse },
55    /// overlayd → main daemon, unsolicited (no `id`).
56    Event(OverlaydEvent),
57}
58
59/// Identifies the container overlayd must wire into the overlay. The agent
60/// owns the container's process/compute-system lifecycle and hands overlayd
61/// just enough to attach it.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "platform", rename_all = "snake_case")]
64pub enum AttachHandle {
65    /// Linux: the container's PID. overlayd opens `/proc/<pid>/ns/net` and
66    /// creates the veth pair into that network namespace.
67    LinuxPid { pid: u32 },
68    /// Windows: the HCS container id (+ the IP the agent reserved, if any).
69    /// overlayd creates the HCN endpoint + per-container namespace on its HCN
70    /// Internal network and returns the bare-lowercase namespace GUID
71    /// ([`AttachResult::namespace_guid`]) for the agent to embed in the
72    /// compute-system document's `Container.Networking.Namespace`.
73    WindowsContainer {
74        container_id: String,
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        ip: Option<IpAddr>,
77    },
78    /// A guest that manages its own overlay interface (the macOS VZ-Linux VM
79    /// runtime). overlayd cannot enter the guest's netns (it is a VM, not a host
80    /// process), so instead of building a veth it **allocates the overlay
81    /// identity** — keypair, address, and the current peer set — registers the
82    /// generated public key in the mesh, and returns it as
83    /// [`OverlaydResponse::GuestConfig`]. The caller ships that config into the
84    /// guest (over vsock) where a kernel `WireGuard` device is brought up. `id` is
85    /// the opaque container id used to scope the allocation + the registered
86    /// peer so `DetachContainer` can release it.
87    GuestManaged { id: String },
88}
89
90/// A request from the main daemon to overlayd.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(tag = "op", rename_all = "snake_case")]
93pub enum OverlaydRequest {
94    /// Push this node's Raft id (cluster-brain context overlayd scopes by).
95    SetLocalNodeId { node_id: u64 },
96    /// Push this node's `WireGuard` public key (base64).
97    SetLocalWgPubkey { pubkey: String },
98
99    /// Bring up (or reuse) this node's base/global overlay. Idempotent: if the
100    /// overlay network already exists (recorded in overlayd's marker), it is
101    /// reused rather than recreated. This is the only place the base overlay
102    /// is created; it is torn down only on a full uninstall.
103    SetupGlobalOverlay {
104        deployment: String,
105        instance_id: String,
106        /// Full cluster CIDR, e.g. `"10.200.0.0/16"`.
107        cluster_cidr: String,
108        /// This node's per-node slice, e.g. `"10.200.0.0/28"`. `None` until the
109        /// leader assigns one.
110        #[serde(default, skip_serializing_if = "Option::is_none")]
111        slice_cidr: Option<String>,
112        wg_port: u16,
113        nat_enabled: bool,
114    },
115    /// Tear down the node's base overlay (e.g. on full uninstall).
116    TeardownGlobalOverlay,
117
118    /// Create the per-service overlay segment (Linux bridge / Windows HCN
119    /// Internal network) for `service`. Returns [`OverlaydResponse::BridgeName`].
120    SetupServiceOverlay { service: String, mode: OverlayMode },
121    /// Remove the per-service overlay segment.
122    TeardownServiceOverlay { service: String },
123
124    /// Allocate (or, with `ip` set on a later attach, validate) an overlay IP
125    /// from the node slice for a container on `service`.
126    AllocateIp { service: String, join_global: bool },
127    /// Return an overlay IP to the allocator.
128    ReleaseIp { ip: IpAddr },
129
130    /// Wire a container into the overlay. Returns [`OverlaydResponse::Attached`].
131    AttachContainer {
132        handle: AttachHandle,
133        service: String,
134        join_global: bool,
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        dns_server: Option<IpAddr>,
137        #[serde(default, skip_serializing_if = "Option::is_none")]
138        dns_domain: Option<String>,
139    },
140    /// Tear down a container's overlay attachment and release its IP.
141    DetachContainer { handle: AttachHandle },
142
143    /// Add a `WireGuard` peer to the base overlay.
144    AddPeer {
145        #[serde(flatten)]
146        peer: PeerSpec,
147        #[serde(default)]
148        scope: PeerScope,
149    },
150    /// Remove a peer by its base64 public key.
151    RemovePeer {
152        pubkey: String,
153        #[serde(default)]
154        scope: PeerScope,
155    },
156    /// Plumb a service subnet into a peer's `AllowedIPs`.
157    AddAllowedIp {
158        pubkey: String,
159        cidr: String,
160        #[serde(default)]
161        scope: PeerScope,
162    },
163    /// Remove a service subnet from a peer's `AllowedIPs`.
164    RemoveAllowedIp {
165        pubkey: String,
166        cidr: String,
167        #[serde(default)]
168        scope: PeerScope,
169    },
170
171    /// Register an overlay DNS A/AAAA record.
172    RegisterDns { name: String, ip: IpAddr },
173    /// Remove an overlay DNS record.
174    UnregisterDns { name: String },
175
176    /// Snapshot overlay state for diagnostics. Returns [`OverlaydResponse::Status`].
177    Status,
178    /// Run one NAT-traversal maintenance tick (probe/refresh endpoints).
179    NatTick,
180    /// Ask overlayd to shut down gracefully (drops the adapter).
181    Shutdown,
182}
183
184/// overlayd's answer to an [`OverlaydRequest`].
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(tag = "result", rename_all = "snake_case")]
187pub enum OverlaydResponse {
188    /// Generic success with no payload.
189    Ok,
190    /// An allocated/validated overlay IP (`AllocateIp`).
191    Ip { ip: IpAddr },
192    /// A completed container attach.
193    Attached(AttachResult),
194    /// The overlay identity for a guest-managed attach
195    /// ([`AttachHandle::GuestManaged`]): the keypair, allocated address, and the
196    /// peer set the guest should configure on its own `WireGuard` device.
197    GuestConfig(GuestOverlayConfig),
198    /// The interface/bridge/network name created (`SetupServiceOverlay`,
199    /// `SetupGlobalOverlay`).
200    BridgeName { name: String },
201    /// A diagnostics snapshot (`Status`).
202    Status(StatusSnapshot),
203    /// A dedicated per-service overlay device's identity (`SetupServiceOverlay`
204    /// in Dedicated mode). Not yet produced by the server — the server still
205    /// returns [`OverlaydResponse::BridgeName`] for now; this variant is the
206    /// wire contract for a later task that switches Dedicated setup over.
207    ServiceOverlay(ServiceOverlayInfo),
208    /// The request failed; `message` is a human-readable reason.
209    Err { message: String },
210}
211
212/// Identity of a dedicated per-service overlay device, reported by
213/// `SetupServiceOverlay` once Dedicated mode is wired up. Shared-mode setups
214/// leave the `wg_*`/`overlay_ip`/`subnet` fields `None`.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ServiceOverlayInfo {
217    pub name: String,
218    pub mode: crate::overlay::OverlayMode,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub wg_public_key: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub wg_port: Option<u16>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub overlay_ip: Option<std::net::IpAddr>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub subnet: Option<String>,
227}
228
229/// Result of [`OverlaydRequest::AttachContainer`].
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct AttachResult {
232    /// The container's overlay IP.
233    pub ip: IpAddr,
234    /// Windows only: the bare-lowercase HCN namespace GUID the agent embeds in
235    /// the compute-system document. `None` on Linux (no HCN namespace).
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub namespace_guid: Option<String>,
238}
239
240/// Overlay identity returned for a guest-managed attach
241/// ([`AttachHandle::GuestManaged`] → [`OverlaydResponse::GuestConfig`]).
242///
243/// The host allocated the address from the node slice, generated the keypair,
244/// and registered `public_key` in the mesh (so peers route to the guest). The
245/// caller ships everything except `public_key` into the guest; `public_key` is
246/// echoed back so the caller can record/deregister the peer it represents.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct GuestOverlayConfig {
249    /// The guest's allocated overlay address.
250    pub overlay_ip: IpAddr,
251    /// Prefix length of the overlay network (interface address + on-link route).
252    pub prefix_len: u8,
253    /// Base64 `WireGuard` private key for the guest's overlay endpoint.
254    pub private_key: String,
255    /// Base64 `WireGuard` public key matching `private_key` (registered in the
256    /// mesh by overlayd; echoed for the caller's bookkeeping).
257    pub public_key: String,
258    /// UDP port the guest's `WireGuard` device should listen on.
259    pub listen_port: u16,
260    /// The peers the guest should configure (other nodes/containers).
261    pub peers: Vec<PeerSpec>,
262    /// Overlay DNS resolver IP for the container, if any.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub dns_server: Option<IpAddr>,
265    /// Overlay DNS search domain, if any.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub dns_domain: Option<String>,
268}
269
270/// Which overlay device a peer / `AllowedIP` op targets. `Global` (default, and
271/// the only value pre-Dedicated senders emit) = the single cluster transport.
272/// `Service` = that service's dedicated per-service transport.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
274#[serde(tag = "scope", rename_all = "snake_case")]
275pub enum PeerScope {
276    #[default]
277    Global,
278    Service {
279        service: String,
280    },
281}
282
283/// A `WireGuard` peer to add to the base overlay. Mirrors
284/// `zlayer_overlay::PeerInfo` but with wire-safe field types.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct PeerSpec {
287    /// base64 `WireGuard` public key.
288    pub public_key: String,
289    /// `host:port` (textual so an unresolved/hostname endpoint survives).
290    pub endpoint: String,
291    /// Comma-separated CIDR list (e.g. `"10.200.0.5/32,10.200.1.0/24"`).
292    pub allowed_ips: String,
293    /// Persistent-keepalive interval, in seconds (0 = disabled).
294    pub persistent_keepalive_secs: u64,
295}
296
297/// An unsolicited notification pushed from overlayd to the main daemon.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(tag = "event", rename_all = "snake_case")]
300pub enum OverlaydEvent {
301    /// A peer's liveness changed (handshake seen / lost).
302    PeerHealthChanged { pubkey: String, healthy: bool },
303    /// NAT traversal moved a peer to a new endpoint.
304    NatEndpointChanged { pubkey: String, endpoint: String },
305}
306
307/// Diagnostics snapshot returned by [`OverlaydRequest::Status`].
308#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
309pub struct StatusSnapshot {
310    /// Base overlay interface name (e.g. `"zl-overlay0"`), if up.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub interface: Option<String>,
313    /// This node's overlay IP, if assigned.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub node_ip: Option<IpAddr>,
316    /// This node's `WireGuard` public key (base64), if up.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub public_key: Option<String>,
319    /// Full cluster CIDR.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub overlay_cidr: Option<String>,
322    /// This node's per-node slice CIDR.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub slice_cidr: Option<String>,
325    /// Number of base-overlay peers.
326    pub peer_count: u32,
327    /// Number of per-service overlays set up on this node.
328    pub service_count: u32,
329    /// Per-peer status.
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub peers: Vec<PeerStatus>,
332    /// Per dedicated per-service overlay device status. Empty unless one or
333    /// more services run in `OverlayMode::Dedicated` on this node.
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub dedicated_services: Vec<DedicatedServiceStatus>,
336}
337
338/// Status of a single dedicated per-service overlay device within a
339/// [`StatusSnapshot`].
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct DedicatedServiceStatus {
342    pub service: String,
343    pub interface: String,
344    pub public_key: String,
345    pub listen_port: u16,
346    pub overlay_ip: std::net::IpAddr,
347    pub subnet: String,
348    pub peer_count: u32,
349}
350
351/// Per-peer status within a [`StatusSnapshot`].
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct PeerStatus {
354    pub public_key: String,
355    pub endpoint: String,
356    pub allowed_ips: String,
357    /// Last successful handshake, Unix seconds; `0` if never.
358    pub last_handshake_unix_secs: i64,
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    /// A frame round-trips through JSON unchanged (the core wire guarantee).
366    fn roundtrip(frame: &OverlaydFrame) {
367        let json = serde_json::to_string(frame).expect("serialize");
368        let back: OverlaydFrame = serde_json::from_str(&json).expect("deserialize");
369        assert_eq!(frame, &back, "frame must round-trip; json was {json}");
370    }
371
372    #[test]
373    fn request_frames_round_trip() {
374        roundtrip(&OverlaydFrame::Request {
375            id: 1,
376            request: OverlaydRequest::SetupGlobalOverlay {
377                deployment: "prod".into(),
378                instance_id: "42".into(),
379                cluster_cidr: "10.200.0.0/16".into(),
380                slice_cidr: Some("10.200.0.0/28".into()),
381                wg_port: 51820,
382                nat_enabled: true,
383            },
384        });
385        roundtrip(&OverlaydFrame::Request {
386            id: 2,
387            request: OverlaydRequest::AttachContainer {
388                handle: AttachHandle::WindowsContainer {
389                    container_id: "ctr-abc".into(),
390                    ip: Some("10.200.0.5".parse().unwrap()),
391                },
392                service: "web".into(),
393                join_global: false,
394                dns_server: Some("10.200.0.1".parse().unwrap()),
395                dns_domain: Some("overlay".into()),
396            },
397        });
398        roundtrip(&OverlaydFrame::Request {
399            id: 3,
400            request: OverlaydRequest::AttachContainer {
401                handle: AttachHandle::LinuxPid { pid: 4242 },
402                service: "web".into(),
403                join_global: true,
404                dns_server: None,
405                dns_domain: None,
406            },
407        });
408    }
409
410    #[test]
411    fn response_and_event_frames_round_trip() {
412        roundtrip(&OverlaydFrame::Response {
413            id: 2,
414            response: OverlaydResponse::Attached(AttachResult {
415                ip: "10.200.0.5".parse().unwrap(),
416                namespace_guid: Some("aabbccdd-eeff-0011-2233-445566778899".into()),
417            }),
418        });
419        roundtrip(&OverlaydFrame::Response {
420            id: 9,
421            response: OverlaydResponse::Err {
422                message: "no slice assigned".into(),
423            },
424        });
425        roundtrip(&OverlaydFrame::Event(OverlaydEvent::PeerHealthChanged {
426            pubkey: "base64key".into(),
427            healthy: false,
428        }));
429    }
430
431    #[test]
432    fn status_snapshot_round_trips_and_defaults() {
433        roundtrip(&OverlaydFrame::Response {
434            id: 7,
435            response: OverlaydResponse::Status(StatusSnapshot {
436                interface: Some("zl-overlay0".into()),
437                node_ip: Some("10.200.0.1".parse().unwrap()),
438                peer_count: 2,
439                service_count: 1,
440                peers: vec![PeerStatus {
441                    public_key: "k".into(),
442                    endpoint: "1.2.3.4:51820".into(),
443                    allowed_ips: "10.200.0.2/32".into(),
444                    last_handshake_unix_secs: 0,
445                }],
446                ..StatusSnapshot::default()
447            }),
448        });
449    }
450
451    fn sample_peer() -> PeerSpec {
452        PeerSpec {
453            public_key: "base64key".into(),
454            endpoint: "1.2.3.4:51820".into(),
455            allowed_ips: "10.200.0.2/32".into(),
456            persistent_keepalive_secs: 25,
457        }
458    }
459
460    #[test]
461    fn peer_ops_round_trip_both_scopes() {
462        // AddPeer, global (default) + service scope.
463        roundtrip(&OverlaydFrame::Request {
464            id: 1,
465            request: OverlaydRequest::AddPeer {
466                peer: sample_peer(),
467                scope: PeerScope::Global,
468            },
469        });
470        roundtrip(&OverlaydFrame::Request {
471            id: 2,
472            request: OverlaydRequest::AddPeer {
473                peer: sample_peer(),
474                scope: PeerScope::Service {
475                    service: "web".into(),
476                },
477            },
478        });
479        // RemovePeer.
480        roundtrip(&OverlaydFrame::Request {
481            id: 3,
482            request: OverlaydRequest::RemovePeer {
483                pubkey: "k".into(),
484                scope: PeerScope::Global,
485            },
486        });
487        roundtrip(&OverlaydFrame::Request {
488            id: 4,
489            request: OverlaydRequest::RemovePeer {
490                pubkey: "k".into(),
491                scope: PeerScope::Service {
492                    service: "web".into(),
493                },
494            },
495        });
496        // AddAllowedIp.
497        roundtrip(&OverlaydFrame::Request {
498            id: 5,
499            request: OverlaydRequest::AddAllowedIp {
500                pubkey: "k".into(),
501                cidr: "10.200.1.0/24".into(),
502                scope: PeerScope::Global,
503            },
504        });
505        roundtrip(&OverlaydFrame::Request {
506            id: 6,
507            request: OverlaydRequest::AddAllowedIp {
508                pubkey: "k".into(),
509                cidr: "10.200.1.0/24".into(),
510                scope: PeerScope::Service {
511                    service: "web".into(),
512                },
513            },
514        });
515        // RemoveAllowedIp.
516        roundtrip(&OverlaydFrame::Request {
517            id: 7,
518            request: OverlaydRequest::RemoveAllowedIp {
519                pubkey: "k".into(),
520                cidr: "10.200.1.0/24".into(),
521                scope: PeerScope::Global,
522            },
523        });
524        roundtrip(&OverlaydFrame::Request {
525            id: 8,
526            request: OverlaydRequest::RemoveAllowedIp {
527                pubkey: "k".into(),
528                cidr: "10.200.1.0/24".into(),
529                scope: PeerScope::Service {
530                    service: "web".into(),
531                },
532            },
533        });
534    }
535
536    #[test]
537    fn add_peer_without_scope_defaults_to_global() {
538        // A pre-Dedicated sender emits no `scope` field. The frame is tagged
539        // `frame: "request"`, the request `op: "add_peer"`, and `PeerSpec` is
540        // flattened so its fields sit at the request level.
541        let json = r#"{
542            "frame": "request",
543            "id": 11,
544            "request": {
545                "op": "add_peer",
546                "public_key": "base64key",
547                "endpoint": "1.2.3.4:51820",
548                "allowed_ips": "10.200.0.2/32",
549                "persistent_keepalive_secs": 25
550            }
551        }"#;
552        let frame: OverlaydFrame = serde_json::from_str(json).expect("deserialize");
553        match frame {
554            OverlaydFrame::Request {
555                request: OverlaydRequest::AddPeer { scope, peer },
556                ..
557            } => {
558                assert_eq!(scope, PeerScope::Global);
559                assert_eq!(peer.public_key, "base64key");
560            }
561            other => panic!("expected AddPeer request, got {other:?}"),
562        }
563    }
564
565    #[test]
566    fn service_overlay_response_round_trips_both_shapes() {
567        // Shared shape: identity fields are None.
568        roundtrip(&OverlaydFrame::Response {
569            id: 20,
570            response: OverlaydResponse::ServiceOverlay(ServiceOverlayInfo {
571                name: "web".into(),
572                mode: crate::overlay::OverlayMode::Shared,
573                wg_public_key: None,
574                wg_port: None,
575                overlay_ip: None,
576                subnet: None,
577            }),
578        });
579        // Dedicated shape: all identity fields populated.
580        roundtrip(&OverlaydFrame::Response {
581            id: 21,
582            response: OverlaydResponse::ServiceOverlay(ServiceOverlayInfo {
583                name: "web".into(),
584                mode: crate::overlay::OverlayMode::Dedicated,
585                wg_public_key: Some("svc-key".into()),
586                wg_port: Some(51821),
587                overlay_ip: Some("10.201.0.1".parse().unwrap()),
588                subnet: Some("10.201.0.0/24".into()),
589            }),
590        });
591    }
592
593    #[test]
594    fn status_snapshot_with_dedicated_service_round_trips() {
595        roundtrip(&OverlaydFrame::Response {
596            id: 22,
597            response: OverlaydResponse::Status(StatusSnapshot {
598                interface: Some("zl-overlay0".into()),
599                node_ip: Some("10.200.0.1".parse().unwrap()),
600                peer_count: 1,
601                service_count: 1,
602                dedicated_services: vec![DedicatedServiceStatus {
603                    service: "web".into(),
604                    interface: "zl-svc-web0".into(),
605                    public_key: "svc-key".into(),
606                    listen_port: 51821,
607                    overlay_ip: "10.201.0.1".parse().unwrap(),
608                    subnet: "10.201.0.0/24".into(),
609                    peer_count: 3,
610                }],
611                ..StatusSnapshot::default()
612            }),
613        });
614    }
615}