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
34pub struct DomainOutcome {
36 pub origin_hostname: String,
39 pub replaced_domain: Option<String>,
40}
41
42pub 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 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}