1use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
2use std::time::{Duration, Instant};
3use subtle::ConstantTimeEq;
4use thiserror::Error;
5
6#[derive(Error, Debug)]
8pub enum PairingError {
9 #[error("Pairing code is invalid or expired")]
10 InvalidCode,
11 #[error("Pairing code has already been used")]
12 CodeAlreadyUsed,
13 #[error("Too many failed attempts. Please restart the bridge to get a new code.")]
14 RateLimited,
15}
16
17#[derive(serde::Serialize)]
19pub struct PairingResponse {
20 #[serde(rename = "agentId")]
22 pub agent_id: String,
23 pub url: String,
24 pub protocol: String,
25 pub version: String,
26 #[serde(rename = "authToken")]
27 pub auth_token: String,
28 #[serde(rename = "certFingerprint", skip_serializing_if = "Option::is_none")]
29 pub cert_fingerprint: Option<String>,
30 #[serde(rename = "clientId", skip_serializing_if = "Option::is_none")]
31 pub client_id: Option<String>,
32 #[serde(rename = "clientSecret", skip_serializing_if = "Option::is_none")]
33 pub client_secret: Option<String>,
34 pub cwd: String,
36 #[serde(rename = "pushRelayUrl", skip_serializing_if = "Option::is_none")]
39 pub relay_url: Option<String>,
40}
41
42#[derive(serde::Serialize)]
44pub struct PairingErrorResponse {
45 pub error: String,
46 pub message: String,
47}
48
49impl PairingErrorResponse {
50 pub fn invalid_code() -> Self {
51 Self {
52 error: "invalid_code".to_string(),
53 message: "Pairing code is invalid or expired".to_string(),
54 }
55 }
56
57 pub fn rate_limited() -> Self {
58 Self {
59 error: "rate_limited".to_string(),
60 message: "Too many failed attempts. Please restart the bridge to get a new code.".to_string(),
61 }
62 }
63}
64
65pub struct PairingManager {
67 pub agent_id: String,
69 code: String,
71 created_at: Instant,
73 used: AtomicBool,
75 attempts: AtomicU32,
77 websocket_url: String,
79 auth_token: String,
80 cert_fingerprint: Option<String>,
81 client_id: Option<String>,
82 client_secret: Option<String>,
83 cwd: String,
85 relay_url: Option<String>,
87 expiry_duration: Duration,
89 max_attempts: u32,
91 tailscale_path: bool,
93}
94
95impl PairingManager {
96 pub fn new_with_cf(
101 agent_id: String,
102 websocket_url: String,
103 auth_token: String,
104 cert_fingerprint: Option<String>,
105 client_id: Option<String>,
106 client_secret: Option<String>,
107 cwd: String,
108 ) -> Self {
109 let code = generate_pairing_code();
110 Self {
111 agent_id,
112 code,
113 created_at: Instant::now(),
114 used: AtomicBool::new(false),
115 attempts: AtomicU32::new(0),
116 websocket_url,
117 auth_token,
118 cert_fingerprint,
119 client_id,
120 client_secret,
121 cwd,
122 relay_url: None,
123 expiry_duration: Duration::from_secs(60),
124 max_attempts: 5,
125 tailscale_path: false,
126 }
127 }
128
129 pub fn with_tailscale_path(mut self) -> Self {
131 self.tailscale_path = true;
132 self
133 }
134
135 pub fn with_relay_url(mut self, url: String) -> Self {
138 self.relay_url = Some(url);
139 self
140 }
141
142 #[allow(dead_code)]
144 pub fn get_code(&self) -> &str {
145 &self.code
146 }
147
148 pub fn get_pairing_url(&self, base_url: &str) -> String {
150 if self.client_id.is_some() {
151 format!("{}/pair/cloudflare?code={}", base_url, self.code)
153 } else if self.tailscale_path {
154 let mut url = format!("{}/pair/tailscale?code={}", base_url, self.code);
156 if let Some(ref fp) = self.cert_fingerprint {
157 url.push_str("&fp=");
158 url.push_str(&urlencoding::encode(fp));
159 }
160 url
161 } else {
162 let mut url = format!("{}/pair/local?code={}", base_url, self.code);
163 if let Some(ref fp) = self.cert_fingerprint {
164 url.push_str("&fp=");
165 url.push_str(&urlencoding::encode(fp));
166 }
167 url
168 }
169 }
170
171 pub fn is_expired(&self) -> bool {
173 self.created_at.elapsed() > self.expiry_duration
174 }
175
176 #[allow(dead_code)]
178 pub fn is_used(&self) -> bool {
179 self.used.load(Ordering::SeqCst)
180 }
181
182 pub fn seconds_remaining(&self) -> u64 {
184 let elapsed = self.created_at.elapsed();
185 if elapsed > self.expiry_duration {
186 0
187 } else {
188 (self.expiry_duration - elapsed).as_secs()
189 }
190 }
191
192 pub fn validate(&self, code: &str) -> Result<PairingResponse, PairingError> {
194 let attempts = self.attempts.load(Ordering::SeqCst);
196 if attempts >= self.max_attempts {
197 return Err(PairingError::RateLimited);
198 }
199
200 if self.used.load(Ordering::SeqCst) {
202 return Err(PairingError::CodeAlreadyUsed);
203 }
204
205 if self.is_expired() {
207 return Err(PairingError::InvalidCode);
208 }
209
210 let code_matches = code.as_bytes().ct_eq(self.code.as_bytes());
214 if code_matches.unwrap_u8() == 0 {
215 self.attempts.fetch_add(1, Ordering::SeqCst);
216 return Err(PairingError::InvalidCode);
217 }
218
219 if self.used.swap(true, Ordering::SeqCst) {
221 return Err(PairingError::CodeAlreadyUsed);
223 }
224
225 Ok(PairingResponse {
226 agent_id: self.agent_id.clone(),
227 url: self.websocket_url.clone(),
228 protocol: "acp".to_string(),
229 version: "1.0".to_string(),
230 auth_token: self.auth_token.clone(),
231 cert_fingerprint: self.cert_fingerprint.clone(),
232 client_id: self.client_id.clone(),
233 client_secret: self.client_secret.clone(),
234 cwd: self.cwd.clone(),
235 relay_url: self.relay_url.clone(),
236 })
237 }
238
239 #[allow(dead_code)]
241 pub fn get_cert_fingerprint(&self) -> Option<&str> {
242 self.cert_fingerprint.as_deref()
243 }
244}
245
246fn generate_pairing_code() -> String {
248 let code: u32 = rand::random_range(100000..1000000);
249 code.to_string()
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn test_code_generation() {
258 let code = generate_pairing_code();
259 assert_eq!(code.len(), 6);
260 assert!(code.chars().all(|c| c.is_ascii_digit()));
261 }
262
263 #[test]
264 fn test_pairing_manager_valid_code() {
265 let manager = PairingManager::new_with_cf(
266 "test-agent-id".to_string(),
267 "wss://192.168.1.100:8080".to_string(),
268 "test-token".to_string(),
269 Some("SHA256:ABC123".to_string()),
270 None,
271 None,
272 "/tmp/test".to_string(),
273 );
274
275 let code = manager.get_code().to_string();
276 let result = manager.validate(&code);
277 assert!(result.is_ok());
278
279 let response = result.unwrap();
280 assert_eq!(response.url, "wss://192.168.1.100:8080");
281 assert_eq!(response.auth_token, "test-token");
282 }
283
284 #[test]
285 fn test_pairing_manager_invalid_code() {
286 let manager = PairingManager::new_with_cf(
287 "test-agent-id".to_string(),
288 "wss://192.168.1.100:8080".to_string(),
289 "test-token".to_string(),
290 None,
291 None,
292 None,
293 "/tmp/test".to_string(),
294 );
295
296 let result = manager.validate("000000");
297 assert!(matches!(result, Err(PairingError::InvalidCode)));
298 }
299
300 #[test]
301 fn test_pairing_manager_one_time_use() {
302 let manager = PairingManager::new_with_cf(
303 "test-agent-id".to_string(),
304 "wss://192.168.1.100:8080".to_string(),
305 "test-token".to_string(),
306 None,
307 None,
308 None,
309 "/tmp/test".to_string(),
310 );
311
312 let code = manager.get_code().to_string();
313
314 assert!(manager.validate(&code).is_ok());
316
317 let result = manager.validate(&code);
319 assert!(matches!(result, Err(PairingError::CodeAlreadyUsed)));
320 }
321
322 #[test]
323 fn test_pairing_manager_rate_limiting() {
324 let manager = PairingManager::new_with_cf(
325 "test-agent-id".to_string(),
326 "wss://192.168.1.100:8080".to_string(),
327 "test-token".to_string(),
328 None,
329 None,
330 None,
331 "/tmp/test".to_string(),
332 );
333
334 for _ in 0..5 {
336 let _ = manager.validate("000000");
337 }
338
339 let result = manager.validate("000000");
341 assert!(matches!(result, Err(PairingError::RateLimited)));
342 }
343
344 #[test]
345 fn test_pairing_url_generation() {
346 let manager = PairingManager::new_with_cf(
347 "test-agent-id".to_string(),
348 "wss://192.168.1.100:8080".to_string(),
349 "test-token".to_string(),
350 Some("SHA256:ABC123".to_string()),
351 None,
352 None,
353 "/tmp/test".to_string(),
354 );
355
356 let url = manager.get_pairing_url("https://192.168.1.100:8080");
357 assert!(url.starts_with("https://192.168.1.100:8080/pair/local?code="));
358 assert!(url.contains("&fp=SHA256"));
359 }
360
361 #[test]
362 fn test_tailscale_serve_pairing_url() {
363 let manager = PairingManager::new_with_cf(
365 "test-agent-id".to_string(),
366 "wss://my-laptop.tail1234.ts.net".to_string(),
367 "test-token".to_string(),
368 None,
369 None,
370 None,
371 "/tmp/test".to_string(),
372 ).with_tailscale_path();
373
374 let url = manager.get_pairing_url("https://my-laptop.tail1234.ts.net");
375 assert!(url.contains("/pair/tailscale?code="), "Expected /pair/tailscale in URL, got: {}", url);
376 assert!(!url.contains("&fp="), "serve mode should have no fingerprint");
377 }
378
379 #[test]
380 fn test_tailscale_ip_pairing_url() {
381 let manager = PairingManager::new_with_cf(
383 "test-agent-id".to_string(),
384 "wss://100.64.0.1:8080".to_string(),
385 "test-token".to_string(),
386 Some("SHA256:AB:CD:EF".to_string()),
387 None,
388 None,
389 "/tmp/test".to_string(),
390 ).with_tailscale_path();
391
392 let url = manager.get_pairing_url("https://100.64.0.1:8080");
393 assert!(url.contains("/pair/tailscale?code="), "Expected /pair/tailscale in URL, got: {}", url);
394 assert!(url.contains("&fp=SHA256"), "ip mode should include fingerprint");
395 }
396}