1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3
4use crate::cloudflare_provision::Provisioner;
5
6#[derive(Serialize)]
7struct DomainAddInput<'a> {
8 project_id: &'a str,
9 domain: &'a str,
10 certificate_pem: Option<&'a str>,
11 private_key_pem: Option<&'a str>,
12 not_after_epoch_seconds: Option<i64>,
13}
14
15#[derive(Deserialize)]
16#[serde(tag = "t", rename_all_fields = "camelCase")]
17enum DomainAdd {
18 Ok {
19 origin_ip: String,
20 needs_dns_record: bool,
21 },
22 NotLoggedIn,
23 NotFound,
24 InvalidDomain {
25 message: String,
26 },
27 CertificateFailed {
28 message: String,
29 },
30 DomainTaken {
31 existing_project_id: String,
32 },
33 AlreadyHasDomain {
34 current_domain: String,
35 },
36 InternalError,
37}
38
39pub struct OriginCertificateRequest<'a> {
45 pub account_id: &'a str,
46 pub zone_id: &'a str,
47 pub api_token: &'a str,
48 pub mint_signing_token: bool,
51}
52
53pub async fn domain_add(
54 project_id: &str,
55 domain: &str,
56 certificate: Option<OriginCertificateRequest<'_>>,
57) -> Result<()> {
58 let creds = crate::credentials::require()?;
59
60 let issued = match certificate {
61 Some(request) => {
62 println!("signing an origin certificate for {domain} (this runs locally)...");
63 Some(
64 Provisioner::new(
65 request.api_token.to_string(),
66 request.account_id.to_string(),
67 request.zone_id.to_string(),
68 )
69 .issue_origin_certificate(domain, request.mint_signing_token)
70 .await?,
71 )
72 }
73 None => None,
74 };
75
76 let client = reqwest::Client::new();
77 let url = format!(
78 "{}/__forte_action/domain_add",
79 creds.control_url.trim_end_matches('/')
80 );
81 let resp = client
82 .post(&url)
83 .bearer_auth(&creds.token)
84 .json(&DomainAddInput {
85 project_id,
86 domain,
87 certificate_pem: issued.as_ref().map(|i| i.certificate_pem.as_str()),
88 private_key_pem: issued.as_ref().map(|i| i.private_key_pem.as_str()),
89 not_after_epoch_seconds: issued.as_ref().map(|i| i.not_after_epoch_seconds),
90 })
91 .send()
92 .await?
93 .error_for_status()?;
94 let raw: DomainAdd = resp.json().await?;
95 match raw {
96 DomainAdd::Ok {
97 origin_ip,
98 needs_dns_record,
99 } => {
100 println!("domain '{domain}' attached to project '{project_id}'");
101 if needs_dns_record {
102 println!();
103 println!("add this record in your Cloudflare dashboard, then you are done:");
104 println!(" A {domain} {origin_ip} (proxied / orange cloud)");
105 println!();
106 println!(
107 "it must stay proxied: the origin certificate is trusted by Cloudflare's \
108 edge only, so turning the record grey breaks the hostname."
109 );
110 } else {
111 println!(
112 "Cloudflare hostname registration is queued; run `forte domain status` to check."
113 );
114 }
115 Ok(())
116 }
117 DomainAdd::CertificateFailed { message } => Err(anyhow!("{message}")),
118 DomainAdd::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
119 DomainAdd::NotFound => Err(anyhow!(
120 "project '{project_id}' not found or not owned by you."
121 )),
122 DomainAdd::InvalidDomain { message } => Err(anyhow!("invalid domain: {message}")),
123 DomainAdd::DomainTaken {
124 existing_project_id,
125 } => Err(anyhow!(
126 "domain '{domain}' already in use by project '{existing_project_id}'"
127 )),
128 DomainAdd::AlreadyHasDomain { current_domain } => Err(anyhow!(
129 "project '{project_id}' already has domain '{current_domain}'; remove it first"
130 )),
131 DomainAdd::InternalError => {
132 Err(anyhow!("domain_add: server error; check fn0-control logs"))
133 }
134 }
135}
136
137#[derive(Serialize)]
138struct DomainProjectInput<'a> {
139 project_id: &'a str,
140}
141
142#[derive(Deserialize)]
143#[serde(tag = "t", rename_all_fields = "camelCase")]
144enum DomainRemove {
145 Ok { removed_domain: String },
146 NotLoggedIn,
147 NotFound,
148 NoDomain,
149 InternalError,
150}
151
152pub async fn domain_remove(project_id: &str) -> Result<()> {
153 let creds = crate::credentials::require()?;
154 let client = reqwest::Client::new();
155 let url = format!(
156 "{}/__forte_action/domain_remove",
157 creds.control_url.trim_end_matches('/')
158 );
159 let resp = client
160 .post(&url)
161 .bearer_auth(&creds.token)
162 .json(&DomainProjectInput { project_id })
163 .send()
164 .await?
165 .error_for_status()?;
166 let raw: DomainRemove = resp.json().await?;
167 match raw {
168 DomainRemove::Ok { removed_domain } => {
169 println!("domain '{removed_domain}' detached from project '{project_id}'");
170 println!("Cloudflare hostname removal is queued.");
171 Ok(())
172 }
173 DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
174 DomainRemove::NotFound => Err(anyhow!(
175 "project '{project_id}' not found or not owned by you."
176 )),
177 DomainRemove::NoDomain => Err(anyhow!(
178 "no custom domain attached to project '{project_id}'."
179 )),
180 DomainRemove::InternalError => Err(anyhow!(
181 "domain_remove: server error; check fn0-control logs"
182 )),
183 }
184}
185
186#[derive(Deserialize)]
187#[serde(tag = "t", rename_all_fields = "camelCase")]
188pub enum DomainStatus {
189 NotConfigured,
190 SelfHosted {
194 domain: String,
195 origin_certificate_ready: bool,
196 origin_certificate_expires_epoch_seconds: Option<i64>,
197 origin_ip: String,
198 },
199 NotLoggedIn,
200 NotFound,
201 InternalError,
202}
203
204pub async fn fetch_domain_status(
205 creds: &crate::credentials::Credentials,
206 project_id: &str,
207) -> Result<DomainStatus> {
208 let client = reqwest::Client::new();
209 let url = format!(
210 "{}/__forte_action/domain_status",
211 creds.control_url.trim_end_matches('/')
212 );
213 let resp = client
214 .post(&url)
215 .bearer_auth(&creds.token)
216 .json(&DomainProjectInput { project_id })
217 .send()
218 .await?
219 .error_for_status()?;
220 Ok(resp.json().await?)
221}
222
223pub async fn domain_status(project_id: &str) -> Result<()> {
224 let creds = crate::credentials::require()?;
225 match fetch_domain_status(&creds, project_id).await? {
226 DomainStatus::NotConfigured => {
227 println!("project '{project_id}' has no custom domain configured.");
228 Ok(())
229 }
230 DomainStatus::SelfHosted {
231 domain,
232 origin_certificate_ready,
233 origin_certificate_expires_epoch_seconds,
234 origin_ip,
235 } => {
236 println!("project '{project_id}' custom domain: {domain}");
237 println!("served from your own Cloudflare account.");
238 if origin_certificate_ready {
239 match origin_certificate_expires_epoch_seconds
240 .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
241 {
242 Some(expires) => println!(
243 "origin certificate: held by fn0, expires {}",
244 expires.format("%Y-%m-%d")
245 ),
246 None => println!("origin certificate: held by fn0"),
247 }
248 } else {
249 println!(
250 "origin certificate: missing — run `forte domain add {domain}` to issue one."
251 );
252 }
253 if !origin_ip.is_empty() {
254 println!("point a proxied A record at {origin_ip}.");
255 }
256 Ok(())
257 }
258 DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
259 DomainStatus::NotFound => Err(anyhow!(
260 "project '{project_id}' not found or not owned by you."
261 )),
262 DomainStatus::InternalError => Err(anyhow!(
263 "domain_status: server error; check fn0-control logs"
264 )),
265 }
266}
267