1use anyhow::{Context, Result};
2use reqwest::{Client, header};
3use serde::{Deserialize, Serialize};
4use tracing::{debug, info, warn};
5
6const CLOUDFLARE_API_BASE: &str = "https://api.cloudflare.com/client/v4";
7
8pub struct CloudflareClient {
10 client: Client,
11 #[allow(dead_code)]
12 api_token: String,
13 account_id: String,
14}
15
16#[derive(Debug, Serialize, Deserialize, Clone)]
17pub struct Tunnel {
18 pub id: String,
19 pub name: String,
20 #[serde(default)]
21 pub secret: String,
22}
23
24#[derive(Debug, Serialize, Deserialize)]
25pub struct AccessApplication {
26 pub id: String,
27 pub name: String,
28 pub domain: String,
29}
30
31#[derive(Debug, Serialize, Deserialize, Clone)]
32pub struct ServiceToken {
33 pub client_id: String,
34 pub client_secret: String,
35}
36
37#[derive(Debug, Deserialize)]
38struct CloudflareResponse {
39 #[serde(default)]
40 result: serde_json::Value,
41 success: bool,
42 #[serde(default)]
43 errors: Vec<CloudflareError>,
44 #[allow(dead_code)]
45 #[serde(default)]
46 messages: Vec<String>,
47}
48
49impl CloudflareResponse {
50 fn into_result<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T> {
51 serde_json::from_value(self.result).context("Failed to deserialize API result")
52 }
53}
54
55#[derive(Debug, Deserialize)]
56struct CloudflareError {
57 code: i32,
58 message: String,
59}
60
61impl CloudflareClient {
62 pub fn new(api_token: String, account_id: String) -> Self {
64 let mut headers = header::HeaderMap::new();
65 headers.insert(
66 header::AUTHORIZATION,
67 header::HeaderValue::from_str(&format!("Bearer {}", api_token))
68 .expect("Invalid API token format"),
69 );
70 headers.insert(
71 header::CONTENT_TYPE,
72 header::HeaderValue::from_static("application/json"),
73 );
74
75 let client = Client::builder()
76 .default_headers(headers)
77 .build()
78 .expect("Failed to build HTTP client");
79
80 Self {
81 client,
82 api_token,
83 account_id,
84 }
85 }
86
87 pub async fn create_or_get_tunnel(&self, name: &str) -> Result<Tunnel> {
89 let list_url = format!(
91 "{}/accounts/{}/cfd_tunnel",
92 CLOUDFLARE_API_BASE, self.account_id
93 );
94
95 let response: CloudflareResponse = self
96 .client
97 .get(&list_url)
98 .send()
99 .await
100 .context("Failed to list tunnels")?
101 .json()
102 .await
103 .context("Failed to parse tunnel list response")?;
104
105 if response.success {
106 let tunnels: Vec<Tunnel> = response.into_result().unwrap_or_default();
107 if let Some(mut existing) = tunnels.into_iter().find(|t| t.name == name) {
108 debug!("Found existing tunnel: {}", existing.id);
109 if let Ok(dir) = get_cloudflared_dir() {
111 let creds_path = dir.join(format!("{}.json", existing.id));
112 if let Ok(content) = std::fs::read_to_string(&creds_path) {
113 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
114 if let Some(s) = json.get("TunnelSecret").and_then(|v| v.as_str()) {
115 if !s.is_empty() {
116 existing.secret = s.to_string();
117 return Ok(existing);
118 }
119 }
120 }
121 }
122 }
123 warn!("Tunnel secret is lost for '{}'. Deleting and recreating...", existing.id);
125 let _ = self.delete_tunnel(&existing.id).await;
126 }
127 }
128
129 debug!("Creating new tunnel: {}", name);
131 let create_url = format!(
132 "{}/accounts/{}/cfd_tunnel",
133 CLOUDFLARE_API_BASE, self.account_id
134 );
135
136 let tunnel_secret = self.generate_tunnel_secret();
137 let payload = serde_json::json!({
138 "name": name,
139 "tunnel_secret": tunnel_secret,
140 });
141
142 let response: CloudflareResponse = self
143 .client
144 .post(&create_url)
145 .json(&payload)
146 .send()
147 .await
148 .context("Failed to create tunnel")?
149 .json()
150 .await
151 .context("Failed to parse tunnel creation response")?;
152
153 if !response.success {
154 anyhow::bail!("Failed to create tunnel: {:?}", response.errors);
155 }
156
157 let mut tunnel: Tunnel = response.into_result().context("No tunnel returned after creation")?;
158 tunnel.secret = tunnel_secret;
159 Ok(tunnel)
160 }
161
162 pub async fn create_dns_record(
164 &self,
165 zone_name: &str,
166 subdomain: &str,
167 tunnel_id: &str,
168 ) -> Result<()> {
169 let zones_url = format!("{}/zones?name={}", CLOUDFLARE_API_BASE, zone_name);
171
172 let zones_response: CloudflareResponse = self
173 .client
174 .get(&zones_url)
175 .send()
176 .await
177 .context("Failed to fetch zone information")?
178 .json()
179 .await
180 .context("Failed to parse zones response")?;
181
182 #[derive(Deserialize)]
183 struct Zone {
184 id: String,
185 }
186
187 let zones: Vec<Zone> = zones_response.into_result().context("Zone not found")?;
188 let zone_id = zones.into_iter().next().context("Zone not found")?.id;
189
190 let dns_url = format!("{}/zones/{}/dns_records", CLOUDFLARE_API_BASE, zone_id);
192 let tunnel_cname = format!("{}.cfargotunnel.com", tunnel_id);
193
194 let payload = serde_json::json!({
195 "type": "CNAME",
196 "name": subdomain,
197 "content": tunnel_cname,
198 "ttl": 1,
199 "proxied": true,
200 });
201
202 let response: CloudflareResponse = self
203 .client
204 .post(&dns_url)
205 .json(&payload)
206 .send()
207 .await
208 .context("Failed to create DNS record")?
209 .json()
210 .await
211 .context("Failed to parse DNS creation response")?;
212
213 if !response.success {
214 if response.errors.iter().any(|e| e.code == 81053 || e.code == 81057) {
216 warn!("DNS record already exists, updating to point to current tunnel...");
217 let full_hostname = format!("{}.{}", subdomain, zone_name);
218 return self.update_dns_record(&zone_id, &full_hostname, &tunnel_cname).await;
219 }
220 anyhow::bail!("Failed to create DNS record: {:?}", response.errors);
221 }
222
223 Ok(())
224 }
225
226 async fn update_dns_record(&self, zone_id: &str, subdomain: &str, content: &str) -> Result<()> {
228 #[derive(Deserialize)]
229 struct DnsRecord {
230 id: String,
231 }
232
233 let list_url = format!(
234 "{}/zones/{}/dns_records?name={}&type=CNAME",
235 CLOUDFLARE_API_BASE, zone_id, subdomain
236 );
237
238 let list_response: CloudflareResponse = self
239 .client
240 .get(&list_url)
241 .send()
242 .await
243 .context("Failed to list DNS records")?
244 .json()
245 .await
246 .context("Failed to parse DNS records list")?;
247
248 let records: Vec<DnsRecord> = list_response.into_result().context("Failed to parse DNS record list")?;
249 let record_id = records.into_iter().next()
250 .context("DNS record not found for update")?
251 .id;
252
253 let update_url = format!("{}/zones/{}/dns_records/{}", CLOUDFLARE_API_BASE, zone_id, record_id);
254 let payload = serde_json::json!({
255 "type": "CNAME",
256 "name": subdomain,
257 "content": content,
258 "ttl": 1,
259 "proxied": true,
260 });
261
262 let response: CloudflareResponse = self
263 .client
264 .put(&update_url)
265 .json(&payload)
266 .send()
267 .await
268 .context("Failed to update DNS record")?
269 .json()
270 .await
271 .context("Failed to parse DNS update response")?;
272
273 if !response.success {
274 anyhow::bail!("Failed to update DNS record: {:?}", response.errors);
275 }
276
277 info!("✅ DNS record updated to point to current tunnel");
278 Ok(())
279 }
280
281 pub async fn create_access_application(&self, hostname: &str) -> Result<AccessApplication> {
283 let url = format!(
284 "{}/accounts/{}/access/apps",
285 CLOUDFLARE_API_BASE, self.account_id
286 );
287
288 let payload = serde_json::json!({
289 "name": format!("ACP Bridge - {}", hostname),
290 "domain": hostname,
291 "type": "self_hosted",
292 "session_duration": "24h",
293 "allowed_idps": [],
294 "auto_redirect_to_identity": false,
295 });
296
297 let response: CloudflareResponse = self
298 .client
299 .post(&url)
300 .json(&payload)
301 .send()
302 .await
303 .context("Failed to create Access Application")?
304 .json()
305 .await
306 .context("Failed to parse Access Application response")?;
307
308 if !response.success || response.result.is_null() {
309 warn!("Access Application creation failed, checking for existing app...");
310 let app = self.find_access_application(hostname).await?;
311 let _ = self.create_service_auth_policy(&app.id, hostname).await;
313 return Ok(app);
314 }
315
316 let app: AccessApplication = response.into_result().context("Failed to parse Access Application")?;
317 self.create_service_auth_policy(&app.id, hostname).await?;
319 Ok(app)
320 }
321
322 async fn find_access_application(&self, hostname: &str) -> Result<AccessApplication> {
324 let url = format!(
325 "{}/accounts/{}/access/apps",
326 CLOUDFLARE_API_BASE, self.account_id
327 );
328
329 let response: CloudflareResponse = self
330 .client
331 .get(&url)
332 .send()
333 .await
334 .context("Failed to list Access Applications")?
335 .json()
336 .await
337 .context("Failed to parse Access Applications list")?;
338
339 let apps: Vec<AccessApplication> = response.into_result().unwrap_or_default();
340 apps.into_iter()
341 .find(|app| app.domain == hostname)
342 .with_context(|| format!("No Access Application found for hostname: {}", hostname))
343 }
344
345 async fn create_service_auth_policy(&self, app_id: &str, hostname: &str) -> Result<()> {
347 let url = format!(
348 "{}/accounts/{}/access/apps/{}/policies",
349 CLOUDFLARE_API_BASE, self.account_id, app_id
350 );
351
352 let payload = serde_json::json!({
353 "name": format!("Service Auth - {}", hostname),
354 "decision": "non_identity",
355 "include": [{
356 "any_valid_service_token": {}
357 }],
358 "precedence": 1,
359 });
360
361 let response: CloudflareResponse = self
362 .client
363 .post(&url)
364 .json(&payload)
365 .send()
366 .await
367 .context("Failed to create Service Auth policy")?
368 .json()
369 .await
370 .context("Failed to parse policy response")?;
371
372 if !response.success {
373 let already_exists = response.errors.iter().any(|e| {
375 e.message.contains("already exists") || e.message.contains("duplicate")
376 });
377 if already_exists {
378 warn!("Service Auth policy already exists, skipping...");
379 return Ok(());
380 }
381 anyhow::bail!("Failed to create Service Auth policy: {:?}", response.errors);
382 }
383
384 Ok(())
385 }
386
387 pub async fn create_service_token(&self, name: &str) -> Result<ServiceToken> {
389 let url = format!(
390 "{}/accounts/{}/access/service_tokens",
391 CLOUDFLARE_API_BASE, self.account_id
392 );
393 let token_name = format!("Mobile Client - {}", name);
394
395 let payload = serde_json::json!({
396 "name": token_name,
397 "duration": "8760h", });
399
400 let response: CloudflareResponse = self
401 .client
402 .post(&url)
403 .json(&payload)
404 .send()
405 .await
406 .context("Failed to create Service Token")?
407 .json()
408 .await
409 .context("Failed to parse Service Token response")?;
410
411 if !response.success || response.result.is_null() {
412 if response.errors.iter().any(|e| e.code == 10000) {
414 anyhow::bail!(
415 "Cloudflare authentication error creating Service Token (code 10000). \
416 Ensure your API token has 'Access: Service Tokens: Edit' permission. \
417 Errors: {:?}",
418 response.errors
419 );
420 }
421
422 warn!(
423 "Service Token creation failed ({}), deleting existing token and retrying...",
424 response.errors.iter().map(|e| format!("{}: {}", e.code, e.message)).collect::<Vec<_>>().join(", ")
425 );
426 self.delete_service_token_by_name(&token_name).await?;
427
428 let retry: CloudflareResponse = self
429 .client
430 .post(&url)
431 .json(&payload)
432 .send()
433 .await
434 .context("Failed to create Service Token (retry)")?
435 .json()
436 .await
437 .context("Failed to parse Service Token response (retry)")?;
438
439 if !retry.success {
440 anyhow::bail!("Failed to create Service Token: {:?}", retry.errors);
441 }
442 return retry.into_result().context("No Service Token returned after retry");
443 }
444
445 response.into_result().context("No Service Token returned")
446 }
447
448 async fn delete_service_token_by_name(&self, name: &str) -> Result<()> {
450 #[derive(Deserialize)]
451 struct TokenInfo {
452 id: String,
453 name: String,
454 }
455
456 let list_url = format!(
457 "{}/accounts/{}/access/service_tokens",
458 CLOUDFLARE_API_BASE, self.account_id
459 );
460
461 let list: CloudflareResponse = self
462 .client
463 .get(&list_url)
464 .send()
465 .await
466 .context("Failed to list Service Tokens")?
467 .json()
468 .await
469 .context("Failed to parse Service Token list")?;
470
471 let tokens: Vec<TokenInfo> = list.into_result().unwrap_or_default();
472 for token in tokens {
473 if token.name == name {
474 let delete_url = format!(
475 "{}/accounts/{}/access/service_tokens/{}",
476 CLOUDFLARE_API_BASE, self.account_id, token.id
477 );
478 self.client
479 .delete(&delete_url)
480 .send()
481 .await
482 .context("Failed to delete existing Service Token")?;
483 info!("🗑️ Deleted existing Service Token '{}'", name);
484 return Ok(());
485 }
486 }
487 Ok(()) }
489
490 pub async fn configure_tunnel_ingress(
492 &self,
493 tunnel_id: &str,
494 hostname: &str,
495 local_port: u16,
496 ) -> Result<()> {
497 let url = format!(
498 "{}/accounts/{}/cfd_tunnel/{}/configurations",
499 CLOUDFLARE_API_BASE, self.account_id, tunnel_id
500 );
501
502 let payload = serde_json::json!({
503 "config": {
504 "ingress": [
505 {
506 "hostname": hostname,
507 "service": format!("http://localhost:{}", local_port),
508 },
509 {
510 "service": "http_status:404",
511 }
512 ],
513 }
514 });
515
516 let response: CloudflareResponse = self
517 .client
518 .put(&url)
519 .json(&payload)
520 .send()
521 .await
522 .context("Failed to configure tunnel ingress")?
523 .json()
524 .await
525 .context("Failed to parse ingress configuration response")?;
526
527 if !response.success {
528 anyhow::bail!("Failed to configure tunnel ingress: {:?}", response.errors);
529 }
530
531 Ok(())
532 }
533
534 async fn delete_tunnel(&self, tunnel_id: &str) -> Result<()> {
536 let url = format!(
537 "{}/accounts/{}/cfd_tunnel/{}",
538 CLOUDFLARE_API_BASE, self.account_id, tunnel_id
539 );
540 let response: CloudflareResponse = self
541 .client
542 .delete(&url)
543 .send()
544 .await
545 .context("Failed to delete tunnel")?
546 .json()
547 .await
548 .context("Failed to parse tunnel delete response")?;
549 if !response.success {
550 anyhow::bail!("Failed to delete tunnel: {:?}", response.errors);
551 }
552 Ok(())
553 }
554
555 fn generate_tunnel_secret(&self) -> String {
557 use base64::{engine::general_purpose, Engine as _};
558 let random_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
559 general_purpose::STANDARD.encode(random_bytes)
560 }
561
562 #[allow(dead_code)]
564 pub fn account_id(&self) -> &str {
565 &self.account_id
566 }
567}
568
569pub fn write_credentials_file(
572 account_id: &str,
573 tunnel_id: &str,
574 tunnel_secret: &str,
575) -> Result<std::path::PathBuf> {
576 let cloudflared_dir = get_cloudflared_dir()?;
577 std::fs::create_dir_all(&cloudflared_dir)
578 .context("Failed to create ~/.cloudflared directory")?;
579
580 let credentials_path = cloudflared_dir.join(format!("{}.json", tunnel_id));
581 let credentials = serde_json::json!({
582 "AccountTag": account_id,
583 "TunnelSecret": tunnel_secret,
584 "TunnelID": tunnel_id,
585 });
586 std::fs::write(&credentials_path, serde_json::to_string_pretty(&credentials)?)
587 .context("Failed to write tunnel credentials file")?;
588
589 #[cfg(unix)]
590 {
591 use std::os::unix::fs::PermissionsExt;
592 let perms = std::fs::Permissions::from_mode(0o600);
593 std::fs::set_permissions(&credentials_path, perms)?;
594 }
595
596 Ok(credentials_path)
597}
598
599pub fn write_cloudflared_config(
602 tunnel_id: &str,
603 credentials_path: &std::path::Path,
604 hostname: &str,
605 local_port: u16,
606) -> Result<std::path::PathBuf> {
607 let cloudflared_dir = get_cloudflared_dir()?;
608 std::fs::create_dir_all(&cloudflared_dir)
609 .context("Failed to create ~/.cloudflared directory")?;
610
611 let config_path = cloudflared_dir.join("config.yml");
612 let credentials_str = credentials_path.to_string_lossy();
613 let config_content = format!(
614 "tunnel: {tunnel_id}\ncredentials-file: {credentials_str}\n\ningress:\n - hostname: {hostname}\n service: http://localhost:{local_port}\n - service: http_status:404\n"
615 );
616 std::fs::write(&config_path, &config_content)
617 .context("Failed to write cloudflared config.yml")?;
618
619 Ok(config_path)
620}
621
622pub fn write_cloudflared_config_at(
627 tunnel_id: &str,
628 credentials_path: &std::path::Path,
629 hostname: &str,
630 local_port: u16,
631 config_path: &std::path::Path,
632) -> Result<()> {
633 if let Some(parent) = config_path.parent() {
634 std::fs::create_dir_all(parent)
635 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
636 }
637 let credentials_str = credentials_path.to_string_lossy();
638 let config_content = format!(
639 "tunnel: {tunnel_id}\ncredentials-file: {credentials_str}\n\ningress:\n - hostname: {hostname}\n service: http://localhost:{local_port}\n - service: http_status:404\n"
640 );
641 std::fs::write(config_path, &config_content)
642 .with_context(|| format!("Failed to write cloudflared config to {}", config_path.display()))?;
643 Ok(())
644}
645
646pub fn cloudflared_config_path() -> Result<std::path::PathBuf> {
648 Ok(get_cloudflared_dir()?.join("config.yml"))
649}
650
651#[allow(dead_code)]
653pub fn cloudflared_credentials_path(tunnel_id: &str) -> Result<std::path::PathBuf> {
654 Ok(get_cloudflared_dir()?.join(format!("{}.json", tunnel_id)))
655}
656
657fn get_cloudflared_dir() -> Result<std::path::PathBuf> {
658 let home = std::env::var("HOME")
659 .or_else(|_| std::env::var("USERPROFILE"))
660 .context("Cannot determine home directory (HOME not set)")?;
661 Ok(std::path::PathBuf::from(home).join(".cloudflared"))
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use std::fs;
668 use tempfile::TempDir;
669
670 fn fake_cloudflared_dir(tmp: &TempDir) -> std::path::PathBuf {
671 let dir = tmp.path().join(".cloudflared");
672 fs::create_dir_all(&dir).unwrap();
673 dir
674 }
675
676 #[test]
677 fn credentials_file_has_required_fields() {
678 let tmp = TempDir::new().unwrap();
679 std::env::set_var("HOME", tmp.path().to_str().unwrap());
681
682 let path = write_credentials_file("acct123", "tunnel-abc", "secret-base64==").unwrap();
683 assert!(path.exists(), "credentials file should be created");
684
685 let content = fs::read_to_string(&path).unwrap();
686 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
687 assert_eq!(json["AccountTag"], "acct123");
688 assert_eq!(json["TunnelSecret"], "secret-base64==");
689 assert_eq!(json["TunnelID"], "tunnel-abc");
690 }
691
692 #[test]
693 fn config_yml_has_correct_sections() {
694 let tmp = TempDir::new().unwrap();
695 std::env::set_var("HOME", tmp.path().to_str().unwrap());
696
697 let creds_path = fake_cloudflared_dir(&tmp).join("tunnel-abc.json");
698 fs::write(&creds_path, "{}").unwrap();
699
700 let config_path = write_cloudflared_config(
701 "tunnel-abc",
702 &creds_path,
703 "agent.example.com",
704 8080,
705 )
706 .unwrap();
707
708 let content = fs::read_to_string(&config_path).unwrap();
709 assert!(content.contains("tunnel: tunnel-abc"), "should have tunnel ID");
710 assert!(content.contains("credentials-file:"), "should have credentials-file");
711 assert!(content.contains("hostname: agent.example.com"), "should have hostname");
712 assert!(content.contains("http://localhost:8080"), "should have local port");
713 assert!(content.contains("http_status:404"), "should have fallback rule");
714 }
715}