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 Configured {
191 domain: String,
192 cloudflare_status: CloudflareStatus,
193 },
194 SelfHosted {
198 domain: String,
199 origin_certificate_ready: bool,
200 origin_certificate_expires_epoch_seconds: Option<i64>,
201 origin_ip: String,
202 },
203 NotLoggedIn,
204 NotFound,
205 InternalError,
206}
207
208#[derive(Deserialize)]
209#[serde(tag = "t", rename_all_fields = "camelCase")]
210pub enum CloudflareStatus {
211 Active,
212 Pending,
213 Missing,
214 Other { value: String },
215}
216
217pub async fn fetch_domain_status(
218 creds: &crate::credentials::Credentials,
219 project_id: &str,
220) -> Result<DomainStatus> {
221 let client = reqwest::Client::new();
222 let url = format!(
223 "{}/__forte_action/domain_status",
224 creds.control_url.trim_end_matches('/')
225 );
226 let resp = client
227 .post(&url)
228 .bearer_auth(&creds.token)
229 .json(&DomainProjectInput { project_id })
230 .send()
231 .await?
232 .error_for_status()?;
233 Ok(resp.json().await?)
234}
235
236pub async fn domain_status(project_id: &str) -> Result<()> {
237 let creds = crate::credentials::require()?;
238 match fetch_domain_status(&creds, project_id).await? {
239 DomainStatus::NotConfigured => {
240 println!("project '{project_id}' has no custom domain configured.");
241 Ok(())
242 }
243 DomainStatus::Configured {
244 domain,
245 cloudflare_status,
246 } => {
247 println!("project '{project_id}' custom domain: {domain}");
248 println!(
249 "cloudflare status: {}",
250 format_cloudflare_status(&cloudflare_status)
251 );
252 Ok(())
253 }
254 DomainStatus::SelfHosted {
255 domain,
256 origin_certificate_ready,
257 origin_certificate_expires_epoch_seconds,
258 origin_ip,
259 } => {
260 println!("project '{project_id}' custom domain: {domain}");
261 println!("served from your own Cloudflare account.");
262 if origin_certificate_ready {
263 match origin_certificate_expires_epoch_seconds
264 .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
265 {
266 Some(expires) => println!(
267 "origin certificate: held by fn0, expires {}",
268 expires.format("%Y-%m-%d")
269 ),
270 None => println!("origin certificate: held by fn0"),
271 }
272 } else {
273 println!(
274 "origin certificate: missing — run `forte domain add {domain}` to issue one."
275 );
276 }
277 if !origin_ip.is_empty() {
278 println!("point a proxied A record at {origin_ip}.");
279 }
280 Ok(())
281 }
282 DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
283 DomainStatus::NotFound => Err(anyhow!(
284 "project '{project_id}' not found or not owned by you."
285 )),
286 DomainStatus::InternalError => Err(anyhow!(
287 "domain_status: server error; check fn0-control logs"
288 )),
289 }
290}
291
292pub(crate) fn format_cloudflare_status(status: &CloudflareStatus) -> String {
293 match status {
294 CloudflareStatus::Active => "active".to_string(),
295 CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
296 CloudflareStatus::Missing => {
297 "missing on Cloudflare (registration may still be in progress)".to_string()
298 }
299 CloudflareStatus::Other { value } => format!("other: {value}"),
300 }
301}