]>
Commit | Line | Data |
---|---|---|
d308dc8a | 1 | use std::fs; |
890b88cb | 2 | use std::ops::ControlFlow; |
d4b84c1d | 3 | use std::path::Path; |
d308dc8a TL |
4 | use std::sync::{Arc, Mutex}; |
5 | use std::time::SystemTime; | |
d4b84c1d WB |
6 | |
7 | use anyhow::{bail, format_err, Error}; | |
d308dc8a | 8 | use lazy_static::lazy_static; |
d4b84c1d WB |
9 | use serde::{Deserialize, Serialize}; |
10 | use serde_json::{json, Value}; | |
25877d05 | 11 | use hex::FromHex; |
d4b84c1d | 12 | |
6ef1b649 | 13 | use proxmox_router::{ |
c1a1e1ae | 14 | http_bail, list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap, |
6ef1b649 WB |
15 | }; |
16 | use proxmox_schema::api; | |
d5790a9f | 17 | use proxmox_sys::{task_log, task_warn}; |
d4b84c1d WB |
18 | |
19 | use proxmox_acme_rs::account::AccountData as AcmeAccountData; | |
20 | use proxmox_acme_rs::Account; | |
21 | ||
8cc3760e DM |
22 | use pbs_api_types::{Authid, PRIV_SYS_MODIFY}; |
23 | ||
d4b84c1d | 24 | use crate::acme::AcmeClient; |
8cc3760e | 25 | use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, KnownAcmeDirectory}; |
d4b84c1d | 26 | use crate::config::acme::plugin::{ |
a8a20e92 | 27 | self, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA, |
d4b84c1d | 28 | }; |
b9700a9f | 29 | use proxmox_rest_server::WorkerTask; |
d4b84c1d WB |
30 | |
31 | pub(crate) const ROUTER: Router = Router::new() | |
32 | .get(&list_subdirs_api_method!(SUBDIRS)) | |
33 | .subdirs(SUBDIRS); | |
34 | ||
35 | const 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 | ||
61 | const 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 | ||
66 | const 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)] | |
80 | pub 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. | |
96 | pub 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)] | |
118 | pub 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 | 146 | pub 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 | ||
160 | fn 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. | |
193 | fn 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 | ||
241 | pub 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. | |
270 | pub 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. | |
316 | pub 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. | |
371 | async 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. | |
394 | fn 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 |
399 | struct ChallengeSchemaWrapper { | |
400 | inner: Arc<Vec<AcmeChallengeSchema>>, | |
401 | } | |
402 | ||
403 | impl 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 | ||
412 | fn 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 |
446 | fn 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: | |
454 | pub 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 | |
480 | fn 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. | |
522 | pub 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. | |
544 | pub 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 | 582 | pub 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. | |
621 | pub 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 | |
638 | pub 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. | |
679 | pub 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 | } |