[pmg-devel] [PATCH perl-rs 4/7] import pmg-rs

Wolfgang Bumiller w.bumiller at proxmox.com
Fri Nov 26 14:55:15 CET 2021


Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
 Cargo.toml                     |   1 +
 Makefile                       |   7 +-
 pmg-rs/Cargo.toml              |  33 +++
 pmg-rs/Makefile                |  75 ++++++
 pmg-rs/debian/changelog        |  50 ++++
 pmg-rs/debian/compat           |   1 +
 pmg-rs/debian/control          |  27 +++
 pmg-rs/debian/copyright        |  16 ++
 pmg-rs/debian/debcargo.toml    |  10 +
 pmg-rs/debian/rules            |   7 +
 pmg-rs/debian/source/format    |   1 +
 pmg-rs/debian/triggers         |   1 +
 pmg-rs/src/acme.rs             | 430 +++++++++++++++++++++++++++++++++
 pmg-rs/src/apt/mod.rs          |   1 +
 pmg-rs/src/apt/repositories.rs | 162 +++++++++++++
 pmg-rs/src/csr.rs              |  24 ++
 pmg-rs/src/lib.rs              |   3 +
 pmg-rs/test.pl                 | 172 +++++++++++++
 18 files changed, 1018 insertions(+), 3 deletions(-)
 create mode 100644 pmg-rs/Cargo.toml
 create mode 100644 pmg-rs/Makefile
 create mode 100644 pmg-rs/debian/changelog
 create mode 100644 pmg-rs/debian/compat
 create mode 100644 pmg-rs/debian/control
 create mode 100644 pmg-rs/debian/copyright
 create mode 100644 pmg-rs/debian/debcargo.toml
 create mode 100755 pmg-rs/debian/rules
 create mode 100644 pmg-rs/debian/source/format
 create mode 100644 pmg-rs/debian/triggers
 create mode 100644 pmg-rs/src/acme.rs
 create mode 100644 pmg-rs/src/apt/mod.rs
 create mode 100644 pmg-rs/src/apt/repositories.rs
 create mode 100644 pmg-rs/src/csr.rs
 create mode 100644 pmg-rs/src/lib.rs
 create mode 100644 pmg-rs/test.pl

diff --git a/Cargo.toml b/Cargo.toml
index 8556b45..6b869d4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,7 @@
 exclude = [ "build", "perl-*" ]
 members = [
     "pve-rs",
+    "pmg-rs",
 ]
 
 [patch.crates-io]
diff --git a/Makefile b/Makefile
index f8dd85a..2cc02fe 100644
--- a/Makefile
+++ b/Makefile
@@ -28,14 +28,15 @@ build:
 	echo system >build/rust-toolchain
 	cp -a ./perl-* ./build/
 	cp -a ./pve-rs ./build
+	cp -a ./pmg-rs ./build
 
 pve-deb: build
 	cd ./build/pve-rs && dpkg-buildpackage -b -uc -us
 	touch $@
 
-# pmg-deb: build
-# 	cd ./build/pmg-rs && dpkg-buildpackage -b -uc -us
-# 	touch $@
+pmg-deb: build
+	cd ./build/pmg-rs && dpkg-buildpackage -b -uc -us
+	touch $@
 
 %-upload: %-deb
 	cd build; \
