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#[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
173pub 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 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}