1pub mod identity;
12pub mod messages;
13
14use futures_util::{SinkExt, StreamExt};
15use messages::*;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18use tokio::sync::{mpsc, watch, Mutex};
19use tokio_tungstenite::tungstenite::Message;
20
21pub use identity::Identity;
22
23#[derive(Debug, thiserror::Error)]
24pub enum Error {
25 #[error("websocket: {0}")]
26 Ws(Box<tokio_tungstenite::tungstenite::Error>),
27 #[error("protocol: {0}")]
28 Protocol(String),
29 #[error("denied: {0}")]
30 Denied(String),
31 #[error("json: {0}")]
32 Json(#[from] serde_json::Error),
33}
34
35impl From<tokio_tungstenite::tungstenite::Error> for Error {
36 fn from(e: tokio_tungstenite::tungstenite::Error) -> Self {
37 Error::Ws(Box::new(e))
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Event {
44 Connected,
46 PilotConnecting {
47 session_id: String,
48 pilot_ip: Option<String>,
49 role: Role,
50 direction: Direction,
51 },
52 Punch {
53 session_id: String,
54 pilot_ip: Option<String>,
55 },
56 SessionRevoked {
57 session_id: String,
58 reason: String,
59 },
60 RelayOpen {
62 session_id: String,
63 url: String,
64 token: String,
65 role: Role,
66 pilot_ip: Option<String>,
67 },
68 Disconnected,
71 Denied {
74 reason: String,
75 },
76}
77
78#[derive(Clone)]
79pub struct Config {
80 pub signal_url: String,
81 pub robot_id: String,
82 pub agent_version: String,
83 pub heartbeat: Duration,
84}
85
86#[derive(Clone)]
88pub struct Handle {
89 tx: mpsc::UnboundedSender<Outbound>,
90 announce: Arc<Mutex<Option<Announce>>>,
91 sessions: Arc<Mutex<Vec<String>>>,
92 status: Arc<Mutex<Option<serde_json::Value>>>,
93 connected: watch::Receiver<bool>,
94}
95
96impl Handle {
97 pub async fn announce(&self, a: Announce) {
99 *self.announce.lock().await = Some(a.clone());
100 if *self.connected.borrow() {
103 let _ = self.tx.send(Outbound::Announce(a));
104 }
105 }
106 pub async fn set_sessions(&self, ids: Vec<String>) {
107 *self.sessions.lock().await = ids;
108 }
109 pub async fn set_status(&self, status: Option<serde_json::Value>) {
110 *self.status.lock().await = status;
111 }
112 pub fn session_accepted(&self, session_id: &str, path_label: &str) {
113 let _ = self.tx.send(Outbound::SessionAccepted {
114 session_id: session_id.into(),
115 path_label: path_label.into(),
116 });
117 }
118 pub fn session_ended(&self, session_id: &str, reason: &str) {
119 let _ = self.tx.send(Outbound::SessionEnded {
120 session_id: session_id.into(),
121 reason: reason.into(),
122 });
123 }
124 pub fn is_connected(&self) -> bool {
125 *self.connected.borrow()
126 }
127}
128
129pub fn start(cfg: Config, identity: Identity) -> (Handle, mpsc::Receiver<Event>) {
132 let (tx, rx) = mpsc::unbounded_channel();
133 let (ev_tx, ev_rx) = mpsc::channel(64);
134 let (conn_tx, conn_rx) = watch::channel(false);
135 let handle = Handle {
136 tx,
137 announce: Arc::new(Mutex::new(None)),
138 sessions: Arc::new(Mutex::new(Vec::new())),
139 status: Arc::new(Mutex::new(None)),
140 connected: conn_rx,
141 };
142 tokio::spawn(run(cfg, identity, handle.clone(), rx, ev_tx, conn_tx));
143 (handle, ev_rx)
144}
145
146const BASE_BACKOFF: Duration = Duration::from_secs(1);
147const MAX_BACKOFF: Duration = Duration::from_secs(30);
148const STABLE_SESSION: Duration = Duration::from_secs(30);
152
153async fn run(
154 cfg: Config,
155 identity: Identity,
156 handle: Handle,
157 mut rx: mpsc::UnboundedReceiver<Outbound>,
158 ev_tx: mpsc::Sender<Event>,
159 conn_tx: watch::Sender<bool>,
160) {
161 let mut backoff = BASE_BACKOFF;
162 loop {
163 let started = Instant::now();
164 let outcome = session(&cfg, &identity, &handle, &mut rx, &ev_tx, &conn_tx).await;
165 if started.elapsed() >= STABLE_SESSION {
167 backoff = BASE_BACKOFF;
168 }
169 match outcome {
170 Ok(()) => backoff = BASE_BACKOFF,
171 Err(Error::Denied(reason)) => {
172 match reason.as_str() {
175 "unknown-robot" => tracing::error!(
176 "signal server denied this robot: it is not enrolled. Redeem an \
177 enrolment token from the console — `seydd --config <file> enrol \
178 --token seyd_enr_…`, or set SEYD_ENROLMENT_TOKEN and restart."
179 ),
180 "key-mismatch" => tracing::error!(
181 "signal server denied this robot: it is enrolled with a different \
182 key. Another agent is using this robot id, or the credential file \
183 was replaced. Delete the robot in the console and enrol again."
184 ),
185 _ => tracing::error!(%reason, "signal server denied this robot"),
186 }
187 let _ = ev_tx.send(Event::Denied { reason }).await;
188 backoff = MAX_BACKOFF;
189 }
190 Err(e) => {
191 tracing::warn!(error = %e, "signal connection failed; retrying in {backoff:?}")
192 }
193 }
194 let _ = conn_tx.send(false);
195 if ev_tx.send(Event::Disconnected).await.is_err() {
196 return; }
198 tokio::time::sleep(backoff).await;
199 backoff = (backoff * 2).min(MAX_BACKOFF);
200 }
201}
202
203async fn session(
204 cfg: &Config,
205 identity: &Identity,
206 handle: &Handle,
207 rx: &mut mpsc::UnboundedReceiver<Outbound>,
208 ev_tx: &mpsc::Sender<Event>,
209 conn_tx: &watch::Sender<bool>,
210) -> Result<(), Error> {
211 let (ws, _) = tokio_tungstenite::connect_async(&cfg.signal_url).await?;
212 let (mut sink, mut stream) = ws.split();
213 tracing::info!(url = %cfg.signal_url, "signal connected");
214
215 let challenge: Inbound = next_json(&mut stream).await?;
217 let nonce = match challenge {
218 Inbound::Challenge { nonce } => nonce,
219 other => {
220 return Err(Error::Protocol(format!(
221 "expected challenge, got {other:?}"
222 )))
223 }
224 };
225 let nonce_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &nonce)
226 .map_err(|e| Error::Protocol(format!("bad nonce: {e}")))?;
227 let auth = Outbound::Auth {
228 v: 2,
229 role: "robot".into(),
230 robot_id: cfg.robot_id.clone(),
231 public_key: identity.public_key_b64(),
232 sig: identity.sign_b64(&nonce_bytes),
233 agent_version: cfg.agent_version.clone(),
234 };
235 sink.send(Message::Text(serde_json::to_string(&auth)?.into()))
236 .await?;
237 match next_json(&mut stream).await? {
238 Inbound::AuthOk { .. } => {}
239 Inbound::Denied { reason } => return Err(Error::Denied(reason)),
240 other => return Err(Error::Protocol(format!("expected auth-ok, got {other:?}"))),
241 }
242 let _ = conn_tx.send(true);
243 let _ = ev_tx.send(Event::Connected).await;
244
245 if let Some(a) = handle.announce.lock().await.clone() {
247 sink.send(Message::Text(
248 serde_json::to_string(&Outbound::Announce(a))?.into(),
249 ))
250 .await?;
251 }
252
253 let mut heartbeat = tokio::time::interval(cfg.heartbeat);
254 heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
255 heartbeat.tick().await; loop {
258 tokio::select! {
259 msg = stream.next() => {
260 let msg = match msg {
261 Some(Ok(Message::Text(t))) => t,
262 Some(Ok(Message::Ping(p))) => { sink.send(Message::Pong(p)).await?; continue; }
263 Some(Ok(Message::Close(_))) | None => return Ok(()),
264 Some(Ok(_)) => continue,
265 Some(Err(e)) => return Err(e.into()),
266 };
267 match serde_json::from_str::<Inbound>(&msg) {
268 Ok(Inbound::PilotConnecting { session_id, pilot_ip, role, direction }) => {
269 let _ = ev_tx.send(Event::PilotConnecting { session_id, pilot_ip, role, direction }).await;
270 }
271 Ok(Inbound::Punch { session_id, pilot_ip }) => {
272 let _ = ev_tx.send(Event::Punch { session_id, pilot_ip }).await;
273 }
274 Ok(Inbound::SessionRevoked { session_id, reason }) => {
275 let _ = ev_tx.send(Event::SessionRevoked { session_id, reason }).await;
276 }
277 Ok(Inbound::RelayOpen { session_id, url, token, role, pilot_ip }) => {
278 let _ = ev_tx.send(Event::RelayOpen { session_id, url, token, role, pilot_ip }).await;
279 }
280 Ok(Inbound::Denied { reason }) => return Err(Error::Denied(reason)),
281 Ok(other) => tracing::debug!(?other, "ignoring signal message"),
282 Err(e) => tracing::debug!(error = %e, raw = %msg, "unparseable signal message"),
283 }
284 }
285 out = rx.recv() => {
286 let Some(out) = out else { return Ok(()) };
287 sink.send(Message::Text(serde_json::to_string(&out)?.into())).await?;
288 }
289 _ = heartbeat.tick() => {
290 let hb = Outbound::Heartbeat {
291 sessions: handle.sessions.lock().await.clone(),
292 status: handle.status.lock().await.clone(),
293 };
294 sink.send(Message::Text(serde_json::to_string(&hb)?.into())).await?;
295 }
296 }
297 }
298}
299
300async fn next_json<S>(stream: &mut S) -> Result<Inbound, Error>
301where
302 S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
303{
304 loop {
305 match stream.next().await {
306 Some(Ok(Message::Text(t))) => return Ok(serde_json::from_str(&t)?),
307 Some(Ok(Message::Close(_))) | None => {
308 return Err(Error::Protocol("closed during handshake".into()))
309 }
310 Some(Ok(_)) => continue,
311 Some(Err(e)) => return Err(e.into()),
312 }
313 }
314}