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