Skip to main content

seydd/
enrol.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! `seydd enrol --token …` — redeem an enrolment token issued by the console.
4//!
5//! This is the robot half of the enrolment loop (PLAN.md §2.5, ADR 0007). The
6//! robot generates its own Ed25519 key, keeps the private half, and sends only
7//! the public half with a one-time token. It never sees a user credential and
8//! never talks to the identity provider: a fleet enrols, and keeps running,
9//! independently of whoever the humans authenticate against.
10
11use anyhow::{bail, Context};
12use seyd_signal_client::Identity;
13use std::path::Path;
14use url::Url;
15
16/// Turns the signal WebSocket URL into the API's origin:
17/// `wss://signal.seyd.io/ws` → `https://signal.seyd.io`.
18pub fn api_base(signal_url: &str) -> anyhow::Result<Url> {
19    let mut url =
20        Url::parse(signal_url).with_context(|| format!("bad signal_url: {signal_url}"))?;
21    let scheme = match url.scheme() {
22        "wss" | "https" => "https",
23        "ws" | "http" => "http",
24        other => bail!("signal_url has scheme {other:?}; expected ws, wss, http or https"),
25    };
26    url.set_scheme(scheme)
27        .ok()
28        .context("could not rewrite the URL scheme")?;
29    url.set_path("");
30    url.set_query(None);
31    url.set_fragment(None);
32    Ok(url)
33}
34
35pub async fn run(
36    robot_id: &str,
37    signal_url: &str,
38    credential_path: &Path,
39    token: &str,
40) -> anyhow::Result<()> {
41    // load_or_create: enrolling twice with the same credential file re-presents
42    // the same key rather than orphaning the first one.
43    let identity = Identity::load_or_create(credential_path)
44        .with_context(|| format!("could not open {}", credential_path.display()))?;
45    let public_key = identity.public_key_b64();
46
47    let url = api_base(signal_url)?.join("api/v1/enrol")?;
48    tracing::info!(robot_id, %url, "enrolling");
49
50    let res = reqwest::Client::new()
51        .post(url.clone())
52        .json(&serde_json::json!({
53            "token": token,
54            "robot_id": robot_id,
55            "public_key": public_key,
56        }))
57        .send()
58        .await
59        .with_context(|| format!("could not reach {url}"))?;
60
61    let status = res.status();
62    let body = res.text().await.unwrap_or_default();
63    if status.is_success() {
64        println!("Enrolled {robot_id}.");
65        println!("Key: {public_key}");
66        println!("Credential: {}", credential_path.display());
67        println!("\nStart the robot with: seydd --config <your seydd.toml>");
68        return Ok(());
69    }
70
71    // The server's codes are deliberately distinguishable; say what to do next
72    // rather than printing a status line.
73    let detail: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
74    let code = detail.get("error").and_then(|v| v.as_str()).unwrap_or("");
75    match (status.as_u16(), code) {
76        (410, _) => bail!(
77            "this enrolment token cannot be used: it is expired, already redeemed, \
78             or the robot id {robot_id:?} is taken. Issue a new one in the console."
79        ),
80        (400, _) => bail!(
81            "the server rejected the request: {}",
82            detail
83                .get("message")
84                .and_then(|v| v.as_str())
85                .unwrap_or(&body)
86        ),
87        (404, _) => bail!(
88            "{url} returned 404 — is signal_url pointing at a Seyd signal server \
89             new enough to have enrolment?"
90        ),
91        _ => bail!("enrolment failed ({status}): {body}"),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn derives_the_api_origin_from_the_signal_url() {
101        assert_eq!(
102            api_base("wss://signal.seyd.io/ws").unwrap().as_str(),
103            "https://signal.seyd.io/"
104        );
105        assert_eq!(
106            api_base("ws://localhost:8080/ws").unwrap().as_str(),
107            "http://localhost:8080/"
108        );
109        // Query strings and paths on the signal URL must not leak into the API call.
110        assert_eq!(
111            api_base("ws://host:1/ws?x=1#f").unwrap().as_str(),
112            "http://host:1/"
113        );
114        assert!(api_base("ftp://nope/ws").is_err());
115    }
116
117    #[test]
118    fn joins_the_enrol_path_onto_the_origin() {
119        let url = api_base("wss://signal.seyd.io/ws")
120            .unwrap()
121            .join("api/v1/enrol")
122            .unwrap();
123        assert_eq!(url.as_str(), "https://signal.seyd.io/api/v1/enrol");
124    }
125}