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