1#![forbid(unsafe_code)]
3use std::path::Path;
14
15use instant_acme::{
16 Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt,
17 NewAccount, NewOrder, OrderStatus, RetryPolicy,
18};
19use serde::{Deserialize, Serialize};
20
21use super::paths::{
22 acme_account_path, acme_domain_dir, cert_pem_path, ensure_dir, key_pem_path, order_json_path,
23 write_secret_file,
24};
25use super::provider::ensure_provider;
26use super::acme_error_map::map_acme_error;
27use crate::domain::{AcmeOrderUrl, HttpsUrl, Rfc3339Utc};
28use crate::errors::{SshCliError, SshCliResult};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum AcmeDirectory {
33 #[default]
35 Production,
36 Staging,
38}
39
40impl AcmeDirectory {
41 fn url(self) -> SshCliResult<HttpsUrl> {
43 let raw = match self {
44 Self::Production => LetsEncrypt::Production.url(),
45 Self::Staging => LetsEncrypt::Staging.url(),
46 };
47 HttpsUrl::try_new(raw).map_err(|e| SshCliError::tls_msg(format!("ACME directory URL: {e}")))
48 }
49
50 fn label(self) -> &'static str {
51 match self {
52 Self::Production => "production",
53 Self::Staging => "staging",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PendingOrder {
61 pub domain: String,
63 pub directory: String,
65 pub order_url: AcmeOrderUrl,
67 pub dns_name: String,
69 pub dns_value: String,
71 pub created_at: Rfc3339Utc,
73}
74
75#[derive(Debug, Clone, Serialize)]
77pub struct AccountStatus {
78 pub present: bool,
80 pub path: String,
82 pub directory: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize)]
88pub struct DomainCertStatus {
89 pub domain: String,
91 pub has_cert: bool,
93 pub has_key: bool,
95 pub has_pending_order: bool,
97 pub cert_path: Option<String>,
99 pub key_path: Option<String>,
101}
102
103pub async fn create_account(
105 config_override: Option<&Path>,
106 directory: AcmeDirectory,
107 contact: &[String],
108 force: bool,
109) -> SshCliResult<AccountStatus> {
110 ensure_provider()?;
111 let path = acme_account_path(config_override)?;
112 if path.exists() && !force {
113 return Err(SshCliError::InvalidArgument(format!(
114 "ACME account already exists at {}; use --force to replace",
115 path.display()
116 )));
117 }
118 if contact.is_empty() {
120 return Err(SshCliError::InvalidArgument(
121 "ACME account create requires at least one --contact mailto:address".into(),
122 ));
123 }
124 for c in contact {
125 let c = c.trim();
126 if !c.to_ascii_lowercase().starts_with("mailto:")
127 || c.len() <= "mailto:".len()
128 || !c.contains('@')
129 {
130 return Err(SshCliError::InvalidArgument(format!(
131 "invalid ACME contact '{c}'; expected mailto:user@example.com"
132 )));
133 }
134 }
135 if let Some(parent) = path.parent() {
136 ensure_dir(parent)?;
137 }
138
139 let contacts: Vec<&str> = contact.iter().map(String::as_str).collect();
140 let directory_url = directory.url()?;
141 let (_account, credentials) = Account::builder()
142 .map_err(|e| map_acme_error("ACME account builder", e))?
143 .create(
144 &NewAccount {
145 contact: &contacts,
146 terms_of_service_agreed: true,
147 only_return_existing: false,
148 },
149 directory_url.as_str().to_owned(),
150 None,
151 )
152 .await
153 .map_err(|e| map_acme_error("ACME create account", e))?;
154
155 let json = serde_json::to_vec_pretty(&credentials)
156 .map_err(|e| SshCliError::tls_msg(format!("serialize ACME credentials: {e}")))?;
157 write_secret_file(&path, &json)?;
158
159 let meta_path = path.with_extension("meta.json");
160 let meta = serde_json::json!({
161 "directory": directory.label(),
162 "created_at": Rfc3339Utc::now().to_rfc3339(),
163 });
164 write_secret_file(
165 &meta_path,
166 serde_json::to_vec_pretty(&meta)
167 .map_err(|e| SshCliError::tls_msg(format!("meta: {e}")))?
168 .as_slice(),
169 )?;
170
171 Ok(AccountStatus {
172 present: true,
173 path: path.display().to_string(),
174 directory: Some(directory.label().to_owned()),
175 })
176}
177
178pub fn load_account_status(config_override: Option<&Path>) -> SshCliResult<AccountStatus> {
180 let path = acme_account_path(config_override)?;
181 let directory = std::fs::read_to_string(path.with_extension("meta.json"))
182 .ok()
183 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
184 .and_then(|v| v.get("directory")?.as_str().map(str::to_owned));
185 Ok(AccountStatus {
186 present: path.is_file(),
187 path: path.display().to_string(),
188 directory,
189 })
190}
191
192async fn load_account(config_override: Option<&Path>) -> SshCliResult<Account> {
193 ensure_provider()?;
194 let path = acme_account_path(config_override)?;
195 if !path.is_file() {
196 return Err(SshCliError::FileNotFound(format!(
197 "ACME account missing at {}; run `ssh-cli tls acme account create`",
198 path.display()
199 )));
200 }
201 let data = std::fs::read_to_string(&path)
202 .map_err(|e| SshCliError::tls_msg(format!("read account: {e}")))?;
203 let credentials: AccountCredentials = serde_json::from_str(&data)
204 .map_err(|e| SshCliError::tls_msg(format!("parse account credentials: {e}")))?;
205 Account::builder()
206 .map_err(|e| map_acme_error("ACME account builder", e))?
207 .from_credentials(credentials)
208 .await
209 .map_err(|e| map_acme_error("restore ACME account", e))
210}
211
212pub async fn acme_issue_print_challenge(
214 config_override: Option<&Path>,
215 domain: &str,
216 directory: AcmeDirectory,
217) -> SshCliResult<PendingOrder> {
218 let account = load_account(config_override).await?;
219 let identifiers = vec![Identifier::Dns(domain.to_owned())];
220 let mut order = account
221 .new_order(&NewOrder::new(identifiers.as_slice()))
222 .await
223 .map_err(|e| map_acme_error("ACME new_order", e))?;
224
225 let order_url = AcmeOrderUrl::try_new(order.url())
226 .map_err(|e| SshCliError::tls_msg(format!("ACME order URL: {e}")))?;
227 let mut dns_name = String::new();
228 let mut dns_value = String::new();
229
230 let mut authorizations = order.authorizations();
231 while let Some(result) = authorizations.next().await {
232 let mut authz = result.map_err(|e| map_acme_error("ACME authz", e))?;
233 match authz.status {
234 AuthorizationStatus::Pending => {}
235 AuthorizationStatus::Valid => continue,
236 other => {
237 return Err(SshCliError::InvalidArgument(format!(
238 "unexpected ACME authorization status: {other:?}"
239 )));
240 }
241 }
242 let challenge = authz
243 .challenge(ChallengeType::Dns01)
244 .ok_or_else(|| SshCliError::InvalidArgument("no DNS-01 challenge on order".into()))?;
245 dns_name = format!("_acme-challenge.{}", challenge.identifier());
246 dns_value = challenge.key_authorization().dns_value();
247 }
248
249 if dns_value.is_empty() {
250 return Err(SshCliError::InvalidArgument(
251 "ACME order had no pending DNS-01 challenge".into(),
252 ));
253 }
254
255 let pending = PendingOrder {
256 domain: domain.to_owned(),
257 directory: directory.label().to_owned(),
258 order_url,
259 dns_name,
260 dns_value,
261 created_at: Rfc3339Utc::now(),
262 };
263
264 let dir = acme_domain_dir(config_override, domain)?;
265 ensure_dir(&dir)?;
266 let order_path = order_json_path(&dir);
267 let bytes = serde_json::to_vec_pretty(&pending)
268 .map_err(|e| SshCliError::tls_msg(format!("serialize pending order: {e}")))?;
269 write_secret_file(&order_path, &bytes)?;
270
271 Ok(pending)
272}
273
274pub async fn acme_complete(
276 config_override: Option<&Path>,
277 domain: &str,
278) -> SshCliResult<DomainCertStatus> {
279 let dir = acme_domain_dir(config_override, domain)?;
280 let pending_path = order_json_path(&dir);
281 if !pending_path.is_file() {
282 return Err(SshCliError::FileNotFound(format!(
283 "no pending ACME order for {domain}; run `tls acme issue --domain {domain} --print-challenge` first"
284 )));
285 }
286
287 let data = std::fs::read_to_string(&pending_path)
288 .map_err(|e| SshCliError::tls_msg(format!("read pending order: {e}")))?;
289 let pending: PendingOrder = serde_json::from_str(&data)
290 .map_err(|e| SshCliError::tls_msg(format!("parse pending order: {e}")))?;
291
292 let account = load_account(config_override).await?;
293 let mut order = account
294 .order(pending.order_url.to_string_owned())
295 .await
296 .map_err(|e| map_acme_error("ACME resume order", e))?;
297
298 let mut authorizations = order.authorizations();
299 while let Some(result) = authorizations.next().await {
300 let mut authz = result.map_err(|e| map_acme_error("ACME authz", e))?;
301 match authz.status {
302 AuthorizationStatus::Pending => {}
303 AuthorizationStatus::Valid => continue,
304 other => {
305 return Err(SshCliError::InvalidArgument(format!(
306 "unexpected ACME authorization status: {other:?}"
307 )));
308 }
309 }
310 let mut challenge = authz
311 .challenge(ChallengeType::Dns01)
312 .ok_or_else(|| SshCliError::InvalidArgument("no DNS-01 challenge".into()))?;
313 challenge
314 .set_ready()
315 .await
316 .map_err(|e| map_acme_error("ACME set_ready", e))?;
317 }
318
319 let status = order
320 .poll_ready(&RetryPolicy::default())
321 .await
322 .map_err(|e| map_acme_error("ACME poll_ready", e))?;
323 if status != OrderStatus::Ready {
324 return Err(SshCliError::InvalidArgument(format!(
325 "ACME order not ready (status={status:?}); verify DNS TXT {} = {}",
326 pending.dns_name, pending.dns_value
327 )));
328 }
329
330 let private_key_pem = order
331 .finalize()
332 .await
333 .map_err(|e| map_acme_error("ACME finalize", e))?;
334 let cert_chain_pem = order
335 .poll_certificate(&RetryPolicy::default())
336 .await
337 .map_err(|e| map_acme_error("ACME poll_certificate", e))?;
338
339 ensure_dir(&dir)?;
340 let cert_path = cert_pem_path(&dir);
341 let key_path = key_pem_path(&dir);
342 write_secret_file(&cert_path, cert_chain_pem.as_bytes())?;
343 write_secret_file(&key_path, private_key_pem.as_bytes())?;
344 let _ = std::fs::remove_file(pending_path);
345
346 Ok(DomainCertStatus {
347 domain: domain.to_owned(),
348 has_cert: true,
349 has_key: true,
350 has_pending_order: false,
351 cert_path: Some(cert_path.display().to_string()),
352 key_path: Some(key_path.display().to_string()),
353 })
354}
355
356pub fn acme_status(
358 config_override: Option<&Path>,
359 domain: Option<&str>,
360) -> SshCliResult<Vec<DomainCertStatus>> {
361 if let Some(d) = domain {
362 return Ok(vec![domain_status(config_override, d)?]);
363 }
364 acme_list(config_override)
365}
366
367pub fn acme_list(config_override: Option<&Path>) -> SshCliResult<Vec<DomainCertStatus>> {
369 let root = super::paths::resolve_tls_root(config_override)?
370 .join(crate::constants::TLS_ACME_DIR_NAME);
371 if !root.exists() {
372 return Ok(Vec::new());
373 }
374 let mut out = Vec::new();
375 for entry in std::fs::read_dir(&root)
376 .map_err(|e| SshCliError::tls_msg(format!("list acme: {e}")))?
377 {
378 let entry = entry.map_err(|e| SshCliError::tls_msg(format!("list acme entry: {e}")))?;
379 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
380 continue;
381 }
382 if let Some(name) = entry.file_name().to_str() {
383 out.push(domain_status(config_override, name)?);
384 }
385 }
386 out.sort_by(|a, b| a.domain.cmp(&b.domain));
387 Ok(out)
388}
389
390fn domain_status(config_override: Option<&Path>, domain: &str) -> SshCliResult<DomainCertStatus> {
391 let dir = acme_domain_dir(config_override, domain)?;
392 let cert = cert_pem_path(&dir);
393 let key = key_pem_path(&dir);
394 let pending = order_json_path(&dir);
395 Ok(DomainCertStatus {
396 domain: domain.to_owned(),
397 has_cert: cert.is_file(),
398 has_key: key.is_file(),
399 has_pending_order: pending.is_file(),
400 cert_path: cert.is_file().then(|| cert.display().to_string()),
401 key_path: key.is_file().then(|| key.display().to_string()),
402 })
403}