1use 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#[derive(Debug, Clone)]
29pub struct AgentConfig {
30 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 pub host_override: Option<String>,
42 pub qos_profile: String,
43 pub max_sessions: u32,
44 pub channels: Vec<ChannelSpec>,
45 pub agent_version: String,
47 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#[derive(Debug, Clone)]
77pub enum AgentEvent {
78 SessionStarted {
80 signal_id: String,
81 role: Role,
82 path_label: String,
83 },
84 SessionEnded {
85 signal_id: String,
86 reason: String,
87 },
88 Command {
90 channel: u8,
91 payload: bytes::Bytes,
92 },
93 RequestedConfig(serde_json::Value),
95 RecoveryRequest {
97 channel: u8,
98 kind: &'static str,
99 reason: &'static str,
100 },
101 SignalConnected,
104 SignalDisconnected,
105 SignalDenied {
107 reason: String,
108 },
109 LayerChanged {
113 channel: u8,
114 layer: u8,
115 name: String,
116 reason: &'static str,
117 },
118 NatReport(serde_json::Value),
121}
122
123#[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 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 let socks = seyd_nat::bind_sockets(cfg.quic_port, cfg.ipv6)?;
143 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 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 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 pub fn push_video(&self, channel: u8, frame: VideoFrame) {
223 self.engine.push_video(channel, frame);
224 }
225
226 pub fn push_video_layer(&self, channel: u8, layer: u8, frame: VideoFrame) {
232 self.engine.push_video_layer(channel, layer, frame);
233 }
234
235 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 pub fn publisher_config(&self, channel: u8, reason: &str) -> serde_json::Value {
264 self.engine.publisher_config(channel, reason)
265 }
266
267 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 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 pub fn stop(&self) {
290 self.shutdown.notify_one();
294 }
295}
296
297struct 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
319async 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 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 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 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 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
446const REGATHER_MIN_INTERVAL: Duration = Duration::from_secs(30);
449const EMPTY_REGATHER_RETRY: Duration = Duration::from_secs(5);
452
453async 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 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
501async 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 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
554async 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
577fn 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 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#[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
637async 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
662static 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
710fn 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
727async 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 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 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 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}