Skip to main content

wings_api/
setup.rs

1use hmac::{Hmac, KeyInit, Mac};
2use http_client::CLIENT;
3use serde::{Deserialize, Serialize};
4use sha2::Sha256;
5use std::time::Duration;
6use utoipa::ToSchema;
7
8const SIGNATURE_HEADER: &str = "X-Pairing-Signature";
9const PROBE_PATH: &str = "/setup/probe";
10const ENROLL_PATH: &str = "/setup/enroll";
11
12#[derive(Debug, ToSchema, Deserialize, Serialize)]
13pub struct SetupProbeDocker {
14    pub available: bool,
15    pub version: Option<String>,
16}
17
18#[derive(Debug, ToSchema, Deserialize, Serialize)]
19pub struct SetupProbe {
20    pub version: String,
21    pub container: bool,
22    pub architecture: String,
23    pub cpu_count: u64,
24    pub memory_bytes: u64,
25    pub disk_bytes: u64,
26    pub ips: Vec<String>,
27    pub api_port: u16,
28    pub sftp_port: u16,
29    pub docker: SetupProbeDocker,
30}
31
32#[derive(Debug)]
33pub enum SetupError {
34    Unreachable(reqwest::Error),
35    NotWaitingForPairing,
36    InvalidPairingCode,
37    Rejected(Option<compact_str::CompactString>),
38}
39
40impl std::fmt::Display for SetupError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Unreachable(_) => write!(f, "the node could not be reached at this address"),
44            Self::NotWaitingForPairing => {
45                write!(
46                    f,
47                    "wings at this address is already configured or too old to pair"
48                )
49            }
50            Self::InvalidPairingCode => write!(f, "the pairing code is incorrect"),
51            Self::Rejected(Some(error)) => write!(f, "{error}"),
52            Self::Rejected(None) => write!(f, "wings rejected the request"),
53        }
54    }
55}
56
57impl std::error::Error for SetupError {}
58
59fn normalize_pairing_code(code: &str) -> String {
60    code.chars()
61        .filter(char::is_ascii_alphanumeric)
62        .map(|c| c.to_ascii_uppercase())
63        .collect()
64}
65
66fn sign(pairing_code: &str, method: &str, path: &str, body: &[u8]) -> String {
67    let mut mac = Hmac::<Sha256>::new_from_slice(normalize_pairing_code(pairing_code).as_bytes())
68        .expect("hmac accepts keys of any length");
69    mac.update(method.as_bytes());
70    mac.update(b"\n");
71    mac.update(path.as_bytes());
72    mac.update(b"\n");
73    mac.update(body);
74
75    hex::encode(mac.finalize().into_bytes())
76}
77
78async fn send(
79    base_url: &str,
80    path: &str,
81    pairing_code: &str,
82    body: Vec<u8>,
83    timeout: Duration,
84) -> Result<reqwest::Response, SetupError> {
85    let response = CLIENT
86        .post(format!("{}{path}", base_url.trim_end_matches('/')))
87        .header("Content-Type", "application/json")
88        .header(SIGNATURE_HEADER, sign(pairing_code, "POST", path, &body))
89        .timeout(timeout)
90        .body(body)
91        .send()
92        .await
93        .map_err(SetupError::Unreachable)?;
94
95    match response.status() {
96        status if status.is_success() => Ok(response),
97        reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED => {
98            Err(SetupError::NotWaitingForPairing)
99        }
100        reqwest::StatusCode::UNAUTHORIZED => Err(SetupError::InvalidPairingCode),
101        _ => Err(SetupError::Rejected(
102            response
103                .json::<super::ApiError>()
104                .await
105                .map(|body| body.error)
106                .ok(),
107        )),
108    }
109}
110
111pub async fn probe(base_url: &str, pairing_code: &str) -> Result<SetupProbe, SetupError> {
112    send(
113        base_url,
114        PROBE_PATH,
115        pairing_code,
116        b"{}".to_vec(),
117        Duration::from_secs(15),
118    )
119    .await?
120    .json()
121    .await
122    .map_err(|_| SetupError::NotWaitingForPairing)
123}
124
125pub async fn enroll(
126    base_url: &str,
127    pairing_code: &str,
128    panel_url: &str,
129    enrollment_code: &str,
130) -> Result<(), SetupError> {
131    let body = serde_json::to_vec(&serde_json::json!({
132        "panel_url": panel_url,
133        "code": enrollment_code,
134    }))
135    .expect("json serialization of string values cannot fail");
136
137    send(
138        base_url,
139        ENROLL_PATH,
140        pairing_code,
141        body,
142        Duration::from_secs(30),
143    )
144    .await?;
145
146    Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    const KNOWN_BODY: &[u8] = br#"{"code":"x"}"#;
154    const KNOWN_SIGNATURE: &str =
155        "f1e766580abcfd6257d8ef54c8cd7d09e3a01132dead1deb853b3a2fdb1dc307";
156
157    // sign
158    #[test]
159    fn sign_matches_known_answer() {
160        assert_eq!(
161            sign("ABCD2345", "POST", "/setup/enroll", KNOWN_BODY),
162            KNOWN_SIGNATURE
163        );
164    }
165
166    #[test]
167    fn sign_normalizes_pairing_code() {
168        for code in ["abcd-2345", " ABCD 2345 "] {
169            assert_eq!(
170                sign(code, "POST", "/setup/enroll", KNOWN_BODY),
171                KNOWN_SIGNATURE
172            );
173        }
174    }
175
176    // normalize_pairing_code
177    #[test]
178    fn keeps_only_ascii_alphanumerics_uppercased() {
179        assert_eq!(normalize_pairing_code("ab-c\u{e4}1 \u{0663}z\n"), "ABC1Z");
180    }
181
182    // SetupError
183    #[test]
184    fn rejected_display_shows_error_or_falls_back() {
185        assert_eq!(
186            SetupError::Rejected(None).to_string(),
187            "wings rejected the request"
188        );
189        assert_eq!(
190            SetupError::Rejected(Some("bad token".into())).to_string(),
191            "bad token"
192        );
193    }
194}