From 1c517ecbabfc04fbd40d9d43e7deb819105181f7 Mon Sep 17 00:00:00 2001 From: Roman Kivalin Date: Sun, 12 Apr 2026 20:47:00 +0200 Subject: [PATCH] Add packaging, CI, and license - MIT license - Arch Linux PKGBUILD - Debian packaging (debian/) - GitHub Actions: CI (test + clippy) and release workflow builds x86_64 + aarch64 binaries, Arch and Debian packages - Default config is now no-op (certificate blocks commented out) - Fix clippy warnings (collapsible ifs, redundant closures) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 17 +++ .github/workflows/release.yaml | 190 +++++++++++++++++++++++++++++++++ LICENSE | 21 ++++ PKGBUILD | 47 ++++++++ debian/changelog | 5 + debian/conffiles | 1 + debian/control | 16 +++ debian/copyright | 5 + debian/rules | 23 ++++ debian/source/format | 1 + examples/config.toml | 113 +++++++++----------- src/acme.rs | 4 +- src/config.rs | 2 +- src/dane.rs | 2 +- src/dns/tsig.rs | 5 +- src/main.rs | 15 ++- src/renew.rs | 10 +- 17 files changed, 394 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 LICENSE create mode 100644 PKGBUILD create mode 100644 debian/changelog create mode 100644 debian/conffiles create mode 100644 debian/control create mode 100644 debian/copyright create mode 100755 debian/rules create mode 100644 debian/source/format diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..d352797 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,17 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: cargo test --locked + - run: cargo clippy --locked -- -D warnings diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..ab040c3 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,190 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + arch: amd64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + arch: arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup target add "${{ matrix.target }}" + + - name: Install cross-compilation tools + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: cargo build --release --locked --target "${{ matrix.target }}" + + - name: Run tests + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: cargo test --locked + + - name: Package binary + run: | + cd "target/${{ matrix.target }}/release" + tar czf "../../../certforge-${{ matrix.arch }}.tar.gz" certforge + cd ../../.. + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: certforge-${{ matrix.arch }} + path: certforge-${{ matrix.arch }}.tar.gz + + package-archlinux: + needs: build + runs-on: ubuntu-latest + container: archlinux:base-devel + strategy: + matrix: + include: + - arch: x86_64 + artifact: amd64 + steps: + - uses: actions/checkout@v6 + + - name: Install dependencies + run: | + pacman -Syu --noconfirm rust + + - name: Build package + run: | + # makepkg can't run as root, create a build user + useradd -m builder + chown -R builder:builder . + # Adjust PKGBUILD source to use local files + cd /tmp + VERSION=${GITHUB_REF_NAME#v} + mkdir -p "certforge-$VERSION" + cp -a "$GITHUB_WORKSPACE"/* "certforge-$VERSION/" + tar czf "certforge-$VERSION.tar.gz" "certforge-$VERSION" + cp "$GITHUB_WORKSPACE/PKGBUILD" . + sed -i "s|pkgver=.*|pkgver=$VERSION|" PKGBUILD + sed -i "s|source=.*|source=(\"certforge-$VERSION.tar.gz\")|" PKGBUILD + chown -R builder:builder /tmp/certforge-* /tmp/PKGBUILD + su builder -c 'makepkg -s --noconfirm --skipinteg' + cp /tmp/*.pkg.tar.zst "$GITHUB_WORKSPACE/" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: archlinux-package + path: "*.pkg.tar.zst" + + package-debian: + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + include: + - arch: amd64 + target: x86_64-unknown-linux-gnu + - arch: arm64 + target: aarch64-unknown-linux-gnu + steps: + - uses: actions/checkout@v6 + + - name: Download binary + uses: actions/download-artifact@v8 + with: + name: certforge-${{ matrix.arch }} + + - name: Build .deb + run: | + VERSION=${GITHUB_REF_NAME#v} + PKG=certforge_${VERSION}-1_${{ matrix.arch }} + mkdir -p "$PKG/DEBIAN" + mkdir -p "$PKG/usr/bin" + mkdir -p "$PKG/usr/lib/systemd/system" + mkdir -p "$PKG/etc/certforge/certs" + + tar xzf "certforge-${{ matrix.arch }}.tar.gz" + install -Dm755 certforge "$PKG/usr/bin/certforge" + install -Dm644 examples/certforge.service "$PKG/usr/lib/systemd/system/certforge.service" + install -Dm644 examples/certforge.timer "$PKG/usr/lib/systemd/system/certforge.timer" + install -Dm644 examples/config.toml "$PKG/etc/certforge/config.toml" + + cat >"$PKG/DEBIAN/control" < + Depends: systemd + Section: admin + Priority: optional + Description: ACME certificate manager with DANE and systemd integration + Certforge automates ACME certificate issuance and renewal using DNS-01 + challenges via RFC 2136 dynamic DNS updates with DANE TLSA support. + CTRL + + echo "/etc/certforge/config.toml" >"$PKG/DEBIAN/conffiles" + + dpkg-deb --build --root-owner-group "$PKG" + mv "${PKG}.deb" "certforge_${VERSION}-1_${{ matrix.arch }}.deb" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: debian-${{ matrix.arch }} + path: "*.deb" + + release: + needs: [build, package-archlinux, package-debian] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=${GITHUB_REF_NAME#v} + # Collect all release files + mkdir release + cp artifacts/certforge-amd64/certforge-amd64.tar.gz release/ + cp artifacts/certforge-arm64/certforge-arm64.tar.gz release/ + cp artifacts/archlinux-package/*.pkg.tar.zst release/ || true + cp artifacts/debian-amd64/*.deb release/ || true + cp artifacts/debian-arm64/*.deb release/ || true + + # Generate checksums + cd release + sha256sum * >SHA256SUMS + cd .. + + gh release create "${GITHUB_REF_NAME}" \ + --title "certforge ${VERSION}" \ + --generate-notes \ + release/* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4241a50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Roman Kivalin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..2e9156f --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,47 @@ +# Maintainer: Roman Kivalin +pkgname=certforge +pkgver=0.1.0 +pkgrel=1 +pkgdesc='ACME certificate manager with DANE and systemd integration' +arch=('x86_64' 'aarch64') +url='https://github.com/rkivalin/certforge' +license=('MIT') +depends=('systemd-libs') +makedepends=('cargo') +backup=('etc/certforge/config.toml') +source=("$pkgname-$pkgver.tar.gz") +sha256sums=('SKIP') + +prepare() { + cd "$pkgname-$pkgver" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$( rustc -vV | sed -n 's/host: //p' )" +} + +build() { + cd "$pkgname-$pkgver" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --frozen --release +} + +check() { + cd "$pkgname-$pkgver" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo test --frozen +} + +package() { + cd "$pkgname-$pkgver" + + install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname" + + install -Dm644 examples/certforge.service "$pkgdir/usr/lib/systemd/system/certforge.service" + install -Dm644 examples/certforge.timer "$pkgdir/usr/lib/systemd/system/certforge.timer" + + install -Dm644 examples/config.toml "$pkgdir/etc/certforge/config.toml" + + install -dm750 "$pkgdir/etc/certforge/certs" + install -dm700 "$pkgdir/var/lib/certforge" +} diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..d33c52a --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +certforge (0.1.0-1) unstable; urgency=low + + * Initial release. + + -- Roman Kivalin Sat, 12 Apr 2026 18:00:00 +0000 diff --git a/debian/conffiles b/debian/conffiles new file mode 100644 index 0000000..ee4d415 --- /dev/null +++ b/debian/conffiles @@ -0,0 +1 @@ +/etc/certforge/config.toml diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..b4eaf89 --- /dev/null +++ b/debian/control @@ -0,0 +1,16 @@ +Source: certforge +Section: admin +Priority: optional +Maintainer: Roman Kivalin +Build-Depends: debhelper-compat (= 13), cargo, rustc +Standards-Version: 4.6.2 +Rules-Requires-Root: no + +Package: certforge +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends}, systemd +Description: ACME certificate manager with DANE and systemd integration + Certforge automates ACME certificate issuance and renewal using DNS-01 + challenges via RFC 2136 dynamic DNS updates. It publishes DANE TLSA + records, integrates with systemd credentials for secret storage, and + supports post-renewal hooks to reload/restart services. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..44e5c2f --- /dev/null +++ b/debian/copyright @@ -0,0 +1,5 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: * +Copyright: 2026 Roman Kivalin +License: MIT diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..f17e2f6 --- /dev/null +++ b/debian/rules @@ -0,0 +1,23 @@ +#!/usr/bin/make -f +export CARGO_HOME = $(CURDIR)/debian/cargo_home +export RUSTUP_TOOLCHAIN = stable + +%: + dh $@ + +override_dh_auto_build: + cargo build --release --frozen + +override_dh_auto_test: + cargo test --release --frozen + +override_dh_auto_install: + install -Dm755 target/release/certforge debian/certforge/usr/bin/certforge + install -Dm644 examples/certforge.service debian/certforge/usr/lib/systemd/system/certforge.service + install -Dm644 examples/certforge.timer debian/certforge/usr/lib/systemd/system/certforge.timer + install -Dm644 examples/config.toml debian/certforge/etc/certforge/config.toml + install -dm750 debian/certforge/etc/certforge/certs + install -dm700 debian/certforge/var/lib/certforge + +override_dh_auto_clean: + cargo clean || true diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/examples/config.toml b/examples/config.toml index 5639cb8..29f0551 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -1,6 +1,8 @@ -# Example certforge configuration +# Certforge configuration # -# Full documentation: see the project README +# This is a reference configuration. Uncomment and adjust the sections +# below to match your environment. No certificates will be managed +# until at least one [[certificate]] block is uncommented. [acme] directory_url = "https://acme-v02.api.letsencrypt.org/directory" @@ -8,18 +10,18 @@ directory_url = "https://acme-v02.api.letsencrypt.org/directory" # directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory" # ACME account key: stored as encrypted systemd credential -account_key_credential = "/etc/credstore.encrypted/acme-account-key" +# account_key_credential = "/etc/credstore.encrypted/acme-account-key" # Or as a plain file: -# account_key_path = "/etc/certforge/account.key" +account_key_path = "/etc/certforge/account.key" contact = ["mailto:admin@example.com"] # Default DNS server for RFC 2136 updates [dns.defaults] server = "ns1.example.com" -tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-key" -# tsig_key_path = "/etc/certforge/tsig.key" -tsig_key_name = "certforge-update." +# tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-key" +tsig_key_path = "/etc/certforge/tsig.key" +tsig_key_name = "certforge." tsig_algorithm = "hmac-sha256" port = 53 protocol = "tcp" @@ -29,57 +31,46 @@ protocol = "tcp" # server = "ns2.example.org" # tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-org" -# Mail server certificate -[[certificate]] -name = "mail" -domains = ["mail.example.com"] -key_type = "ecdsa-p256" -key_credential = "/etc/credstore.encrypted/mail-tls-key" -cert_path = "/etc/certforge/certs/mail.pem" -renew_before_days = 30 -rotate_key = false - - # DANE TLSA for SMTP (port 25) - [[certificate.dane]] - usage = "ee" - selector = "spki" - matching = "sha256" - names = ["_25._tcp.mail.example.com"] - ttl = 300 - pre_publish = true - - # DANE TLSA for IMAP (port 993) - [[certificate.dane]] - usage = "ee" - selector = "spki" - matching = "sha256" - names = ["_993._tcp.mail.example.com"] - ttl = 300 - - [[certificate.hook]] - type = "systemd-reload" - unit = "postfix.service" - - [[certificate.hook]] - type = "systemd-restart" - unit = "dovecot.service" - -# Web server certificate -[[certificate]] -name = "web" -domains = ["example.com", "www.example.com"] -key_type = "ecdsa-p256" -key_credential = "/etc/credstore.encrypted/web-tls-key" -cert_path = "/etc/certforge/certs/web.pem" -renew_before_days = 30 - - [[certificate.dane]] - usage = "ee" - selector = "spki" - matching = "sha256" - names = ["_443._tcp.example.com", "_443._tcp.www.example.com"] - ttl = 3600 - - [[certificate.hook]] - type = "systemd-reload" - unit = "nginx.service" +# Uncomment and configure certificate blocks below. +# +# [[certificate]] +# name = "mail" +# domains = ["mail.example.com"] +# key_type = "ecdsa-p256" +# key_credential = "/etc/credstore.encrypted/mail-tls-key" +# # key_path = "/etc/certforge/keys/mail.key" +# cert_path = "/etc/certforge/certs/mail.pem" +# renew_before_days = 30 +# rotate_key = false +# +# # DANE TLSA for SMTP (port 25) +# [[certificate.dane]] +# usage = "ee" +# selector = "spki" +# matching = "sha256" +# names = ["_25._tcp.mail.example.com"] +# ttl = 300 +# pre_publish = true +# +# [[certificate.hook]] +# type = "systemd-reload" +# unit = "postfix.service" +# +# [[certificate]] +# name = "web" +# domains = ["example.com", "www.example.com"] +# key_type = "ecdsa-p256" +# key_credential = "/etc/credstore.encrypted/web-tls-key" +# cert_path = "/etc/certforge/certs/web.pem" +# renew_before_days = 30 +# +# [[certificate.dane]] +# usage = "ee" +# selector = "spki" +# matching = "sha256" +# names = ["_443._tcp.example.com", "_443._tcp.www.example.com"] +# ttl = 3600 +# +# [[certificate.hook]] +# type = "systemd-reload" +# unit = "nginx.service" diff --git a/src/acme.rs b/src/acme.rs index 71e1a04..0cc4e56 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -39,7 +39,7 @@ impl AcmeClient { Error::Config(format!("invalid account credentials: {e}")) })?; let account = Account::builder() - .map_err(|e| Error::Acme(e))? + .map_err(Error::Acme)? .from_credentials(creds) .await?; tracing::info!(id = %account.id(), "loaded existing ACME account"); @@ -48,7 +48,7 @@ impl AcmeClient { Err(_) => { tracing::info!(directory = %config.directory_url, "creating new ACME account"); let (account, creds) = Account::builder() - .map_err(|e| Error::Acme(e))? + .map_err(Error::Acme)? .create( &NewAccount { contact: &config diff --git a/src/config.rs b/src/config.rs index cea6756..93bd622 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,7 @@ use crate::error::{Error, Result}; pub struct Config { pub acme: AcmeConfig, pub dns: DnsConfig, - #[serde(rename = "certificate")] + #[serde(rename = "certificate", default)] pub certificates: Vec, } diff --git a/src/dane.rs b/src/dane.rs index 3679033..d0ecb26 100644 --- a/src/dane.rs +++ b/src/dane.rs @@ -66,7 +66,7 @@ pub async fn publish_tlsa( let zone = find_zone(&name)?; updater - .replace_tlsa_records(&zone, &name, &[record.clone()], dane.ttl) + .replace_tlsa_records(&zone, &name, std::slice::from_ref(record), dane.ttl) .await?; } diff --git a/src/dns/tsig.rs b/src/dns/tsig.rs index 67b85cf..b551fe8 100644 --- a/src/dns/tsig.rs +++ b/src/dns/tsig.rs @@ -47,11 +47,10 @@ fn decode_tsig_key(raw: &[u8]) -> Result> { let trimmed: Vec = raw.iter().copied().filter(|b| !b.is_ascii_whitespace()).collect(); // Try standard base64 first, then URL-safe - if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&trimmed) { - if !decoded.is_empty() { + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&trimmed) + && !decoded.is_empty() { return Ok(decoded); } - } // If it doesn't decode as base64, assume it's already raw binary Ok(raw.to_vec()) diff --git a/src/main.rs b/src/main.rs index b91f1a0..6bf980f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,11 +103,10 @@ async fn main() -> anyhow::Result<()> { fn print_status(config: &config::Config, name_filter: Option<&str>) { for cert_config in &config.certificates { - if let Some(filter) = name_filter { - if cert_config.name != filter { + if let Some(filter) = name_filter + && cert_config.name != filter { continue; } - } println!("Certificate: {}", cert_config.name); println!(" Domains: {}", cert_config.domains.join(", ")); @@ -149,11 +148,10 @@ async fn dane_publish( dry_run: bool, ) -> anyhow::Result<()> { for cert_config in &config.certificates { - if let Some(filter) = name_filter { - if cert_config.name != filter { + if let Some(filter) = name_filter + && cert_config.name != filter { continue; } - } if cert_config.dane.is_empty() { continue; @@ -196,11 +194,10 @@ async fn dane_check( name_filter: Option<&str>, ) -> anyhow::Result<()> { for cert_config in &config.certificates { - if let Some(filter) = name_filter { - if cert_config.name != filter { + if let Some(filter) = name_filter + && cert_config.name != filter { continue; } - } if cert_config.dane.is_empty() { continue; diff --git a/src/renew.rs b/src/renew.rs index 2ed2d24..e9d9e17 100644 --- a/src/renew.rs +++ b/src/renew.rs @@ -28,11 +28,10 @@ pub async fn run( let acme = AcmeClient::new(&config.acme).await?; for cert_config in &config.certificates { - if let Some(filter) = name_filter { - if cert_config.name != filter { + if let Some(filter) = name_filter + && cert_config.name != filter { continue; } - } match renew_certificate(config, &acme, cert_config, &mut state, state_dir, force, dry_run) .await @@ -83,8 +82,8 @@ async fn renew_certificate( } // Check if existing certificate needs renewal - if !force { - if let Ok(cert) = CertInfo::load(&cert_config.cert_path) { + if !force + && let Ok(cert) = CertInfo::load(&cert_config.cert_path) { let days = cert.days_until_expiry(); if !cert.expires_within_days(cert_config.renew_before_days) { tracing::info!( @@ -101,7 +100,6 @@ async fn renew_certificate( ); } // If cert doesn't exist or can't be parsed, proceed with issuance - } // Determine key pair let key = if let Some(pending) = state.pending_rotations.remove(&cert_config.name) {