nexus_core/host/
cloudflare.rs1use 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#[derive(Debug, Clone, Deserialize)]
20pub struct Account {
21 pub id: String,
22 pub name: String,
23}
24
25#[derive(Debug, Clone, Deserialize)]
27pub struct Zone {
28 pub id: String,
29 pub name: String,
30}
31
32#[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#[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
65pub 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
72pub 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
82pub 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 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
158pub 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 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}