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) <noreply@anthropic.com>
This commit is contained in:
Roman Kivalin
2026-04-12 20:47:00 +02:00
co-authored by Claude Opus 4.6
parent 32697369cf
commit 1c517ecbab
17 changed files with 394 additions and 83 deletions
+17
View File
@@ -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
+190
View File
@@ -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" <<CTRL
Package: certforge
Version: ${VERSION}-1
Architecture: ${{ matrix.arch }}
Maintainer: Roman Kivalin <roman@shl.dev>
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/*
+21
View File
@@ -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.
+47
View File
@@ -0,0 +1,47 @@
# Maintainer: Roman Kivalin <roman@shl.dev>
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"
}
+5
View File
@@ -0,0 +1,5 @@
certforge (0.1.0-1) unstable; urgency=low
* Initial release.
-- Roman Kivalin <roman@shl.dev> Sat, 12 Apr 2026 18:00:00 +0000
+1
View File
@@ -0,0 +1 @@
/etc/certforge/config.toml
+16
View File
@@ -0,0 +1,16 @@
Source: certforge
Section: admin
Priority: optional
Maintainer: Roman Kivalin <roman@shl.dev>
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.
+5
View File
@@ -0,0 +1,5 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Files: *
Copyright: 2026 Roman Kivalin
License: MIT
Vendored Executable
+23
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.0 (quilt)
+52 -61
View File
@@ -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"
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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<CertificateConfig>,
}
+1 -1
View File
@@ -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?;
}
+2 -3
View File
@@ -47,11 +47,10 @@ fn decode_tsig_key(raw: &[u8]) -> Result<Vec<u8>> {
let trimmed: Vec<u8> = 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())
+6 -9
View File
@@ -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;
+4 -6
View File
@@ -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) {