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    /// A project on its owner's own Cloudflare account. Their edge holds the
191    /// visitor-facing certificate, so there is no fn0-side DV status to report;
192    /// what fn0 holds is the origin certificate the worker presents.
193    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