]> git.proxmox.com Git - proxmox-backup.git/blame - src/api2/config/acme.rs
update to first proxmox crate split
[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
6ef1b649
WB
11use proxmox_router::{
12 http_bail, list_subdirs_api_method, Permission, Router, SubdirMap, RpcEnvironment,
13};
14use proxmox_schema::api;
d4b84c1d
WB
15
16use proxmox_acme_rs::account::AccountData as AcmeAccountData;
17use proxmox_acme_rs::Account;
18
8cc3760e 19use pbs_api_types::{Authid, PRIV_SYS_MODIFY};
ccc3896f 20use pbs_tools::ops::ControlFlow;
1ec0d70d 21use pbs_tools::{task_log, task_warn};
8cc3760e 22
d4b84c1d 23use crate::acme::AcmeClient;
8cc3760e 24use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, KnownAcmeDirectory};
d4b84c1d 25use crate::config::acme::plugin::{
a8a20e92 26 self, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA,
d4b84c1d 27};
b9700a9f 28use proxmox_rest_server::WorkerTask;
d4b84c1d
WB
29
30pub(crate) const ROUTER: Router = Router::new()
31 .get(&list_subdirs_api_method!(SUBDIRS))
32 .subdirs(SUBDIRS);
33
34const 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
60const 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
65const 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: {
39c5db7f 72 name: { type: AcmeAccountName },
d4b84c1d
WB
73 },
74)]
75/// An ACME Account entry.
76///
77/// Currently only contains a 'name' property.
78#[derive(Serialize)]
79pub struct AccountEntry {
39c5db7f 80 name: AcmeAccountName,
d4b84c1d
WB
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.
95pub 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)]
117pub 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: {
39c5db7f 135 name: { type: AcmeAccountName },
d4b84c1d
WB
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.
39c5db7f 145pub async fn get_account(name: AcmeAccountName) -> Result<AccountInfo, Error> {
d4b84c1d
WB
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
159fn 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: {
39c5db7f 169 type: AcmeAccountName,
d4b84c1d
WB
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.
192fn register_account(
39c5db7f 193 name: Option<AcmeAccountName>,
d4b84c1d
WB
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
875d53ef
TL
202 let name = name.unwrap_or_else(|| unsafe {
203 AcmeAccountName::from_string_unchecked("default".to_string())
204 });
d4b84c1d
WB
205
206 if Path::new(&crate::config::acme::account_path(&name)).exists() {
ee0c5c8e 207 http_bail!(BAD_REQUEST, "account {} already exists", name);
d4b84c1d
WB
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",
9fe4c790 218 Some(name.to_string()),
049a22a3 219 auth_id.to_string(),
d4b84c1d
WB
220 true,
221 move |worker| async move {
222 let mut client = AcmeClient::new(directory);
223
1ec0d70d 224 task_log!(worker, "Registering ACME account '{}'...", &name);
d4b84c1d
WB
225
226 let account =
227 do_register_account(&mut client, &name, tos_url.is_some(), contact, None).await?;
228
1ec0d70d
DM
229 task_log!(
230 worker,
d4b84c1d
WB
231 "Registration successful, account URL: {}",
232 account.location
1ec0d70d 233 );
d4b84c1d
WB
234
235 Ok(())
236 },
237 )
238}
239
240pub async fn do_register_account<'a>(
241 client: &'a mut AcmeClient,
39c5db7f 242 name: &AcmeAccountName,
d4b84c1d
WB
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: {
39c5db7f 256 name: { type: AcmeAccountName },
d4b84c1d
WB
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.
269pub fn update_account(
39c5db7f 270 name: AcmeAccountName,
d4b84c1d
WB
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",
9fe4c790 279 Some(name.to_string()),
049a22a3 280 auth_id.to_string(),
d4b84c1d
WB
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: {
39c5db7f 300 name: { type: AcmeAccountName },
d4b84c1d
WB
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.
315pub fn deactivate_account(
39c5db7f 316 name: AcmeAccountName,
d4b84c1d
WB
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",
9fe4c790 324 Some(name.to_string()),
049a22a3 325 auth_id.to_string(),
d4b84c1d
WB
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) => {
1ec0d70d
DM
336 task_warn!(
337 worker,
ee0c5c8e 338 "error deactivating account {}, proceedeing anyway - {}",
d4b84c1d 339 name, err,
1ec0d70d 340 );
d4b84c1d
WB
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.
369async 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.
392fn get_directories() -> Result<&'static [KnownAcmeDirectory], Error> {
393 Ok(crate::config::acme::KNOWN_ACME_DIRECTORIES)
394}
395
d308dc8a
TL
396/// Wrapper for efficient Arc use when returning the ACME challenge-plugin schema for serializing
397struct ChallengeSchemaWrapper {
398 inner: Arc<Vec<AcmeChallengeSchema>>,
399}
400
401impl 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
410fn 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
d4b84c1d
WB
433#[api(
434 access: {
435 permission: &Permission::Anybody,
436 },
437 returns: {
438 description: "ACME Challenge Plugin Shema.",
439 type: Array,
60643023 440 items: { type: AcmeChallengeSchema },
d4b84c1d
WB
441 },
442)]
443/// Get named known ACME directory endpoints.
d308dc8a
TL
444fn get_challenge_schema() -> Result<ChallengeSchemaWrapper, Error> {
445 get_cached_challenge_schemas()
d4b84c1d
WB
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:
452pub 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)]
a8a20e92 470 alidation_delay: Option<u32>,
d4b84c1d
WB
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
478fn 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.
520pub fn list_plugins(mut rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>, Error> {
d4b84c1d
WB
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.
542pub fn get_plugin(id: String, mut rpcenv: &mut dyn RpcEnvironment) -> Result<PluginConfig, Error> {
d4b84c1d
WB
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: {
a8a20e92 564 type: DnsPluginCore,
d4b84c1d
WB
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.
a8a20e92 580pub fn add_plugin(r#type: String, core: DnsPluginCore, data: String) -> Result<(), Error> {
d4b84c1d
WB
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"))?;
d4b84c1d 588
a8a20e92 589 let id = core.id.clone();
d4b84c1d
WB
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
a8a20e92 598 let plugin = serde_json::to_value(DnsPlugin { core, data })?;
d4b84c1d
WB
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.
619pub fn delete_plugin(id: String) -> Result<(), Error> {
d4b84c1d
WB
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
a8a20e92
DM
631#[api()]
632#[derive(Serialize, Deserialize)]
633#[serde(rename_all="kebab-case")]
634#[allow(non_camel_case_types)]
635/// Deletable property name
636pub enum DeletableProperty {
637 /// Delete the disable property
638 disable,
639 /// Delete the validation-delay property
640 validation_delay,
641}
642
d4b84c1d
WB
643#[api(
644 input: {
645 properties: {
a8a20e92
DM
646 id: { schema: PLUGIN_ID_SCHEMA },
647 update: {
d4b84c1d
WB
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 },
a8a20e92
DM
657 delete: {
658 description: "List of properties to delete.",
659 type: Array,
d4b84c1d 660 optional: true,
a8a20e92
DM
661 items: {
662 type: DeletableProperty,
663 }
d4b84c1d 664 },
a8a20e92
DM
665 digest: {
666 description: "Digest to protect against concurrent updates",
d4b84c1d
WB
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.
677pub fn update_plugin(
a8a20e92
DM
678 id: String,
679 update: DnsPluginCoreUpdater,
d4b84c1d 680 data: Option<String>,
a8a20e92 681 delete: Option<Vec<DeletableProperty>>,
d4b84c1d
WB
682 digest: Option<String>,
683) -> Result<(), Error> {
d4b84c1d
WB
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"))?;
d4b84c1d
WB
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())?;
a8a20e92
DM
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 }
d4b84c1d 716 }
a8a20e92
DM
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
d4b84c1d
WB
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}