Skip to main content

fn0_deploy/
domain.rs

1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3
4use crate::cloudflare::CloudSetup;
5use crate::cloudflare_provision::Provisioner;
6
7#[derive(Serialize)]
8struct DomainSetInput<'a> {
9    project_id: &'a str,
10    domain: &'a str,
11    certificate_pem: &'a str,
12    private_key_pem: &'a str,
13    not_after_epoch_seconds: i64,
14}
15
16#[derive(Deserialize)]
17#[serde(tag = "t", rename_all_fields = "camelCase")]
18enum DomainSet {
19    Ok {
20        origin_hostname: String,
21        replaced_domain: Option<String>,
22    },
23    NotLoggedIn,
24    NotFound,
25    InvalidDomain {
26        message: String,
27    },
28    DomainTaken {
29        existing_project_id: String,
30    },
31    InternalError,
32}
33
34/// What the caller has to tell the user once the domain is registered.
35pub struct DomainOutcome {
36    /// The proxied CNAME target for the domain. Until that record exists, the
37    /// hostname resolves nowhere.
38    pub origin_hostname: String,
39    pub replaced_domain: Option<String>,
40}
41
42/// Points a project at a domain, replacing whatever it answered on before.
43///
44/// The origin certificate is signed here rather than by fn0: only a token with
45/// `SSL and Certificates -> Edit` can sign one, and fn0 holds no such token by
46/// design.
47///
48/// The CORS allowlist on the project's buckets is the app's own origin, so it
49/// is rewritten every time, even when the domain has not changed. Provisioning
50/// already wrote it for the domain a project is set up with, so the first write
51/// is redundant — but making it unconditional is what leaves a way to repair an
52/// allowlist that drifted, and re-running with an unchanged domain is how a
53/// user would expect to do that.
54pub async fn set_domain(setup: &CloudSetup<'_>) -> Result<DomainOutcome> {
55    println!(
56        "signing an origin certificate for {} (this runs locally)...",
57        setup.domain
58    );
59    let issued = provisioner(setup)
60        .issue_origin_certificate(setup.domain, setup.mint_from_setup_token)
61        .await?;
62
63    let creds = crate::credentials::require()?;
64    let url = format!(
65        "{}/__forte_action/domain_set",
66        creds.control_url.trim_end_matches('/')
67    );
68    let project_id = setup.project_id;
69    let domain = setup.domain;
70    let response: DomainSet = reqwest::Client::new()
71        .post(&url)
72        .bearer_auth(&creds.token)
73        .json(&DomainSetInput {
74            project_id,
75            domain,
76            certificate_pem: &issued.certificate_pem,
77            private_key_pem: &issued.private_key_pem,
78            not_after_epoch_seconds: issued.not_after_epoch_seconds,
79        })
80        .send()
81        .await?
82        .error_for_status()?
83        .json()
84        .await?;
85
86    let (origin_hostname, replaced_domain) = match response {
87        DomainSet::Ok {
88            origin_hostname,
89            replaced_domain,
90        } => (origin_hostname, replaced_domain),
91        DomainSet::NotLoggedIn => {
92            return Err(anyhow!("control rejected token; sign in again."));
93        }
94        DomainSet::NotFound => {
95            return Err(anyhow!(
96                "project '{project_id}' not found or not owned by you."
97            ));
98        }
99        DomainSet::InvalidDomain { message } => {
100            return Err(anyhow!("invalid domain: {message}"));
101        }
102        DomainSet::DomainTaken {
103            existing_project_id,
104        } => {
105            return Err(anyhow!(
106                "domain '{domain}' is already in use by project '{existing_project_id}'"
107            ));
108        }
109        DomainSet::InternalError => {
110            return Err(anyhow!("domain_set: server error; check fn0-control logs"));
111        }
112    };
113
114    provisioner(setup)
115        .put_app_cors(project_id, &setup.app_origin(), setup.mint_from_setup_token)
116        .await?;
117    provisioner(setup)
118        .ensure_app_cache(
119            setup.domain,
120            replaced_domain.as_deref(),
121            setup.mint_from_setup_token,
122        )
123        .await?;
124
125    Ok(DomainOutcome {
126        origin_hostname,
127        replaced_domain,
128    })
129}
130
131fn provisioner(setup: &CloudSetup<'_>) -> Provisioner {
132    Provisioner::new(
133        setup.api_token.to_string(),
134        setup.account_id.to_string(),
135        setup.zone_id.to_string(),
136    )
137}
138
139#[derive(Serialize)]
140struct DomainProjectInput<'a> {
141    project_id: &'a str,
142}
143
144#[derive(Deserialize)]
145#[serde(tag = "t", rename_all_fields = "camelCase")]
146pub enum DomainStatus {
147    NotConfigured,
148    /// A project on its owner's own Cloudflare account. Their edge holds the
149    /// visitor-facing certificate, so there is no fn0-side DV status to report;
150    /// what fn0 holds is the origin certificate the worker presents.
151    SelfHosted {
152        domain: String,
153        origin_certificate_ready: bool,
154        origin_certificate_expires_epoch_seconds: Option<i64>,
155        origin_hostname: String,
156    },
157    NotLoggedIn,
158    NotFound,
159    InternalError,
160}
161
162pub async fn fetch_domain_status(
163    creds: &crate::credentials::Credentials,
164    project_id: &str,
165) -> Result<DomainStatus> {
166    let url = format!(
167        "{}/__forte_action/domain_status",
168        creds.control_url.trim_end_matches('/')
169    );
170    let resp = reqwest::Client::new()
171        .post(&url)
172        .bearer_auth(&creds.token)
173        .json(&DomainProjectInput { project_id })
174        .send()
175        .await?
176        .error_for_status()?;
177    Ok(resp.json().await?)
178}