1use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17
18use crate::front_door::{dns_record_name, FrontDoorRecord};
19
20#[cfg(not(target_arch = "wasm32"))]
22const CF_API_BASE: &str = "https://api.cloudflare.com/client/v4";
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CfConfig {
27 pub api_token: String,
29 pub zone_id: String,
31}
32
33pub fn dns_record_payload(rec: &FrontDoorRecord) -> Value {
40 json!({
41 "type": "TXT",
42 "name": dns_record_name(&rec.domain),
43 "content": rec.to_dns_txt(),
44 "ttl": 300,
45 })
46}
47
48pub fn parse_zone_list(json: &Value) -> Vec<(String, String)> {
53 let Some(arr) = json.get("result").and_then(Value::as_array) else {
54 return Vec::new();
55 };
56 arr.iter()
57 .filter_map(|z| {
58 let id = z.get("id").and_then(Value::as_str)?;
59 let name = z.get("name").and_then(Value::as_str)?;
60 Some((id.to_string(), name.to_string()))
61 })
62 .collect()
63}
64
65pub fn parse_verify_token(json: &Value) -> Result<(), String> {
71 let success = json
72 .get("success")
73 .and_then(Value::as_bool)
74 .unwrap_or(false);
75 if !success {
76 return Err(format!("cloudflare token verify unsuccessful: {json}"));
77 }
78 let status = json
79 .get("result")
80 .and_then(|r| r.get("status"))
81 .and_then(Value::as_str)
82 .unwrap_or("");
83 if status == "active" {
84 Ok(())
85 } else {
86 Err(format!("cloudflare token not active (status: {status:?})"))
87 }
88}
89
90#[cfg(not(target_arch = "wasm32"))]
94fn client() -> Result<reqwest::blocking::Client, String> {
95 use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
96 let mut headers = HeaderMap::new();
100 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
101 let _ = AUTHORIZATION;
103 reqwest::blocking::Client::builder()
104 .timeout(std::time::Duration::from_secs(8))
105 .default_headers(headers)
106 .build()
107 .map_err(|e| format!("cloudflare http client build failed: {e}"))
108}
109
110#[cfg(not(target_arch = "wasm32"))]
112fn authed(
113 req: reqwest::blocking::RequestBuilder,
114 token: &str,
115) -> reqwest::blocking::RequestBuilder {
116 req.header("Authorization", format!("Bearer {token}"))
117 .header("Content-Type", "application/json")
118}
119
120#[cfg(not(target_arch = "wasm32"))]
122fn read_json(resp: reqwest::blocking::Response) -> Result<Value, String> {
123 let status = resp.status();
124 let body = resp
125 .text()
126 .map_err(|e| format!("cloudflare read body failed: {e}"))?;
127 if !status.is_success() {
128 return Err(format!("cloudflare HTTP {status}: {body}"));
129 }
130 serde_json::from_str(&body).map_err(|e| format!("cloudflare JSON parse failed: {e}: {body}"))
131}
132
133#[cfg(not(target_arch = "wasm32"))]
135pub fn verify_token(token: &str) -> Result<(), String> {
136 let url = format!("{CF_API_BASE}/user/tokens/verify");
137 let resp = authed(client()?.get(&url), token)
138 .send()
139 .map_err(|e| format!("cloudflare verify request failed: {e}"))?;
140 let json = read_json(resp)?;
141 parse_verify_token(&json)
142}
143
144#[cfg(not(target_arch = "wasm32"))]
146pub fn list_zones(token: &str) -> Result<Vec<(String, String)>, String> {
147 let url = format!("{CF_API_BASE}/zones");
148 let resp = authed(client()?.get(&url), token)
149 .send()
150 .map_err(|e| format!("cloudflare zones request failed: {e}"))?;
151 let json = read_json(resp)?;
152 Ok(parse_zone_list(&json))
153}
154
155#[cfg(not(target_arch = "wasm32"))]
157pub fn publish_front_door(cfg: &CfConfig, rec: &FrontDoorRecord) -> Result<String, String> {
158 let url = format!("{CF_API_BASE}/zones/{}/dns_records", cfg.zone_id);
159 let payload = dns_record_payload(rec);
160 let resp = authed(client()?.post(&url), &cfg.api_token)
161 .json(&payload)
162 .send()
163 .map_err(|e| format!("cloudflare publish request failed: {e}"))?;
164 let json = read_json(resp)?;
165 json.get("result")
166 .and_then(|r| r.get("id"))
167 .and_then(Value::as_str)
168 .map(|s| s.to_string())
169 .ok_or_else(|| format!("cloudflare publish returned no result.id: {json}"))
170}
171
172#[cfg(not(target_arch = "wasm32"))]
174pub fn provision_r2_bucket(token: &str, account_id: &str, bucket_name: &str) -> Result<(), String> {
175 let url = format!("{CF_API_BASE}/accounts/{account_id}/r2/buckets");
176 let payload = json!({ "name": bucket_name });
177 let resp = authed(client()?.post(&url), token)
178 .json(&payload)
179 .send()
180 .map_err(|e| format!("cloudflare r2 provision request failed: {e}"))?;
181
182 let status = resp.status();
184 let body = resp.text().unwrap_or_default();
185 if !status.is_success() && !body.contains("10015") {
186 return Err(format!("cloudflare r2 provision HTTP {status}: {body}"));
187 }
188 Ok(())
189}
190
191#[cfg(not(target_arch = "wasm32"))]
193pub fn provision_worker(
194 token: &str,
195 account_id: &str,
196 script_name: &str,
197 script_content: &str,
198) -> Result<(), String> {
199 let url = format!("{CF_API_BASE}/accounts/{account_id}/workers/scripts/{script_name}");
200
201 let mut req = client()?.put(&url);
203 req = req
204 .header("Authorization", format!("Bearer {token}"))
205 .header("Content-Type", "application/javascript");
206
207 let resp = req
208 .body(script_content.to_string())
209 .send()
210 .map_err(|e| format!("cloudflare worker provision request failed: {e}"))?;
211
212 let _json = read_json(resp)?;
213 Ok(())
214}
215
216#[cfg(not(target_arch = "wasm32"))]
219pub fn provision_tunnel(
220 token: &str,
221 account_id: &str,
222 tunnel_name: &str,
223 tunnel_secret_b64: &str,
224) -> Result<String, String> {
225 let url = format!("{CF_API_BASE}/accounts/{account_id}/cfd_tunnel");
226 let payload = json!({ "name": tunnel_name, "tunnel_secret": tunnel_secret_b64 });
227 let resp = authed(client()?.post(&url), token)
228 .json(&payload)
229 .send()
230 .map_err(|e| format!("cloudflare tunnel provision request failed: {e}"))?;
231
232 let json = read_json(resp)?;
233 json.get("result")
234 .and_then(|r| r.get("id"))
235 .and_then(Value::as_str)
236 .map(|s| s.to_string())
237 .ok_or_else(|| format!("cloudflare tunnel response missing id: {json}"))
238}
239
240#[cfg(not(target_arch = "wasm32"))]
242pub fn route_tunnel_dns(
243 token: &str,
244 zone_id: &str,
245 record_name: &str,
246 tunnel_id: &str,
247) -> Result<String, String> {
248 let url = format!("{CF_API_BASE}/zones/{zone_id}/dns_records");
249 let payload = json!({
250 "type": "CNAME",
251 "name": record_name,
252 "content": format!("{tunnel_id}.cfargotunnel.com"),
253 "proxied": true,
254 "ttl": 1
255 });
256
257 let resp = authed(client()?.post(&url), token)
258 .json(&payload)
259 .send()
260 .map_err(|e| format!("cloudflare tunnel dns route request failed: {e}"))?;
261
262 let status = resp.status();
264 let body = resp.text().unwrap_or_default();
265 if !status.is_success() {
266 if body.contains("81053") {
267 return Ok("already_exists".to_string());
268 }
269 return Err(format!("cloudflare tunnel dns route HTTP {status}: {body}"));
270 }
271
272 let json: Value = serde_json::from_str(&body).unwrap_or_default();
273 json.get("result")
274 .and_then(|r| r.get("id"))
275 .and_then(Value::as_str)
276 .map(|s| s.to_string())
277 .ok_or_else(|| format!("cloudflare tunnel dns route missing id: {body}"))
278}
279
280#[cfg(not(target_arch = "wasm32"))]
282pub fn provision_pages_project(
283 token: &str,
284 account_id: &str,
285 project_name: &str,
286 github_repo: &str,
287) -> Result<String, String> {
288 let url = format!("{}/accounts/{}/pages/projects", CF_API_BASE, account_id);
289 let payload = json!({
290 "name": project_name,
291 "source": {
292 "type": "github",
293 "config": {
294 "owner": github_repo.split('/').next().unwrap_or(""),
295 "repo_name": github_repo.split('/').nth(1).unwrap_or(""),
296 "production_branch": "main",
297 "pr_comments_enabled": false,
298 "deployments_enabled": true
299 }
300 },
301 "build_config": {
302 "build_command": "",
303 "destination_dir": "",
304 "root_dir": "",
305 "web_analytics_tag": null,
306 "web_analytics_token": null
307 }
308 });
309
310 let resp = authed(client()?.post(&url), token)
311 .json(&payload)
312 .send()
313 .map_err(|e| format!("cloudflare pages provision request failed: {}", e))?;
314
315 let status = resp.status();
316 let body = resp.text().unwrap_or_default();
317
318 if !status.is_success() && !body.contains("8000007") {
320 return Err(format!(
321 "cloudflare pages provision HTTP {}: {}",
322 status, body
323 ));
324 }
325
326 Ok("success".to_string())
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::domains::AgentType;
333
334 fn minimal_record() -> FrontDoorRecord {
335 FrontDoorRecord {
336 domain: "a.example".into(),
337 agent_type: AgentType::NaturalPerson,
338 front_door_did: "did:qdp:a".into(),
339 name: None,
340 webid: None,
341 services: vec![],
342 identity_pubkey_hex: None,
343 wireguard_pubkey_hex: None,
344 overlay_addr: None,
345 profile_url: None,
346 }
347 }
348
349 #[test]
350 fn dns_record_payload_is_a_qdp_txt_record() {
351 let p = dns_record_payload(&minimal_record());
352 assert_eq!(p["type"], "TXT");
353 let name = p["name"].as_str().unwrap();
354 assert!(
355 name.starts_with("_qdp."),
356 "name should be _qdp.<domain>, got {name}"
357 );
358 let content = p["content"].as_str().unwrap();
359 assert!(!content.is_empty(), "TXT content must be non-empty");
360 assert_eq!(p["ttl"], 300);
361 }
362
363 #[test]
364 fn parse_zone_list_collects_id_name_pairs() {
365 let json = json!({ "result": [ { "id": "z1", "name": "a.example" } ] });
366 assert_eq!(
367 parse_zone_list(&json),
368 vec![("z1".to_string(), "a.example".to_string())]
369 );
370 }
371
372 #[test]
373 fn parse_zone_list_empty_when_no_result() {
374 assert!(parse_zone_list(&json!({})).is_empty());
375 assert!(parse_zone_list(&json!({ "result": [] })).is_empty());
376 }
377
378 #[test]
379 fn parse_verify_token_ok_only_when_active() {
380 assert!(
381 parse_verify_token(&json!({ "success": true, "result": { "status": "active" } }))
382 .is_ok()
383 );
384 assert!(parse_verify_token(
386 &json!({ "success": true, "result": { "status": "disabled" } })
387 )
388 .is_err());
389 assert!(
391 parse_verify_token(&json!({ "success": false, "result": { "status": "active" } }))
392 .is_err()
393 );
394 assert!(parse_verify_token(&json!({})).is_err());
396 }
397}