Skip to main content

seyd_core/
agent.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Agent lifecycle: everything between a configuration and a running robot —
4//! sockets, candidate discovery, certificate, QUIC endpoint, the session
5//! [`Engine`], signaling, and the maintenance that keeps all of it alive
6//! (port-mapping lease renewal, certificate rotation, network-change
7//! re-gather).
8//!
9//! `seydd` and `seyd-ffi` are both *hosts* of this type. A host supplies media
10//! (`push_video`, `push_message`), consumes [`AgentEvent`], and owns nothing of
11//! the lifecycle itself — which is what keeps the daemon and the SDK from
12//! drifting apart. Anything a host does differently (where frames come from,
13//! where commands go, how the publisher is told to reconfigure) stays in the
14//! host; anything that must be identical everywhere lives here.
15
16use crate::channels::ChannelSpec;
17use crate::engine::{Engine, EngineConfig, Event, Role, VideoFrame};
18use seyd_signal_client::messages::{Announce, Candidate, ChannelInfo};
19use seyd_transport::{Cert, Endpoint, TransportConfig};
20use std::net::IpAddr;
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24use tokio::sync::{mpsc, Notify};
25
26/// How a robot is configured, independent of where that configuration came
27/// from — `seydd.toml`, a C `seyd_config`, or a test.
28#[derive(Debug, Clone)]
29pub struct AgentConfig {
30    /// Ask for the cheapest useful recovery point (LTR → intra-refresh → IDR)
31    /// instead of always demanding a keyframe. Turn off for a publisher that
32    /// mishandles any `kind` but `idr`.
33    pub recovery_ladder: bool,
34    pub robot_id: String,
35    pub signal_url: String,
36    pub credential_path: PathBuf,
37    pub quic_port: u16,
38    pub ipv6: bool,
39    pub port_mapping: bool,
40    /// Skip discovery and advertise this host only (LAN testing).
41    pub host_override: Option<String>,
42    pub qos_profile: String,
43    pub max_sessions: u32,
44    pub channels: Vec<ChannelSpec>,
45    /// Reported to the signal server, e.g. `seydd/0.1.0`.
46    pub agent_version: String,
47    /// Accept a session carried by the cloud relay when the pilot could not
48    /// connect directly (ADR 0010). The relay is a last resort the pilot
49    /// chooses after its candidate race fails; this only says whether the
50    /// robot will serve it. Off means such pilots get a diagnosis instead.
51    pub relay: bool,
52}
53
54impl Default for AgentConfig {
55    fn default() -> Self {
56        AgentConfig {
57            robot_id: String::new(),
58            signal_url: String::new(),
59            recovery_ladder: true,
60            credential_path: PathBuf::from("/var/lib/seyd/robot.key"),
61            quic_port: 4433,
62            ipv6: true,
63            port_mapping: true,
64            host_override: None,
65            qos_profile: "balanced".into(),
66            max_sessions: 4,
67            channels: Vec::new(),
68            agent_version: concat!("seyd-core/", env!("CARGO_PKG_VERSION")).into(),
69            relay: true,
70        }
71    }
72}
73
74/// What a running agent tells its host. The engine's own events pass through
75/// unchanged; the rest describe the lifecycle the host no longer runs itself.
76#[derive(Debug, Clone)]
77pub enum AgentEvent {
78    /// A pilot session was admitted. Signaling has already been told.
79    SessionStarted {
80        signal_id: String,
81        role: Role,
82        path_label: String,
83    },
84    SessionEnded {
85        signal_id: String,
86        reason: String,
87    },
88    /// A driver sent a message on a command channel.
89    Command {
90        channel: u8,
91        payload: bytes::Bytes,
92    },
93    /// Seyd's request to the publisher (`video-config`).
94    RequestedConfig(serde_json::Value),
95    /// A pilot needs a recovery point on this channel.
96    RecoveryRequest {
97        channel: u8,
98        kind: &'static str,
99        reason: &'static str,
100    },
101    /// The signaling connection came up or went down. Robots are unreachable
102    /// while it is down, so a host with a UI should show it.
103    SignalConnected,
104    SignalDisconnected,
105    /// Signaling refused authentication — almost always a misconfiguration.
106    SignalDenied {
107        reason: String,
108    },
109    /// The relayed simulcast layer changed (ADR 0008). A host that can force a
110    /// keyframe on the incoming layer should do so — the engine switches on that
111    /// layer's next keyframe, and asking makes it the next one.
112    LayerChanged {
113        channel: u8,
114        layer: u8,
115        name: String,
116        reason: &'static str,
117    },
118    /// Candidates were (re-)gathered and announced. Carries the `NatReport`
119    /// as JSON; the host may surface it or ignore it.
120    NatReport(serde_json::Value),
121}
122
123/// A running robot agent. Cheap to clone; every clone refers to the same agent.
124#[derive(Clone)]
125pub struct Agent {
126    engine: Engine,
127    signal: seyd_signal_client::Handle,
128    shutdown: Arc<Notify>,
129    channels: Arc<Vec<ChannelSpec>>,
130}
131
132impl Agent {
133    /// Bind, discover, announce, and start serving. Returns once the agent is
134    /// announced and accepting sessions; the maintenance loop runs in the
135    /// background until [`Agent::stop`] or the event receiver is dropped.
136    pub async fn start(cfg: AgentConfig) -> anyhow::Result<(Agent, mpsc::Receiver<AgentEvent>)> {
137        let profile = seyd_qos::get(&cfg.qos_profile)
138            .ok_or_else(|| anyhow::anyhow!("unknown qos_profile {:?}", cfg.qos_profile))?;
139        let specs = cfg.channels.clone();
140
141        // ── sockets, discovery, certificate, transport ───────────────────
142        let socks = seyd_nat::bind_sockets(cfg.quic_port, cfg.ipv6)?;
143        // Clones share the fds, so a later re-gather runs STUN on the very
144        // sockets QUIC is serving (never rebound) — same rule as at startup.
145        let gather_socks: Vec<std::net::UdpSocket> = socks
146            .iter()
147            .map(|s| s.try_clone())
148            .collect::<Result<_, _>>()?;
149        let gathered = seyd_nat::gather(&socks, &gather_opts(&cfg)).await;
150        for c in &gathered.candidates {
151            tracing::info!(label = %c.label, priority = c.priority, probe = c.needs_probe, url = %c.url, "candidate");
152        }
153        tracing::info!(hint = ?gathered.p2p_hint, "p2p hint");
154        let cert = Cert::generate(&gathered.san_ips)?;
155        tracing::info!(fingerprint = %cert.fingerprint_hex(), days = cert.days_left() as u32, "certificate");
156        let endpoint = Arc::new(Endpoint::bind(socks, &cert, TransportConfig::default())?);
157
158        // ── engine ───────────────────────────────────────────────────────
159        let (engine, events) = Engine::new(EngineConfig {
160            recovery_ladder: cfg.recovery_ladder,
161            channels: specs.clone(),
162            profile,
163            max_sessions: cfg.max_sessions as usize,
164            chunk_len: seyd_wire::v2::DEFAULT_CHUNK_LEN,
165        });
166        tokio::spawn(engine.clone().accept_loop(endpoint.clone()));
167
168        // ── signaling ────────────────────────────────────────────────────
169        let identity = seyd_signal_client::Identity::load_or_create(&cfg.credential_path)?;
170        tracing::info!(public_key = %identity.public_key_b64(), "robot identity");
171        let (signal, signal_events) = seyd_signal_client::start(
172            seyd_signal_client::Config {
173                signal_url: cfg.signal_url.clone(),
174                robot_id: cfg.robot_id.clone(),
175                agent_version: cfg.agent_version.clone(),
176                heartbeat: Duration::from_secs(5),
177            },
178            identity,
179        );
180        let mut fps = Fingerprints::new();
181        signal
182            .announce(build_announce(
183                &gathered,
184                fps.advertise(&cert),
185                &specs,
186                cfg.max_sessions,
187                cfg.relay,
188            )?)
189            .await;
190
191        let shutdown = Arc::new(Notify::new());
192        let agent = Agent {
193            engine: engine.clone(),
194            signal: signal.clone(),
195            shutdown: shutdown.clone(),
196            channels: Arc::new(specs.clone()),
197        };
198
199        let (host_tx, host_rx) = mpsc::channel(256);
200        tokio::spawn(maintain(
201            Lifecycle {
202                cfg,
203                specs,
204                gather_socks,
205                gathered,
206                cert,
207                fps,
208                engine,
209                endpoint,
210                signal,
211                shutdown,
212            },
213            events,
214            signal_events,
215            host_tx,
216        ));
217        Ok((agent, host_rx))
218    }
219
220    /// Hand an encoded picture to the engine. Never blocks: a frame that finds
221    /// the slot full is dropped by the engine's admission control.
222    pub fn push_video(&self, channel: u8, frame: VideoFrame) {
223        self.engine.push_video(channel, frame);
224    }
225
226    /// As `push_video`, for a channel publishing several simulcast layers
227    /// (ADR 0008). Offer every layer's frames; the agent relays one and drops
228    /// the rest, so switching costs a keyframe and never a reconnect. On a
229    /// channel that declared no ladder this is `push_video` and `layer` is
230    /// ignored.
231    pub fn push_video_layer(&self, channel: u8, layer: u8, frame: VideoFrame) {
232        self.engine.push_video_layer(channel, layer, frame);
233    }
234
235    /// Hand a message to a sensor channel.
236    pub fn push_message(&self, channel: u8, payload: &[u8]) {
237        self.engine.push_message(channel, payload);
238    }
239
240    pub fn channels(&self) -> &[ChannelSpec] {
241        &self.channels
242    }
243
244    pub fn channel_by_name(&self, name: &str) -> Option<&ChannelSpec> {
245        self.channels.iter().find(|c| c.name == name)
246    }
247
248    pub fn counters(&self) -> &crate::engine::Counters {
249        self.engine.counters()
250    }
251
252    pub fn session_count(&self) -> usize {
253        self.engine.session_count()
254    }
255
256    pub fn profile(&self) -> &'static seyd_qos::Profile {
257        self.engine.profile()
258    }
259
260    /// The `video-config` a publisher should be sent for `channel` under the
261    /// profile in force, with the bitrate clamped to what that channel's
262    /// publisher declared it can produce (`ChannelSpec::max_bitrate_kbps`).
263    pub fn publisher_config(&self, channel: u8, reason: &str) -> serde_json::Value {
264        self.engine.publisher_config(channel, reason)
265    }
266
267    /// Switch the QoS ceiling. `false` if no profile by that name exists.
268    pub fn set_qos_profile(&self, name: &str, reason: &str) -> bool {
269        match seyd_qos::get(name) {
270            Some(p) => {
271                self.engine.set_profile(p, reason);
272                true
273            }
274            None => false,
275        }
276    }
277
278    /// Free-form robot status, forwarded to the console through signaling.
279    pub async fn set_status(&self, status: Option<serde_json::Value>) {
280        self.signal.set_status(status).await;
281    }
282
283    pub fn signal_connected(&self) -> bool {
284        self.signal.is_connected()
285    }
286
287    /// Stop serving. The maintenance loop exits and the endpoint closes;
288    /// in-flight sessions are torn down.
289    pub fn stop(&self) {
290        // `notify_one`, not `notify_waiters`: the maintenance loop may be busy
291        // in another `select!` branch, and a stop that arrived then must be
292        // remembered rather than dropped.
293        self.shutdown.notify_one();
294    }
295}
296
297// ── maintenance ──────────────────────────────────────────────────────────
298
299/// The mutable half of a running agent, owned solely by [`maintain`].
300struct Lifecycle {
301    cfg: AgentConfig,
302    specs: Vec<ChannelSpec>,
303    gather_socks: Vec<std::net::UdpSocket>,
304    gathered: seyd_nat::Gathered,
305    cert: Cert,
306    fps: Fingerprints,
307    engine: Engine,
308    endpoint: Arc<Endpoint>,
309    signal: seyd_signal_client::Handle,
310    shutdown: Arc<Notify>,
311}
312
313enum Maint {
314    NetworkChanged(String),
315    RotateCheck,
316    RotateNow,
317}
318
319/// The agent's one event loop: engine events and signaling events in, host
320/// events out, with lease renewal, certificate rotation and re-gather on the
321/// side. Exits on [`Agent::stop`] or when the host drops its receiver.
322async fn maintain(
323    mut lc: Lifecycle,
324    mut events: mpsc::Receiver<Event>,
325    mut signal_events: mpsc::Receiver<seyd_signal_client::Event>,
326    host: mpsc::Sender<AgentEvent>,
327) {
328    let (maint_tx, mut maint_rx) = mpsc::channel::<Maint>(8);
329    let (mapping_tx, mapping_rx) = tokio::sync::watch::channel(lc.gathered.mapping.clone());
330    tokio::spawn(renew_loop(mapping_rx, maint_tx.clone()));
331    {
332        // Interface changes → re-gather. The watcher's own force sender is
333        // unused here; renewal failures go through the maint channel directly.
334        let tx = maint_tx.clone();
335        let mut watch = seyd_nat::watch_network_changes();
336        tokio::spawn(async move {
337            while let Some(reason) = watch.events.recv().await {
338                if tx.send(Maint::NetworkChanged(reason)).await.is_err() {
339                    return;
340                }
341            }
342        });
343    }
344    {
345        let tx = maint_tx;
346        tokio::spawn(async move {
347            // Test hook: force a rotation shortly after startup.
348            if let Some(secs) = std::env::var("SEYD_CERT_ROTATE_AFTER_SECS")
349                .ok()
350                .and_then(|v| v.parse::<u64>().ok())
351            {
352                tokio::time::sleep(Duration::from_secs(secs)).await;
353                let _ = tx.send(Maint::RotateNow).await;
354            }
355            loop {
356                tokio::time::sleep(Duration::from_secs(3600)).await;
357                if tx.send(Maint::RotateCheck).await.is_err() {
358                    return;
359                }
360            }
361        });
362    }
363    let mut last_regather = Instant::now();
364    // A network change inside the rate limit, or a re-gather that found
365    // nothing, is not dropped: it is held here and run when due. Found on
366    // the Tello demo (2026-10-06): the Wi-Fi switched networks, the re-gather
367    // ran in the instant both old addresses were gone and the new one not yet
368    // up — no route, STUN failed, zero candidates — and the address that
369    // arrived a second later was "ignored (rate-limited)". The agent then
370    // announced an empty candidate list until restarted, and every pilot,
371    // including one on the same machine, went straight to the relay.
372    let mut pending_regather: Option<(String, Instant)> = None;
373
374    loop {
375        let due = pending_regather
376            .as_ref()
377            .map(|(_, at)| *at)
378            .unwrap_or_else(|| Instant::now() + Duration::from_secs(3600));
379        tokio::select! {
380            _ = tokio::time::sleep_until(tokio::time::Instant::from_std(due)), if pending_regather.is_some() => {
381                let (reason, _) = pending_regather.take().unwrap();
382                if !regather(&mut lc, &format!("{reason} (deferred)"), &mut last_regather, &mut pending_regather, &mapping_tx, &host).await { break }
383            }
384            ev = signal_events.recv() => {
385                let Some(ev) = ev else { break };
386                match ev {
387                    seyd_signal_client::Event::PilotConnecting { session_id, pilot_ip, role, .. } => {
388                        let role = match role { seyd_signal_client::messages::Role::Driver => Role::Driver, _ => Role::Observer };
389                        lc.engine.expect_pilot(&session_id, role);
390                        probe(&lc.endpoint, pilot_ip);
391                    }
392                    seyd_signal_client::Event::Punch { pilot_ip, .. } => probe(&lc.endpoint, pilot_ip),
393                    seyd_signal_client::Event::SessionRevoked { session_id, reason } => lc.engine.revoke(&session_id, &reason),
394                    seyd_signal_client::Event::RelayOpen { session_id, url, token, role, pilot_ip } => {
395                        if lc.cfg.relay {
396                            let role = match role { seyd_signal_client::messages::Role::Driver => Role::Driver, _ => Role::Observer };
397                            lc.engine.expect_pilot(&session_id, role);
398                            tokio::spawn(open_relay(lc.engine.clone(), session_id, url, token, pilot_ip));
399                        } else {
400                            tracing::warn!(session = %session_id, "relay-open ignored: relay disabled in config");
401                        }
402                    }
403                    seyd_signal_client::Event::Denied { reason } => {
404                        tracing::error!(%reason, "signal denied");
405                        if host.send(AgentEvent::SignalDenied { reason }).await.is_err() { break }
406                    }
407                    seyd_signal_client::Event::Connected => {
408                        if host.send(AgentEvent::SignalConnected).await.is_err() { break }
409                    }
410                    seyd_signal_client::Event::Disconnected => {
411                        if host.send(AgentEvent::SignalDisconnected).await.is_err() { break }
412                    }
413                }
414            }
415            ev = events.recv() => {
416                let Some(ev) = ev else { break };
417                // Signaling is the agent's business; the host only learns what happened.
418                let out = match ev {
419                    Event::SessionStarted { signal_id, role, path_label } => {
420                        lc.signal.session_accepted(&signal_id, &path_label);
421                        lc.signal.set_sessions(lc.engine.signal_session_ids()).await;
422                        AgentEvent::SessionStarted { signal_id, role, path_label }
423                    }
424                    Event::SessionEnded { signal_id, reason } => {
425                        lc.signal.session_ended(&signal_id, &reason);
426                        lc.signal.set_sessions(lc.engine.signal_session_ids()).await;
427                        AgentEvent::SessionEnded { signal_id, reason }
428                    }
429                    Event::Command { channel, payload } => AgentEvent::Command { channel, payload },
430                    Event::RequestedConfig(v) => AgentEvent::RequestedConfig(v),
431                    Event::RecoveryRequest { channel, kind, reason } => AgentEvent::RecoveryRequest { channel, kind, reason },
432                    Event::LayerChanged { channel, layer, name, reason } => AgentEvent::LayerChanged { channel, layer, name, reason },
433                };
434                if host.send(out).await.is_err() { break }
435            }
436            ev = maint_rx.recv() => {
437                let Some(ev) = ev else { break };
438                if !handle_maint(&mut lc, ev, &mut last_regather, &mut pending_regather, &mapping_tx, &host).await { break }
439            }
440            _ = lc.shutdown.notified() => { tracing::info!("agent stopping"); break; }
441        }
442    }
443    lc.endpoint.close();
444}
445
446/// Re-gathers are rate-limited to one per this interval; a change inside it
447/// is deferred to the end of it, never dropped.
448const REGATHER_MIN_INTERVAL: Duration = Duration::from_secs(30);
449/// A re-gather that found no candidate at all is retried after this long:
450/// it almost certainly ran while an interface was between addresses.
451const EMPTY_REGATHER_RETRY: Duration = Duration::from_secs(5);
452
453/// Re-gather candidates after a network change, rotate the certificate if
454/// the addresses changed, announce. Returns `false` when the host is gone.
455async fn regather(
456    lc: &mut Lifecycle,
457    reason: &str,
458    last_regather: &mut Instant,
459    pending_regather: &mut Option<(String, Instant)>,
460    mapping_tx: &tokio::sync::watch::Sender<Option<seyd_nat::PortMapping>>,
461    host: &mpsc::Sender<AgentEvent>,
462) -> bool {
463    *last_regather = Instant::now();
464    tracing::info!(%reason, "network change — re-gathering candidates");
465    let new_g = seyd_nat::gather(&lc.gather_socks, &gather_opts(&lc.cfg)).await;
466    let san_changed = {
467        let a: std::collections::BTreeSet<&IpAddr> = lc.gathered.san_ips.iter().collect();
468        let b: std::collections::BTreeSet<&IpAddr> = new_g.san_ips.iter().collect();
469        a != b
470    };
471    for c in &new_g.candidates {
472        tracing::info!(label = %c.label, priority = c.priority, url = %c.url, "candidate");
473    }
474    if new_g.candidates.is_empty() {
475        tracing::warn!(
476            retry_s = EMPTY_REGATHER_RETRY.as_secs(),
477            "re-gather found no candidates — the interface is probably between addresses; retrying"
478        );
479        *pending_regather = Some((
480            "empty re-gather".into(),
481            Instant::now() + EMPTY_REGATHER_RETRY,
482        ));
483    }
484    lc.gathered = new_g;
485    let _ = mapping_tx.send(lc.gathered.mapping.clone());
486    if san_changed {
487        // New addresses must be in the certificate's SAN or Chrome
488        // rejects the handshake.
489        if let Err(e) = do_rotate(
490            &mut lc.cert,
491            &mut lc.fps,
492            &lc.endpoint,
493            &lc.gathered.san_ips,
494        ) {
495            tracing::error!(error = %e, "certificate rotation after network change failed");
496        }
497    }
498    announce(lc, host).await
499}
500
501/// One maintenance event. Returns `false` when the host is gone.
502async fn handle_maint(
503    lc: &mut Lifecycle,
504    ev: Maint,
505    last_regather: &mut Instant,
506    pending_regather: &mut Option<(String, Instant)>,
507    mapping_tx: &tokio::sync::watch::Sender<Option<seyd_nat::PortMapping>>,
508    host: &mpsc::Sender<AgentEvent>,
509) -> bool {
510    let rotate_due = match &ev {
511        Maint::RotateNow => true,
512        Maint::RotateCheck => lc.cert.days_left() < 3.0,
513        Maint::NetworkChanged(_) => false,
514    };
515    if let Maint::NetworkChanged(reason) = &ev {
516        let elapsed = last_regather.elapsed();
517        if elapsed < REGATHER_MIN_INTERVAL {
518            // Not dropped: run it when the rate limit expires. The latest
519            // reason wins the label; the earliest due time stays.
520            let at = *last_regather + REGATHER_MIN_INTERVAL;
521            let at = pending_regather
522                .as_ref()
523                .map(|(_, t)| (*t).min(at))
524                .unwrap_or(at);
525            tracing::info!(%reason, in_s = (at - Instant::now()).as_secs(), "network change deferred (rate-limited)");
526            *pending_regather = Some((reason.clone(), at));
527        } else if !regather(
528            lc,
529            reason,
530            last_regather,
531            pending_regather,
532            mapping_tx,
533            host,
534        )
535        .await
536        {
537            return false;
538        }
539    }
540    if rotate_due {
541        match do_rotate(
542            &mut lc.cert,
543            &mut lc.fps,
544            &lc.endpoint,
545            &lc.gathered.san_ips,
546        ) {
547            Ok(()) => return announce(lc, host).await,
548            Err(e) => tracing::error!(error = %e, "certificate rotation failed"),
549        }
550    }
551    true
552}
553
554/// Publish the current candidates and fingerprints, and tell the host.
555/// Returns `false` when the host is gone.
556async fn announce(lc: &mut Lifecycle, host: &mpsc::Sender<AgentEvent>) -> bool {
557    let fingerprints = lc.fps.advertise(&lc.cert);
558    match build_announce(
559        &lc.gathered,
560        fingerprints,
561        &lc.specs,
562        lc.cfg.max_sessions,
563        lc.cfg.relay,
564    ) {
565        Ok(a) => {
566            let report = a.nat_report.clone();
567            lc.signal.announce(a).await;
568            host.send(AgentEvent::NatReport(report)).await.is_ok()
569        }
570        Err(e) => {
571            tracing::error!(error = %e, "building announce failed");
572            true
573        }
574    }
575}
576
577/// Nudge an address-restricted NAT open toward a pilot we were told about.
578fn probe(endpoint: &Endpoint, ip: Option<String>) {
579    let Some(ip) = ip.and_then(|s| s.parse::<IpAddr>().ok()) else {
580        return;
581    };
582    if let Some(idx) = endpoint.socket_index_for(ip) {
583        if let Some(p) = endpoint.prober(idx) {
584            // Chrome's QUIC source port is unknowable; the probe only helps
585            // address-restricted NATs, where the port is irrelevant.
586            p.start_default(std::net::SocketAddr::new(ip, 443));
587        }
588    }
589}
590
591fn gather_opts(cfg: &AgentConfig) -> seyd_nat::GatherOpts {
592    seyd_nat::GatherOpts {
593        port: cfg.quic_port,
594        host_override: cfg.host_override.as_deref().and_then(|h| h.parse().ok()),
595        port_mapping: cfg.port_mapping,
596        ipv6: cfg.ipv6,
597    }
598}
599
600/// Which certificate fingerprints to advertise. After a rotation the outgoing
601/// certificate stays advertised until it expires, so a pilot holding an older
602/// offer can still connect.
603#[derive(Default)]
604struct Fingerprints {
605    old: Option<(String, Instant)>,
606}
607
608impl Fingerprints {
609    fn new() -> Self {
610        Fingerprints::default()
611    }
612
613    fn advertise(&mut self, cert: &Cert) -> Vec<String> {
614        if let Some((_, expires)) = &self.old {
615            if Instant::now() >= *expires {
616                self.old = None;
617            }
618        }
619        let mut v = vec![cert.fingerprint_hex()];
620        if let Some((fp, _)) = &self.old {
621            v.push(fp.clone());
622        }
623        v
624    }
625
626    fn retire(&mut self, cert: &Cert) {
627        let remaining = cert.days_left() * 86_400.0;
628        if remaining > 0.0 {
629            self.old = Some((
630                cert.fingerprint_hex(),
631                Instant::now() + Duration::from_secs_f64(remaining),
632            ));
633        }
634    }
635}
636
637/// Dial the cloud relay for one session and hand it to the engine (ADR 0010).
638/// Runs on its own task so a slow relay handshake never stalls signaling.
639async fn open_relay(
640    engine: Engine,
641    session_id: String,
642    url: String,
643    token: String,
644    pilot_ip: Option<String>,
645) {
646    let id = RELAY_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
647    tracing::info!(session = %session_id, %url, pilot_ip = ?pilot_ip, "pilot on the relay; dialling");
648    match seyd_transport::Session::relay(
649        id,
650        &url,
651        &session_id,
652        &token,
653        TransportConfig::default().datagram_send_buffer,
654    )
655    .await
656    {
657        Ok(session) => engine.serve(session),
658        Err(e) => tracing::warn!(session = %session_id, error = %e, "relay attach failed"),
659    }
660}
661
662/// Session ids for relayed sessions: the endpoint numbers its own from 1, so
663/// these start high enough never to collide.
664static RELAY_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1 << 40);
665
666fn build_announce(
667    gathered: &seyd_nat::Gathered,
668    cert_fingerprints: Vec<String>,
669    specs: &[ChannelSpec],
670    max_sessions: u32,
671    relay: bool,
672) -> anyhow::Result<Announce> {
673    Ok(Announce {
674        candidates: gathered
675            .candidates
676            .iter()
677            .map(|c| Candidate {
678                url: c.url.clone(),
679                label: c.label.clone(),
680                priority: c.priority,
681                needs_probe: c.needs_probe,
682                family: c.family,
683            })
684            .collect(),
685        cert_fingerprints,
686        alpns: vec!["h3".into()],
687        nat_report: serde_json::to_value(&gathered.nat_report)?,
688        channels: specs
689            .iter()
690            .map(|s| ChannelInfo {
691                id: s.id,
692                kind: serde_json::to_value(s.kind)
693                    .unwrap()
694                    .as_str()
695                    .unwrap()
696                    .to_string(),
697                name: s.name.clone(),
698                codec: s.codec.clone(),
699                fps: s.fps,
700            })
701            .collect(),
702        p2p_hint: format!("{:?}", gathered.p2p_hint)
703            .to_lowercase()
704            .replace("lanonly", "lan-only"),
705        max_sessions,
706        relay,
707    })
708}
709
710/// Generate and install a fresh certificate; the outgoing one stays advertised
711/// until it expires (see [`Fingerprints`]).
712fn do_rotate(
713    cert: &mut Cert,
714    fps: &mut Fingerprints,
715    endpoint: &Endpoint,
716    san_ips: &[IpAddr],
717) -> anyhow::Result<()> {
718    let new_cert = Cert::generate(san_ips)?;
719    endpoint.rotate(&new_cert)?;
720    fps.retire(cert);
721    tracing::info!(new = %new_cert.fingerprint_hex(), old = %cert.fingerprint_hex(),
722                   days = new_cert.days_left() as u32, "certificate rotated");
723    *cert = new_cert;
724    Ok(())
725}
726
727/// Renew the router port mapping at half its lease. A moved external address
728/// or three consecutive failures is treated as a network change; the task then
729/// waits for the re-gather to publish a fresh mapping.
730async fn renew_loop(
731    mut rx: tokio::sync::watch::Receiver<Option<seyd_nat::PortMapping>>,
732    maint: mpsc::Sender<Maint>,
733) {
734    let mut failures = 0u32;
735    loop {
736        let current = rx.borrow_and_update().clone();
737        let Some(mut m) = current else {
738            if rx.changed().await.is_err() {
739                return;
740            }
741            failures = 0;
742            continue;
743        };
744        if m.lifetime_s == 0 {
745            // The router calls it permanent; nothing to renew.
746            if rx.changed().await.is_err() {
747                return;
748            }
749            failures = 0;
750            continue;
751        }
752        loop {
753            let wait = Duration::from_secs(u64::from((m.lifetime_s / 2).max(60)));
754            tokio::select! {
755                r = rx.changed() => {
756                    if r.is_err() { return; }
757                    failures = 0;
758                    break;
759                }
760                _ = tokio::time::sleep(wait) => {
761                    match seyd_nat::renew(&m).await {
762                        Some(newm) => {
763                            failures = 0;
764                            if newm.external_ip != m.external_ip || newm.external_port != m.external_port {
765                                tracing::warn!(
766                                    old = %format!("{}:{}", m.external_ip, m.external_port),
767                                    new = %format!("{}:{}", newm.external_ip, newm.external_port),
768                                    "port mapping moved — treating as network change");
769                                let _ = maint.send(Maint::NetworkChanged("port mapping external address changed".into())).await;
770                                if rx.changed().await.is_err() { return; }
771                                break;
772                            }
773                            tracing::debug!(lease_s = newm.lifetime_s, "port mapping renewed");
774                            m = newm;
775                        }
776                        None => {
777                            failures += 1;
778                            tracing::warn!(failures, "port mapping renewal failed");
779                            if failures >= 3 {
780                                failures = 0;
781                                let _ = maint.send(Maint::NetworkChanged("port mapping renewal failed 3 times".into())).await;
782                                if rx.changed().await.is_err() { return; }
783                                break;
784                            }
785                        }
786                    }
787                }
788            }
789        }
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    #[test]
798    fn fingerprints_carry_old_until_expiry() {
799        let cert = Cert::generate(&["127.0.0.1".parse().unwrap()]).unwrap();
800        let newer = Cert::generate(&["127.0.0.1".parse().unwrap()]).unwrap();
801        // Old still valid → both advertised, new first.
802        let mut fps = Fingerprints::new();
803        fps.retire(&cert);
804        assert_eq!(
805            fps.advertise(&newer),
806            vec![newer.fingerprint_hex(), cert.fingerprint_hex()]
807        );
808        // Old expired → pruned.
809        let mut fps = Fingerprints {
810            old: Some((
811                cert.fingerprint_hex(),
812                Instant::now() - Duration::from_secs(1),
813            )),
814        };
815        assert_eq!(fps.advertise(&newer), vec![newer.fingerprint_hex()]);
816        assert!(fps.old.is_none());
817    }
818}