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