diff --git a/pmg-rs/Cargo.toml b/pmg-rs/Cargo.toml
new file mode 100644
index 0000000..02f59de
--- /dev/null
+++ b/pmg-rs/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "pmg-rs"
+version = "0.3.2"
+authors = [
+    "Proxmox Support Team <support at proxmox.com>",
+    "Wolfgang Bumiller <w.bumiller at proxmox.com>",
+    "Fabian Ebner <f.ebner at proxmox.com>",
+]
+edition = "2018"
+license = "AGPL-3"
+description = "PMG parts which have been ported to rust"
+exclude = [
+    "build",
+    "debian",
+    "PMG",
+]
+
+[lib]
+crate-type = [ "cdylib" ]
+
+[dependencies]
+anyhow = "1.0"
+hex = "0.4"
+openssl = "0.10.32"
+serde = "1.0"
+serde_bytes = "0.11.3"
+serde_json = "1.0"
+
+perlmod = { version = "0.8.1", features = [ "exporter" ] }
+
+proxmox-acme-rs = { version = "0.3.1", features = ["client"] }
+
+proxmox-apt = "0.8.0"
diff --git a/pmg-rs/Makefile b/pmg-rs/Makefile
new file mode 100644
index 0000000..a290544
--- /dev/null
+++ b/pmg-rs/Makefile
@@ -0,0 +1,75 @@
+include /usr/share/dpkg/default.mk
+
+PACKAGE=libpmg-rs-perl
+
+ARCH:=$(shell dpkg-architecture -qDEB_BUILD_ARCH)
+export GITVERSION:=$(shell git rev-parse HEAD)
+
+PERL_INSTALLVENDORARCH != perl -MConfig -e 'print $$Config{installvendorarch};'
+PERL_INSTALLVENDORLIB != perl -MConfig -e 'print $$Config{installvendorlib};'
+
+MAIN_DEB=${PACKAGE}_${DEB_VERSION}_${ARCH}.deb
+DBGSYM_DEB=${PACKAGE}-dbgsym_${DEB_VERSION}_${ARCH}.deb
+DEBS=$(MAIN_DEB) $(DBGSYM_DEB)
+
+DESTDIR=
+
+PM_DIRS := \
+	PMG/RS/APT
+
+PM_FILES := \
+	PMG/RS/Acme.pm \
+	PMG/RS/APT/Repositories.pm \
+	PMG/RS/CSR.pm
+
+ifeq ($(BUILD_MODE), release)
+CARGO_BUILD_ARGS += --release
+endif
+
+all:
+ifneq ($(BUILD_MODE), skip)
+	cargo build $(CARGO_BUILD_ARGS)
+endif
+
+# always re-create this dir
+# but also copy the local target/ and PMG/ dirs as a build-cache
+.PHONY: build
+build:
+	rm -rf build
+	cargo build --release
+	rsync -a debian Makefile Cargo.toml Cargo.lock src target PMG build/
+
+.PHONY: install
+install: target/release/libpmg_rs.so
+	install -d -m755 $(DESTDIR)$(PERL_INSTALLVENDORARCH)/auto
+	install -m644 target/release/libpmg_rs.so $(DESTDIR)$(PERL_INSTALLVENDORARCH)/auto/libpmg_rs.so
+	install -d -m755 $(DESTDIR)$(PERL_INSTALLVENDORLIB)/PMG/RS
+	for i in $(PM_DIRS); do \
+	  install -d -m755 $(DESTDIR)$(PERL_INSTALLVENDORLIB)/$$i; \
+	done
+	for i in $(PM_FILES); do \
+	  install -m644 $$i $(DESTDIR)$(PERL_INSTALLVENDORLIB)/$$i; \
+	done
+
+.PHONY: deb
+deb: $(MAIN_DEB)
+$(MAIN_DEB): build
+	cd build; dpkg-buildpackage -b -us -uc --no-pre-clean
+	lintian $(DEBS)
+
+distclean: clean
+
+clean:
+	cargo clean
+	rm -rf *.deb *.dsc *.tar.gz *.buildinfo *.changes Cargo.lock build
+	find . -name '*~' -exec rm {} ';'
+
+.PHONY: dinstall
+dinstall: ${DEBS}
+	dpkg -i ${DEBS}
+
+.PHONY: upload
+upload: ${DEBS}
+	# check if working directory is clean
+	git diff --exit-code --stat && git diff --exit-code --stat --staged
+	tar cf - ${DEBS} | ssh -X repoman at repo.proxmox.com upload --product pmg --dist bullseye
diff --git a/pmg-rs/debian/changelog b/pmg-rs/debian/changelog
new file mode 100644
index 0000000..afc3e60
--- /dev/null
+++ b/pmg-rs/debian/changelog
@@ -0,0 +1,50 @@
+libpmg-rs-perl (0.3.2) bullseye; urgency=medium
+
+  * acme: add proxy support
+
+ -- Proxmox Support Team <support at proxmox.com>  Thu, 18 Nov 2021 11:18:01 +0100
+
+libpmg-rs-perl (0.3.1) bullseye; urgency=medium
+
+  * update to proxmox-acme-rs 0.3
+
+ -- Proxmox Support Team <support at proxmox.com>  Thu, 21 Oct 2021 13:13:46 +0200
+
+libpmg-rs-perl (0.3.0) bullseye; urgency=medium
+
+  * update proxmox-apt to 0.6.0
+
+ -- Proxmox Support Team <support at proxmox.com>  Fri, 30 Jul 2021 10:56:35 +0200
+
+libpmg-rs-perl (0.2.0-1) bullseye; urgency=medium
+
+  * add bindings for proxmox-apt
+
+ -- Proxmox Support Team <support at proxmox.com>  Tue, 13 Jul 2021 12:48:04 +0200
+
+libpmg-rs-perl (0.1.3-1) bullseye; urgency=medium
+
+  * re-build for Proxmox Mail Gateway 7 / Debian 11 Bullseye
+
+ -- Proxmox Support Team <support at proxmox.com>  Thu, 27 May 2021 19:58:08 +0200
+
+libpmg-rs-perl (0.1.2-1) buster; urgency=medium
+
+  * update proxmox-acme-rs to 0.1.4 to store the 'created' account field if it
+    is available
+
+  * set account file permission to 0700
+
+ -- Proxmox Support Team <support at proxmox.com>  Mon, 29 Mar 2021 11:22:54 +0200
+
+libpmg-rs-perl (0.1.1-1) unstable; urgency=medium
+
+  * update proxmox-acme-rs to 0.1.3 to fix ecsda signature padding
+
+ -- Proxmox Support Team <support at proxmox.com>  Wed, 17 Mar 2021 13:43:12 +0100
+
+libpmg-rs-perl (0.1-1) unstable; urgency=medium
+
+  * initial release
+
+ -- Proxmox Support Team <support at proxmox.com>  Mon, 22 Feb 2021 13:40:10 +0100
diff --git a/pmg-rs/debian/compat b/pmg-rs/debian/compat
new file mode 100644
index 0000000..48082f7
--- /dev/null
+++ b/pmg-rs/debian/compat
@@ -0,0 +1 @@
+12
diff --git a/pmg-rs/debian/control b/pmg-rs/debian/control
new file mode 100644
index 0000000..be632ba
--- /dev/null
+++ b/pmg-rs/debian/control
@@ -0,0 +1,27 @@
+Source: libpmg-rs-perl
+Section: perl
+Priority: optional
+Maintainer: Proxmox Support Team <support at proxmox.com>
+Build-Depends:
+ debhelper (>= 12),
+ librust-anyhow-1+default-dev,
+ librust-hex-0.4+default-dev,
+ librust-openssl-0.10+default-dev (>= 0.10.32-~~),
+ librust-perlmod-0.8+default-dev,
+ librust-perlmod-0.8+exporter-dev,
+ librust-proxmox-acme-rs-0.3+client-dev (>= 0.3.1-~~),
+ librust-proxmox-acme-rs-0.3+default-dev (>= 0.3.1-~~),
+ librust-proxmox-apt-0.8+default-dev,
+ librust-serde-1+default-dev,
+ librust-serde-bytes-0.11+default-dev (>= 0.11.3-~~),
+ librust-serde-json-1+default-dev,
+Standards-Version: 4.3.0
+Homepage: https://www.proxmox.com
+
+Package: libpmg-rs-perl
+Architecture: any
+Depends: ${perl:Depends},
+         ${shlibs:Depends},
+Description: Components of Proxmox Mail Gateway which have been ported to Rust.
+ Contains parts of Proxmox Mail Gateway which have been ported to, or newly
+ implemented in the Rust programming language.
diff --git a/pmg-rs/debian/copyright b/pmg-rs/debian/copyright
new file mode 100644
index 0000000..477c305
--- /dev/null
+++ b/pmg-rs/debian/copyright
@@ -0,0 +1,16 @@
+Copyright (C) 2020-2021 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support at proxmox.com>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
diff --git a/pmg-rs/debian/debcargo.toml b/pmg-rs/debian/debcargo.toml
new file mode 100644
index 0000000..8aa085f
--- /dev/null
+++ b/pmg-rs/debian/debcargo.toml
@@ -0,0 +1,10 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support at proxmox.com>"
+
+[source]
+section = "perl"
+vcs_git = "git://git.proxmox.com/git/proxmox.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
+
+[packages.libpmg-rs-perl]
diff --git a/pmg-rs/debian/rules b/pmg-rs/debian/rules
new file mode 100755
index 0000000..0f5be05
--- /dev/null
+++ b/pmg-rs/debian/rules
@@ -0,0 +1,7 @@
+#!/usr/bin/make -f
+
+#export DH_VERBOSE=1
+export BUILD_MODE=release
+
+%:
+	dh $@
diff --git a/pmg-rs/debian/source/format b/pmg-rs/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/pmg-rs/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/pmg-rs/debian/triggers b/pmg-rs/debian/triggers
new file mode 100644
index 0000000..59dd688
--- /dev/null
+++ b/pmg-rs/debian/triggers
@@ -0,0 +1 @@
+activate-noawait pve-api-updates
diff --git a/pmg-rs/src/acme.rs b/pmg-rs/src/acme.rs
new file mode 100644
index 0000000..0429a0d
--- /dev/null
+++ b/pmg-rs/src/acme.rs
@@ -0,0 +1,430 @@
+//! `PMG::RS::Acme` perl module.
+//!
+//! The functions in here are perl bindings.
+
+use std::fs::OpenOptions;
+use std::io::{self, Write};
+use std::os::unix::fs::OpenOptionsExt;
+
+use anyhow::{format_err, Error};
+use serde::{Deserialize, Serialize};
+
+use proxmox_acme_rs::account::AccountData as AcmeAccountData;
+use proxmox_acme_rs::{Account, Client};
+
+/// Our on-disk format inherited from PVE's proxmox-acme code.
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AccountData {
+    /// The account's location URL.
+    location: String,
+
+    /// The account dat.
+    account: AcmeAccountData,
+
+    /// The private key as PEM formatted string.
+    key: String,
+
+    /// ToS URL the user agreed to.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    tos: Option<String>,
+
+    #[serde(skip_serializing_if = "is_false", default)]
+    debug: bool,
+
+    /// The directory's URL.
+    directory_url: String,
+}
+
+#[inline]
+fn is_false(b: &bool) -> bool {
+    !*b
+}
+
+struct Inner {
+    client: Client,
+    account_path: Option<String>,
+    tos: Option<String>,
+    debug: bool,
+}
+
+impl Inner {
+    pub fn new(api_directory: String) -> Result<Self, Error> {
+        Ok(Self {
+            client: Client::new(api_directory),
+            account_path: None,
+            tos: None,
+            debug: false,
+        })
+    }
+
+    pub fn load(account_path: String) -> Result<Self, Error> {
+        let data = std::fs::read(&account_path)?;
+        let data: AccountData = serde_json::from_slice(&data)?;
+
+        let mut client = Client::new(data.directory_url);
+        client.set_account(Account::from_parts(data.location, data.key, data.account));
+
+        Ok(Self {
+            client,
+            account_path: Some(account_path),
+            tos: data.tos,
+            debug: data.debug,
+        })
+    }
+
+    pub fn new_account(
+        &mut self,
+        account_path: String,
+        tos_agreed: bool,
+        contact: Vec<String>,
+        rsa_bits: Option<u32>,
+    ) -> Result<(), Error> {
+        self.tos = if tos_agreed {
+            self.client.terms_of_service_url()?.map(str::to_owned)
+        } else {
+            None
+        };
+
+        let _account = self.client.new_account(contact, tos_agreed, rsa_bits)?;
+        let file = OpenOptions::new()
+            .write(true)
+            .create(true)
+            .mode(0o600)
+            .open(&account_path)
+            .map_err(|err| format_err!("failed to open {:?} for writing: {}", account_path, err))?;
+        self.write_to(file).map_err(|err| {
+            format_err!(
+                "failed to write acme account to {:?}: {}",
+                account_path,
+                err
+            )
+        })?;
+        self.account_path = Some(account_path);
+
+        Ok(())
+    }
+
+    /// Convenience helper around `.client.account().ok_or_else(||...)`
+    fn account(&self) -> Result<&Account, Error> {
+        self.client
+            .account()
+            .ok_or_else(|| format_err!("missing account"))
+    }
+
+    fn to_account_data(&self) -> Result<AccountData, Error> {
+        let account = self.account()?;
+
+        Ok(AccountData {
+            location: account.location.clone(),
+            key: account.private_key.clone(),
+            account: AcmeAccountData {
+                only_return_existing: false, // don't actually write this out in case it's set
+                ..account.data.clone()
+            },
+            tos: self.tos.clone(),
+            debug: self.debug,
+            directory_url: self.client.directory_url().to_owned(),
+        })
+    }
+
+    fn write_to<T: io::Write>(&mut self, out: T) -> Result<(), Error> {
+        let data = self.to_account_data()?;
+
+        Ok(serde_json::to_writer_pretty(out, &data)?)
+    }
+
+    pub fn update_account<T: Serialize>(&mut self, data: &T) -> Result<(), Error> {
+        let account_path = self
+            .account_path
+            .as_deref()
+            .ok_or_else(|| format_err!("missing account path"))?;
+        self.client.update_account(data)?;
+
+        let tmp_path = format!("{}.tmp", account_path);
+        // FIXME: move proxmox::tools::replace_file & make_temp out into a nice *little* crate...
+        let mut file = OpenOptions::new()
+            .write(true)
+            .create(true)
+            .mode(0o600)
+            .open(&tmp_path)
+            .map_err(|err| format_err!("failed to open {:?} for writing: {}", tmp_path, err))?;
+        self.write_to(&mut file).map_err(|err| {
+            format_err!("failed to write acme account to {:?}: {}", tmp_path, err)
+        })?;
+        file.flush().map_err(|err| {
+            format_err!("failed to flush acme account file {:?}: {}", tmp_path, err)
+        })?;
+
+        // re-borrow since we needed `self` as mut earlier
+        let account_path = self.account_path.as_deref().unwrap();
+        std::fs::rename(&tmp_path, account_path).map_err(|err| {
+            format_err!(
+                "failed to rotate temp file into place ({:?} -> {:?}): {}",
+                &tmp_path,
+                account_path,
+                err
+            )
+        })?;
+        drop(file);
+        Ok(())
+    }
+
+    pub fn revoke_certificate(&mut self, data: &[u8], reason: Option<u32>) -> Result<(), Error> {
+        Ok(self.client.revoke_certificate(data, reason)?)
+    }
+
+    pub fn set_proxy(&mut self, proxy: String) {
+        self.client.set_proxy(proxy)
+    }
+}
+
+#[perlmod::package(name = "PMG::RS::Acme", lib = "pmg_rs")]
+pub mod export {
+    use std::collections::HashMap;
+    use std::convert::TryFrom;
+    use std::sync::Mutex;
+
+    use anyhow::Error;
+    use serde_bytes::{ByteBuf, Bytes};
+
+    use perlmod::Value;
+    use proxmox_acme_rs::directory::Meta;
+    use proxmox_acme_rs::order::OrderData;
+    use proxmox_acme_rs::{Authorization, Challenge, Order};
+
+    use super::{AccountData, Inner};
+
+    const CLASSNAME: &str = "PMG::RS::Acme";
+
+    /// An Acme client instance.
+    pub struct Acme {
+        inner: Mutex<Inner>,
+    }
+
+    impl<'a> TryFrom<&'a Value> for &'a Acme {
+        type Error = Error;
+
+        fn try_from(value: &'a Value) -> Result<&'a Acme, Error> {
+            Ok(unsafe { value.from_blessed_box(CLASSNAME)? })
+        }
+    }
+
+    fn bless(class: Value, mut ptr: Box<Acme>) -> Result<Value, Error> {
+        let value = Value::new_pointer::<Acme>(&mut *ptr);
+        let value = Value::new_ref(&value);
+        let this = value.bless_sv(&class)?;
+        let _perl = Box::leak(ptr);
+        Ok(this)
+    }
+
+    /// Create a new ACME client instance given an account path and an API directory URL.
+    #[export(raw_return)]
+    pub fn new(#[raw] class: Value, api_directory: String) -> Result<Value, Error> {
+        bless(
+            class,
+            Box::new(Acme {
+                inner: Mutex::new(Inner::new(api_directory)?),
+            }),
+        )
+    }
+
+    /// Load an existing account.
+    #[export(raw_return)]
+    pub fn load(#[raw] class: Value, account_path: String) -> Result<Value, Error> {
+        bless(
+            class,
+            Box::new(Acme {
+                inner: Mutex::new(Inner::load(account_path)?),
+            }),
+        )
+    }
+
+    #[export(name = "DESTROY")]
+    fn destroy(#[raw] this: Value) {
+        perlmod::destructor!(this, Acme: CLASSNAME);
+    }
+
+    /// Create a new account.
+    ///
+    /// `tos_agreed` is usually not optional, but may be set later via an update.
+    /// The `contact` list should be a list of `mailto:` strings (or others, if the directory
+    /// allows the).
+    ///
+    /// In case an RSA key should be generated, an `rsa_bits` parameter should be provided.
+    /// Otherwise a P-256 EC key will be generated.
+    #[export]
+    pub fn new_account(
+        #[try_from_ref] this: &Acme,
+        account_path: String,
+        tos_agreed: bool,
+        contact: Vec<String>,
+        rsa_bits: Option<u32>,
+    ) -> Result<(), Error> {
+        this.inner
+            .lock()
+            .unwrap()
+            .new_account(account_path, tos_agreed, contact, rsa_bits)
+    }
+
+    /// Get the directory's meta information.
+    #[export]
+    pub fn get_meta(#[try_from_ref] this: &Acme) -> Result<Option<Meta>, Error> {
+        match this.inner.lock().unwrap().client.directory()?.meta() {
+            Some(meta) => Ok(Some(meta.clone())),
+            None => Ok(None),
+        }
+    }
+
+    /// Get the account's directory URL.
+    #[export]
+    pub fn directory(#[try_from_ref] this: &Acme) -> Result<String, Error> {
+        Ok(this.inner.lock().unwrap().client.directory()?.url.clone())
+    }
+
+    /// Serialize the account data.
+    #[export]
+    pub fn account(#[try_from_ref] this: &Acme) -> Result<AccountData, Error> {
+        this.inner.lock().unwrap().to_account_data()
+    }
+
+    /// Get the account's location URL.
+    #[export]
+    pub fn location(#[try_from_ref] this: &Acme) -> Result<String, Error> {
+        Ok(this.inner.lock().unwrap().account()?.location.clone())
+    }
+
+    /// Get the account's agreed-to ToS URL.
+    #[export]
+    pub fn tos_url(#[try_from_ref] this: &Acme) -> Option<String> {
+        this.inner.lock().unwrap().tos.clone()
+    }
+
+    /// Get the debug flag.
+    #[export]
+    pub fn debug(#[try_from_ref] this: &Acme) -> bool {
+        this.inner.lock().unwrap().debug
+    }
+
+    /// Get the debug flag.
+    #[export]
+    pub fn set_debug(#[try_from_ref] this: &Acme, on: bool) {
+        this.inner.lock().unwrap().debug = on;
+    }
+
+    /// Place a new order.
+    #[export]
+    pub fn new_order(
+        #[try_from_ref] this: &Acme,
+        domains: Vec<String>,
+    ) -> Result<(String, OrderData), Error> {
+        let order: Order = this.inner.lock().unwrap().client.new_order(domains)?;
+        Ok((order.location, order.data))
+    }
+
+    /// Get the authorization info given an authorization URL.
+    ///
+    /// This should be an URL found in the `authorizations` array in the `OrderData` returned from
+    /// `new_order`.
+    #[export]
+    pub fn get_authorization(
+        #[try_from_ref] this: &Acme,
+        url: &str,
+    ) -> Result<Authorization, Error> {
+        Ok(this.inner.lock().unwrap().client.get_authorization(url)?)
+    }
+
+    /// Query an order given its URL.
+    ///
+    /// The corresponding URL is returned as first value from the `new_order` call.
+    #[export]
+    pub fn get_order(#[try_from_ref] this: &Acme, url: &str) -> Result<OrderData, Error> {
+        Ok(this.inner.lock().unwrap().client.get_order(url)?)
+    }
+
+    /// Get the key authorization string for a challenge given a token.
+    #[export]
+    pub fn key_authorization(#[try_from_ref] this: &Acme, token: &str) -> Result<String, Error> {
+        Ok(this.inner.lock().unwrap().client.key_authorization(token)?)
+    }
+
+    /// Get the key dns-01 TXT challenge value for a token.
+    #[export]
+    pub fn dns_01_txt_value(#[try_from_ref] this: &Acme, token: &str) -> Result<String, Error> {
+        Ok(this.inner.lock().unwrap().client.dns_01_txt_value(token)?)
+    }
+
+    /// Request validation of a challenge by URL.
+    ///
+    /// Given an `Authorization`, it'll contain `challenges`. These contain `url`s pointing to a
+    /// method used to request challenge authorization. This is the URL used for this method,
+    /// *after* performing the necessary steps to satisfy the challenge. (Eg. after setting up a
+    /// DNS TXT entry using the `dns-01` type challenge's key authorization.
+    #[export]
+    pub fn request_challenge_validation(
+        #[try_from_ref] this: &Acme,
+        url: &str,
+    ) -> Result<Challenge, Error> {
+        Ok(this
+            .inner
+            .lock()
+            .unwrap()
+            .client
+            .request_challenge_validation(url)?)
+    }
+
+    /// Request finalization of an order.
+    ///
+    /// The `url` should be the 'finalize' URL of the order.
+    #[export]
+    pub fn finalize_order(
+        #[try_from_ref] this: &Acme,
+        url: &str,
+        csr: &Bytes,
+    ) -> Result<(), Error> {
+        Ok(this.inner.lock().unwrap().client.finalize(url, csr)?)
+    }
+
+    /// Download the certificate for an order.
+    ///
+    /// The `url` should be the 'certificate' URL of the order.
+    #[export]
+    pub fn get_certificate(#[try_from_ref] this: &Acme, url: &str) -> Result<ByteBuf, Error> {
+        Ok(ByteBuf::from(
+            this.inner.lock().unwrap().client.get_certificate(url)?,
+        ))
+    }
+
+    /// Update account data.
+    ///
+    /// This can be used for example to deactivate an account or agree to ToS later on.
+    #[export]
+    pub fn update_account(
+        #[try_from_ref] this: &Acme,
+        data: HashMap<String, serde_json::Value>,
+    ) -> Result<(), Error> {
+        this.inner.lock().unwrap().update_account(&data)?;
+        Ok(())
+    }
+
+    /// Revoke an existing certificate using the certificate in PEM or DER form.
+    #[export]
+    pub fn revoke_certificate(
+        #[try_from_ref] this: &Acme,
+        data: &[u8],
+        reason: Option<u32>,
+    ) -> Result<(), Error> {
+        this.inner
+            .lock()
+            .unwrap()
+            .revoke_certificate(&data, reason)?;
+        Ok(())
+    }
+
+    /// Set a proxy
+    #[export]
+    pub fn set_proxy(#[try_from_ref] this: &Acme, proxy: String) {
+        this.inner.lock().unwrap().set_proxy(proxy)
+    }
+
+}
diff --git a/pmg-rs/src/apt/mod.rs b/pmg-rs/src/apt/mod.rs
new file mode 100644
index 0000000..574c1a7
--- /dev/null
+++ b/pmg-rs/src/apt/mod.rs
@@ -0,0 +1 @@
+mod repositories;
diff --git a/pmg-rs/src/apt/repositories.rs b/pmg-rs/src/apt/repositories.rs
new file mode 100644
index 0000000..75207c7
--- /dev/null
+++ b/pmg-rs/src/apt/repositories.rs
@@ -0,0 +1,162 @@
+#[perlmod::package(name = "PMG::RS::APT::Repositories", lib = "pmg_rs")]
+mod export {
+    use std::convert::TryInto;
+
+    use anyhow::{bail, Error};
+    use serde::{Deserialize, Serialize};
+
+    use proxmox_apt::repositories::{
+        APTRepositoryFile, APTRepositoryFileError, APTRepositoryHandle, APTRepositoryInfo,
+        APTStandardRepository,
+    };
+
+    #[derive(Deserialize, Serialize)]
+    #[serde(rename_all = "kebab-case")]
+    /// Result for the repositories() function
+    pub struct RepositoriesResult {
+        /// Successfully parsed files.
+        pub files: Vec<APTRepositoryFile>,
+
+        /// Errors for files that could not be parsed or read.
+        pub errors: Vec<APTRepositoryFileError>,
+
+        /// Common digest for successfully parsed files.
+        pub digest: String,
+
+        /// Additional information/warnings about repositories.
+        pub infos: Vec<APTRepositoryInfo>,
+
+        /// Standard repositories and their configuration status.
+        pub standard_repos: Vec<APTStandardRepository>,
+    }
+
+    #[derive(Deserialize, Serialize)]
+    #[serde(rename_all = "kebab-case")]
+    /// For changing an existing repository.
+    pub struct ChangeProperties {
+        /// Whether the repository should be enabled or not.
+        pub enabled: Option<bool>,
+    }
+
+    /// Get information about configured and standard repositories.
+    #[export]
+    pub fn repositories() -> Result<RepositoriesResult, Error> {
+        let (files, errors, digest) = proxmox_apt::repositories::repositories()?;
+        let digest = hex::encode(&digest);
+
+        let suite = proxmox_apt::repositories::get_current_release_codename()?;
+
+        let infos = proxmox_apt::repositories::check_repositories(&files, suite);
+        let standard_repos = proxmox_apt::repositories::standard_repositories(&files, "pmg", suite);
+
+        Ok(RepositoriesResult {
+            files,
+            errors,
+            digest,
+            infos,
+            standard_repos,
+        })
+    }
+
+    /// Add the repository identified by the `handle`.
+    /// If the repository is already configured, it will be set to enabled.
+    ///
+    /// The `digest` parameter asserts that the configuration has not been modified.
+    #[export]
+    pub fn add_repository(handle: &str, digest: Option<&str>) -> Result<(), Error> {
+        let (mut files, errors, current_digest) = proxmox_apt::repositories::repositories()?;
+
+        let handle: APTRepositoryHandle = handle.try_into()?;
+        let suite = proxmox_apt::repositories::get_current_release_codename()?;
+
+        if let Some(digest) = digest {
+            let expected_digest = hex::decode(digest)?;
+            if expected_digest != current_digest {
+                bail!("detected modified configuration - file changed by other user? Try again.");
+            }
+        }
+
+        // check if it's already configured first
+        for file in files.iter_mut() {
+            for repo in file.repositories.iter_mut() {
+                if repo.is_referenced_repository(handle, "pmg", &suite.to_string()) {
+                    if repo.enabled {
+                        return Ok(());
+                    }
+
+                    repo.set_enabled(true);
+                    file.write()?;
+
+                    return Ok(());
+                }
+            }
+        }
+
+        let (repo, path) = proxmox_apt::repositories::get_standard_repository(handle, "pmg", suite);
+
+        if let Some(error) = errors.iter().find(|error| error.path == path) {
+            bail!(
+                "unable to parse existing file {} - {}",
+                error.path,
+                error.error,
+            );
+        }
+
+        if let Some(file) = files.iter_mut().find(|file| file.path == path) {
+            file.repositories.push(repo);
+
+            file.write()?;
+        } else {
+            let mut file = match APTRepositoryFile::new(&path)? {
+                Some(file) => file,
+                None => bail!("invalid path - {}", path),
+            };
+
+            file.repositories.push(repo);
+
+            file.write()?;
+        }
+
+        Ok(())
+    }
+
+    /// Change the properties of the specified repository.
+    ///
+    /// The `digest` parameter asserts that the configuration has not been modified.
+    #[export]
+    pub fn change_repository(
+        path: &str,
+        index: usize,
+        options: ChangeProperties,
+        digest: Option<&str>,
+    ) -> Result<(), Error> {
+        let (mut files, errors, current_digest) = proxmox_apt::repositories::repositories()?;
+
+        if let Some(digest) = digest {
+            let expected_digest = hex::decode(digest)?;
+            if expected_digest != current_digest {
+                bail!("detected modified configuration - file changed by other user? Try again.");
+            }
+        }
+
+        if let Some(error) = errors.iter().find(|error| error.path == path) {
+            bail!("unable to parse file {} - {}", error.path, error.error);
+        }
+
+        if let Some(file) = files.iter_mut().find(|file| file.path == path) {
+            if let Some(repo) = file.repositories.get_mut(index) {
+                if let Some(enabled) = options.enabled {
+                    repo.set_enabled(enabled);
+                }
+
+                file.write()?;
+            } else {
+                bail!("invalid index - {}", index);
+            }
+        } else {
+            bail!("invalid path - {}", path);
+        }
+
+        Ok(())
+    }
+}
diff --git a/pmg-rs/src/csr.rs b/pmg-rs/src/csr.rs
new file mode 100644
index 0000000..961a2cf
--- /dev/null
+++ b/pmg-rs/src/csr.rs
@@ -0,0 +1,24 @@
+#[perlmod::package(name = "PMG::RS::CSR", lib = "pmg_rs")]
+pub mod export {
+    use std::collections::HashMap;
+
+    use anyhow::Error;
+    use serde_bytes::ByteBuf;
+
+    use proxmox_acme_rs::util::Csr;
+
+    /// Generates a CSR and its accompanying private key.
+    ///
+    /// The CSR is DER formatted, the private key is a PEM formatted pkcs8 private key.
+    #[export]
+    pub fn generate_csr(
+        identifiers: Vec<&str>,
+        attributes: HashMap<String, &str>,
+    ) -> Result<(ByteBuf, ByteBuf), Error> {
+        let csr = Csr::generate(&identifiers, &attributes)?;
+        Ok((
+            ByteBuf::from(csr.data),
+            ByteBuf::from(csr.private_key_pem),
+        ))
+    }
+}
diff --git a/pmg-rs/src/lib.rs b/pmg-rs/src/lib.rs
new file mode 100644
index 0000000..47e61b5
--- /dev/null
+++ b/pmg-rs/src/lib.rs
@@ -0,0 +1,3 @@
+pub mod acme;
+pub mod apt;
+pub mod csr;
diff --git a/pmg-rs/test.pl b/pmg-rs/test.pl
new file mode 100644
index 0000000..3d2a6df
--- /dev/null
+++ b/pmg-rs/test.pl
@@ -0,0 +1,172 @@
+#!/usr/bin/env perl
+
+use v5.28.0;
+use Data::Dumper;
+
+use lib '.';
+use PMG::RS::Acme;
+use PMG::RS::CSR;
+
+# "Config:" The Acme server URL:
+my $DIR = 'https://acme-staging-v02.api.letsencrypt.org/directory';
+
+# Useage:
+#
+# * Create a new account:
+#     | ~/ $ ./test.pl ./account.json new 'somebody at example.invalid"
+#
+#   The `./account.json` will be created using an EC P-256 key.
+#   Optionally an RSA key size can be passed as additional parameter to generate
+#   an account with an RSA key instead.
+#
+# From here on out the `./account.json` file must already exist:
+#
+# * Place a new order:
+#     | ~/ $ ./test.pl ./account.json new-order my.domain.com
+#     | $VAR1 = {
+#     |     ... order data ...
+#     |     'authorizations' => [
+#     |         'https://acme.example/auths/1244',
+#     |         ... possibly more ...
+#     |     ]
+#     | }
+#     | Order URL: https://acme.example/order/1793
+#
+#   Note: This   ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
+#     URL will be used later for finalization and certifiate download.
+#   The `$VAR1` dump contains the order JSON data.
+#   The 'authorizations' URLs are going to be used next.
+#
+# * Get authorization info
+#     | ~/ $ ./test.pl ./account.json get-auth 'https://acme.example/auths/1244'
+#     | $VAR1 = {
+#     |     ... auth data ...
+#     |     'challenges' => [
+#     |         {
+#     |             'type' => 'dns-01',
+#     |             'url' => 'https://acme.example/challenge/8188/dns1'
+#     |         }
+#     |         ... likely more ...
+#     |     ]
+#     | }
+#     | Key Authorization = SuperVeryMegaLongValue
+#     | dns-01 TXT value = ShorterValue
+#
+#   Now perform the things you need to for the challenge, eg. setup the DNS
+#   entry using the provided TXT value.
+#   Then use the correct challenge's URL with req-auth
+#
+# * Request challenge validation
+#     | ~/ $ ./test.pl ./account.json \
+#     |      req-challenge 'https://acme.example/challenge/8188/dns1
+#
+# * Repeat the above 2 steps for all authorizations.
+# * Wait for the order to be valid via `get-order`
+#     | ~/ $ ./test.pl ./account.json get-order 'https://acme.example/order/1793'
+#     | $VAR1 = {
+#     |     'status' => 'valid',
+#     |     'finalize' => 'some URL',
+#     |     ... order data ...
+#     | }
+#     | Order URL: https://acme.example/order/1793
+#
+# * Finalize the order via the *Order URL* and a private key to sign the
+#   request with (eg. generated via `openssl genrsa` or `openssl ecparam`).
+#     | ~/ $ ./test.pl ./account.json \
+#     |      finalize my.domain.com ./my-private-key.pem \
+#     |      'https://acme.example/order/1793'
+#
+# * Wait for a 'certificate' property to pop up in the order
+#   (check via 'get-order')
+#
+# * Grab the certificate with the Order URL and a destination file name:
+#     | ~/ $ ./test.pl ./account.json get-cert \
+#     |      'https://acme.example/order/1793' \
+#     |      ./my-cert.pem
+
+
+my $account = shift // die "missing account file\n";
+my $cmd = shift // die "missing account file\n";
+
+sub load : prototype($) {
+    my ($file) = @_;
+    open(my $fh, '<', $file) or die "open($file): $!\n";
+    my $data = do {
+        local $/ = undef;
+        <$fh>
+    };
+    close($fh);
+    return $data;
+}
+
+sub store : prototype($$) {
+    my ($file, $data) = @_;
+    open(my $fh, '>', $file) or die "open($file): $!\n";
+    syswrite($fh, $data) == length($data)
+        or die "failed to write data to $file: $!\n";
+    close($fh);
+}
+
+if ($cmd eq 'new') {
+    my $mail = shift // die "missing mail address\n";
+    my $rsa_bits = shift;
+    if (defined($rsa_bits)) {
+        $rsa_bits = int($rsa_bits);
+    }
+    my $acme = PMG::RS::Acme->new($DIR);
+    $acme->new_account($account, 1, ["mailto:$mail"], undef);
+} elsif ($cmd eq 'get-meta') {
+    #my $acme = PMG::RS::Acme->new($DIR);
+    my $acme = PMG::RS::Acme->new('https%3A%2F%2Facme-v02.api.letsencrypt.org%2Fdirectory');
+    my $data = $acme->get_meta();
+    say Dumper($data);
+} elsif ($cmd eq 'new-order') {
+    my $domain = shift // die "missing domain\n";
+    my $acme = PMG::RS::Acme->load($account);
+    my ($url, $order) = $acme->new_order([$domain]);
+    say Dumper($order);
+    say "Order URL: $url\n";
+} elsif ($cmd eq 'get-auth') {
+    my $url = shift // die "missing url\n";
+    my $acme = PMG::RS::Acme->load($account);
+    my $auth = $acme->get_authorization($url);
+    say Dumper($auth);
+    for my $challenge ($auth->{challenges}->@*) {
+        next if $challenge->{type} ne 'dns-01';
+        say "Key Authorization = ".$acme->key_authorization($challenge->{token});
+        say "dns-01 TXT value = ".$acme->dns_01_txt_value($challenge->{token});
+    }
+} elsif ($cmd eq 'req-challenge') {
+    my $url = shift // die "missing url\n";
+    my $acme = PMG::RS::Acme->load($account);
+    my $challenge = $acme->request_challenge_validation($url);
+    say Dumper($challenge);
+} elsif ($cmd eq 'finalize') {
+    my $domain = shift // die 'missing domain\n';
+    my $pkfile = shift // die "missing private key file\n";
+    my $order_url = shift // die "missing order URL\n";
+    my ($csr_der, $pkey_pem) = PMG::RS::CSR::generate_csr([$domain], {});
+    store($pkfile, $pkey_pem);
+    my $acme = PMG::RS::Acme->load($account);
+    my $order = $acme->get_order($order_url);
+    say Dumper($order);
+    die "order not ready\n" if $order->{status} ne 'ready';
+    $acme->finalize_order($order->{finalize}, $csr_der);
+} elsif ($cmd eq 'get-order') {
+    my $order_url = shift // die "missing order URL\n";
+    my $acme = PMG::RS::Acme->load($account);
+    my $order = $acme->get_order($order_url);
+    say Dumper($order);
+} elsif ($cmd eq 'get-cert') {
+    my $order_url = shift // die "missing order URL\n";
+    my $file_name = shift // die "missing destination file name\n";
+    my $acme = PMG::RS::Acme->load($account);
+    my $order = $acme->get_order($order_url);
+    my $cert_url = $order->{certificate};
+    die "certificate not ready\n" if !$cert_url;
+    say Dumper($order);
+    my $cert = $acme->get_certificate($cert_url);
+    store($file_name, $cert);
+} else {
+    die "unknown command '$cmd'\n";
+}
-- 
2.30.2





More information about the pmg-devel mailing list