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