use std::net::IpAddr;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
pub use crate::nat_wire::{NatCandidateWire, NatConfigSpec};
pub use crate::overlay::{OverlayConfig, OverlayMode};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "frame", rename_all = "snake_case")]
pub enum OverlaydFrame {
Request { id: u64, request: OverlaydRequest },
Response { id: u64, response: OverlaydResponse },
Event(OverlaydEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "platform", rename_all = "snake_case")]
pub enum AttachHandle {
LinuxPid { pid: u32 },
WindowsContainer {
container_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ip: Option<IpAddr>,
},
GuestManaged { id: String },
HostShared { id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum OverlaydRequest {
SetLocalNodeId { node_id: u64 },
SetLocalWgPubkey { pubkey: String },
SetupGlobalOverlay {
deployment: String,
instance_id: String,
cluster_cidr: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
slice_cidr: Option<String>,
wg_port: u16,
#[serde(default)]
host_adapter_mandatory: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
nat: Option<NatConfigSpec>,
},
TeardownGlobalOverlay,
SetupServiceOverlay { service: String, mode: OverlayMode },
TeardownServiceOverlay { service: String },
AllocateIp { service: String, join_global: bool },
ReleaseIp { ip: IpAddr },
AttachContainer {
handle: AttachHandle,
service: String,
join_global: bool,
#[serde(default)]
ephemeral: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
isolation_network: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
dns_server: Option<IpAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
dns_domain: Option<String>,
},
DetachContainer { handle: AttachHandle },
AddPeer {
#[serde(flatten)]
peer: PeerSpec,
#[serde(default)]
scope: PeerScope,
},
RemovePeer {
pubkey: String,
#[serde(default)]
scope: PeerScope,
},
AddAllowedIp {
pubkey: String,
cidr: String,
#[serde(default)]
scope: PeerScope,
},
RemoveAllowedIp {
pubkey: String,
cidr: String,
#[serde(default)]
scope: PeerScope,
},
MintEdgePeer {
name: String,
ttl_secs: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allow: Vec<String>,
node_endpoint: String,
},
RevokeEdgePeer { name: String },
ListEdgePeers,
RegisterDns { name: String, ip: IpAddr },
UnregisterDns { name: String },
WriteScopedResolver {
zone: String,
node_ip: IpAddr,
#[serde(default, skip_serializing_if = "Option::is_none")]
port: Option<u16>,
},
RemoveScopedResolver { zone: String },
PruneOrphanBridges { live_bridge_names: Vec<String> },
Status,
NatTick,
NatStatus,
Shutdown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum OverlaydResponse {
Ok,
Ip { ip: IpAddr },
Attached(AttachResult),
GuestConfig(GuestOverlayConfig),
BridgeName { name: String },
Status(StatusSnapshot),
NatStatus(NatStatusWire),
PrunedBridges { reclaimed: Vec<String> },
ServiceOverlay(ServiceOverlayInfo),
EdgeConfig(EdgeConfig),
EdgePeers { peers: Vec<EdgePeerStatus> },
Err { message: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceOverlayInfo {
pub name: String,
pub mode: crate::overlay::OverlayMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wg_public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wg_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlay_ip: Option<std::net::IpAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subnet: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttachResult {
pub ip: IpAddr,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace_guid: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuestOverlayConfig {
pub overlay_ip: IpAddr,
pub prefix_len: u8,
pub private_key: String,
pub public_key: String,
pub listen_port: u16,
pub peers: Vec<PeerSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns_server: Option<IpAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns_domain: Option<String>,
}
pub const EDGE_CONFIG_VERSION: u32 = 1;
const EDGE_TOKEN_PREFIX: &str = "zledge1.";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct EdgeConfig {
pub version: u32,
pub name: String,
#[schema(value_type = String, example = "10.200.0.9")]
pub overlay_ip: IpAddr,
pub prefix_len: u8,
pub private_key: String,
pub public_key: String,
pub peers: Vec<PeerSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>, example = "10.200.0.1")]
pub dns_server: Option<IpAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns_domain: Option<String>,
pub expires_at_unix: u64,
}
impl EdgeConfig {
#[must_use]
pub fn to_token(&self) -> String {
let json = serde_json::to_string(self)
.expect("EdgeConfig JSON serialization is infallible (no non-string map keys)");
format!("{EDGE_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(json))
}
pub fn from_token(token: &str) -> Result<Self, EdgeTokenError> {
let encoded = token
.strip_prefix(EDGE_TOKEN_PREFIX)
.ok_or(EdgeTokenError::BadPrefix)?;
let bytes = URL_SAFE_NO_PAD.decode(encoded)?;
let probe: EdgeConfigVersionProbe = serde_json::from_slice(&bytes)?;
if probe.version != EDGE_CONFIG_VERSION {
return Err(EdgeTokenError::UnsupportedVersion {
found: probe.version,
});
}
Ok(serde_json::from_slice(&bytes)?)
}
}
#[derive(Deserialize)]
struct EdgeConfigVersionProbe {
version: u32,
}
#[derive(Debug, thiserror::Error)]
pub enum EdgeTokenError {
#[error("edge token missing `{EDGE_TOKEN_PREFIX}` prefix")]
BadPrefix,
#[error("edge token base64 decode failed: {0}")]
Decode(#[from] base64::DecodeError),
#[error("edge token JSON parse failed: {0}")]
Parse(#[from] serde_json::Error),
#[error("unsupported edge config version {found} (expected {EDGE_CONFIG_VERSION})")]
UnsupportedVersion {
found: u32,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct EdgePeerStatus {
pub name: String,
#[schema(value_type = String, example = "10.200.0.9")]
pub overlay_ip: IpAddr,
pub public_key: String,
pub minted_at_unix: u64,
pub expires_at_unix: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_handshake_unix_secs: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(tag = "scope", rename_all = "snake_case")]
pub enum PeerScope {
#[default]
Global,
Service {
service: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PeerSpec {
pub public_key: String,
pub endpoint: String,
pub allowed_ips: String,
pub persistent_keepalive_secs: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidates: Vec<NatCandidateWire>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum OverlaydEvent {
PeerHealthChanged { pubkey: String, healthy: bool },
NatEndpointChanged { pubkey: String, endpoint: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interface: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_ip: Option<IpAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlay_cidr: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice_cidr: Option<String>,
pub peer_count: u32,
pub service_count: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<PeerStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dedicated_services: Vec<DedicatedServiceStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DedicatedServiceStatus {
pub service: String,
pub interface: String,
pub public_key: String,
pub listen_port: u16,
pub overlay_ip: std::net::IpAddr,
pub subnet: String,
pub peer_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerStatus {
pub public_key: String,
pub endpoint: String,
pub allowed_ips: String,
pub last_handshake_unix_secs: i64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NatStatusWire {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidates: Vec<NatCandidateWire>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<NatPeerWire>,
#[serde(default)]
pub last_refresh: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NatPeerWire {
pub node_id: String,
pub connection_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_endpoint: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nat_wire::{RelayServerSpec, TurnServerSpec};
fn roundtrip(frame: &OverlaydFrame) {
let json = serde_json::to_string(frame).expect("serialize");
let back: OverlaydFrame = serde_json::from_str(&json).expect("deserialize");
assert_eq!(frame, &back, "frame must round-trip; json was {json}");
}
#[test]
fn request_frames_round_trip() {
roundtrip(&OverlaydFrame::Request {
id: 1,
request: OverlaydRequest::SetupGlobalOverlay {
deployment: "prod".into(),
instance_id: "42".into(),
cluster_cidr: "10.200.0.0/16".into(),
slice_cidr: Some("10.200.0.0/28".into()),
wg_port: 51820,
host_adapter_mandatory: true,
nat: Some(NatConfigSpec {
enabled: true,
stun_servers: vec!["stun.l.google.com:19302".into()],
turn_servers: vec![TurnServerSpec {
addr: "turn.example.com:3478".into(),
username: "u".into(),
credential: "p".into(),
}],
hole_punch_timeout_secs: 15,
stun_refresh_interval_secs: 60,
max_candidate_pairs: 10,
relay_server: Some(RelayServerSpec {
listen_port: 3478,
external_addr: "1.2.3.4:3478".into(),
max_sessions: 100,
auth_credential: Some("cluster-secret".into()),
}),
}),
},
});
roundtrip(&OverlaydFrame::Request {
id: 2,
request: OverlaydRequest::AttachContainer {
handle: AttachHandle::WindowsContainer {
container_id: "ctr-abc".into(),
ip: Some("10.200.0.5".parse().unwrap()),
},
service: "web".into(),
join_global: false,
ephemeral: false,
isolation_network: None,
dns_server: Some("10.200.0.1".parse().unwrap()),
dns_domain: Some("overlay".into()),
},
});
roundtrip(&OverlaydFrame::Request {
id: 3,
request: OverlaydRequest::AttachContainer {
handle: AttachHandle::LinuxPid { pid: 4242 },
service: "web".into(),
join_global: true,
ephemeral: false,
isolation_network: Some("job-net".into()),
dns_server: None,
dns_domain: None,
},
});
}
#[test]
fn response_and_event_frames_round_trip() {
roundtrip(&OverlaydFrame::Response {
id: 2,
response: OverlaydResponse::Attached(AttachResult {
ip: "10.200.0.5".parse().unwrap(),
namespace_guid: Some("aabbccdd-eeff-0011-2233-445566778899".into()),
}),
});
roundtrip(&OverlaydFrame::Response {
id: 9,
response: OverlaydResponse::Err {
message: "no slice assigned".into(),
},
});
roundtrip(&OverlaydFrame::Event(OverlaydEvent::PeerHealthChanged {
pubkey: "base64key".into(),
healthy: false,
}));
}
#[test]
fn prune_orphan_bridges_round_trips() {
roundtrip(&OverlaydFrame::Request {
id: 30,
request: OverlaydRequest::PruneOrphanBridges {
live_bridge_names: vec!["zl-prod-0-web-b".into(), "zl-prod-0-api-b".into()],
},
});
roundtrip(&OverlaydFrame::Response {
id: 30,
response: OverlaydResponse::PrunedBridges {
reclaimed: vec!["zl-1ca4568944-b".into(), "zl-81c6bc17c7-b".into()],
},
});
}
#[test]
fn status_snapshot_round_trips_and_defaults() {
roundtrip(&OverlaydFrame::Response {
id: 7,
response: OverlaydResponse::Status(StatusSnapshot {
interface: Some("zl-overlay0".into()),
node_ip: Some("10.200.0.1".parse().unwrap()),
peer_count: 2,
service_count: 1,
peers: vec![PeerStatus {
public_key: "k".into(),
endpoint: "1.2.3.4:51820".into(),
allowed_ips: "10.200.0.2/32".into(),
last_handshake_unix_secs: 0,
}],
..StatusSnapshot::default()
}),
});
}
fn sample_peer() -> PeerSpec {
PeerSpec {
public_key: "base64key".into(),
endpoint: "1.2.3.4:51820".into(),
allowed_ips: "10.200.0.2/32".into(),
persistent_keepalive_secs: 25,
candidates: vec![
NatCandidateWire {
candidate_type: "host".into(),
address: "192.168.1.5:51820".into(),
priority: 100,
},
NatCandidateWire {
candidate_type: "server-reflexive".into(),
address: "203.0.113.5:51820".into(),
priority: 50,
},
],
}
}
#[test]
fn peer_ops_round_trip_both_scopes() {
roundtrip(&OverlaydFrame::Request {
id: 1,
request: OverlaydRequest::AddPeer {
peer: sample_peer(),
scope: PeerScope::Global,
},
});
roundtrip(&OverlaydFrame::Request {
id: 2,
request: OverlaydRequest::AddPeer {
peer: sample_peer(),
scope: PeerScope::Service {
service: "web".into(),
},
},
});
roundtrip(&OverlaydFrame::Request {
id: 3,
request: OverlaydRequest::RemovePeer {
pubkey: "k".into(),
scope: PeerScope::Global,
},
});
roundtrip(&OverlaydFrame::Request {
id: 4,
request: OverlaydRequest::RemovePeer {
pubkey: "k".into(),
scope: PeerScope::Service {
service: "web".into(),
},
},
});
roundtrip(&OverlaydFrame::Request {
id: 5,
request: OverlaydRequest::AddAllowedIp {
pubkey: "k".into(),
cidr: "10.200.1.0/24".into(),
scope: PeerScope::Global,
},
});
roundtrip(&OverlaydFrame::Request {
id: 6,
request: OverlaydRequest::AddAllowedIp {
pubkey: "k".into(),
cidr: "10.200.1.0/24".into(),
scope: PeerScope::Service {
service: "web".into(),
},
},
});
roundtrip(&OverlaydFrame::Request {
id: 7,
request: OverlaydRequest::RemoveAllowedIp {
pubkey: "k".into(),
cidr: "10.200.1.0/24".into(),
scope: PeerScope::Global,
},
});
roundtrip(&OverlaydFrame::Request {
id: 8,
request: OverlaydRequest::RemoveAllowedIp {
pubkey: "k".into(),
cidr: "10.200.1.0/24".into(),
scope: PeerScope::Service {
service: "web".into(),
},
},
});
}
#[test]
fn add_peer_without_scope_defaults_to_global() {
let json = r#"{
"frame": "request",
"id": 11,
"request": {
"op": "add_peer",
"public_key": "base64key",
"endpoint": "1.2.3.4:51820",
"allowed_ips": "10.200.0.2/32",
"persistent_keepalive_secs": 25
}
}"#;
let frame: OverlaydFrame = serde_json::from_str(json).expect("deserialize");
match frame {
OverlaydFrame::Request {
request: OverlaydRequest::AddPeer { scope, peer },
..
} => {
assert_eq!(scope, PeerScope::Global);
assert_eq!(peer.public_key, "base64key");
assert!(peer.candidates.is_empty());
}
other => panic!("expected AddPeer request, got {other:?}"),
}
}
#[test]
fn setup_global_overlay_without_nat_field_defaults_to_none() {
let json = r#"{
"frame": "request",
"id": 12,
"request": {
"op": "setup_global_overlay",
"deployment": "prod",
"instance_id": "1",
"cluster_cidr": "10.200.0.0/16",
"wg_port": 51820
}
}"#;
let frame: OverlaydFrame = serde_json::from_str(json).expect("deserialize");
match frame {
OverlaydFrame::Request {
request: OverlaydRequest::SetupGlobalOverlay { nat, .. },
..
} => assert!(nat.is_none(), "missing nat field must default to None"),
other => panic!("expected SetupGlobalOverlay, got {other:?}"),
}
}
#[test]
fn add_peer_with_candidates_round_trips() {
roundtrip(&OverlaydFrame::Request {
id: 40,
request: OverlaydRequest::AddPeer {
peer: sample_peer(),
scope: PeerScope::Global,
},
});
}
#[test]
fn nat_status_request_and_response_round_trip() {
roundtrip(&OverlaydFrame::Request {
id: 41,
request: OverlaydRequest::NatStatus,
});
roundtrip(&OverlaydFrame::Response {
id: 41,
response: OverlaydResponse::NatStatus(NatStatusWire {
candidates: vec![NatCandidateWire {
candidate_type: "host".into(),
address: "192.168.1.5:51820".into(),
priority: 100,
}],
peers: vec![NatPeerWire {
node_id: "base64peerkey".into(),
connection_type: "hole-punched".into(),
remote_endpoint: Some("203.0.113.9:51820".into()),
}],
last_refresh: 1_700_000_000,
}),
});
roundtrip(&OverlaydFrame::Response {
id: 42,
response: OverlaydResponse::NatStatus(NatStatusWire::default()),
});
}
#[test]
fn service_overlay_response_round_trips_both_shapes() {
roundtrip(&OverlaydFrame::Response {
id: 20,
response: OverlaydResponse::ServiceOverlay(ServiceOverlayInfo {
name: "web".into(),
mode: crate::overlay::OverlayMode::Shared,
wg_public_key: None,
wg_port: None,
overlay_ip: None,
subnet: None,
}),
});
roundtrip(&OverlaydFrame::Response {
id: 21,
response: OverlaydResponse::ServiceOverlay(ServiceOverlayInfo {
name: "web".into(),
mode: crate::overlay::OverlayMode::Dedicated,
wg_public_key: Some("svc-key".into()),
wg_port: Some(51821),
overlay_ip: Some("10.201.0.1".parse().unwrap()),
subnet: Some("10.201.0.0/24".into()),
}),
});
}
#[test]
fn status_snapshot_with_dedicated_service_round_trips() {
roundtrip(&OverlaydFrame::Response {
id: 22,
response: OverlaydResponse::Status(StatusSnapshot {
interface: Some("zl-overlay0".into()),
node_ip: Some("10.200.0.1".parse().unwrap()),
peer_count: 1,
service_count: 1,
dedicated_services: vec![DedicatedServiceStatus {
service: "web".into(),
interface: "zl-svc-web0".into(),
public_key: "svc-key".into(),
listen_port: 51821,
overlay_ip: "10.201.0.1".parse().unwrap(),
subnet: "10.201.0.0/24".into(),
peer_count: 3,
}],
..StatusSnapshot::default()
}),
});
}
fn sample_edge_config() -> EdgeConfig {
EdgeConfig {
version: EDGE_CONFIG_VERSION,
name: "ci-runner-7".into(),
overlay_ip: "10.200.0.9".parse().unwrap(),
prefix_len: 16,
private_key: "edge-priv-b64".into(),
public_key: "edge-pub-b64".into(),
peers: vec![sample_peer()],
dns_server: Some("10.200.0.1".parse().unwrap()),
dns_domain: Some("overlay".into()),
expires_at_unix: 1_700_000_600,
}
}
#[test]
fn edge_peer_requests_round_trip() {
roundtrip(&OverlaydFrame::Request {
id: 50,
request: OverlaydRequest::MintEdgePeer {
name: "ci-runner-7".into(),
ttl_secs: 600,
allow: vec!["10.200.0.0/16".into(), "10.201.0.0/24".into()],
node_endpoint: "203.0.113.7:51820".into(),
},
});
roundtrip(&OverlaydFrame::Request {
id: 51,
request: OverlaydRequest::RevokeEdgePeer {
name: "ci-runner-7".into(),
},
});
roundtrip(&OverlaydFrame::Request {
id: 52,
request: OverlaydRequest::ListEdgePeers,
});
}
#[test]
fn edge_peer_responses_round_trip() {
roundtrip(&OverlaydFrame::Response {
id: 50,
response: OverlaydResponse::EdgeConfig(sample_edge_config()),
});
roundtrip(&OverlaydFrame::Response {
id: 52,
response: OverlaydResponse::EdgePeers {
peers: vec![EdgePeerStatus {
name: "ci-runner-7".into(),
overlay_ip: "10.200.0.9".parse().unwrap(),
public_key: "edge-pub-b64".into(),
minted_at_unix: 1_700_000_000,
expires_at_unix: 1_700_000_600,
last_handshake_unix_secs: Some(1_700_000_030),
allowed: vec!["10.200.0.0/16".into()],
}],
},
});
roundtrip(&OverlaydFrame::Response {
id: 53,
response: OverlaydResponse::EdgePeers {
peers: vec![EdgePeerStatus {
name: "fresh".into(),
overlay_ip: "10.200.0.10".parse().unwrap(),
public_key: "k".into(),
minted_at_unix: 1_700_000_000,
expires_at_unix: 1_700_000_600,
last_handshake_unix_secs: None,
allowed: vec![],
}],
},
});
}
#[test]
fn mint_edge_peer_without_allow_defaults_to_empty() {
let json = r#"{
"frame": "request",
"id": 54,
"request": {
"op": "mint_edge_peer",
"name": "ci-runner-7",
"ttl_secs": 600,
"node_endpoint": "203.0.113.7:51820"
}
}"#;
let frame: OverlaydFrame = serde_json::from_str(json).expect("deserialize");
match frame {
OverlaydFrame::Request {
request:
OverlaydRequest::MintEdgePeer {
name,
ttl_secs,
allow,
node_endpoint,
},
..
} => {
assert_eq!(name, "ci-runner-7");
assert_eq!(ttl_secs, 600);
assert!(
allow.is_empty(),
"missing allow field must default to empty"
);
assert_eq!(node_endpoint, "203.0.113.7:51820");
}
other => panic!("expected MintEdgePeer request, got {other:?}"),
}
}
#[test]
fn edge_config_token_round_trips() {
let config = sample_edge_config();
let token = config.to_token();
assert!(token.starts_with("zledge1."), "token was {token}");
let back = EdgeConfig::from_token(&token).expect("decode");
assert_eq!(config, back);
}
#[test]
fn edge_config_token_rejects_bad_prefix() {
let err = EdgeConfig::from_token("zledge2.abc").expect_err("must reject");
assert!(matches!(err, EdgeTokenError::BadPrefix), "got {err:?}");
}
#[test]
fn edge_config_token_rejects_bad_base64() {
let err = EdgeConfig::from_token("zledge1.!!!not-base64!!!").expect_err("must reject");
assert!(matches!(err, EdgeTokenError::Decode(_)), "got {err:?}");
}
#[test]
fn edge_config_token_rejects_non_json_payload() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
let token = format!("zledge1.{}", URL_SAFE_NO_PAD.encode("not json"));
let err = EdgeConfig::from_token(&token).expect_err("must reject");
assert!(matches!(err, EdgeTokenError::Parse(_)), "got {err:?}");
}
#[test]
fn edge_config_token_rejects_wrong_version() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
let mut value = serde_json::to_value(sample_edge_config()).expect("to_value");
value["version"] = serde_json::json!(EDGE_CONFIG_VERSION + 1);
let token = format!("zledge1.{}", URL_SAFE_NO_PAD.encode(value.to_string()));
let err = EdgeConfig::from_token(&token).expect_err("must reject");
assert!(
matches!(
err,
EdgeTokenError::UnsupportedVersion { found } if found == EDGE_CONFIG_VERSION + 1
),
"got {err:?}"
);
}
}