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