From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 5052881FD4 for ; Fri, 26 Nov 2021 14:56:29 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 46A13194E9 for ; Fri, 26 Nov 2021 14:55:59 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id ACDA419254 for ; Fri, 26 Nov 2021 14:55:45 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 876A944C64 for ; Fri, 26 Nov 2021 14:55:45 +0100 (CET) From: Wolfgang Bumiller To: pmg-devel@lists.proxmox.com Date: Fri, 26 Nov 2021 14:55:15 +0100 Message-Id: <20211126135524.117846-12-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20211126135524.117846-1-w.bumiller@proxmox.com> References: <20211126135524.117846-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -1.387 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_SHORT 0.001 Use of a URL Shortener for very short URL SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [repositories.rs, lib.rs, proxmox.com, default.mk, repositories.pm, mod.rs, csr.pm, test.pl, acme.pm, csr.rs, gnu.org, acme.rs, letsencrypt.org] URI_PHISH 3.625 Phishing using web form Subject: [pmg-devel] [PATCH perl-rs 4/7] import pmg-rs X-BeenThere: pmg-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Mail Gateway development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 26 Nov 2021 13:56:29 -0000 Signed-off-by: Wolfgang Bumiller --- 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 ", + "Wolfgang Bumiller ", + "Fabian Ebner ", +] +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@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 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 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 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 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 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 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 Wed, 17 Mar 2021 13:43:12 +0100 + +libpmg-rs-perl (0.1-1) unstable; urgency=medium + + * initial release + + -- Proxmox Support Team 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 +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 + +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 . 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 " + +[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, + + #[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, + tos: Option, + debug: bool, +} + +impl Inner { + pub fn new(api_directory: String) -> Result { + Ok(Self { + client: Client::new(api_directory), + account_path: None, + tos: None, + debug: false, + }) + } + + pub fn load(account_path: String) -> Result { + 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, + rsa_bits: Option, + ) -> 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 { + 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(&mut self, out: T) -> Result<(), Error> { + let data = self.to_account_data()?; + + Ok(serde_json::to_writer_pretty(out, &data)?) + } + + pub fn update_account(&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) -> 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, + } + + 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) -> Result { + let value = Value::new_pointer::(&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 { + 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 { + 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, + rsa_bits: Option, + ) -> 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, 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 { + Ok(this.inner.lock().unwrap().client.directory()?.url.clone()) + } + + /// Serialize the account data. + #[export] + pub fn account(#[try_from_ref] this: &Acme) -> Result { + this.inner.lock().unwrap().to_account_data() + } + + /// Get the account's location URL. + #[export] + pub fn location(#[try_from_ref] this: &Acme) -> Result { + 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 { + 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, + ) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ) -> 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, + ) -> 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, + + /// Errors for files that could not be parsed or read. + pub errors: Vec, + + /// Common digest for successfully parsed files. + pub digest: String, + + /// Additional information/warnings about repositories. + pub infos: Vec, + + /// Standard repositories and their configuration status. + pub standard_repos: Vec, + } + + #[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, + } + + /// Get information about configured and standard repositories. + #[export] + pub fn repositories() -> Result { + 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, + ) -> 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@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