]> git.proxmox.com Git - proxmox-backup.git/blame - src/api2/config/acme.rs
config: acme: rust format
[proxmox-backup.git] / src / api2 / config / acme.rs
CommitLineData
d308dc8a 1use std::fs;
d4b84c1d 2use std::path::Path;
d308dc8a
TL
3use std::sync::{Arc, Mutex};
4use std::time::SystemTime;
d4b84c1d
WB
5
6use anyhow::{bail, format_err, Error};
d308dc8a 7use lazy_static::lazy_static;
d4b84c1d
WB
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10
11use proxmox::api::router::SubdirMap;
12use proxmox::api::schema::Updatable;
13use proxmox::api::{api, Permission, Router, RpcEnvironment};
14use proxmox::http_bail;
15use proxmox::list_subdirs_api_method;
16
17use proxmox_acme_rs::account::AccountData as AcmeAccountData;
18use proxmox_acme_rs::Account;
19
20use crate::acme::AcmeClient;
60643023 21use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, Authid, KnownAcmeDirectory};
d4b84c1d
WB
22use crate::config::acl::PRIV_SYS_MODIFY;
23use crate::config::acme::plugin::{
24 DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA,
25};
d4b84c1d
WB
26use crate::server::WorkerTask;
27use crate::tools::ControlFlow;
28
29pub(crate) const ROUTER: Router = Router::new()
30 .get(&list_subdirs_api_method!(SUBDIRS))
31 .subdirs(SUBDIRS);
32
33const SUBDIRS: SubdirMap = &[
34 (
35 "account",
36 &Router::new()
37 .get(&API_METHOD_LIST_ACCOUNTS)
38 .post(&API_METHOD_REGISTER_ACCOUNT)
39 .match_all("name", &ACCOUNT_ITEM_ROUTER),
40 ),
41 (
42 "challenge-schema",
43 &Router::new().get(&API_METHOD_GET_CHALLENGE_SCHEMA),
44 ),
45 (
46 "directories",
47 &Router::new().get(&API_METHOD_GET_DIRECTORIES),
48 ),
49 (
50 "plugins",
51 &Router::new()
52 .get(&API_METHOD_LIST_PLUGINS)
53 .post(&API_METHOD_ADD_PLUGIN)
54 .match_all("id", &PLUGIN_ITEM_ROUTER),
55 ),
56 ("tos", &Router::new().get(&API_METHOD_GET_TOS)),
57];
58
59const ACCOUNT_ITEM_ROUTER: Router = Router::new()
60 .get(&API_METHOD_GET_ACCOUNT)
61 .put(&API_METHOD_UPDATE_ACCOUNT)
62 .delete(&API_METHOD_DEACTIVATE_ACCOUNT);
63
64const PLUGIN_ITEM_ROUTER: Router = Router::new()
65 .get(&API_METHOD_GET_PLUGIN)
66 .put(&API_METHOD_UPDATE_PLUGIN)
67 .delete(&API_METHOD_DELETE_PLUGIN);
68
69#[api(
70 properties: {
39c5db7f 71 name: { type: AcmeAccountName },
d4b84c1d
WB
72 },
73)]
74/// An ACME Account entry.
75///
76/// Currently only contains a 'name' property.
77#[derive(Serialize)]
78pub struct AccountEntry {
39c5db7f 79 name: AcmeAccountName,
d4b84c1d
WB
80}
81
82#[api(
83 access: {
84 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
85 },
86 returns: {
87 type: Array,
88 items: { type: AccountEntry },
89 description: "List of ACME accounts.",
90 },
91 protected: true,
92)]
93/// List ACME accounts.
94pub fn list_accounts() -> Result<Vec<AccountEntry>, Error> {
95 let mut entries = Vec::new();
96 crate::config::acme::foreach_acme_account(|name| {
97 entries.push(AccountEntry { name });
98 ControlFlow::Continue(())
99 })?;
100 Ok(entries)
101}
102
103#[api(
104 properties: {
105 account: { type: Object, properties: {}, additional_properties: true },
106 tos: {
107 type: String,
108 optional: true,
109 },
110 },
111)]
112/// ACME Account information.
113///
114/// This is what we return via the API.
115#[derive(Serialize)]
116pub struct AccountInfo {
117 /// Raw account data.
118 account: AcmeAccountData,
119
120 /// The ACME directory URL the account was created at.
121 directory: String,
122
123 /// The account's own URL within the ACME directory.
124 location: String,
125
126 /// The ToS URL, if the user agreed to one.
127 #[serde(skip_serializing_if = "Option::is_none")]
128 tos: Option<String>,
129}
130
131#[api(
132 input: {
133 properties: {
39c5db7f 134 name: { type: AcmeAccountName },
d4b84c1d
WB
135 },
136 },
137 access: {
138 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
139 },
140 returns: { type: AccountInfo },
141 protected: true,
142)]
143/// Return existing ACME account information.
39c5db7f 144pub async fn get_account(name: AcmeAccountName) -> Result<AccountInfo, Error> {
d4b84c1d
WB
145 let client = AcmeClient::load(&name).await?;
146 let account = client.account()?;
147 Ok(AccountInfo {
148 location: account.location.clone(),
149 tos: client.tos().map(str::to_owned),
150 directory: client.directory_url().to_owned(),
151 account: AcmeAccountData {
152 only_return_existing: false, // don't actually write this out in case it's set
153 ..account.data.clone()
154 },
155 })
156}
157
158fn account_contact_from_string(s: &str) -> Vec<String> {
159 s.split(&[' ', ';', ',', '\0'][..])
160 .map(|s| format!("mailto:{}", s))
161 .collect()
162}
163
164#[api(
165 input: {
166 properties: {
167 name: {
39c5db7f 168 type: AcmeAccountName,
d4b84c1d
WB
169 optional: true,
170 },
171 contact: {
172 description: "List of email addresses.",
173 },
174 tos_url: {
175 description: "URL of CA TermsOfService - setting this indicates agreement.",
176 optional: true,
177 },
178 directory: {
179 type: String,
180 description: "The ACME Directory.",
181 optional: true,
182 },
183 },
184 },
185 access: {
186 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
187 },
188 protected: true,
189)]
190/// Register an ACME account.
191fn register_account(
39c5db7f 192 name: Option<AcmeAccountName>,
d4b84c1d
WB
193 // Todo: email & email-list schema
194 contact: String,
195 tos_url: Option<String>,
196 directory: Option<String>,
197 rpcenv: &mut dyn RpcEnvironment,
198) -> Result<String, Error> {
199 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
200
875d53ef
TL
201 let name = name.unwrap_or_else(|| unsafe {
202 AcmeAccountName::from_string_unchecked("default".to_string())
203 });
d4b84c1d
WB
204
205 if Path::new(&crate::config::acme::account_path(&name)).exists() {
ee0c5c8e 206 http_bail!(BAD_REQUEST, "account {} already exists", name);
d4b84c1d
WB
207 }
208
209 let directory = directory.unwrap_or_else(|| {
210 crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
211 .url
212 .to_owned()
213 });
214
215 WorkerTask::spawn(
216 "acme-register",
217 None,
218 auth_id,
219 true,
220 move |worker| async move {
221 let mut client = AcmeClient::new(directory);
222
223 worker.log("Registering ACME account...");
224
225 let account =
226 do_register_account(&mut client, &name, tos_url.is_some(), contact, None).await?;
227
228 worker.log(format!(
229 "Registration successful, account URL: {}",
230 account.location
231 ));
232
233 Ok(())
234 },
235 )
236}
237
238pub async fn do_register_account<'a>(
239 client: &'a mut AcmeClient,
39c5db7f 240 name: &AcmeAccountName,
d4b84c1d
WB
241 agree_to_tos: bool,
242 contact: String,
243 rsa_bits: Option<u32>,
244) -> Result<&'a Account, Error> {
245 let contact = account_contact_from_string(&contact);
246 Ok(client
247 .new_account(name, agree_to_tos, contact, rsa_bits)
248 .await?)
249}
250
251#[api(
252 input: {
253 properties: {
39c5db7f 254 name: { type: AcmeAccountName },
d4b84c1d
WB
255 contact: {
256 description: "List of email addresses.",
257 optional: true,
258 },
259 },
260 },
261 access: {
262 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
263 },
264 protected: true,
265)]
266/// Update an ACME account.
267pub fn update_account(
39c5db7f 268 name: AcmeAccountName,
d4b84c1d
WB
269 // Todo: email & email-list schema
270 contact: Option<String>,
271 rpcenv: &mut dyn RpcEnvironment,
272) -> Result<String, Error> {
273 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
274
275 WorkerTask::spawn(
276 "acme-update",
277 None,
278 auth_id,
279 true,
280 move |_worker| async move {
281 let data = match contact {
282 Some(data) => json!({
283 "contact": account_contact_from_string(&data),
284 }),
285 None => json!({}),
286 };
287
288 AcmeClient::load(&name).await?.update_account(&data).await?;
289
290 Ok(())
291 },
292 )
293}
294
295#[api(
296 input: {
297 properties: {
39c5db7f 298 name: { type: AcmeAccountName },
d4b84c1d
WB
299 force: {
300 description:
301 "Delete account data even if the server refuses to deactivate the account.",
302 optional: true,
303 default: false,
304 },
305 },
306 },
307 access: {
308 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
309 },
310 protected: true,
311)]
312/// Deactivate an ACME account.
313pub fn deactivate_account(
39c5db7f 314 name: AcmeAccountName,
d4b84c1d
WB
315 force: bool,
316 rpcenv: &mut dyn RpcEnvironment,
317) -> Result<String, Error> {
318 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
319
320 WorkerTask::spawn(
321 "acme-deactivate",
322 None,
323 auth_id,
324 true,
325 move |worker| async move {
326 match AcmeClient::load(&name)
327 .await?
328 .update_account(&json!({"status": "deactivated"}))
329 .await
330 {
331 Ok(_account) => (),
332 Err(err) if !force => return Err(err),
333 Err(err) => {
334 worker.warn(format!(
ee0c5c8e 335 "error deactivating account {}, proceedeing anyway - {}",
d4b84c1d
WB
336 name, err,
337 ));
338 }
339 }
340 crate::config::acme::mark_account_deactivated(&name)?;
341 Ok(())
342 },
343 )
344}
345
346#[api(
347 input: {
348 properties: {
349 directory: {
350 type: String,
351 description: "The ACME Directory.",
352 optional: true,
353 },
354 },
355 },
356 access: {
357 permission: &Permission::Anybody,
358 },
359 returns: {
360 type: String,
361 optional: true,
362 description: "The ACME Directory's ToS URL, if any.",
363 },
364)]
365/// Get the Terms of Service URL for an ACME directory.
366async fn get_tos(directory: Option<String>) -> Result<Option<String>, Error> {
367 let directory = directory.unwrap_or_else(|| {
368 crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
369 .url
370 .to_owned()
371 });
372 Ok(AcmeClient::new(directory)
373 .terms_of_service_url()
374 .await?
375 .map(str::to_owned))
376}
377
378#[api(
379 access: {
380 permission: &Permission::Anybody,
381 },
382 returns: {
383 description: "List of known ACME directories.",
384 type: Array,
385 items: { type: KnownAcmeDirectory },
386 },
387)]
388/// Get named known ACME directory endpoints.
389fn get_directories() -> Result<&'static [KnownAcmeDirectory], Error> {
390 Ok(crate::config::acme::KNOWN_ACME_DIRECTORIES)
391}
392
d308dc8a
TL
393/// Wrapper for efficient Arc use when returning the ACME challenge-plugin schema for serializing
394struct ChallengeSchemaWrapper {
395 inner: Arc<Vec<AcmeChallengeSchema>>,
396}
397
398impl Serialize for ChallengeSchemaWrapper {
399 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
400 where
401 S: serde::Serializer,
402 {
403 self.inner.serialize(serializer)
404 }
405}
406
407fn get_cached_challenge_schemas() -> Result<ChallengeSchemaWrapper, Error> {
408 lazy_static! {
409 static ref CACHE: Mutex<Option<(Arc<Vec<AcmeChallengeSchema>>, SystemTime)>> =
410 Mutex::new(None);
411 }
412
413 // the actual loading code
414 let mut last = CACHE.lock().unwrap();
415
416 let actual_mtime = fs::metadata(crate::config::acme::ACME_DNS_SCHEMA_FN)?.modified()?;
417
418 let schema = match &*last {
419 Some((schema, cached_mtime)) if *cached_mtime >= actual_mtime => schema.clone(),
420 _ => {
421 let new_schema = Arc::new(crate::config::acme::load_dns_challenge_schema()?);
422 *last = Some((Arc::clone(&new_schema), actual_mtime));
423 new_schema
424 }
425 };
426
427 Ok(ChallengeSchemaWrapper { inner: schema })
428}
429
d4b84c1d
WB
430#[api(
431 access: {
432 permission: &Permission::Anybody,
433 },
434 returns: {
435 description: "ACME Challenge Plugin Shema.",
436 type: Array,
60643023 437 items: { type: AcmeChallengeSchema },
d4b84c1d
WB
438 },
439)]
440/// Get named known ACME directory endpoints.
d308dc8a
TL
441fn get_challenge_schema() -> Result<ChallengeSchemaWrapper, Error> {
442 get_cached_challenge_schemas()
d4b84c1d
WB
443}
444
445#[api]
446#[derive(Default, Deserialize, Serialize)]
447#[serde(rename_all = "kebab-case")]
448/// The API's format is inherited from PVE/PMG:
449pub struct PluginConfig {
450 /// Plugin ID.
451 plugin: String,
452
453 /// Plugin type.
454 #[serde(rename = "type")]
455 ty: String,
456
457 /// DNS Api name.
458 api: Option<String>,
459
460 /// Plugin configuration data.
461 data: Option<String>,
462
463 /// Extra delay in seconds to wait before requesting validation.
464 ///
465 /// Allows to cope with long TTL of DNS records.
466 #[serde(skip_serializing_if = "Option::is_none", default)]
467 validation_delay: Option<u32>,
468
469 /// Flag to disable the config.
470 #[serde(skip_serializing_if = "Option::is_none", default)]
471 disable: Option<bool>,
472}
473
474// See PMG/PVE's $modify_cfg_for_api sub
475fn modify_cfg_for_api(id: &str, ty: &str, data: &Value) -> PluginConfig {
476 let mut entry = data.clone();
477
478 let obj = entry.as_object_mut().unwrap();
479 obj.remove("id");
480 obj.insert("plugin".to_string(), Value::String(id.to_owned()));
481 obj.insert("type".to_string(), Value::String(ty.to_owned()));
482
483 // FIXME: This needs to go once the `Updater` is fixed.
484 // None of these should be able to fail unless the user changed the files by hand, in which
485 // case we leave the unmodified string in the Value for now. This will be handled with an error
486 // later.
487 if let Some(Value::String(ref mut data)) = obj.get_mut("data") {
488 if let Ok(new) = base64::decode_config(&data, base64::URL_SAFE_NO_PAD) {
489 if let Ok(utf8) = String::from_utf8(new) {
490 *data = utf8;
491 }
492 }
493 }
494
495 // PVE/PMG do this explicitly for ACME plugins...
496 // obj.insert("digest".to_string(), Value::String(digest.clone()));
497
498 serde_json::from_value(entry).unwrap_or_else(|_| PluginConfig {
499 plugin: "*Error*".to_string(),
500 ty: "*Error*".to_string(),
501 ..Default::default()
502 })
503}
504
505#[api(
506 access: {
507 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
508 },
509 protected: true,
510 returns: {
511 type: Array,
512 description: "List of ACME plugin configurations.",
513 items: { type: PluginConfig },
514 },
515)]
516/// List ACME challenge plugins.
517pub fn list_plugins(mut rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>, Error> {
518 use crate::config::acme::plugin;
519
520 let (plugins, digest) = plugin::config()?;
521 rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
522 Ok(plugins
523 .iter()
524 .map(|(id, (ty, data))| modify_cfg_for_api(&id, &ty, data))
525 .collect())
526}
527
528#[api(
529 input: {
530 properties: {
531 id: { schema: PLUGIN_ID_SCHEMA },
532 },
533 },
534 access: {
535 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
536 },
537 protected: true,
538 returns: { type: PluginConfig },
539)]
540/// List ACME challenge plugins.
541pub fn get_plugin(id: String, mut rpcenv: &mut dyn RpcEnvironment) -> Result<PluginConfig, Error> {
542 use crate::config::acme::plugin;
543
544 let (plugins, digest) = plugin::config()?;
545 rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
546
547 match plugins.get(&id) {
548 Some((ty, data)) => Ok(modify_cfg_for_api(&id, &ty, &data)),
549 None => http_bail!(NOT_FOUND, "no such plugin"),
550 }
551}
552
553// Currently we only have "the" standalone plugin and DNS plugins so we can just flatten a
554// DnsPluginUpdater:
555//
556// FIXME: The 'id' parameter should not be "optional" in the schema.
557#[api(
558 input: {
559 properties: {
560 type: {
561 type: String,
562 description: "The ACME challenge plugin type.",
563 },
564 core: {
565 type: DnsPluginCoreUpdater,
566 flatten: true,
567 },
568 data: {
569 type: String,
570 // This is different in the API!
571 description: "DNS plugin data (base64 encoded with padding).",
572 },
573 },
574 },
575 access: {
576 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
577 },
578 protected: true,
579)]
580/// Add ACME plugin configuration.
581pub fn add_plugin(r#type: String, core: DnsPluginCoreUpdater, data: String) -> Result<(), Error> {
582 use crate::config::acme::plugin;
583
584 // Currently we only support DNS plugins and the standalone plugin is "fixed":
585 if r#type != "dns" {
586 bail!("invalid ACME plugin type: {:?}", r#type);
587 }
588
589 let data = String::from_utf8(base64::decode(&data)?)
590 .map_err(|_| format_err!("data must be valid UTF-8"))?;
591 //core.api_fixup()?;
592
593 // FIXME: Solve the Updater with non-optional fields thing...
594 let id = core
595 .id
596 .clone()
597 .ok_or_else(|| format_err!("missing required 'id' parameter"))?;
598
599 let _lock = plugin::lock()?;
600
601 let (mut plugins, _digest) = plugin::config()?;
602 if plugins.contains_key(&id) {
603 bail!("ACME plugin ID {:?} already exists", id);
604 }
605
606 let plugin = serde_json::to_value(DnsPlugin {
607 core: DnsPluginCore::try_build_from(core)?,
608 data,
609 })?;
610
611 plugins.insert(id, r#type, plugin);
612
613 plugin::save_config(&plugins)?;
614
615 Ok(())
616}
617
618#[api(
619 input: {
620 properties: {
621 id: { schema: PLUGIN_ID_SCHEMA },
622 },
623 },
624 access: {
625 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
626 },
627 protected: true,
628)]
629/// Delete an ACME plugin configuration.
630pub fn delete_plugin(id: String) -> Result<(), Error> {
631 use crate::config::acme::plugin;
632
633 let _lock = plugin::lock()?;
634
635 let (mut plugins, _digest) = plugin::config()?;
636 if plugins.remove(&id).is_none() {
637 http_bail!(NOT_FOUND, "no such plugin");
638 }
639 plugin::save_config(&plugins)?;
640
641 Ok(())
642}
643
644#[api(
645 input: {
646 properties: {
647 core_update: {
648 type: DnsPluginCoreUpdater,
649 flatten: true,
650 },
651 data: {
652 type: String,
653 optional: true,
654 // This is different in the API!
655 description: "DNS plugin data (base64 encoded with padding).",
656 },
657 digest: {
658 description: "Digest to protect against concurrent updates",
659 optional: true,
660 },
661 delete: {
662 description: "Options to remove from the configuration",
663 optional: true,
664 },
665 },
666 },
667 access: {
668 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
669 },
670 protected: true,
671)]
672/// Update an ACME plugin configuration.
673pub fn update_plugin(
674 core_update: DnsPluginCoreUpdater,
675 data: Option<String>,
676 delete: Option<String>,
677 digest: Option<String>,
678) -> Result<(), Error> {
679 use crate::config::acme::plugin;
680
681 let data = data
682 .as_deref()
683 .map(base64::decode)
684 .transpose()?
685 .map(String::from_utf8)
686 .transpose()
687 .map_err(|_| format_err!("data must be valid UTF-8"))?;
688 //core_update.api_fixup()?;
689
690 // unwrap: the id is matched by this method's API path
691 let id = core_update.id.clone().unwrap();
692
693 let delete: Vec<&str> = delete
694 .as_deref()
695 .unwrap_or("")
696 .split(&[' ', ',', ';', '\0'][..])
697 .collect();
698
699 let _lock = plugin::lock()?;
700
701 let (mut plugins, expected_digest) = plugin::config()?;
702
703 if let Some(digest) = digest {
704 let digest = proxmox::tools::hex_to_digest(&digest)?;
705 crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
706 }
707
708 match plugins.get_mut(&id) {
709 Some((ty, ref mut entry)) => {
710 if ty != "dns" {
711 bail!("cannot update plugin of type {:?}", ty);
712 }
713
714 let mut plugin: DnsPlugin = serde_json::from_value(entry.clone())?;
715 plugin.core.update_from(core_update, &delete)?;
716 if let Some(data) = data {
717 plugin.data = data;
718 }
719 *entry = serde_json::to_value(plugin)?;
720 }
721 None => http_bail!(NOT_FOUND, "no such plugin"),
722 }
723
724 plugin::save_config(&plugins)?;
725
726 Ok(())
727}