]> git.proxmox.com Git - proxmox-backup.git/blob - src/api2/config/acme.rs
move acl to pbs_config workspaces, pbs_api_types cleanups
[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::api::router::SubdirMap;
12 use proxmox::api::{api, Permission, Router, RpcEnvironment};
13 use proxmox::http_bail;
14 use proxmox::list_subdirs_api_method;
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
21 use crate::acme::AcmeClient;
22 use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, KnownAcmeDirectory};
23 use crate::config::acme::plugin::{
24 self, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA,
25 };
26 use crate::server::WorkerTask;
27 use crate::tools::ControlFlow;
28
29 pub(crate) const ROUTER: Router = Router::new()
30 .get(&list_subdirs_api_method!(SUBDIRS))
31 .subdirs(SUBDIRS);
32
33 const 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
59 const 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
64 const 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: {
71 name: { type: AcmeAccountName },
72 },
73 )]
74 /// An ACME Account entry.
75 ///
76 /// Currently only contains a 'name' property.
77 #[derive(Serialize)]
78 pub struct AccountEntry {
79 name: AcmeAccountName,
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.
94 pub 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)]
116 pub 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: {
134 name: { type: AcmeAccountName },
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.
144 pub async fn get_account(name: AcmeAccountName) -> Result<AccountInfo, Error> {
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
158 fn 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: {
168 type: AcmeAccountName,
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.
191 fn register_account(
192 name: Option<AcmeAccountName>,
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
201 let name = name.unwrap_or_else(|| unsafe {
202 AcmeAccountName::from_string_unchecked("default".to_string())
203 });
204
205 if Path::new(&crate::config::acme::account_path(&name)).exists() {
206 http_bail!(BAD_REQUEST, "account {} already exists", name);
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 Some(name.to_string()),
218 auth_id,
219 true,
220 move |worker| async move {
221 let mut client = AcmeClient::new(directory);
222
223 worker.log(format!("Registering ACME account '{}'...", &name));
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
238 pub async fn do_register_account<'a>(
239 client: &'a mut AcmeClient,
240 name: &AcmeAccountName,
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: {
254 name: { type: AcmeAccountName },
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.
267 pub fn update_account(
268 name: AcmeAccountName,
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 Some(name.to_string()),
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: {
298 name: { type: AcmeAccountName },
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.
313 pub fn deactivate_account(
314 name: AcmeAccountName,
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 Some(name.to_string()),
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!(
335 "error deactivating account {}, proceedeing anyway - {}",
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.
366 async 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.
389 fn get_directories() -> Result<&'static [KnownAcmeDirectory], Error> {
390 Ok(crate::config::acme::KNOWN_ACME_DIRECTORIES)
391 }
392
393 /// Wrapper for efficient Arc use when returning the ACME challenge-plugin schema for serializing
394 struct ChallengeSchemaWrapper {
395 inner: Arc<Vec<AcmeChallengeSchema>>,
396 }
397
398 impl 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
407 fn 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
430 #[api(
431 access: {
432 permission: &Permission::Anybody,
433 },
434 returns: {
435 description: "ACME Challenge Plugin Shema.",
436 type: Array,
437 items: { type: AcmeChallengeSchema },
438 },
439 )]
440 /// Get named known ACME directory endpoints.
441 fn get_challenge_schema() -> Result<ChallengeSchemaWrapper, Error> {
442 get_cached_challenge_schemas()
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:
449 pub 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 alidation_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
475 fn 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.
517 pub fn list_plugins(mut rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>, Error> {
518 let (plugins, digest) = plugin::config()?;
519 rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
520 Ok(plugins
521 .iter()
522 .map(|(id, (ty, data))| modify_cfg_for_api(&id, &ty, data))
523 .collect())
524 }
525
526 #[api(
527 input: {
528 properties: {
529 id: { schema: PLUGIN_ID_SCHEMA },
530 },
531 },
532 access: {
533 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
534 },
535 protected: true,
536 returns: { type: PluginConfig },
537 )]
538 /// List ACME challenge plugins.
539 pub fn get_plugin(id: String, mut rpcenv: &mut dyn RpcEnvironment) -> Result<PluginConfig, Error> {
540 let (plugins, digest) = plugin::config()?;
541 rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
542
543 match plugins.get(&id) {
544 Some((ty, data)) => Ok(modify_cfg_for_api(&id, &ty, &data)),
545 None => http_bail!(NOT_FOUND, "no such plugin"),
546 }
547 }
548
549 // Currently we only have "the" standalone plugin and DNS plugins so we can just flatten a
550 // DnsPluginUpdater:
551 //
552 // FIXME: The 'id' parameter should not be "optional" in the schema.
553 #[api(
554 input: {
555 properties: {
556 type: {
557 type: String,
558 description: "The ACME challenge plugin type.",
559 },
560 core: {
561 type: DnsPluginCore,
562 flatten: true,
563 },
564 data: {
565 type: String,
566 // This is different in the API!
567 description: "DNS plugin data (base64 encoded with padding).",
568 },
569 },
570 },
571 access: {
572 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
573 },
574 protected: true,
575 )]
576 /// Add ACME plugin configuration.
577 pub fn add_plugin(r#type: String, core: DnsPluginCore, data: String) -> Result<(), Error> {
578 // Currently we only support DNS plugins and the standalone plugin is "fixed":
579 if r#type != "dns" {
580 bail!("invalid ACME plugin type: {:?}", r#type);
581 }
582
583 let data = String::from_utf8(base64::decode(&data)?)
584 .map_err(|_| format_err!("data must be valid UTF-8"))?;
585
586 let id = core.id.clone();
587
588 let _lock = plugin::lock()?;
589
590 let (mut plugins, _digest) = plugin::config()?;
591 if plugins.contains_key(&id) {
592 bail!("ACME plugin ID {:?} already exists", id);
593 }
594
595 let plugin = serde_json::to_value(DnsPlugin { core, data })?;
596
597 plugins.insert(id, r#type, plugin);
598
599 plugin::save_config(&plugins)?;
600
601 Ok(())
602 }
603
604 #[api(
605 input: {
606 properties: {
607 id: { schema: PLUGIN_ID_SCHEMA },
608 },
609 },
610 access: {
611 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
612 },
613 protected: true,
614 )]
615 /// Delete an ACME plugin configuration.
616 pub fn delete_plugin(id: String) -> Result<(), Error> {
617 let _lock = plugin::lock()?;
618
619 let (mut plugins, _digest) = plugin::config()?;
620 if plugins.remove(&id).is_none() {
621 http_bail!(NOT_FOUND, "no such plugin");
622 }
623 plugin::save_config(&plugins)?;
624
625 Ok(())
626 }
627
628 #[api()]
629 #[derive(Serialize, Deserialize)]
630 #[serde(rename_all="kebab-case")]
631 #[allow(non_camel_case_types)]
632 /// Deletable property name
633 pub enum DeletableProperty {
634 /// Delete the disable property
635 disable,
636 /// Delete the validation-delay property
637 validation_delay,
638 }
639
640 #[api(
641 input: {
642 properties: {
643 id: { schema: PLUGIN_ID_SCHEMA },
644 update: {
645 type: DnsPluginCoreUpdater,
646 flatten: true,
647 },
648 data: {
649 type: String,
650 optional: true,
651 // This is different in the API!
652 description: "DNS plugin data (base64 encoded with padding).",
653 },
654 delete: {
655 description: "List of properties to delete.",
656 type: Array,
657 optional: true,
658 items: {
659 type: DeletableProperty,
660 }
661 },
662 digest: {
663 description: "Digest to protect against concurrent updates",
664 optional: true,
665 },
666 },
667 },
668 access: {
669 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
670 },
671 protected: true,
672 )]
673 /// Update an ACME plugin configuration.
674 pub fn update_plugin(
675 id: String,
676 update: DnsPluginCoreUpdater,
677 data: Option<String>,
678 delete: Option<Vec<DeletableProperty>>,
679 digest: Option<String>,
680 ) -> Result<(), Error> {
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
689 let _lock = plugin::lock()?;
690
691 let (mut plugins, expected_digest) = plugin::config()?;
692
693 if let Some(digest) = digest {
694 let digest = proxmox::tools::hex_to_digest(&digest)?;
695 crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
696 }
697
698 match plugins.get_mut(&id) {
699 Some((ty, ref mut entry)) => {
700 if ty != "dns" {
701 bail!("cannot update plugin of type {:?}", ty);
702 }
703
704 let mut plugin: DnsPlugin = serde_json::from_value(entry.clone())?;
705
706 if let Some(delete) = delete {
707 for delete_prop in delete {
708 match delete_prop {
709 DeletableProperty::validation_delay => { plugin.core.validation_delay = None; },
710 DeletableProperty::disable => { plugin.core.disable = None; },
711 }
712 }
713 }
714 if let Some(data) = data { plugin.data = data; }
715 if let Some(api) = update.api { plugin.core.api = api; }
716 if update.validation_delay.is_some() { plugin.core.validation_delay = update.validation_delay; }
717 if update.disable.is_some() { plugin.core.disable = update.disable; }
718
719
720 *entry = serde_json::to_value(plugin)?;
721 }
722 None => http_bail!(NOT_FOUND, "no such plugin"),
723 }
724
725 plugin::save_config(&plugins)?;
726
727 Ok(())
728 }