Skip to main content

bridge/
cloudflare.rs

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
8/// Cloudflare API client for Zero Trust operations
9pub 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    /// Create a new Cloudflare API client
63    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    /// Create or retrieve existing tunnel
88    pub async fn create_or_get_tunnel(&self, name: &str) -> Result<Tunnel> {
89        // First, check if tunnel already exists
90        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                // Recover the secret from the local credentials file (the API never returns it)
110                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                // Secret is lost — delete this tunnel and fall through to create a new one
124                warn!("Tunnel secret is lost for '{}'. Deleting and recreating...", existing.id);
125                let _ = self.delete_tunnel(&existing.id).await;
126            }
127        }
128
129        // Create new tunnel
130        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    /// Create DNS CNAME record for tunnel
163    pub async fn create_dns_record(
164        &self,
165        zone_name: &str,
166        subdomain: &str,
167        tunnel_id: &str,
168    ) -> Result<()> {
169        // Get zone ID from zone name
170        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        // Create DNS record
191        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            // Error 81053/81057: record with that name already exists — update it instead
215            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    /// Find and update an existing DNS CNAME record by name.
227    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    /// Create Zero Trust Access Application
282    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            // Policy may already exist; ignore errors from duplicate policy creation
312            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        // Create Service Auth policy
318        self.create_service_auth_policy(&app.id, hostname).await?;
319        Ok(app)
320    }
321
322    /// Find an existing Access Application by hostname.
323    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    /// Create Service Auth policy for the application
346    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            // Ignore "already exists" type errors — policy from a previous run is fine
374            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    /// Generate a Service Token for mobile authentication
388    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", // 1 year
398        });
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            // Auth errors can't be resolved by deleting and retrying — surface immediately.
413            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    /// List service tokens and delete the one matching `name`.
449    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(()) // not found — that's fine, proceed to create
488    }
489
490    /// Configure tunnel ingress rules
491    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    /// Delete a tunnel by ID
535    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    /// Generate a secure tunnel secret
556    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    /// Get the account ID for this client
563    #[allow(dead_code)]
564    pub fn account_id(&self) -> &str {
565        &self.account_id
566    }
567}
568
569/// Write the cloudflared tunnel credentials JSON file to ~/.cloudflared/<tunnel-id>.json.
570/// This file is required by `cloudflared tunnel run` to authenticate to Cloudflare.
571pub 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
599/// Write the cloudflared config.yml to ~/.cloudflared/config.yml.
600/// This configures which tunnel to run and the ingress rules.
601pub 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
622/// Write a cloudflared config to an explicit path (e.g. inside a per-project folder).
623/// Use this instead of `write_cloudflared_config` when multiple independent bridge
624/// instances need to run simultaneously from different project folders — each project
625/// gets its own config file so they don't overwrite each other.
626pub 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
646/// Return the path to the cloudflared config YAML (does not check existence).
647pub fn cloudflared_config_path() -> Result<std::path::PathBuf> {
648    Ok(get_cloudflared_dir()?.join("config.yml"))
649}
650
651/// Return the path to the cloudflared credentials file for a given tunnel ID.
652#[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        // Override HOME so write_credentials_file uses tmp dir
680        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}