]>
Commit | Line | Data |
---|---|---|
d308dc8a | 1 | use std::fs; |
d4b84c1d | 2 | use std::path::Path; |
d308dc8a TL |
3 | use std::sync::{Arc, Mutex}; |
4 | use std::time::SystemTime; | |
d4b84c1d WB |
5 | |
6 | use anyhow::{bail, format_err, Error}; | |
d308dc8a | 7 | use lazy_static::lazy_static; |
d4b84c1d WB |
8 | use serde::{Deserialize, Serialize}; |
9 | use serde_json::{json, Value}; | |
10 | ||
6ef1b649 WB |
11 | use proxmox_router::{ |
12 | http_bail, list_subdirs_api_method, Permission, Router, SubdirMap, RpcEnvironment, | |
13 | }; | |
14 | use proxmox_schema::api; | |
d4b84c1d WB |
15 | |
16 | use proxmox_acme_rs::account::AccountData as AcmeAccountData; | |
17 | use proxmox_acme_rs::Account; | |
18 | ||
8cc3760e | 19 | use pbs_api_types::{Authid, PRIV_SYS_MODIFY}; |
ccc3896f | 20 | use pbs_tools::ops::ControlFlow; |
1ec0d70d | 21 | use pbs_tools::{task_log, task_warn}; |
8cc3760e | 22 | |
d4b84c1d | 23 | use crate::acme::AcmeClient; |
8cc3760e | 24 | use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, KnownAcmeDirectory}; |
d4b84c1d | 25 | use crate::config::acme::plugin::{ |
a8a20e92 | 26 | self, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA, |
d4b84c1d | 27 | }; |
b9700a9f | 28 | use proxmox_rest_server::WorkerTask; |
d4b84c1d WB |
29 | |
30 | pub(crate) const ROUTER: Router = Router::new() | |
31 | .get(&list_subdirs_api_method!(SUBDIRS)) | |
32 | .subdirs(SUBDIRS); | |
33 | ||
34 | const 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 | ||
60 | const 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 | ||
65 | const 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)] | |
79 | pub 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. | |
95 | pub 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)] | |
117 | pub 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 | 145 | pub 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 | ||
159 | fn 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. | |
192 | fn 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 | ||
240 | pub 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. | |
269 | pub 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. | |
315 | pub 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. | |
369 | async 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. | |
392 | fn 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 |
397 | struct ChallengeSchemaWrapper { | |
398 | inner: Arc<Vec<AcmeChallengeSchema>>, | |
399 | } | |
400 | ||
401 | impl 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 | ||
410 | fn 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 |
444 | fn 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: | |
452 | pub 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 | |
478 | fn 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. | |
520 | pub 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. | |
542 | pub 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 | 580 | pub 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. | |
619 | pub 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 | |
636 | pub 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. | |
677 | pub 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 | } |