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 #[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 #[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 #[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}