Skip to main content

nexus_core/host/
cloudflare.rs

1//! Minimal Cloudflare v4 control-plane calls for `nexus host --setup`.
2//!
3//! The workspace already carries an async `reqwest` client, so setup uses the
4//! documented REST endpoints directly instead of adding a second Cloudflare
5//! SDK and HTTP stack. The API token is read from `CF_API_TOKEN` and is never
6//! included in returned errors or written to disk.
7
8use std::path::PathBuf;
9use std::time::Duration;
10
11use anyhow::{Context as _, Result, bail};
12use base64::Engine as _;
13use serde::Deserialize;
14use sha2::{Digest as _, Sha256};
15
16const API: &str = "https://api.cloudflare.com/client/v4";
17
18/// A Cloudflare account selectable during setup.
19#[derive(Debug, Clone, Deserialize)]
20pub struct Account {
21    pub id: String,
22    pub name: String,
23}
24
25/// A DNS zone selectable during setup.
26#[derive(Debug, Clone, Deserialize)]
27pub struct Zone {
28    pub id: String,
29    pub name: String,
30}
31
32/// Inputs for creating a named tunnel and its DNS route.
33#[derive(Debug, Clone)]
34pub struct SetupOptions {
35    pub account_id: String,
36    pub zone_id: String,
37    pub hostname: String,
38    pub tunnel_name: String,
39    pub port: u16,
40}
41
42/// Files and identifiers produced by named-tunnel setup.
43#[derive(Debug, Clone)]
44pub struct SetupResult {
45    pub tunnel_id: String,
46    pub hostname: String,
47    pub credentials_path: PathBuf,
48    pub config_path: PathBuf,
49}
50
51#[derive(Debug, Deserialize)]
52struct ApiResponse<T> {
53    success: bool,
54    result: Option<T>,
55    #[serde(default)]
56    errors: Vec<ApiError>,
57}
58
59#[derive(Debug, Deserialize)]
60struct ApiError {
61    #[serde(default)]
62    message: String,
63}
64
65/// List accounts visible to `CF_API_TOKEN`.
66pub async fn list_accounts() -> Result<Vec<Account>> {
67    let client = client()?;
68    let response = client.get(format!("{API}/accounts")).send().await?;
69    parse_response(response).await
70}
71
72/// List DNS zones visible to `CF_API_TOKEN`.
73pub async fn list_zones() -> Result<Vec<Zone>> {
74    let client = client()?;
75    let response = client
76        .get(format!("{API}/zones?per_page=100"))
77        .send()
78        .await?;
79    parse_response(response).await
80}
81
82/// Create a named tunnel, add a proxied CNAME, fetch its connector token, and
83/// write the local credentials/config files used by `cloudflared`.
84pub async fn provision_named_tunnel(options: &SetupOptions) -> Result<SetupResult> {
85    if options.hostname.trim().is_empty() || options.tunnel_name.trim().is_empty() {
86        bail!("tunnel hostname and name must not be empty");
87    }
88    let client = client()?;
89    let secret = tunnel_secret();
90    let create = client
91        .post(format!("{API}/accounts/{}/cfd_tunnel", options.account_id))
92        .json(&serde_json::json!({
93            "name": options.tunnel_name,
94            "tunnel_secret": secret,
95            "config_src": "local",
96        }))
97        .send()
98        .await?;
99    let tunnel: TunnelResult = parse_response(create).await?;
100
101    let dns = client
102        .post(format!("{API}/zones/{}/dns_records", options.zone_id))
103        .json(&serde_json::json!({
104            "type": "CNAME",
105            "name": options.hostname,
106            "content": format!("{}.cfargotunnel.com", tunnel.id),
107            "proxied": true,
108        }))
109        .send()
110        .await?;
111    let _: serde_json::Value = parse_response(dns).await?;
112
113    // The token endpoint is useful for cloudflared's `tunnel run --token`
114    // form, but the credentials JSON is the stable local artifact used by a
115    // named config. Fetch it so setup verifies connector authorization too.
116    let token_response = client
117        .get(format!(
118            "{API}/accounts/{}/cfd_tunnel/{}/token",
119            options.account_id, tunnel.id
120        ))
121        .send()
122        .await?;
123    let _token: String = parse_response(token_response).await?;
124
125    let home = std::env::var_os("HOME")
126        .map(PathBuf::from)
127        .context("HOME is not set; cannot locate ~/.cloudflared")?;
128    let dir = home.join(".cloudflared");
129    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
130    let credentials_path = dir.join(format!("{}.json", tunnel.id));
131    let credentials = serde_json::json!({
132        "AccountTag": options.account_id,
133        "TunnelSecret": secret,
134        "TunnelID": tunnel.id,
135    });
136    write_private(&credentials_path, &serde_json::to_vec_pretty(&credentials)?)?;
137    let config_path = dir.join(format!("nexus-{}.yml", tunnel.id));
138    write_named_config(
139        &config_path,
140        &credentials_path,
141        &tunnel.id,
142        &options.hostname,
143        options.port,
144    )?;
145    Ok(SetupResult {
146        tunnel_id: tunnel.id,
147        hostname: options.hostname.clone(),
148        credentials_path,
149        config_path,
150    })
151}
152
153#[derive(Debug, Deserialize)]
154struct TunnelResult {
155    id: String,
156}
157
158/// Rewrite a named tunnel's local ingress for the current host port. The
159/// tunnel identity and credential path are reused; no Cloudflare API call is
160/// needed when the saved configuration is still present.
161pub fn write_named_config(
162    config_path: &std::path::Path,
163    credentials_path: &std::path::Path,
164    tunnel_id: &str,
165    hostname: &str,
166    port: u16,
167) -> Result<()> {
168    let config = format!(
169        "tunnel: {tunnel_id}\ncredentials-file: {}\ningress:\n  - hostname: {}\n    service: http://127.0.0.1:{port}\n  - service: http_status:404\n",
170        yaml_quote(&credentials_path.display().to_string()),
171        hostname,
172    );
173    write_private(config_path, config.as_bytes())
174}
175
176fn client() -> Result<reqwest::Client> {
177    let token = std::env::var("CF_API_TOKEN").context("CF_API_TOKEN is not set")?;
178    if token.trim().is_empty() {
179        bail!("CF_API_TOKEN is empty");
180    }
181    let mut headers = reqwest::header::HeaderMap::new();
182    headers.insert(
183        reqwest::header::AUTHORIZATION,
184        reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))?,
185    );
186    reqwest::Client::builder()
187        .default_headers(headers)
188        .connect_timeout(Duration::from_secs(5))
189        .timeout(Duration::from_secs(30))
190        .user_agent("nexus-chat host setup")
191        .build()
192        .context("building Cloudflare API client")
193}
194
195async fn parse_response<T: for<'de> Deserialize<'de>>(response: reqwest::Response) -> Result<T> {
196    let status = response.status();
197    let body: ApiResponse<T> = response
198        .json()
199        .await
200        .with_context(|| format!("parsing Cloudflare API response ({status})"))?;
201    if !status.is_success() || !body.success {
202        let detail = body
203            .errors
204            .first()
205            .map_or("request failed", |error| error.message.as_str());
206        bail!("Cloudflare API request failed ({status}): {detail}");
207    }
208    body.result
209        .context("Cloudflare API response omitted result")
210}
211
212fn tunnel_secret() -> String {
213    let mut bytes = [0u8; 32];
214    let first = uuid::Uuid::new_v4().into_bytes();
215    let second = uuid::Uuid::new_v4().into_bytes();
216    bytes[..16].copy_from_slice(&first);
217    bytes[16..].copy_from_slice(&second);
218    // Hashing the UUID material makes the value independent of UUID version
219    // bits while retaining 256 bits of entropy for Cloudflare's secret.
220    let digest = Sha256::digest(bytes);
221    base64::engine::general_purpose::STANDARD.encode(digest)
222}
223
224fn yaml_quote(value: &str) -> String {
225    format!("'{}'", value.replace('\'', "''"))
226}
227
228fn write_private(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
229    std::fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
230    #[cfg(unix)]
231    {
232        use std::os::unix::fs::PermissionsExt as _;
233        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
234            .with_context(|| format!("protecting {}", path.display()))?;
235    }
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::{tunnel_secret, yaml_quote};
242
243    #[test]
244    fn generated_tunnel_secret_is_base64_and_32_bytes() {
245        let secret = tunnel_secret();
246        let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, secret)
247            .expect("base64 secret");
248        assert_eq!(bytes.len(), 32);
249    }
250
251    #[test]
252    fn yaml_quote_escapes_single_quotes() {
253        assert_eq!(yaml_quote("a'b"), "'a''b'");
254    }
255}