Skip to main content

fn0_deploy/
domain.rs

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
39/// Attaching a domain to a project on the owner's own Cloudflare account needs
40/// an origin certificate, and only a token with `SSL and Certificates -> Edit`
41/// can sign one. fn0 does not hold such a token by design, so the CLI signs the
42/// certificate here and uploads it. `account_id`/`zone_id`/`api_token` are the
43/// same ones used for `cloudflare connect`.
44pub struct OriginCertificateRequest<'a> {
45    pub account_id: &'a str,
46    pub zone_id: &'a str,
47    pub api_token: &'a str,
48    /// `true` when the token can only create tokens, so a signing token has to
49    /// be minted from it; `false` when it can sign directly.
50    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    /// A project on its owner's own Cloudflare account. Their edge holds the
195    /// visitor-facing certificate, so there is no fn0-side DV status to report;
196    /// what fn0 holds is the origin certificate the worker presents.
197    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}