]> git.proxmox.com Git - proxmox-perl-rs.git/blob - pmg-rs/src/acme.rs
acme: add eab fields for pmg
[proxmox-perl-rs.git] / pmg-rs / src / acme.rs
1 //! `PMG::RS::Acme` perl module.
2 //!
3 //! The functions in here are perl bindings.
4
5 use std::fs::OpenOptions;
6 use std::io::{self, Write};
7 use std::os::unix::fs::OpenOptionsExt;
8
9 use anyhow::{format_err, Error};
10 use serde::{Deserialize, Serialize};
11
12 use proxmox_acme::account::AccountData as AcmeAccountData;
13 use proxmox_acme::{Account, Client};
14
15 /// Our on-disk format inherited from PVE's proxmox-acme code.
16 #[derive(Deserialize, Serialize)]
17 #[serde(rename_all = "camelCase")]
18 pub struct AccountData {
19 /// The account's location URL.
20 location: String,
21
22 /// The account dat.
23 account: AcmeAccountData,
24
25 /// The private key as PEM formatted string.
26 key: String,
27
28 /// ToS URL the user agreed to.
29 #[serde(skip_serializing_if = "Option::is_none")]
30 tos: Option<String>,
31
32 #[serde(skip_serializing_if = "is_false", default)]
33 debug: bool,
34
35 /// The directory's URL.
36 directory_url: String,
37 }
38
39 #[inline]
40 fn is_false(b: &bool) -> bool {
41 !*b
42 }
43
44 struct Inner {
45 client: Client,
46 account_path: Option<String>,
47 tos: Option<String>,
48 debug: bool,
49 }
50
51 impl Inner {
52 pub fn new(api_directory: String) -> Result<Self, Error> {
53 Ok(Self {
54 client: Client::new(api_directory),
55 account_path: None,
56 tos: None,
57 debug: false,
58 })
59 }
60
61 pub fn load(account_path: String) -> Result<Self, Error> {
62 let data = std::fs::read(&account_path)?;
63 let data: AccountData = serde_json::from_slice(&data)?;
64
65 let mut client = Client::new(data.directory_url);
66 client.set_account(Account::from_parts(data.location, data.key, data.account));
67
68 Ok(Self {
69 client,
70 account_path: Some(account_path),
71 tos: data.tos,
72 debug: data.debug,
73 })
74 }
75
76 pub fn new_account(
77 &mut self,
78 account_path: String,
79 tos_agreed: bool,
80 contact: Vec<String>,
81 rsa_bits: Option<u32>,
82 eab_creds: Option<(String, String)>,
83 ) -> Result<(), Error> {
84 self.tos = if tos_agreed {
85 self.client.terms_of_service_url()?.map(str::to_owned)
86 } else {
87 None
88 };
89
90 let _account = self
91 .client
92 .new_account(contact, tos_agreed, rsa_bits, eab_creds)?;
93 let file = OpenOptions::new()
94 .write(true)
95 .create(true)
96 .mode(0o600)
97 .open(&account_path)
98 .map_err(|err| format_err!("failed to open {:?} for writing: {}", account_path, err))?;
99 self.write_to(file).map_err(|err| {
100 format_err!(
101 "failed to write acme account to {:?}: {}",
102 account_path,
103 err
104 )
105 })?;
106 self.account_path = Some(account_path);
107
108 Ok(())
109 }
110
111 /// Convenience helper around `.client.account().ok_or_else(||...)`
112 fn account(&self) -> Result<&Account, Error> {
113 self.client
114 .account()
115 .ok_or_else(|| format_err!("missing account"))
116 }
117
118 fn to_account_data(&self) -> Result<AccountData, Error> {
119 let account = self.account()?;
120
121 Ok(AccountData {
122 location: account.location.clone(),
123 key: account.private_key.clone(),
124 account: AcmeAccountData {
125 only_return_existing: false, // don't actually write this out in case it's set
126 ..account.data.clone()
127 },
128 tos: self.tos.clone(),
129 debug: self.debug,
130 directory_url: self.client.directory_url().to_owned(),
131 })
132 }
133
134 fn write_to<T: io::Write>(&mut self, out: T) -> Result<(), Error> {
135 let data = self.to_account_data()?;
136
137 Ok(serde_json::to_writer_pretty(out, &data)?)
138 }
139
140 pub fn update_account<T: Serialize>(&mut self, data: &T) -> Result<(), Error> {
141 let account_path = self
142 .account_path
143 .as_deref()
144 .ok_or_else(|| format_err!("missing account path"))?;
145 self.client.update_account(data)?;
146
147 let tmp_path = format!("{}.tmp", account_path);
148 // FIXME: move proxmox::tools::replace_file & make_temp out into a nice *little* crate...
149 let mut file = OpenOptions::new()
150 .write(true)
151 .create(true)
152 .mode(0o600)
153 .open(&tmp_path)
154 .map_err(|err| format_err!("failed to open {:?} for writing: {}", tmp_path, err))?;
155 self.write_to(&mut file).map_err(|err| {
156 format_err!("failed to write acme account to {:?}: {}", tmp_path, err)
157 })?;
158 file.flush().map_err(|err| {
159 format_err!("failed to flush acme account file {:?}: {}", tmp_path, err)
160 })?;
161
162 // re-borrow since we needed `self` as mut earlier
163 let account_path = self.account_path.as_deref().unwrap();
164 std::fs::rename(&tmp_path, account_path).map_err(|err| {
165 format_err!(
166 "failed to rotate temp file into place ({:?} -> {:?}): {}",
167 &tmp_path,
168 account_path,
169 err
170 )
171 })?;
172 drop(file);
173 Ok(())
174 }
175
176 pub fn revoke_certificate(&mut self, data: &[u8], reason: Option<u32>) -> Result<(), Error> {
177 Ok(self.client.revoke_certificate(data, reason)?)
178 }
179
180 pub fn set_proxy(&mut self, proxy: String) {
181 self.client.set_proxy(proxy)
182 }
183 }
184
185 #[perlmod::package(name = "PMG::RS::Acme")]
186 pub mod export {
187 use std::collections::HashMap;
188 use std::sync::Mutex;
189
190 use anyhow::Error;
191 use serde_bytes::{ByteBuf, Bytes};
192
193 use perlmod::Value;
194 use proxmox_acme::directory::Meta;
195 use proxmox_acme::order::OrderData;
196 use proxmox_acme::{Authorization, Challenge, Order};
197
198 use super::{AccountData, Inner};
199
200 perlmod::declare_magic!(Box<Acme> : &Acme as "PMG::RS::Acme");
201
202 /// An Acme client instance.
203 pub struct Acme {
204 inner: Mutex<Inner>,
205 }
206
207 /// Create a new ACME client instance given an account path and an API directory URL.
208 #[export(raw_return)]
209 pub fn new(#[raw] class: Value, api_directory: String) -> Result<Value, Error> {
210 Ok(perlmod::instantiate_magic!(
211 &class,
212 MAGIC => Box::new(Acme {
213 inner: Mutex::new(Inner::new(api_directory)?),
214 })
215 ))
216 }
217
218 /// Load an existing account.
219 #[export(raw_return)]
220 pub fn load(#[raw] class: Value, account_path: String) -> Result<Value, Error> {
221 Ok(perlmod::instantiate_magic!(
222 &class,
223 MAGIC => Box::new(Acme {
224 inner: Mutex::new(Inner::load(account_path)?),
225 })
226 ))
227 }
228
229 /// Create a new account.
230 ///
231 /// `tos_agreed` is usually not optional, but may be set later via an update.
232 /// The `contact` list should be a list of `mailto:` strings (or others, if the directory
233 /// allows the).
234 ///
235 /// In case an RSA key should be generated, an `rsa_bits` parameter should be provided.
236 /// Otherwise a P-256 EC key will be generated.
237 #[export]
238 pub fn new_account(
239 #[try_from_ref] this: &Acme,
240 account_path: String,
241 tos_agreed: bool,
242 contact: Vec<String>,
243 rsa_bits: Option<u32>,
244 eab_kid: Option<String>,
245 eab_hmac_key: Option<String>,
246 ) -> Result<(), Error> {
247 this.inner.lock().unwrap().new_account(
248 account_path,
249 tos_agreed,
250 contact,
251 rsa_bits,
252 eab_kid.zip(eab_hmac_key),
253 )
254 }
255
256 /// Get the directory's meta information.
257 #[export]
258 pub fn get_meta(#[try_from_ref] this: &Acme) -> Result<Option<Meta>, Error> {
259 match this.inner.lock().unwrap().client.directory()?.meta() {
260 Some(meta) => Ok(Some(meta.clone())),
261 None => Ok(None),
262 }
263 }
264
265 /// Get the account's directory URL.
266 #[export]
267 pub fn directory(#[try_from_ref] this: &Acme) -> Result<String, Error> {
268 Ok(this.inner.lock().unwrap().client.directory()?.url.clone())
269 }
270
271 /// Serialize the account data.
272 #[export]
273 pub fn account(#[try_from_ref] this: &Acme) -> Result<AccountData, Error> {
274 this.inner.lock().unwrap().to_account_data()
275 }
276
277 /// Get the account's location URL.
278 #[export]
279 pub fn location(#[try_from_ref] this: &Acme) -> Result<String, Error> {
280 Ok(this.inner.lock().unwrap().account()?.location.clone())
281 }
282
283 /// Get the account's agreed-to ToS URL.
284 #[export]
285 pub fn tos_url(#[try_from_ref] this: &Acme) -> Option<String> {
286 this.inner.lock().unwrap().tos.clone()
287 }
288
289 /// Get the debug flag.
290 #[export]
291 pub fn debug(#[try_from_ref] this: &Acme) -> bool {
292 this.inner.lock().unwrap().debug
293 }
294
295 /// Get the debug flag.
296 #[export]
297 pub fn set_debug(#[try_from_ref] this: &Acme, on: bool) {
298 this.inner.lock().unwrap().debug = on;
299 }
300
301 /// Place a new order.
302 #[export]
303 pub fn new_order(
304 #[try_from_ref] this: &Acme,
305 domains: Vec<String>,
306 ) -> Result<(String, OrderData), Error> {
307 let order: Order = this.inner.lock().unwrap().client.new_order(domains)?;
308 Ok((order.location, order.data))
309 }
310
311 /// Get the authorization info given an authorization URL.
312 ///
313 /// This should be an URL found in the `authorizations` array in the `OrderData` returned from
314 /// `new_order`.
315 #[export]
316 pub fn get_authorization(
317 #[try_from_ref] this: &Acme,
318 url: &str,
319 ) -> Result<Authorization, Error> {
320 Ok(this.inner.lock().unwrap().client.get_authorization(url)?)
321 }
322
323 /// Query an order given its URL.
324 ///
325 /// The corresponding URL is returned as first value from the `new_order` call.
326 #[export]
327 pub fn get_order(#[try_from_ref] this: &Acme, url: &str) -> Result<OrderData, Error> {
328 Ok(this.inner.lock().unwrap().client.get_order(url)?)
329 }
330
331 /// Get the key authorization string for a challenge given a token.
332 #[export]
333 pub fn key_authorization(#[try_from_ref] this: &Acme, token: &str) -> Result<String, Error> {
334 Ok(this.inner.lock().unwrap().client.key_authorization(token)?)
335 }
336
337 /// Get the key dns-01 TXT challenge value for a token.
338 #[export]
339 pub fn dns_01_txt_value(#[try_from_ref] this: &Acme, token: &str) -> Result<String, Error> {
340 Ok(this.inner.lock().unwrap().client.dns_01_txt_value(token)?)
341 }
342
343 /// Request validation of a challenge by URL.
344 ///
345 /// Given an `Authorization`, it'll contain `challenges`. These contain `url`s pointing to a
346 /// method used to request challenge authorization. This is the URL used for this method,
347 /// *after* performing the necessary steps to satisfy the challenge. (Eg. after setting up a
348 /// DNS TXT entry using the `dns-01` type challenge's key authorization.
349 #[export]
350 pub fn request_challenge_validation(
351 #[try_from_ref] this: &Acme,
352 url: &str,
353 ) -> Result<Challenge, Error> {
354 Ok(this
355 .inner
356 .lock()
357 .unwrap()
358 .client
359 .request_challenge_validation(url)?)
360 }
361
362 /// Request finalization of an order.
363 ///
364 /// The `url` should be the 'finalize' URL of the order.
365 #[export]
366 pub fn finalize_order(
367 #[try_from_ref] this: &Acme,
368 url: &str,
369 csr: &Bytes,
370 ) -> Result<(), Error> {
371 Ok(this.inner.lock().unwrap().client.finalize(url, csr)?)
372 }
373
374 /// Download the certificate for an order.
375 ///
376 /// The `url` should be the 'certificate' URL of the order.
377 #[export]
378 pub fn get_certificate(#[try_from_ref] this: &Acme, url: &str) -> Result<ByteBuf, Error> {
379 Ok(ByteBuf::from(
380 this.inner.lock().unwrap().client.get_certificate(url)?,
381 ))
382 }
383
384 /// Update account data.
385 ///
386 /// This can be used for example to deactivate an account or agree to ToS later on.
387 #[export]
388 pub fn update_account(
389 #[try_from_ref] this: &Acme,
390 data: HashMap<String, serde_json::Value>,
391 ) -> Result<(), Error> {
392 this.inner.lock().unwrap().update_account(&data)?;
393 Ok(())
394 }
395
396 /// Revoke an existing certificate using the certificate in PEM or DER form.
397 #[export]
398 pub fn revoke_certificate(
399 #[try_from_ref] this: &Acme,
400 data: &[u8],
401 reason: Option<u32>,
402 ) -> Result<(), Error> {
403 this.inner
404 .lock()
405 .unwrap()
406 .revoke_certificate(&data, reason)?;
407 Ok(())
408 }
409
410 /// Set a proxy
411 #[export]
412 pub fn set_proxy(#[try_from_ref] this: &Acme, proxy: String) {
413 this.inner.lock().unwrap().set_proxy(proxy)
414 }
415 }