Skip to main content

mesh_client/client/
control_plane.rs

1use crate::client::builder::MeshClient;
2use crate::crypto::OwnerKeypair;
3use crate::proto::node::{
4    NodeConfigSnapshot, OwnerControlApplyConfigRequest, OwnerControlApplyConfigResponse,
5    OwnerControlConfigSnapshot, OwnerControlConfigUpdate, OwnerControlEnvelope, OwnerControlError,
6    OwnerControlErrorCode, OwnerControlGetConfigRequest, OwnerControlHandshake,
7    OwnerControlRefreshInventoryRequest, OwnerControlRequest, OwnerControlResponse,
8    OwnerControlWatchAccepted, OwnerControlWatchConfigRequest, OwnerControlWatchConfigResponse,
9    SignedNodeOwnership,
10};
11use crate::protocol::{
12    ALPN_CONTROL_V1, ALPN_V1, NODE_PROTOCOL_GENERATION, decode_owner_control_envelope,
13    write_len_prefixed,
14};
15use anyhow::Context;
16use base64::Engine;
17use iroh::{Endpoint, EndpointAddr};
18use prost::Message;
19use std::fmt;
20use std::sync::atomic::{AtomicU64, Ordering};
21use thiserror::Error;
22
23const DEFAULT_NODE_CERT_LIFETIME_SECS: u64 = 7 * 24 * 60 * 60;
24const NODE_OWNERSHIP_VERSION: u32 = 1;
25const SIGNING_DOMAIN_TAG: &[u8] = b"mesh-llm-node-ownership-v1:";
26const OWNER_CONTROL_CONNECT_TIMEOUT_SECS: u64 = 8;
27
28fn owner_control_client_bind_addr() -> std::net::SocketAddr {
29    std::net::SocketAddr::from(([0, 0, 0, 0], 0))
30}
31
32/// Explicit owner-control bootstrap policy for new config clients.
33///
34/// Negotiation matrix:
35/// - new client + explicit control endpoint -> use `mesh-llm-control/1`; configured control
36///   failures stay on the control lane and return structured errors.
37/// - new client + no control endpoint -> return `ControlEndpointRequired`.
38///
39/// Config and inventory mutation is intentionally exclusive to `mesh-llm-control/1`.
40/// The legacy mesh-plane config stream IDs remain reserved, but no client bootstrap path
41/// falls back to them.
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
43pub struct ControlPlaneBootstrapOptions {
44    control_endpoint: Option<String>,
45}
46
47impl ControlPlaneBootstrapOptions {
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    pub fn with_control_endpoint(mut self, control_endpoint: impl Into<String>) -> Self {
53        self.control_endpoint = Some(control_endpoint.into());
54        self
55    }
56
57    pub fn control_endpoint(&self) -> Option<&str> {
58        self.control_endpoint.as_deref()
59    }
60
61    pub fn select_transport(
62        &self,
63    ) -> Result<ConfigTransportSelection, ControlPlaneNegotiationError> {
64        match self.control_endpoint() {
65            Some(endpoint) => Ok(ConfigTransportSelection::OwnerControl {
66                endpoint: endpoint.to_string(),
67                retry_policy: ControlPlaneRetryPolicy::NoSilentLegacyDowngrade,
68            }),
69            None => Err(ControlPlaneNegotiationError::endpoint_required()),
70        }
71    }
72
73    pub fn configured_endpoint_failure(
74        &self,
75        code: OwnerControlErrorCode,
76        message: impl Into<String>,
77    ) -> ControlPlaneNegotiationError {
78        debug_assert!(self.control_endpoint.is_some());
79        ControlPlaneNegotiationError::structured(code, message, false)
80    }
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub enum ConfigTransportSelection {
85    OwnerControl {
86        endpoint: String,
87        retry_policy: ControlPlaneRetryPolicy,
88    },
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum ControlPlaneRetryPolicy {
93    NoSilentLegacyDowngrade,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct ControlPlaneNegotiationError {
98    pub code: OwnerControlErrorCode,
99    pub message: String,
100    pub legacy_retry_allowed: bool,
101}
102
103impl fmt::Display for ControlPlaneNegotiationError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "{:?}: {}", self.code, self.message)
106    }
107}
108
109impl std::error::Error for ControlPlaneNegotiationError {}
110
111impl ControlPlaneNegotiationError {
112    pub fn endpoint_required() -> Self {
113        Self {
114            code: OwnerControlErrorCode::ControlEndpointRequired,
115            message: "owner-control endpoint must be provided explicitly".to_string(),
116            legacy_retry_allowed: false,
117        }
118    }
119
120    pub fn structured(
121        code: OwnerControlErrorCode,
122        message: impl Into<String>,
123        legacy_retry_allowed: bool,
124    ) -> Self {
125        Self {
126            code,
127            message: message.into(),
128            legacy_retry_allowed,
129        }
130    }
131}
132
133#[derive(Debug, Error)]
134pub enum ControlPlaneClientError {
135    #[error(transparent)]
136    Negotiation(#[from] ControlPlaneNegotiationError),
137    #[error(transparent)]
138    Remote(#[from] OwnerControlRemoteError),
139    #[error("control transport error: {0}")]
140    Transport(String),
141    #[error("control protocol error: {0}")]
142    Protocol(String),
143}
144
145#[derive(Clone, Debug, PartialEq, Eq)]
146pub struct OwnerControlRemoteError {
147    pub code: OwnerControlErrorCode,
148    pub message: String,
149    pub request_id: Option<u64>,
150    pub current_revision: Option<u64>,
151}
152
153impl fmt::Display for OwnerControlRemoteError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(f, "{:?}: {}", self.code, self.message)
156    }
157}
158
159impl std::error::Error for OwnerControlRemoteError {}
160
161impl From<OwnerControlError> for OwnerControlRemoteError {
162    fn from(error: OwnerControlError) -> Self {
163        Self {
164            code: OwnerControlErrorCode::try_from(error.code)
165                .unwrap_or(OwnerControlErrorCode::BadRequest),
166            message: error.message,
167            request_id: error.request_id,
168            current_revision: error.current_revision,
169        }
170    }
171}
172
173/// Control-plane bootstrap is explicit and out-of-band.
174///
175/// Callers either receive an owner-control session bound to a configured endpoint,
176/// or a structured error. The client never performs a silent downgrade.
177pub enum ControlPlaneConnection {
178    OwnerControl(Box<OwnerControlClient>),
179}
180
181pub struct OwnerControlClient {
182    endpoint_token: String,
183    endpoint: Endpoint,
184    connection: iroh::endpoint::Connection,
185    owner_keypair: OwnerKeypair,
186    next_request_id: AtomicU64,
187}
188
189pub struct OwnerControlWatchStream {
190    send: iroh::endpoint::SendStream,
191    recv: iroh::endpoint::RecvStream,
192    request_id: u64,
193    closed: bool,
194}
195
196pub enum OwnerControlWatchEvent {
197    Accepted(OwnerControlWatchAccepted),
198    Snapshot(OwnerControlConfigSnapshot),
199    Update(OwnerControlConfigUpdate),
200}
201
202impl MeshClient {
203    /// Bootstrap config transport using the explicit owner-control endpoint policy.
204    ///
205    /// Owner-control endpoints are not discovered through gossip or status APIs;
206    /// callers must provide them explicitly through out-of-band bootstrap.
207    pub async fn connect_control_plane(
208        &self,
209        options: ControlPlaneBootstrapOptions,
210    ) -> Result<ControlPlaneConnection, ControlPlaneClientError> {
211        match options.select_transport()? {
212            ConfigTransportSelection::OwnerControl { endpoint, .. } => {
213                OwnerControlClient::connect(endpoint, self.config.owner_keypair.clone(), &options)
214                    .await
215                    .map(Box::new)
216                    .map(ControlPlaneConnection::OwnerControl)
217            }
218        }
219    }
220}
221
222impl OwnerControlClient {
223    async fn connect(
224        endpoint_token: String,
225        owner_keypair: OwnerKeypair,
226        options: &ControlPlaneBootstrapOptions,
227    ) -> Result<Self, ControlPlaneClientError> {
228        let control_addr = decode_endpoint_addr_token(&endpoint_token).map_err(|error| {
229            ControlPlaneClientError::Negotiation(options.configured_endpoint_failure(
230                OwnerControlErrorCode::ControlUnavailable,
231                format!("invalid owner-control endpoint token: {error}"),
232            ))
233        })?;
234        let mut builder = Endpoint::builder(iroh::endpoint::presets::Minimal)
235            .secret_key(iroh::SecretKey::generate())
236            .alpns(vec![ALPN_CONTROL_V1.to_vec()])
237            .bind_addr(owner_control_client_bind_addr())
238            .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
239        builder = builder.relay_mode(relay_mode_from_endpoint_addr(&control_addr));
240        let endpoint = builder
241            .bind()
242            .await
243            .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
244        if control_addr.relay_urls().next().is_some() {
245            let _ = tokio::time::timeout(
246                std::time::Duration::from_secs(OWNER_CONTROL_CONNECT_TIMEOUT_SECS),
247                endpoint.online(),
248            )
249            .await;
250        }
251        let connection = match tokio::time::timeout(
252            std::time::Duration::from_secs(OWNER_CONTROL_CONNECT_TIMEOUT_SECS),
253            endpoint.connect(control_addr.clone(), ALPN_CONTROL_V1),
254        )
255        .await
256        {
257            Ok(Ok(connection)) => connection,
258            Ok(Err(error)) => {
259                let error =
260                    configured_endpoint_connect_error(&endpoint, control_addr, options, error)
261                        .await;
262                endpoint.close().await;
263                return Err(error);
264            }
265            Err(_) => {
266                endpoint.close().await;
267                return Err(ControlPlaneClientError::Negotiation(options.configured_endpoint_failure(
268                    OwnerControlErrorCode::ControlUnavailable,
269                    format!(
270                        "remote owner-control endpoint is unavailable or unreachable: connect timed out after {OWNER_CONTROL_CONNECT_TIMEOUT_SECS}s"
271                    ),
272                )));
273            }
274        };
275        Ok(Self {
276            endpoint_token,
277            endpoint,
278            connection,
279            owner_keypair,
280            next_request_id: AtomicU64::new(1),
281        })
282    }
283
284    pub fn endpoint_token(&self) -> &str {
285        &self.endpoint_token
286    }
287
288    pub fn local_node_id(&self) -> [u8; 32] {
289        *self.endpoint.id().as_bytes()
290    }
291
292    pub fn target_node_id(&self) -> [u8; 32] {
293        *self.connection.remote_id().as_bytes()
294    }
295
296    pub async fn close(&self) {
297        self.connection
298            .close(0u32.into(), b"owner-control-client-close");
299        self.endpoint.close().await;
300    }
301
302    pub async fn get_config(&self) -> Result<OwnerControlConfigSnapshot, ControlPlaneClientError> {
303        let response = self
304            .send_unary_request(|request_id, requester_node_id, target_node_id| {
305                OwnerControlRequest {
306                    request_id,
307                    get_config: Some(OwnerControlGetConfigRequest {
308                        requester_node_id,
309                        target_node_id,
310                    }),
311                    watch_config: None,
312                    apply_config: None,
313                    refresh_inventory: None,
314                }
315            })
316            .await?;
317        response
318            .get_config
319            .and_then(|response| response.snapshot)
320            .ok_or_else(|| {
321                ControlPlaneClientError::Protocol(
322                    "owner-control get_config response missing snapshot payload".to_string(),
323                )
324            })
325    }
326
327    pub async fn apply_config(
328        &self,
329        expected_revision: u64,
330        config: NodeConfigSnapshot,
331    ) -> Result<OwnerControlApplyConfigResponse, ControlPlaneClientError> {
332        let response = self
333            .send_unary_request(|request_id, requester_node_id, target_node_id| {
334                OwnerControlRequest {
335                    request_id,
336                    get_config: None,
337                    watch_config: None,
338                    apply_config: Some(OwnerControlApplyConfigRequest {
339                        requester_node_id,
340                        target_node_id,
341                        expected_revision,
342                        config: Some(config),
343                    }),
344                    refresh_inventory: None,
345                }
346            })
347            .await?;
348        response.apply_config.ok_or_else(|| {
349            ControlPlaneClientError::Protocol(
350                "owner-control apply_config response missing apply payload".to_string(),
351            )
352        })
353    }
354
355    pub async fn refresh_inventory(
356        &self,
357    ) -> Result<OwnerControlConfigSnapshot, ControlPlaneClientError> {
358        let response = self
359            .send_unary_request(|request_id, requester_node_id, target_node_id| {
360                OwnerControlRequest {
361                    request_id,
362                    get_config: None,
363                    watch_config: None,
364                    apply_config: None,
365                    refresh_inventory: Some(OwnerControlRefreshInventoryRequest {
366                        requester_node_id,
367                        target_node_id,
368                    }),
369                }
370            })
371            .await?;
372        response
373            .refresh_inventory
374            .and_then(|response| response.snapshot)
375            .ok_or_else(|| {
376                ControlPlaneClientError::Protocol(
377                    "owner-control refresh_inventory response missing snapshot payload".to_string(),
378                )
379            })
380    }
381
382    pub async fn watch_config(
383        &self,
384        include_snapshot: bool,
385    ) -> Result<OwnerControlWatchStream, ControlPlaneClientError> {
386        let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
387        let (mut send, recv) = self.open_authenticated_stream().await?;
388        let envelope = OwnerControlEnvelope {
389            r#gen: NODE_PROTOCOL_GENERATION,
390            handshake: None,
391            request: Some(OwnerControlRequest {
392                request_id,
393                get_config: None,
394                watch_config: Some(OwnerControlWatchConfigRequest {
395                    requester_node_id: self.endpoint.id().as_bytes().to_vec(),
396                    target_node_id: self.connection.remote_id().as_bytes().to_vec(),
397                    include_snapshot,
398                }),
399                apply_config: None,
400                refresh_inventory: None,
401            }),
402            response: None,
403            error: None,
404        };
405        write_len_prefixed(&mut send, &envelope.encode_to_vec())
406            .await
407            .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
408        Ok(OwnerControlWatchStream {
409            send,
410            recv,
411            request_id,
412            closed: false,
413        })
414    }
415
416    async fn send_unary_request<F>(
417        &self,
418        build_request: F,
419    ) -> Result<OwnerControlResponse, ControlPlaneClientError>
420    where
421        F: FnOnce(u64, Vec<u8>, Vec<u8>) -> OwnerControlRequest,
422    {
423        let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
424        let (mut send, mut recv) = self.open_authenticated_stream().await?;
425        let envelope = OwnerControlEnvelope {
426            r#gen: NODE_PROTOCOL_GENERATION,
427            handshake: None,
428            request: Some(build_request(
429                request_id,
430                self.endpoint.id().as_bytes().to_vec(),
431                self.connection.remote_id().as_bytes().to_vec(),
432            )),
433            response: None,
434            error: None,
435        };
436        write_len_prefixed(&mut send, &envelope.encode_to_vec())
437            .await
438            .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
439        let envelope = read_owner_control_message(&mut recv).await?;
440        let _ = send.finish();
441        decode_response_envelope(request_id, envelope)
442    }
443
444    async fn open_authenticated_stream(
445        &self,
446    ) -> Result<(iroh::endpoint::SendStream, iroh::endpoint::RecvStream), ControlPlaneClientError>
447    {
448        let (mut send, recv) = self
449            .connection
450            .open_bi()
451            .await
452            .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
453        let handshake = OwnerControlEnvelope {
454            r#gen: NODE_PROTOCOL_GENERATION,
455            handshake: Some(OwnerControlHandshake {
456                ownership: Some(sign_node_ownership_proto(
457                    &self.owner_keypair,
458                    self.endpoint.id().as_bytes(),
459                )),
460            }),
461            request: None,
462            response: None,
463            error: None,
464        };
465        write_len_prefixed(&mut send, &handshake.encode_to_vec())
466            .await
467            .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
468        Ok((send, recv))
469    }
470}
471
472impl OwnerControlWatchStream {
473    pub fn request_id(&self) -> u64 {
474        self.request_id
475    }
476
477    pub async fn next(&mut self) -> Result<OwnerControlWatchEvent, ControlPlaneClientError> {
478        let envelope = read_owner_control_message(&mut self.recv).await?;
479        let response = decode_response_envelope(self.request_id, envelope)?;
480        let watch = response.watch_config.ok_or_else(|| {
481            ControlPlaneClientError::Protocol(
482                "owner-control watch response missing watch_config payload".to_string(),
483            )
484        })?;
485        decode_watch_event(watch)
486    }
487
488    pub async fn close(&mut self) -> Result<(), ControlPlaneClientError> {
489        if self.closed {
490            return Ok(());
491        }
492        self.send
493            .finish()
494            .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
495        self.closed = true;
496        Ok(())
497    }
498
499    pub async fn cancel(&mut self) -> Result<(), ControlPlaneClientError> {
500        self.close().await
501    }
502}
503
504impl Drop for OwnerControlWatchStream {
505    fn drop(&mut self) {
506        if !self.closed {
507            let _ = self.send.finish();
508            self.closed = true;
509        }
510    }
511}
512
513fn decode_watch_event(
514    watch: OwnerControlWatchConfigResponse,
515) -> Result<OwnerControlWatchEvent, ControlPlaneClientError> {
516    if let Some(accepted) = watch.accepted {
517        return Ok(OwnerControlWatchEvent::Accepted(accepted));
518    }
519    if let Some(snapshot) = watch.snapshot {
520        return Ok(OwnerControlWatchEvent::Snapshot(snapshot));
521    }
522    if let Some(update) = watch.update {
523        return Ok(OwnerControlWatchEvent::Update(update));
524    }
525    Err(ControlPlaneClientError::Protocol(
526        "owner-control watch response missing accepted/snapshot/update payload".to_string(),
527    ))
528}
529
530fn decode_response_envelope(
531    expected_request_id: u64,
532    envelope: OwnerControlEnvelope,
533) -> Result<OwnerControlResponse, ControlPlaneClientError> {
534    if let Some(error) = envelope.error {
535        return Err(ControlPlaneClientError::Remote(error.into()));
536    }
537    let response = envelope.response.ok_or_else(|| {
538        ControlPlaneClientError::Protocol(
539            "owner-control response envelope missing response payload".to_string(),
540        )
541    })?;
542    if response.request_id != expected_request_id {
543        return Err(ControlPlaneClientError::Protocol(format!(
544            "owner-control response request_id mismatch: expected {expected_request_id}, got {}",
545            response.request_id
546        )));
547    }
548    Ok(response)
549}
550
551async fn read_owner_control_message(
552    recv: &mut iroh::endpoint::RecvStream,
553) -> Result<OwnerControlEnvelope, ControlPlaneClientError> {
554    let bytes = crate::protocol::read_len_prefixed(recv)
555        .await
556        .map_err(|error| ControlPlaneClientError::Transport(error.to_string()))?;
557    decode_owner_control_envelope(&bytes)
558        .map_err(|error| ControlPlaneClientError::Protocol(error.to_string()))
559}
560
561async fn configured_endpoint_connect_error(
562    endpoint: &Endpoint,
563    control_addr: EndpointAddr,
564    options: &ControlPlaneBootstrapOptions,
565    error: iroh::endpoint::ConnectError,
566) -> ControlPlaneClientError {
567    let message = error.to_string();
568    let legacy_mesh_reachable = legacy_mesh_probe(endpoint, control_addr).await;
569    let (code, rendered) = if legacy_mesh_reachable || is_alpn_mismatch_message(&message) {
570        (
571            OwnerControlErrorCode::ControlUnsupported,
572            format!("remote endpoint did not negotiate mesh-llm-control/1: {message}"),
573        )
574    } else {
575        (
576            OwnerControlErrorCode::ControlUnavailable,
577            format!("remote owner-control endpoint is unavailable or unreachable: {message}"),
578        )
579    };
580    ControlPlaneClientError::Negotiation(options.configured_endpoint_failure(code, rendered))
581}
582
583async fn legacy_mesh_probe(_endpoint: &Endpoint, control_addr: EndpointAddr) -> bool {
584    let Ok(probe_endpoint) = Endpoint::builder(iroh::endpoint::presets::Minimal)
585        .secret_key(iroh::SecretKey::generate())
586        .alpns(vec![ALPN_V1.to_vec()])
587        .relay_mode(relay_mode_from_endpoint_addr(&control_addr))
588        .bind_addr(owner_control_client_bind_addr())
589    else {
590        return false;
591    };
592    let Ok(probe_endpoint) = probe_endpoint.bind().await else {
593        return false;
594    };
595    if control_addr.relay_urls().next().is_some() {
596        let _ =
597            tokio::time::timeout(std::time::Duration::from_secs(3), probe_endpoint.online()).await;
598    }
599    let reachable = match tokio::time::timeout(
600        std::time::Duration::from_secs(3),
601        probe_endpoint.connect(control_addr, ALPN_V1),
602    )
603    .await
604    {
605        Ok(Ok(connection)) => {
606            connection.close(0u32.into(), b"owner-control-legacy-probe-complete");
607            true
608        }
609        _ => false,
610    };
611    probe_endpoint.close().await;
612    reachable
613}
614
615fn relay_mode_from_endpoint_addr(addr: &EndpointAddr) -> iroh::endpoint::RelayMode {
616    match relay_map_from_endpoint_addr(addr) {
617        Some(relay_map) => iroh::endpoint::RelayMode::Custom(relay_map),
618        None => iroh::endpoint::RelayMode::Disabled,
619    }
620}
621
622fn is_alpn_mismatch_message(message: &str) -> bool {
623    let lowered = message.to_ascii_lowercase();
624    lowered.contains("alpn")
625        || lowered.contains("application protocol")
626        || lowered.contains("no application protocol")
627}
628
629fn decode_endpoint_addr_token(invite_token: &str) -> anyhow::Result<EndpointAddr> {
630    let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
631        .decode(invite_token)
632        .context("invalid endpoint encoding")?;
633    serde_json::from_slice(&json).context("invalid endpoint JSON")
634}
635
636fn relay_map_from_endpoint_addr(addr: &EndpointAddr) -> Option<iroh::RelayMap> {
637    let configs: Vec<_> = addr
638        .relay_urls()
639        .cloned()
640        .map(|url| iroh::RelayConfig::new(url, None))
641        .collect();
642    if configs.is_empty() {
643        None
644    } else {
645        Some(iroh::RelayMap::from_iter(configs))
646    }
647}
648
649fn sign_node_ownership_proto(
650    owner: &OwnerKeypair,
651    node_endpoint_id: &[u8; 32],
652) -> SignedNodeOwnership {
653    let issued_at_unix_ms = current_time_unix_ms();
654    let expires_at_unix_ms =
655        issued_at_unix_ms + DEFAULT_NODE_CERT_LIFETIME_SECS.saturating_mul(1000);
656    let cert_id = uuid::Uuid::new_v4().simple().to_string();
657    let owner_sign_public_key = owner.verifying_key().as_bytes().to_vec();
658    let owner_id = owner.owner_id();
659    let signature_payload = canonical_claim_bytes(CanonicalClaim {
660        version: NODE_OWNERSHIP_VERSION,
661        cert_id: &cert_id,
662        owner_id: &owner_id,
663        owner_sign_public_key: &owner_sign_public_key,
664        node_endpoint_id,
665        issued_at_unix_ms,
666        expires_at_unix_ms,
667        node_label: None,
668        hostname_hint: None,
669    });
670    SignedNodeOwnership {
671        version: NODE_OWNERSHIP_VERSION,
672        cert_id,
673        owner_id,
674        owner_sign_public_key,
675        node_endpoint_id: node_endpoint_id.to_vec(),
676        issued_at_unix_ms,
677        expires_at_unix_ms,
678        node_label: None,
679        hostname_hint: None,
680        signature: owner.sign_bytes(&signature_payload).to_vec(),
681    }
682}
683
684struct CanonicalClaim<'a> {
685    version: u32,
686    cert_id: &'a str,
687    owner_id: &'a str,
688    owner_sign_public_key: &'a [u8],
689    node_endpoint_id: &'a [u8; 32],
690    issued_at_unix_ms: u64,
691    expires_at_unix_ms: u64,
692    node_label: Option<&'a str>,
693    hostname_hint: Option<&'a str>,
694}
695
696fn canonical_claim_bytes(claim: CanonicalClaim<'_>) -> Vec<u8> {
697    let mut buf = Vec::with_capacity(256);
698    buf.extend_from_slice(SIGNING_DOMAIN_TAG);
699    buf.extend_from_slice(&claim.version.to_le_bytes());
700    write_string(&mut buf, claim.cert_id);
701    write_string(&mut buf, claim.owner_id);
702    buf.extend_from_slice(claim.owner_sign_public_key);
703    buf.extend_from_slice(claim.node_endpoint_id);
704    buf.extend_from_slice(&claim.issued_at_unix_ms.to_le_bytes());
705    buf.extend_from_slice(&claim.expires_at_unix_ms.to_le_bytes());
706    write_optional_string(&mut buf, claim.node_label);
707    write_optional_string(&mut buf, claim.hostname_hint);
708    buf
709}
710
711fn write_string(buf: &mut Vec<u8>, value: &str) {
712    buf.extend_from_slice(&(value.len() as u64).to_le_bytes());
713    buf.extend_from_slice(value.as_bytes());
714}
715
716fn write_optional_string(buf: &mut Vec<u8>, value: Option<&str>) {
717    match value {
718        Some(value) => {
719            buf.push(1);
720            write_string(buf, value);
721        }
722        None => buf.push(0),
723    }
724}
725
726fn current_time_unix_ms() -> u64 {
727    std::time::SystemTime::now()
728        .duration_since(std::time::UNIX_EPOCH)
729        .unwrap_or_default()
730        .as_millis() as u64
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use std::str::FromStr;
737
738    #[test]
739    fn owner_control_client_binds_wildcard_for_direct_remote_endpoints() {
740        let bind_addr = owner_control_client_bind_addr();
741
742        assert_eq!(bind_addr.port(), 0);
743        assert!(
744            bind_addr.ip().is_unspecified(),
745            "owner-control clients must not be loopback-bound when dialing explicit remote endpoints"
746        );
747    }
748
749    #[test]
750    fn relay_mode_uses_custom_relays_from_endpoint_addr() {
751        let addr = EndpointAddr::new(iroh::SecretKey::generate().public()).with_relay_url(
752            iroh::RelayUrl::from_str("https://relay.example.com").expect("relay URL parses"),
753        );
754
755        assert!(matches!(
756            relay_mode_from_endpoint_addr(&addr),
757            iroh::endpoint::RelayMode::Custom(_)
758        ));
759    }
760
761    #[test]
762    fn relay_mode_is_disabled_without_endpoint_relays() {
763        let addr = EndpointAddr::new(iroh::SecretKey::generate().public());
764
765        assert!(matches!(
766            relay_mode_from_endpoint_addr(&addr),
767            iroh::endpoint::RelayMode::Disabled
768        ));
769    }
770}