]> git.proxmox.com Git - proxmox-backup.git/blame - src/api2/config/acme.rs
tree-wide: fix needless borrows
[proxmox-backup.git] / src / api2 / config / acme.rs
CommitLineData
d308dc8a 1use std::fs;
890b88cb 2use std::ops::ControlFlow;
d4b84c1d 3use std::path::Path;
d308dc8a
TL
4use std::sync::{Arc, Mutex};
5use std::time::SystemTime;
d4b84c1d
WB
6
7use anyhow::{bail, format_err, Error};
d308dc8a 8use lazy_static::lazy_static;
d4b84c1d
WB
9use serde::{Deserialize, Serialize};
10use serde_json::{json, Value};
25877d05 11use hex::FromHex;
d4b84c1d 12
6ef1b649 13use proxmox_router::{
c1a1e1ae 14 http_bail, list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap,
6ef1b649
WB
15};
16use proxmox_schema::api;
d5790a9f 17use proxmox_sys::{task_log, task_warn};
d4b84c1d
WB
18
19use proxmox_acme_rs::account::AccountData as AcmeAccountData;
20use proxmox_acme_rs::Account;
21
8cc3760e
DM
22use pbs_api_types::{Authid, PRIV_SYS_MODIFY};
23
d4b84c1d 24use crate::acme::AcmeClient;
8cc3760e 25use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, KnownAcmeDirectory};
d4b84c1d 26use crate::config::acme::plugin::{
a8a20e92 27 self, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA,
d4b84c1d 28};
b9700a9f 29use proxmox_rest_server::WorkerTask;
d4b84c1d
WB
30
31pub(crate) const ROUTER: Router = Router::new()
32 .get(&list_subdirs_api_method!(SUBDIRS))
33 .subdirs(SUBDIRS);
34
35const 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
61const 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
66const 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: {
39c5db7f 73 name: { type: AcmeAccountName },
d4b84c1d
WB
74 },
75)]
76/// An ACME Account entry.
77///
78/// Currently only contains a 'name' property.
79#[derive(Serialize)]
80pub struct AccountEntry {
39c5db7f 81 name: AcmeAccountName,
d4b84c1d
WB
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.
96pub 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)]
118pub 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: {
39c5db7f 136 name: { type: AcmeAccountName },
d4b84c1d
WB
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.
39c5db7f 146pub async fn get_account(name: AcmeAccountName) -> Result<AccountInfo, Error> {
d4b84c1d
WB
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
160fn 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: {
39c5db7f 170 type: AcmeAccountName,
d4b84c1d
WB
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 },
186 },
187 access: {
188 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
189 },
190 protected: true,
191)]
192/// Register an ACME account.
193fn register_account(
39c5db7f 194 name: Option<AcmeAccountName>,
d4b84c1d
WB
195 // Todo: email & email-list schema
196 contact: String,
197 tos_url: Option<String>,
198 directory: Option<String>,
199 rpcenv: &mut dyn RpcEnvironment,
200) -> Result<String, Error> {
201 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
202
875d53ef
TL
203 let name = name.unwrap_or_else(|| unsafe {
204 AcmeAccountName::from_string_unchecked("default".to_string())
205 });
d4b84c1d
WB
206
207 if Path::new(&crate::config::acme::account_path(&name)).exists() {
ee0c5c8e 208 http_bail!(BAD_REQUEST, "account {} already exists", name);
d4b84c1d
WB
209 }
210
211 let directory = directory.unwrap_or_else(|| {
212 crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
213 .url
214 .to_owned()
215 });
216
217 WorkerTask::spawn(
218 "acme-register",
9fe4c790 219 Some(name.to_string()),
049a22a3 220 auth_id.to_string(),
d4b84c1d
WB
221 true,
222 move |worker| async move {
223 let mut client = AcmeClient::new(directory);
224
1ec0d70d 225 task_log!(worker, "Registering ACME account '{}'...", &name);
d4b84c1d
WB
226
227 let account =
228 do_register_account(&mut client, &name, tos_url.is_some(), contact, None).await?;
229
1ec0d70d
DM
230 task_log!(
231 worker,
d4b84c1d
WB
232 "Registration successful, account URL: {}",
233 account.location
1ec0d70d 234 );
d4b84c1d
WB
235
236 Ok(())
237 },
238 )
239}
240
241pub async fn do_register_account<'a>(
242 client: &'a mut AcmeClient,
39c5db7f 243 name: &AcmeAccountName,
d4b84c1d
WB
244 agree_to_tos: bool,
245 contact: String,
246 rsa_bits: Option<u32>,
247) -> Result<&'a Account, Error> {
248 let contact = account_contact_from_string(&contact);
249 Ok(client
250 .new_account(name, agree_to_tos, contact, rsa_bits)
251 .await?)
252}
253
254#[api(
255 input: {
256 properties: {
39c5db7f 257 name: { type: AcmeAccountName },
d4b84c1d
WB
258 contact: {
259 description: "List of email addresses.",
260 optional: true,
261 },
262 },
263 },
264 access: {
265 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
266 },
267 protected: true,
268)]
269/// Update an ACME account.
270pub fn update_account(
39c5db7f 271 name: AcmeAccountName,
d4b84c1d
WB
272 // Todo: email & email-list schema
273 contact: Option<String>,
274 rpcenv: &mut dyn RpcEnvironment,
275) -> Result<String, Error> {
276 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
277
278 WorkerTask::spawn(
279 "acme-update",
9fe4c790 280 Some(name.to_string()),
049a22a3 281 auth_id.to_string(),
d4b84c1d
WB
282 true,
283 move |_worker| async move {
284 let data = match contact {
285 Some(data) => json!({
286 "contact": account_contact_from_string(&data),
287 }),
288 None => json!({}),
289 };
290
291 AcmeClient::load(&name).await?.update_account(&data).await?;
292
293 Ok(())
294 },
295 )
296}
297
298#[api(
299 input: {
300 properties: {
39c5db7f 301 name: { type: AcmeAccountName },
d4b84c1d
WB
302 force: {
303 description:
304 "Delete account data even if the server refuses to deactivate the account.",
305 optional: true,
306 default: false,
307 },
308 },
309 },
310 access: {
311 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
312 },
313 protected: true,
314)]
315/// Deactivate an ACME account.
316pub fn deactivate_account(
39c5db7f 317 name: AcmeAccountName,
d4b84c1d
WB
318 force: bool,
319 rpcenv: &mut dyn RpcEnvironment,
320) -> Result<String, Error> {
321 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
322
323 WorkerTask::spawn(
324 "acme-deactivate",
9fe4c790 325 Some(name.to_string()),
049a22a3 326 auth_id.to_string(),
d4b84c1d
WB
327 true,
328 move |worker| async move {
329 match AcmeClient::load(&name)
330 .await?
331 .update_account(&json!({"status": "deactivated"}))
332 .await
333 {
334 Ok(_account) => (),
335 Err(err) if !force => return Err(err),
336 Err(err) => {
1ec0d70d
DM
337 task_warn!(
338 worker,
ee0c5c8e 339 "error deactivating account {}, proceedeing anyway - {}",
c1a1e1ae
SI
340 name,
341 err,
1ec0d70d 342 );
d4b84c1d
WB
343 }
344 }
345 crate::config::acme::mark_account_deactivated(&name)?;
346 Ok(())
347 },
348 )
349}
350
351#[api(
352 input: {
353 properties: {
354 directory: {
355 type: String,
356 description: "The ACME Directory.",
357 optional: true,
358 },
359 },
360 },
361 access: {
362 permission: &Permission::Anybody,
363 },
364 returns: {
365 type: String,
366 optional: true,
367 description: "The ACME Directory's ToS URL, if any.",
368 },
369)]
370/// Get the Terms of Service URL for an ACME directory.
371async fn get_tos(directory: Option<String>) -> Result<Option<String>, Error> {
372 let directory = directory.unwrap_or_else(|| {
373 crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
374 .url
375 .to_owned()
376 });
377 Ok(AcmeClient::new(directory)
378 .terms_of_service_url()
379 .await?
380 .map(str::to_owned))
381}
382
383#[api(
384 access: {
385 permission: &Permission::Anybody,
386 },
387 returns: {
388 description: "List of known ACME directories.",
389 type: Array,
390 items: { type: KnownAcmeDirectory },
391 },
392)]
393/// Get named known ACME directory endpoints.
394fn get_directories() -> Result<&'static [KnownAcmeDirectory], Error> {
395 Ok(crate::config::acme::KNOWN_ACME_DIRECTORIES)
396}
397
d308dc8a
TL
398/// Wrapper for efficient Arc use when returning the ACME challenge-plugin schema for serializing
399struct ChallengeSchemaWrapper {
400 inner: Arc<Vec<AcmeChallengeSchema>>,
401}
402
403impl Serialize for ChallengeSchemaWrapper {
404 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
405 where
406 S: serde::Serializer,
407 {
408 self.inner.serialize(serializer)
409 }
410}
411
412fn get_cached_challenge_schemas() -> Result<ChallengeSchemaWrapper, Error> {
413 lazy_static! {
414 static ref CACHE: Mutex<Option<(Arc<Vec<AcmeChallengeSchema>>, SystemTime)>> =
415 Mutex::new(None);
416 }
417
418 // the actual loading code
419 let mut last = CACHE.lock().unwrap();
420
421 let actual_mtime = fs::metadata(crate::config::acme::ACME_DNS_SCHEMA_FN)?.modified()?;
422
423 let schema = match &*last {
424 Some((schema, cached_mtime)) if *cached_mtime >= actual_mtime => schema.clone(),
425 _ => {
426 let new_schema = Arc::new(crate::config::acme::load_dns_challenge_schema()?);
427 *last = Some((Arc::clone(&new_schema), actual_mtime));
428 new_schema
429 }
430 };
431
432 Ok(ChallengeSchemaWrapper { inner: schema })
433}
434
d4b84c1d
WB
435#[api(
436 access: {
437 permission: &Permission::Anybody,
438 },
439 returns: {
440 description: "ACME Challenge Plugin Shema.",
441 type: Array,
60643023 442 items: { type: AcmeChallengeSchema },
d4b84c1d
WB
443 },
444)]
445/// Get named known ACME directory endpoints.
d308dc8a
TL
446fn get_challenge_schema() -> Result<ChallengeSchemaWrapper, Error> {
447 get_cached_challenge_schemas()
d4b84c1d
WB
448}
449
450#[api]
451#[derive(Default, Deserialize, Serialize)]
452#[serde(rename_all = "kebab-case")]
453/// The API's format is inherited from PVE/PMG:
454pub struct PluginConfig {
455 /// Plugin ID.
456 plugin: String,
457
458 /// Plugin type.
459 #[serde(rename = "type")]
460 ty: String,
461
462 /// DNS Api name.
463 api: Option<String>,
464
465 /// Plugin configuration data.
466 data: Option<String>,
467
468 /// Extra delay in seconds to wait before requesting validation.
469 ///
470 /// Allows to cope with long TTL of DNS records.
471 #[serde(skip_serializing_if = "Option::is_none", default)]
7c2431d4 472 validation_delay: Option<u32>,
d4b84c1d
WB
473
474 /// Flag to disable the config.
475 #[serde(skip_serializing_if = "Option::is_none", default)]
476 disable: Option<bool>,
477}
478
479// See PMG/PVE's $modify_cfg_for_api sub
480fn modify_cfg_for_api(id: &str, ty: &str, data: &Value) -> PluginConfig {
481 let mut entry = data.clone();
482
483 let obj = entry.as_object_mut().unwrap();
484 obj.remove("id");
485 obj.insert("plugin".to_string(), Value::String(id.to_owned()));
486 obj.insert("type".to_string(), Value::String(ty.to_owned()));
487
488 // FIXME: This needs to go once the `Updater` is fixed.
489 // None of these should be able to fail unless the user changed the files by hand, in which
490 // case we leave the unmodified string in the Value for now. This will be handled with an error
491 // later.
492 if let Some(Value::String(ref mut data)) = obj.get_mut("data") {
493 if let Ok(new) = base64::decode_config(&data, base64::URL_SAFE_NO_PAD) {
494 if let Ok(utf8) = String::from_utf8(new) {
495 *data = utf8;
496 }
497 }
498 }
499
500 // PVE/PMG do this explicitly for ACME plugins...
501 // obj.insert("digest".to_string(), Value::String(digest.clone()));
502
503 serde_json::from_value(entry).unwrap_or_else(|_| PluginConfig {
504 plugin: "*Error*".to_string(),
505 ty: "*Error*".to_string(),
506 ..Default::default()
507 })
508}
509
510#[api(
511 access: {
512 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
513 },
514 protected: true,
515 returns: {
516 type: Array,
517 description: "List of ACME plugin configurations.",
518 items: { type: PluginConfig },
519 },
520)]
521/// List ACME challenge plugins.
522pub fn list_plugins(mut rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>, Error> {
d4b84c1d 523 let (plugins, digest) = plugin::config()?;
25877d05 524 rpcenv["digest"] = hex::encode(&digest).into();
d4b84c1d
WB
525 Ok(plugins
526 .iter()
9a37bd6c 527 .map(|(id, (ty, data))| modify_cfg_for_api(id, ty, data))
d4b84c1d
WB
528 .collect())
529}
530
531#[api(
532 input: {
533 properties: {
534 id: { schema: PLUGIN_ID_SCHEMA },
535 },
536 },
537 access: {
538 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
539 },
540 protected: true,
541 returns: { type: PluginConfig },
542)]
543/// List ACME challenge plugins.
544pub fn get_plugin(id: String, mut rpcenv: &mut dyn RpcEnvironment) -> Result<PluginConfig, Error> {
d4b84c1d 545 let (plugins, digest) = plugin::config()?;
25877d05 546 rpcenv["digest"] = hex::encode(&digest).into();
d4b84c1d
WB
547
548 match plugins.get(&id) {
9a37bd6c 549 Some((ty, data)) => Ok(modify_cfg_for_api(&id, ty, data)),
d4b84c1d
WB
550 None => http_bail!(NOT_FOUND, "no such plugin"),
551 }
552}
553
554// Currently we only have "the" standalone plugin and DNS plugins so we can just flatten a
555// DnsPluginUpdater:
556//
557// FIXME: The 'id' parameter should not be "optional" in the schema.
558#[api(
559 input: {
560 properties: {
561 type: {
562 type: String,
563 description: "The ACME challenge plugin type.",
564 },
565 core: {
a8a20e92 566 type: DnsPluginCore,
d4b84c1d
WB
567 flatten: true,
568 },
569 data: {
570 type: String,
571 // This is different in the API!
572 description: "DNS plugin data (base64 encoded with padding).",
573 },
574 },
575 },
576 access: {
577 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
578 },
579 protected: true,
580)]
581/// Add ACME plugin configuration.
a8a20e92 582pub fn add_plugin(r#type: String, core: DnsPluginCore, data: String) -> Result<(), Error> {
d4b84c1d
WB
583 // Currently we only support DNS plugins and the standalone plugin is "fixed":
584 if r#type != "dns" {
585 bail!("invalid ACME plugin type: {:?}", r#type);
586 }
587
588 let data = String::from_utf8(base64::decode(&data)?)
589 .map_err(|_| format_err!("data must be valid UTF-8"))?;
d4b84c1d 590
a8a20e92 591 let id = core.id.clone();
d4b84c1d
WB
592
593 let _lock = plugin::lock()?;
594
595 let (mut plugins, _digest) = plugin::config()?;
596 if plugins.contains_key(&id) {
597 bail!("ACME plugin ID {:?} already exists", id);
598 }
599
a8a20e92 600 let plugin = serde_json::to_value(DnsPlugin { core, data })?;
d4b84c1d
WB
601
602 plugins.insert(id, r#type, plugin);
603
604 plugin::save_config(&plugins)?;
605
606 Ok(())
607}
608
609#[api(
610 input: {
611 properties: {
612 id: { schema: PLUGIN_ID_SCHEMA },
613 },
614 },
615 access: {
616 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
617 },
618 protected: true,
619)]
620/// Delete an ACME plugin configuration.
621pub fn delete_plugin(id: String) -> Result<(), Error> {
d4b84c1d
WB
622 let _lock = plugin::lock()?;
623
624 let (mut plugins, _digest) = plugin::config()?;
625 if plugins.remove(&id).is_none() {
626 http_bail!(NOT_FOUND, "no such plugin");
627 }
628 plugin::save_config(&plugins)?;
629
630 Ok(())
631}
632
a8a20e92
DM
633#[api()]
634#[derive(Serialize, Deserialize)]
c1a1e1ae 635#[serde(rename_all = "kebab-case")]
a8a20e92
DM
636#[allow(non_camel_case_types)]
637/// Deletable property name
638pub enum DeletableProperty {
639 /// Delete the disable property
640 disable,
641 /// Delete the validation-delay property
642 validation_delay,
643}
644
d4b84c1d
WB
645#[api(
646 input: {
647 properties: {
a8a20e92
DM
648 id: { schema: PLUGIN_ID_SCHEMA },
649 update: {
d4b84c1d
WB
650 type: DnsPluginCoreUpdater,
651 flatten: true,
652 },
653 data: {
654 type: String,
655 optional: true,
656 // This is different in the API!
657 description: "DNS plugin data (base64 encoded with padding).",
658 },
a8a20e92
DM
659 delete: {
660 description: "List of properties to delete.",
661 type: Array,
d4b84c1d 662 optional: true,
a8a20e92
DM
663 items: {
664 type: DeletableProperty,
665 }
d4b84c1d 666 },
a8a20e92
DM
667 digest: {
668 description: "Digest to protect against concurrent updates",
d4b84c1d
WB
669 optional: true,
670 },
671 },
672 },
673 access: {
674 permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
675 },
676 protected: true,
677)]
678/// Update an ACME plugin configuration.
679pub fn update_plugin(
a8a20e92
DM
680 id: String,
681 update: DnsPluginCoreUpdater,
d4b84c1d 682 data: Option<String>,
a8a20e92 683 delete: Option<Vec<DeletableProperty>>,
d4b84c1d
WB
684 digest: Option<String>,
685) -> Result<(), Error> {
d4b84c1d
WB
686 let data = data
687 .as_deref()
688 .map(base64::decode)
689 .transpose()?
690 .map(String::from_utf8)
691 .transpose()
692 .map_err(|_| format_err!("data must be valid UTF-8"))?;
d4b84c1d
WB
693
694 let _lock = plugin::lock()?;
695
696 let (mut plugins, expected_digest) = plugin::config()?;
697
698 if let Some(digest) = digest {
25877d05 699 let digest = <[u8; 32]>::from_hex(&digest)?;
d4b84c1d
WB
700 crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
701 }
702
703 match plugins.get_mut(&id) {
704 Some((ty, ref mut entry)) => {
705 if ty != "dns" {
706 bail!("cannot update plugin of type {:?}", ty);
707 }
708
709 let mut plugin: DnsPlugin = serde_json::from_value(entry.clone())?;
a8a20e92
DM
710
711 if let Some(delete) = delete {
712 for delete_prop in delete {
713 match delete_prop {
c1a1e1ae
SI
714 DeletableProperty::validation_delay => {
715 plugin.core.validation_delay = None;
716 }
717 DeletableProperty::disable => {
718 plugin.core.disable = None;
719 }
a8a20e92
DM
720 }
721 }
d4b84c1d 722 }
c1a1e1ae
SI
723 if let Some(data) = data {
724 plugin.data = data;
725 }
726 if let Some(api) = update.api {
727 plugin.core.api = api;
728 }
729 if update.validation_delay.is_some() {
730 plugin.core.validation_delay = update.validation_delay;
731 }
732 if update.disable.is_some() {
733 plugin.core.disable = update.disable;
734 }
a8a20e92 735
d4b84c1d
WB
736 *entry = serde_json::to_value(plugin)?;
737 }
738 None => http_bail!(NOT_FOUND, "no such plugin"),
739 }
740
741 plugin::save_config(&plugins)?;
742
743 Ok(())
744}