Compare commits
15 Commits
v0.1.9
...
feat/20-cr
| Author | SHA1 | Date | |
|---|---|---|---|
| be085bbf35 | |||
| cfdb874062 | |||
| fe9bdce3c1 | |||
| 734b55b292 | |||
| c629c5b710 | |||
| 5349cbbd05 | |||
| 80f8f4fed2 | |||
| a3b299b116 | |||
| 2d33973b5f | |||
| 6ddb511cb0 | |||
| cc21868b6c | |||
| 32803ff27c | |||
| 0bca0c7784 | |||
| 2ac40076f5 | |||
| 4375f915ca |
209
.github/workflows/ci.yml
vendored
209
.github/workflows/ci.yml
vendored
@ -9,10 +9,17 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ── Quality Gates (GitHub-hosted, all triggers) ──────────────────────────
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
name: Rust Format
|
name: fmt
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@ -68,24 +75,18 @@ jobs:
|
|||||||
- run: cargo test --test enrollment_test
|
- run: cargo test --test enrollment_test
|
||||||
- run: cargo test --test enrollment_e2e
|
- run: cargo test --test enrollment_e2e
|
||||||
|
|
||||||
build-deb:
|
# ── Release Preparation (tag push only) ───────────────────────────────────
|
||||||
name: Build & Release
|
|
||||||
needs: [fmt, clippy, test, enrollment-tests]
|
prepare-release:
|
||||||
|
name: Prepare Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev dpkg-dev
|
|
||||||
- name: Build .deb package
|
|
||||||
run: |
|
|
||||||
. "$HOME/.cargo/env"
|
|
||||||
sudo env "PATH=$PATH" dpkg-buildpackage -us -uc -b -d
|
|
||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
id: release_notes
|
id: release_notes
|
||||||
run: |
|
run: |
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||||
@ -97,9 +98,185 @@ jobs:
|
|||||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
- name: Upload to GitHub Release
|
- name: Create GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
body: ${{ steps.release_notes.outputs.notes }}
|
body: ${{ steps.release_notes.outputs.notes }}
|
||||||
files: ../linux-patch-api_*.deb
|
|
||||||
|
# ── Build Jobs (tag push only, self-hosted runners) ───────────────────────
|
||||||
|
|
||||||
|
build-deb-u2404:
|
||||||
|
name: Build .deb (Ubuntu 24.04)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit, prepare-release]
|
||||||
|
runs-on: [self-hosted, linux, ubuntu-24.04]
|
||||||
|
steps:
|
||||||
|
- name: Clean previous build artifacts from root
|
||||||
|
run: sudo rm -rf releases/ || true
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||||
|
- name: Add Rust to PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Build .deb package
|
||||||
|
run: chmod +x scripts/build-package.sh && scripts/build-package.sh
|
||||||
|
- name: Rename package with distro suffix
|
||||||
|
run: |
|
||||||
|
FILE=$(ls linux-patch-api_*_amd64.deb 2>/dev/null | head -1)
|
||||||
|
if [ -n "$FILE" ]; then
|
||||||
|
mv "$FILE" "$(echo "$FILE" | sed 's/_amd64/_u2404_amd64/')"
|
||||||
|
fi
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: linux-patch-api_*_u2404_amd64.deb
|
||||||
|
|
||||||
|
build-deb-u2204:
|
||||||
|
name: Build .deb (Ubuntu 22.04)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit, prepare-release]
|
||||||
|
runs-on: [self-hosted, linux, ubuntu-22.04]
|
||||||
|
steps:
|
||||||
|
- name: Clean previous build artifacts from root
|
||||||
|
run: sudo rm -rf releases/ || true
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||||
|
- name: Add Rust to PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Build .deb package
|
||||||
|
run: chmod +x scripts/build-package.sh && scripts/build-package.sh
|
||||||
|
- name: Rename package with distro suffix
|
||||||
|
run: |
|
||||||
|
FILE=$(ls linux-patch-api_*_amd64.deb 2>/dev/null | head -1)
|
||||||
|
if [ -n "$FILE" ]; then
|
||||||
|
mv "$FILE" "$(echo "$FILE" | sed 's/_amd64/_u2204_amd64/')"
|
||||||
|
fi
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: linux-patch-api_*_u2204_amd64.deb
|
||||||
|
|
||||||
|
build-deb-debian13:
|
||||||
|
name: Build .deb (Debian 13)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit, prepare-release]
|
||||||
|
runs-on: [self-hosted, linux, debian-13]
|
||||||
|
steps:
|
||||||
|
- name: Clean previous build artifacts from root
|
||||||
|
run: sudo rm -rf releases/ || true
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||||
|
- name: Add Rust to PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Build .deb package
|
||||||
|
run: chmod +x scripts/build-package.sh && scripts/build-package.sh
|
||||||
|
- name: Rename package with distro suffix
|
||||||
|
run: |
|
||||||
|
FILE=$(ls linux-patch-api_*_amd64.deb 2>/dev/null | head -1)
|
||||||
|
if [ -n "$FILE" ]; then
|
||||||
|
mv "$FILE" "$(echo "$FILE" | sed 's/_amd64/_debian13_amd64/')"
|
||||||
|
fi
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: linux-patch-api_*_debian13_amd64.deb
|
||||||
|
|
||||||
|
build-rpm-fedora:
|
||||||
|
name: Build .rpm (Fedora)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit, prepare-release]
|
||||||
|
runs-on: [self-hosted, linux, fedora]
|
||||||
|
steps:
|
||||||
|
- name: Clean previous build artifacts from root
|
||||||
|
run: sudo rm -rf releases/ || true
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo dnf install -y systemd-devel openssl-devel pkg-config gcc make
|
||||||
|
- name: Add Rust to PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Build release binary
|
||||||
|
run: cargo build --release
|
||||||
|
- name: Build RPM package
|
||||||
|
run: chmod +x build-rpm.sh && SKIP_CARGO_BUILD=1 sudo -E ./build-rpm.sh
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: releases/linux-patch-api-*.rpm
|
||||||
|
|
||||||
|
build-rpm-almalinux:
|
||||||
|
name: Build .rpm (AlmaLinux 10)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit, prepare-release]
|
||||||
|
runs-on: [self-hosted, linux, almalinux-10]
|
||||||
|
steps:
|
||||||
|
- name: Clean previous build artifacts from root
|
||||||
|
run: sudo rm -rf releases/ || true
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo dnf install -y systemd-devel openssl-devel pkg-config gcc make
|
||||||
|
- name: Add Rust to PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Build release binary
|
||||||
|
run: cargo build --release
|
||||||
|
- name: Build RPM package
|
||||||
|
run: chmod +x build-rpm.sh && SKIP_CARGO_BUILD=1 sudo -E ./build-rpm.sh
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: releases/linux-patch-api-*.rpm
|
||||||
|
|
||||||
|
build-arch:
|
||||||
|
name: Build .pkg.tar.zst (Arch Linux)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit, prepare-release]
|
||||||
|
runs-on: [self-hosted, linux, arch]
|
||||||
|
steps:
|
||||||
|
- name: Clean previous build artifacts from root
|
||||||
|
run: sudo rm -rf releases/ || true
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo pacman -Syu --noconfirm systemd openssl pkg-config gcc
|
||||||
|
- name: Add Rust to PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Build release binary
|
||||||
|
run: cargo build --release
|
||||||
|
- name: Build Arch package
|
||||||
|
run: chmod +x build-arch.sh && SKIP_CARGO_BUILD=1 ./build-arch.sh
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: releases/*.pkg.tar.zst
|
||||||
|
|
||||||
|
build-alpine:
|
||||||
|
name: Build .apk (Alpine)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [fmt, clippy, test, enrollment-tests, audit, prepare-release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
env:
|
||||||
|
HOME: /root
|
||||||
|
steps:
|
||||||
|
- name: Install prerequisites for actions/checkout
|
||||||
|
run: apk add --no-cache bash git curl tar
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Alpine build dependencies
|
||||||
|
run: apk add --no-cache gcc musl-dev openssl-dev openssl elogind-dev alpine-sdk abuild
|
||||||
|
- name: Install Rust via rustup
|
||||||
|
run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
- name: Add Rust to PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Add musl target
|
||||||
|
run: rustup target add x86_64-unknown-linux-musl
|
||||||
|
- name: Build release binary (musl target)
|
||||||
|
run: cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
- name: Build Alpine package
|
||||||
|
run: |
|
||||||
|
chmod +x build-alpine.sh
|
||||||
|
SKIP_CARGO_BUILD=1 ./build-alpine.sh
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: releases/linux-patch-api-*.apk
|
||||||
|
|||||||
12
Cargo.lock
generated
12
Cargo.lock
generated
@ -390,6 +390,15 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arc-swap"
|
||||||
|
version = "1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arraydeque"
|
name = "arraydeque"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@ -1925,7 +1934,9 @@ dependencies = [
|
|||||||
"actix-web-actors",
|
"actix-web-actors",
|
||||||
"addr",
|
"addr",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"arc-swap",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"config",
|
"config",
|
||||||
@ -4271,6 +4282,7 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
"nom",
|
"nom",
|
||||||
"oid-registry",
|
"oid-registry",
|
||||||
|
"ring",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
|
|||||||
@ -23,7 +23,7 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
rustls = { version = "0.23", features = ["aws_lc_rs"] }
|
rustls = { version = "0.23", features = ["aws_lc_rs"] }
|
||||||
rustls-pemfile = "2"
|
rustls-pemfile = "2"
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = "0.26"
|
||||||
x509-parser = "0.16"
|
x509-parser = { version = "0.16", features = ["verify"] }
|
||||||
|
|
||||||
# WebSocket support (actix-web-actors provides WebSocket for Actix-web)
|
# WebSocket support (actix-web-actors provides WebSocket for Actix-web)
|
||||||
tokio-tungstenite = "0.21"
|
tokio-tungstenite = "0.21"
|
||||||
@ -83,6 +83,12 @@ socket2 = { version = "0.5", features = ["all"] }
|
|||||||
# File locking for concurrent-safe whitelist modifications
|
# File locking for concurrent-safe whitelist modifications
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
|
|
||||||
|
# Atomic swapping for CRL state updates without rebuilding ServerConfig
|
||||||
|
arc-swap = "1"
|
||||||
|
|
||||||
|
# Base64 decoding for PEM CRL parsing
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
actix-rt = "2"
|
actix-rt = "2"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Version:** 1.0.0
|
**Version:** 1.0.0
|
||||||
**Status:** Production Ready
|
**Status:** Production Ready
|
||||||
**License:** Internal Use Only
|
**License:** [Apache 2.0](LICENSE)
|
||||||
|
|
||||||
Secure REST API for remote package and patch management on Linux systems.
|
Secure REST API for remote package and patch management on Linux systems.
|
||||||
|
|
||||||
@ -691,7 +691,9 @@ linux-patch-api --check-config
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Internal Use Only - Not for external distribution
|
This project is licensed under the [Apache License 2.0](LICENSE).
|
||||||
|
|
||||||
|
Copyright 2025-2026 Draco Lunaris
|
||||||
|
|
||||||
**Version:** 1.0.0
|
**Version:** 1.0.0
|
||||||
**Release Date:** 2026-07-17
|
**Release Date:** 2026-07-17
|
||||||
|
|||||||
@ -22,10 +22,22 @@ fi
|
|||||||
# Generate abuild signing keys
|
# Generate abuild signing keys
|
||||||
echo "Generating abuild signing keys..."
|
echo "Generating abuild signing keys..."
|
||||||
apk add --no-cache abuild
|
apk add --no-cache abuild
|
||||||
|
|
||||||
|
# Force HOME to /root for consistent key generation location
|
||||||
|
export HOME=/root
|
||||||
|
mkdir -p "$HOME/.abuild"
|
||||||
abuild-keygen -a -n 2>&1 | tee /tmp/keygen.log
|
abuild-keygen -a -n 2>&1 | tee /tmp/keygen.log
|
||||||
KEYFILE=$(ls /root/.abuild/*.rsa 2>/dev/null | head -1)
|
|
||||||
|
# Find the generated key using find (ls fails on dash-prefixed filenames)
|
||||||
|
KEYFILE=$(find "$HOME/.abuild" -name "*.rsa" ! -name "*.pub" -type f 2>/dev/null | head -1)
|
||||||
if [ -z "$KEYFILE" ]; then
|
if [ -z "$KEYFILE" ]; then
|
||||||
KEYFILE=$(ls /root/.abuild/-*.rsa 2>/dev/null | head -1)
|
# Fallback: check other common locations where keys might end up
|
||||||
|
KEYFILE=$(find /github/home/.abuild -name "*.rsa" ! -name "*.pub" -type f 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$KEYFILE" ]; then
|
||||||
|
echo "ERROR: No abuild signing key found!"
|
||||||
|
echo "Searched: $HOME/.abuild, /github/home/.abuild"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Found key: $KEYFILE"
|
echo "Found key: $KEYFILE"
|
||||||
echo "PACKAGER_PRIVKEY=\"$KEYFILE\"" > /etc/abuild.conf
|
echo "PACKAGER_PRIVKEY=\"$KEYFILE\"" > /etc/abuild.conf
|
||||||
@ -117,6 +129,10 @@ EOF
|
|||||||
# Build APK package
|
# Build APK package
|
||||||
echo "Building APK package..."
|
echo "Building APK package..."
|
||||||
|
|
||||||
|
# Determine the directory where abuild keys were generated
|
||||||
|
KEY_DIR=$(dirname "$KEYFILE" 2>/dev/null || echo "$HOME/.abuild")
|
||||||
|
echo "Key directory: $KEY_DIR"
|
||||||
|
|
||||||
# For CI environments where we may run as root or as a build user
|
# For CI environments where we may run as root or as a build user
|
||||||
if [ "$(id -u)" = "0" ]; then
|
if [ "$(id -u)" = "0" ]; then
|
||||||
echo "Running as root - creating build user for abuild..."
|
echo "Running as root - creating build user for abuild..."
|
||||||
@ -127,17 +143,18 @@ if [ "$(id -u)" = "0" ]; then
|
|||||||
chown -R builduser:builduser "$WORKSPACE_DIR"
|
chown -R builduser:builduser "$WORKSPACE_DIR"
|
||||||
|
|
||||||
# Set up builduser home directory for abuild
|
# Set up builduser home directory for abuild
|
||||||
|
# Copy keys from wherever abuild-keygen put them (KEY_DIR)
|
||||||
mkdir -p /home/builduser/.abuild
|
mkdir -p /home/builduser/.abuild
|
||||||
cp /root/.abuild/* /home/builduser/.abuild/ 2>/dev/null || true
|
cp "$KEY_DIR"/* /home/builduser/.abuild/ 2>/dev/null || true
|
||||||
chown -R builduser:builduser /home/builduser/.abuild
|
chown -R builduser:builduser /home/builduser/.abuild
|
||||||
|
|
||||||
KEYFILE=$(ls /home/builduser/.abuild/*.rsa 2>/dev/null | head -1)
|
BUILDUSER_KEYFILE=$(ls /home/builduser/.abuild/*.rsa 2>/dev/null | head -1)
|
||||||
if [ -z "$KEYFILE" ]; then
|
if [ -z "$BUILDUSER_KEYFILE" ]; then
|
||||||
KEYFILE=$(ls /home/builduser/.abuild/-*.rsa 2>/dev/null | head -1)
|
BUILDUSER_KEYFILE=$(ls /home/builduser/.abuild/-*.rsa 2>/dev/null | head -1)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Key file: $KEYFILE"
|
echo "Builduser key file: $BUILDUSER_KEYFILE"
|
||||||
echo "PACKAGER_PRIVKEY=\"$KEYFILE\"" > /home/builduser/.abuild/abuild.conf
|
echo "PACKAGER_PRIVKEY=\"$BUILDUSER_KEYFILE\"" > /home/builduser/.abuild/abuild.conf
|
||||||
chown builduser:builduser /home/builduser/.abuild/abuild.conf
|
chown builduser:builduser /home/builduser/.abuild/abuild.conf
|
||||||
|
|
||||||
# Install public key BEFORE abuild (fixes UNTRUSTED signature)
|
# Install public key BEFORE abuild (fixes UNTRUSTED signature)
|
||||||
|
|||||||
2
debian/control
vendored
2
debian/control
vendored
@ -14,6 +14,8 @@ Vcs-Browser: https://gitea.moon-dragon.us/echo/linux_patch_api
|
|||||||
|
|
||||||
Package: linux-patch-api
|
Package: linux-patch-api
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
|
Version: 1.2.0-1
|
||||||
|
Installed-Size: 0
|
||||||
Depends: systemd,
|
Depends: systemd,
|
||||||
libsystemd0,
|
libsystemd0,
|
||||||
${shlibs:Depends},
|
${shlibs:Depends},
|
||||||
|
|||||||
2
debian/rules
vendored
Executable file → Normal file
2
debian/rules
vendored
Executable file → Normal file
@ -8,7 +8,7 @@ export DEB_CARGO_BUILD_FLAGS=--release
|
|||||||
dh $@
|
dh $@
|
||||||
|
|
||||||
override_dh_auto_build:
|
override_dh_auto_build:
|
||||||
. "$$HOME/.cargo/env" && cargo build --release --target x86_64-unknown-linux-gnu
|
cargo build --release --target x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
override_dh_auto_install:
|
override_dh_auto_install:
|
||||||
dh_auto_install
|
dh_auto_install
|
||||||
|
|||||||
147
scripts/build-package.sh
Normal file
147
scripts/build-package.sh
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Linux Patch API — Build .deb Package for Ubuntu 24.04
|
||||||
|
# =============================================================================
|
||||||
|
# Produces: linux-patch-api_<version>-1_amd64.deb
|
||||||
|
# Prerequisites:
|
||||||
|
# - Rust toolchain (cargo, rustc >= 1.75)
|
||||||
|
# - dpkg-deb
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
VERSION="1.2.0"
|
||||||
|
RELEASE="1"
|
||||||
|
PKG_NAME="linux-patch-api"
|
||||||
|
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"
|
||||||
|
BUILD_DIR="${PROJECT_ROOT}/package-build"
|
||||||
|
|
||||||
|
info "=== Linux Patch API — Package Build ==="
|
||||||
|
info "Version: ${VERSION}-${RELEASE}"
|
||||||
|
info "Target: Ubuntu 24.04 (noble) amd64"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Build Rust binary (release mode)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
info "Step 1/4: Building Rust binary (release mode)..."
|
||||||
|
cd "${PROJECT_ROOT}"
|
||||||
|
cargo build --release 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Verify binary exists
|
||||||
|
[[ -f "${PROJECT_ROOT}/target/release/linux-patch-api" ]] || error "linux-patch-api not found in target/release/"
|
||||||
|
info "Rust binary built successfully."
|
||||||
|
|
||||||
|
# Strip debug symbols for smaller package
|
||||||
|
strip "${PROJECT_ROOT}/target/release/linux-patch-api" 2>/dev/null || warn "strip failed (may already be stripped)"
|
||||||
|
info "Binary stripped."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Assemble package directory structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
info "Step 2/4: Assembling package structure..."
|
||||||
|
rm -rf "${BUILD_DIR}"
|
||||||
|
mkdir -p "${BUILD_DIR}/DEBIAN"
|
||||||
|
mkdir -p "${BUILD_DIR}/usr/bin"
|
||||||
|
mkdir -p "${BUILD_DIR}/etc/linux_patch_api"
|
||||||
|
mkdir -p "${BUILD_DIR}/etc/linux_patch_api/certs"
|
||||||
|
mkdir -p "${BUILD_DIR}/lib/systemd/system"
|
||||||
|
mkdir -p "${BUILD_DIR}/var/log/linux_patch_api"
|
||||||
|
mkdir -p "${BUILD_DIR}/var/lib/linux_patch_api"
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
cp "${PROJECT_ROOT}/target/release/linux-patch-api" "${BUILD_DIR}/usr/bin/linux-patch-api"
|
||||||
|
chmod 755 "${BUILD_DIR}/usr/bin/linux-patch-api"
|
||||||
|
|
||||||
|
# Systemd service
|
||||||
|
cp "${PROJECT_ROOT}/configs/linux-patch-api.service" "${BUILD_DIR}/lib/systemd/system/"
|
||||||
|
|
||||||
|
# Configuration files (live configs for admin editing)
|
||||||
|
cp "${PROJECT_ROOT}/configs/config.yaml.example" "${BUILD_DIR}/etc/linux_patch_api/config.yaml"
|
||||||
|
cp "${PROJECT_ROOT}/configs/whitelist.yaml.example" "${BUILD_DIR}/etc/linux_patch_api/whitelist.yaml"
|
||||||
|
|
||||||
|
# Example config files (referenced by postinst for first-run setup)
|
||||||
|
cp "${PROJECT_ROOT}/configs/config.yaml.example" "${BUILD_DIR}/etc/linux_patch_api/config.yaml.example"
|
||||||
|
cp "${PROJECT_ROOT}/configs/whitelist.yaml.example" "${BUILD_DIR}/etc/linux_patch_api/whitelist.yaml.example"
|
||||||
|
|
||||||
|
# Calculate installed size BEFORE generating control file
|
||||||
|
INSTALLED_SIZE=$(du -sk "${BUILD_DIR}" | cut -f1)
|
||||||
|
|
||||||
|
# Generate DEBIAN/control from scratch for dpkg-deb --build
|
||||||
|
# (debian/control uses dpkg-buildpackage substitution variables like
|
||||||
|
# ${shlibs:Depends} that dpkg-deb cannot resolve)
|
||||||
|
cat > "${BUILD_DIR}/DEBIAN/control" <<EOF
|
||||||
|
Package: linux-patch-api
|
||||||
|
Version: ${VERSION}-${RELEASE}
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: Echo <echo@moon-dragon.us>
|
||||||
|
Installed-Size: ${INSTALLED_SIZE}
|
||||||
|
Depends: systemd, libsystemd0
|
||||||
|
Section: admin
|
||||||
|
Priority: optional
|
||||||
|
Homepage: https://github.com/Draco-Lunaris/Linux-Patch-Api
|
||||||
|
Description: Secure remote package management API for Linux systems
|
||||||
|
Linux Patch API provides a secure, mTLS-authenticated REST API for
|
||||||
|
remote package management operations including package installation
|
||||||
|
and removal, security patch application, system health monitoring,
|
||||||
|
and job queue management with WebSocket status streaming.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Conffiles
|
||||||
|
cat > "${BUILD_DIR}/DEBIAN/conffiles" << 'EOF'
|
||||||
|
/etc/linux_patch_api/config.yaml
|
||||||
|
/etc/linux_patch_api/whitelist.yaml
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Maintainer scripts
|
||||||
|
cp "${PROJECT_ROOT}/debian/postinst" "${BUILD_DIR}/DEBIAN/postinst"
|
||||||
|
cp "${PROJECT_ROOT}/debian/prerm" "${BUILD_DIR}/DEBIAN/prerm"
|
||||||
|
cp "${PROJECT_ROOT}/debian/postrm" "${BUILD_DIR}/DEBIAN/postrm"
|
||||||
|
chmod 755 "${BUILD_DIR}/DEBIAN/postinst" "${BUILD_DIR}/DEBIAN/prerm" "${BUILD_DIR}/DEBIAN/postrm"
|
||||||
|
|
||||||
|
info "Package structure assembled (${INSTALLED_SIZE} KB)."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Build .deb package
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
info "Step 3/4: Building .deb package..."
|
||||||
|
dpkg-deb --build "${BUILD_DIR}" "${PROJECT_ROOT}/${DEB_NAME}"
|
||||||
|
info ".deb package created: ${DEB_NAME}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Verify and summarize
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
info "Step 4/4: Verifying package..."
|
||||||
|
dpkg-deb --info "${PROJECT_ROOT}/${DEB_NAME}"
|
||||||
|
echo
|
||||||
|
dpkg-deb --contents "${PROJECT_ROOT}/${DEB_NAME}" | head -20 || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
PKG_SIZE=$(du -h "${PROJECT_ROOT}/${DEB_NAME}" | cut -f1)
|
||||||
|
|
||||||
|
info "=== Package Build Complete ==="
|
||||||
|
info "Package: ${DEB_NAME}"
|
||||||
|
info "Size: ${PKG_SIZE}"
|
||||||
|
echo
|
||||||
|
echo -e "${CYAN}Installation instructions:${NC}"
|
||||||
|
echo " 1. Copy ${DEB_NAME} to the target Ubuntu 24.04 host"
|
||||||
|
echo " 2. Install: sudo dpkg -i ${DEB_NAME}"
|
||||||
|
echo " 3. Or with auto-deps: sudo apt install ./${DEB_NAME}"
|
||||||
|
echo " 4. Configure: /etc/linux_patch_api/config.yaml"
|
||||||
|
echo " 5. Start: systemctl enable --now linux-patch-api.service"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Cleanup build directory
|
||||||
|
rm -rf "${BUILD_DIR}"
|
||||||
|
info "Build directory cleaned up."
|
||||||
@ -12,6 +12,7 @@ use tracing::{error, info, warn};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::packages::ApiResponse;
|
use super::packages::ApiResponse;
|
||||||
|
use crate::auth::crl::{CrlStatus, SharedCrlState};
|
||||||
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
|
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
|
||||||
use crate::packages::PackageManagerBackend;
|
use crate::packages::PackageManagerBackend;
|
||||||
|
|
||||||
@ -47,6 +48,8 @@ pub struct HealthData {
|
|||||||
pub version: String,
|
pub version: String,
|
||||||
pub last_cache_update: Option<String>, // RFC3339 timestamp
|
pub last_cache_update: Option<String>, // RFC3339 timestamp
|
||||||
pub cache_status: String, // "fresh", "stale", "unknown", "failed"
|
pub cache_status: String, // "fresh", "stale", "unknown", "failed"
|
||||||
|
pub crl_status: Option<String>, // "valid", "expired", "missing", "invalid", "degraded"
|
||||||
|
pub crl_age_seconds: Option<u64>, // age of on-disk CRL file
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service status response data
|
/// Service status response data
|
||||||
@ -113,6 +116,7 @@ pub async fn get_system_info(
|
|||||||
pub async fn health_check(
|
pub async fn health_check(
|
||||||
backend: web::Data<Box<dyn PackageManagerBackend>>,
|
backend: web::Data<Box<dyn PackageManagerBackend>>,
|
||||||
cache_state: web::Data<crate::packages::cache::PackageCacheState>,
|
cache_state: web::Data<crate::packages::cache::PackageCacheState>,
|
||||||
|
crl_state: web::Data<SharedCrlState>,
|
||||||
_req: HttpRequest,
|
_req: HttpRequest,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let _request_id = Uuid::new_v4().to_string();
|
let _request_id = Uuid::new_v4().to_string();
|
||||||
@ -134,7 +138,7 @@ pub async fn health_check(
|
|||||||
|
|
||||||
// Check cache status and refresh if stale
|
// Check cache status and refresh if stale
|
||||||
let cache_status_val = cache_state.status();
|
let cache_status_val = cache_state.status();
|
||||||
let (status, cache_status_str, last_cache_update) = if cache_state.is_stale() {
|
let (mut status, cache_status_str, last_cache_update) = if cache_state.is_stale() {
|
||||||
match backend.refresh_package_cache(&cache_state) {
|
match backend.refresh_package_cache(&cache_state) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let updated = cache_state.status();
|
let updated = cache_state.status();
|
||||||
@ -161,12 +165,31 @@ pub async fn health_check(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CRL status from shared state
|
||||||
|
let crl = crl_state.load();
|
||||||
|
let crl_status_str = match crl.status {
|
||||||
|
CrlStatus::Valid
|
||||||
|
| CrlStatus::Expired
|
||||||
|
| CrlStatus::Missing
|
||||||
|
| CrlStatus::Invalid
|
||||||
|
| CrlStatus::Degraded => {
|
||||||
|
// Downgrade overall health if CRL is invalid
|
||||||
|
if crl.status == CrlStatus::Invalid {
|
||||||
|
status = "degraded".to_string();
|
||||||
|
}
|
||||||
|
crl.status.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let crl_age = crl.crl_age_seconds();
|
||||||
|
|
||||||
let response = ApiResponse::success(HealthData {
|
let response = ApiResponse::success(HealthData {
|
||||||
status,
|
status,
|
||||||
uptime_seconds,
|
uptime_seconds,
|
||||||
version,
|
version,
|
||||||
last_cache_update,
|
last_cache_update,
|
||||||
cache_status: cache_status_str,
|
cache_status: cache_status_str,
|
||||||
|
crl_status: Some(crl_status_str),
|
||||||
|
crl_age_seconds: crl_age,
|
||||||
});
|
});
|
||||||
|
|
||||||
HttpResponse::Ok().json(response)
|
HttpResponse::Ok().json(response)
|
||||||
@ -386,6 +409,8 @@ mod tests {
|
|||||||
version: "0.1.0".to_string(),
|
version: "0.1.0".to_string(),
|
||||||
last_cache_update: Some("2026-05-27T14:00:00+00:00".to_string()),
|
last_cache_update: Some("2026-05-27T14:00:00+00:00".to_string()),
|
||||||
cache_status: "fresh".to_string(),
|
cache_status: "fresh".to_string(),
|
||||||
|
crl_status: Some("valid".to_string()),
|
||||||
|
crl_age_seconds: Some(3600),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&health).unwrap();
|
let json = serde_json::to_string(&health).unwrap();
|
||||||
assert!(json.contains("healthy"));
|
assert!(json.contains("healthy"));
|
||||||
|
|||||||
419
src/auth/crl.rs
Normal file
419
src/auth/crl.rs
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
//! CRL (Certificate Revocation List) Loading, Parsing, and Refresh
|
||||||
|
//!
|
||||||
|
//! Provides CRL consumption for agent-side mTLS revocation enforcement.
|
||||||
|
//! Parses CRL from disk, verifies signature against pinned CA,
|
||||||
|
//! builds an in-memory revoked-serial index, and refreshes from the manager.
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
use x509_parser::prelude::FromDer;
|
||||||
|
use x509_parser::revocation_list::CertificateRevocationList;
|
||||||
|
|
||||||
|
/// CRL status reported via the health endpoint.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CrlStatus {
|
||||||
|
/// CRL loaded, signature valid, not expired.
|
||||||
|
Valid,
|
||||||
|
/// CRL loaded and signature valid, but nextUpdate has passed.
|
||||||
|
Expired,
|
||||||
|
/// No CRL file found on disk.
|
||||||
|
Missing,
|
||||||
|
/// CRL exists but failed signature verification -- fail-closed.
|
||||||
|
Invalid,
|
||||||
|
/// CRL fetch or load failed; operating in degraded (WebPKI-only) mode.
|
||||||
|
Degraded,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CrlStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
CrlStatus::Valid => write!(f, "valid"),
|
||||||
|
CrlStatus::Expired => write!(f, "expired"),
|
||||||
|
CrlStatus::Missing => write!(f, "missing"),
|
||||||
|
CrlStatus::Invalid => write!(f, "invalid"),
|
||||||
|
CrlStatus::Degraded => write!(f, "degraded"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory CRL state, atomically swapped on refresh via ArcSwap.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CrlState {
|
||||||
|
/// Hex-encoded serial numbers of revoked certificates (lowercase, no prefix).
|
||||||
|
pub revoked_serials: HashSet<String>,
|
||||||
|
/// CRL status for health reporting.
|
||||||
|
pub status: CrlStatus,
|
||||||
|
/// Time the CRL file was last modified (used to compute age).
|
||||||
|
pub crl_mtime: Option<SystemTime>,
|
||||||
|
/// When this CrlState was loaded into memory.
|
||||||
|
pub loaded_at: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CrlState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
revoked_serials: HashSet::new(),
|
||||||
|
status: CrlStatus::Missing,
|
||||||
|
crl_mtime: None,
|
||||||
|
loaded_at: SystemTime::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrlState {
|
||||||
|
/// Check whether a certificate serial is revoked.
|
||||||
|
pub fn is_revoked(&self, serial_hex: &str) -> bool {
|
||||||
|
self.revoked_serials.contains(serial_hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Age of the on-disk CRL file in seconds.
|
||||||
|
pub fn crl_age_seconds(&self) -> Option<u64> {
|
||||||
|
self.crl_mtime.and_then(|mtime| {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(mtime)
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared, atomically-swappable CRL handle.
|
||||||
|
pub type SharedCrlState = Arc<ArcSwap<CrlState>>;
|
||||||
|
|
||||||
|
/// Create a new shared CRL state (initially missing).
|
||||||
|
pub fn new_shared_state() -> SharedCrlState {
|
||||||
|
Arc::new(ArcSwap::from_pointee(CrlState::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the hex-encoded serial from a DER-encoded X.509 certificate.
|
||||||
|
/// Returns lowercase hex with no separators or prefix.
|
||||||
|
pub fn cert_serial_hex(cert_der: &[u8]) -> Option<String> {
|
||||||
|
x509_parser::parse_x509_certificate(cert_der)
|
||||||
|
.ok()
|
||||||
|
.map(|(_, cert)| format_serial_hex(&cert.serial))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a BigUint serial as lowercase hex string (no 0x prefix, no colons).
|
||||||
|
fn format_serial_hex(serial: &x509_parser::num_bigint::BigUint) -> String {
|
||||||
|
let bytes = serial.to_bytes_be();
|
||||||
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and validate a CRL from disk.
|
||||||
|
///
|
||||||
|
/// Steps:
|
||||||
|
/// 1. Read PEM file
|
||||||
|
/// 2. Parse CRL with x509-parser
|
||||||
|
/// 3. Verify CRL signature against the CA certificate
|
||||||
|
/// 4. Build in-memory revoked-serial index
|
||||||
|
/// 5. Check nextUpdate for staleness
|
||||||
|
///
|
||||||
|
/// Returns the new CrlState. On signature failure, returns CrlStatus::Invalid (fail-closed).
|
||||||
|
/// On missing file, returns CrlStatus::Missing. On parse error, returns CrlStatus::Degraded.
|
||||||
|
pub fn load_crl(crl_path: &Path, ca_cert_der: &[u8]) -> CrlState {
|
||||||
|
let crl_bytes = match fs::read(crl_path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
info!(path = %crl_path.display(), "No CRL file found -- operating in WebPKI-only mode");
|
||||||
|
return CrlState {
|
||||||
|
status: CrlStatus::Missing,
|
||||||
|
crl_mtime: None,
|
||||||
|
loaded_at: SystemTime::now(),
|
||||||
|
revoked_serials: HashSet::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
warn!(path = %crl_path.display(), error = %e, "Failed to read CRL file");
|
||||||
|
return CrlState {
|
||||||
|
status: CrlStatus::Degraded,
|
||||||
|
crl_mtime: None,
|
||||||
|
loaded_at: SystemTime::now(),
|
||||||
|
revoked_serials: HashSet::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let crl_mtime = fs::metadata(crl_path).ok().and_then(|m| m.modified().ok());
|
||||||
|
|
||||||
|
// Parse PEM: extract the DER block between BEGIN/END X509 CRL markers
|
||||||
|
let crl_der = match extract_pem_crl_der(&crl_bytes) {
|
||||||
|
Some(der) => der,
|
||||||
|
None => {
|
||||||
|
// Try parsing as raw DER
|
||||||
|
crl_bytes.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse CRL
|
||||||
|
let (_, crl) = match CertificateRevocationList::from_der(&crl_der) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Failed to parse CRL -- marking as invalid");
|
||||||
|
return CrlState {
|
||||||
|
status: CrlStatus::Invalid,
|
||||||
|
crl_mtime,
|
||||||
|
loaded_at: SystemTime::now(),
|
||||||
|
revoked_serials: HashSet::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify CRL signature against CA
|
||||||
|
let (_, ca_cert) = match x509_parser::parse_x509_certificate(ca_cert_der) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Failed to parse CA cert for CRL signature verification");
|
||||||
|
return CrlState {
|
||||||
|
status: CrlStatus::Invalid,
|
||||||
|
crl_mtime,
|
||||||
|
loaded_at: SystemTime::now(),
|
||||||
|
revoked_serials: HashSet::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let verify_result = crl.verify_signature(ca_cert.public_key());
|
||||||
|
|
||||||
|
if let Err(e) = verify_result {
|
||||||
|
error!(error = %e, "CRL signature verification FAILED -- refusing to use this CRL (fail-closed)");
|
||||||
|
return CrlState {
|
||||||
|
status: CrlStatus::Invalid,
|
||||||
|
crl_mtime,
|
||||||
|
loaded_at: SystemTime::now(),
|
||||||
|
revoked_serials: HashSet::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build revoked serial index
|
||||||
|
let revoked_serials: HashSet<String> = crl
|
||||||
|
.iter_revoked_certificates()
|
||||||
|
.map(|revoked| format_serial_hex(revoked.serial()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
revoked_count = revoked_serials.len(),
|
||||||
|
"CRL loaded and signature verified"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check nextUpdate for staleness
|
||||||
|
let now = x509_parser::time::ASN1Time::now();
|
||||||
|
let is_expired = crl.next_update().map(|next| next < now).unwrap_or(false);
|
||||||
|
|
||||||
|
let status = if is_expired {
|
||||||
|
warn!("CRL nextUpdate has passed -- CRL is stale, continuing with degraded status");
|
||||||
|
CrlStatus::Expired
|
||||||
|
} else {
|
||||||
|
CrlStatus::Valid
|
||||||
|
};
|
||||||
|
|
||||||
|
CrlState {
|
||||||
|
revoked_serials,
|
||||||
|
status,
|
||||||
|
crl_mtime,
|
||||||
|
loaded_at: SystemTime::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract DER bytes from a PEM-encoded CRL.
|
||||||
|
/// Looks for `-----BEGIN X509 CRL-----` / `-----END X509 CRL-----` blocks.
|
||||||
|
fn extract_pem_crl_der(pem_bytes: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let pem_str = String::from_utf8_lossy(pem_bytes);
|
||||||
|
let begin_marker = "-----BEGIN X509 CRL-----";
|
||||||
|
let end_marker = "-----END X509 CRL-----";
|
||||||
|
|
||||||
|
let begin_idx = pem_str.find(begin_marker)?;
|
||||||
|
let after_begin = begin_idx + begin_marker.len();
|
||||||
|
let end_idx = pem_str[after_begin..].find(end_marker)?;
|
||||||
|
let b64_block = pem_str[after_begin..after_begin + end_idx].trim();
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(b64_block)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the CRL from the manager, verify, persist, and update in-memory state.
|
||||||
|
///
|
||||||
|
/// The CRL endpoint is public (no auth): GET {manager_url}/api/v1/pki/crl.pem
|
||||||
|
pub async fn refresh_crl(
|
||||||
|
manager_url: &str,
|
||||||
|
crl_path: &Path,
|
||||||
|
ca_cert_der: &[u8],
|
||||||
|
shared_state: &SharedCrlState,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let crl_url = format!("{}/api/v1/pki/crl.pem", manager_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
info!(url = %crl_url, "Fetching CRL from manager");
|
||||||
|
|
||||||
|
let response = reqwest::get(&crl_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("CRL fetch request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
return Err(format!("CRL fetch returned HTTP {}", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
let crl_pem = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read CRL response body: {}", e))?;
|
||||||
|
|
||||||
|
// Persist to disk (atomic write via temp file)
|
||||||
|
let parent = crl_path.parent().unwrap_or(Path::new("/tmp"));
|
||||||
|
if !parent.exists() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Failed to create CRL directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp_path = crl_path.with_extension("pem.tmp");
|
||||||
|
fs::write(&tmp_path, &crl_pem).map_err(|e| format!("Failed to write temp CRL file: {}", e))?;
|
||||||
|
|
||||||
|
fs::rename(&tmp_path, crl_path)
|
||||||
|
.map_err(|e| format!("Failed to rename temp CRL file: {}", e))?;
|
||||||
|
|
||||||
|
debug!(path = %crl_path.display(), "CRL persisted to disk");
|
||||||
|
|
||||||
|
// Load the freshly written CRL to get a validated CrlState
|
||||||
|
let new_state = load_crl(crl_path, ca_cert_der);
|
||||||
|
|
||||||
|
if new_state.status == CrlStatus::Invalid {
|
||||||
|
return Err("CRL signature verification failed after fetch".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
status = %new_state.status,
|
||||||
|
revoked = new_state.revoked_serials.len(),
|
||||||
|
"CRL refreshed successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Atomically swap the in-memory state
|
||||||
|
shared_state.store(Arc::new(new_state));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the CRL refresh background task.
|
||||||
|
///
|
||||||
|
/// Runs on a 24-hour interval. On failure, logs a warning and continues
|
||||||
|
/// serving with the existing (possibly stale) CRL.
|
||||||
|
pub fn spawn_crl_refresh_task(
|
||||||
|
manager_url: String,
|
||||||
|
crl_path: PathBuf,
|
||||||
|
ca_cert_der: Vec<u8>,
|
||||||
|
shared_state: SharedCrlState,
|
||||||
|
) {
|
||||||
|
let interval = Duration::from_secs(24 * 60 * 60); // 24 hours
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Initial small delay to let the server finish binding
|
||||||
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let result = refresh_crl(&manager_url, &crl_path, &ca_cert_der, &shared_state).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("CRL background refresh completed successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
error = %e,
|
||||||
|
"CRL background refresh failed -- continuing with current CRL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info!(
|
||||||
|
interval_secs = interval.as_secs(),
|
||||||
|
"CRL refresh background task spawned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_serial_hex() {
|
||||||
|
use x509_parser::num_bigint::BigUint;
|
||||||
|
let serial = BigUint::from(0x0123_abcdu64);
|
||||||
|
let hex = format_serial_hex(&serial);
|
||||||
|
assert_eq!(hex, "0123abcd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_serial_hex_single_byte() {
|
||||||
|
use x509_parser::num_bigint::BigUint;
|
||||||
|
let serial = BigUint::from(0x42u64);
|
||||||
|
let hex = format_serial_hex(&serial);
|
||||||
|
assert_eq!(hex, "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crl_state_default_is_missing() {
|
||||||
|
let state = CrlState::default();
|
||||||
|
assert_eq!(state.status, CrlStatus::Missing);
|
||||||
|
assert!(state.revoked_serials.is_empty());
|
||||||
|
assert!(state.crl_mtime.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crl_state_is_revoked() {
|
||||||
|
let mut state = CrlState::default();
|
||||||
|
state.revoked_serials.insert("deadbeef".to_string());
|
||||||
|
assert!(state.is_revoked("deadbeef"));
|
||||||
|
assert!(!state.is_revoked("cafef00d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crl_status_display() {
|
||||||
|
assert_eq!(CrlStatus::Valid.to_string(), "valid");
|
||||||
|
assert_eq!(CrlStatus::Expired.to_string(), "expired");
|
||||||
|
assert_eq!(CrlStatus::Missing.to_string(), "missing");
|
||||||
|
assert_eq!(CrlStatus::Invalid.to_string(), "invalid");
|
||||||
|
assert_eq!(CrlStatus::Degraded.to_string(), "degraded");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_pem_crl_der_invalid() {
|
||||||
|
// Not PEM
|
||||||
|
assert!(extract_pem_crl_der(b"not pem").is_none());
|
||||||
|
// PEM but wrong type
|
||||||
|
assert!(extract_pem_crl_der(
|
||||||
|
b"-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----"
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shared_crl_state_swap() {
|
||||||
|
let shared = new_shared_state();
|
||||||
|
let initial = shared.load();
|
||||||
|
assert_eq!(initial.status, CrlStatus::Missing);
|
||||||
|
|
||||||
|
let new_state = CrlState {
|
||||||
|
status: CrlStatus::Valid,
|
||||||
|
revoked_serials: {
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
set.insert("abc".to_string());
|
||||||
|
set
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
shared.store(Arc::new(new_state));
|
||||||
|
|
||||||
|
let updated = shared.load();
|
||||||
|
assert_eq!(updated.status, CrlStatus::Valid);
|
||||||
|
assert!(updated.is_revoked("abc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,9 +6,11 @@
|
|||||||
//! - Silent drop for non-compliant connections
|
//! - Silent drop for non-compliant connections
|
||||||
//! - Comprehensive audit logging
|
//! - Comprehensive audit logging
|
||||||
|
|
||||||
|
pub mod crl;
|
||||||
pub mod mtls;
|
pub mod mtls;
|
||||||
pub mod whitelist;
|
pub mod whitelist;
|
||||||
|
|
||||||
|
pub use crl::{new_shared_state, CrlState, CrlStatus, SharedCrlState};
|
||||||
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware};
|
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware};
|
||||||
pub use whitelist::{WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware};
|
pub use whitelist::{WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware};
|
||||||
|
|
||||||
|
|||||||
139
src/auth/mtls.rs
139
src/auth/mtls.rs
@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Provides mutual TLS authentication middleware for Actix-web.
|
//! Provides mutual TLS authentication middleware for Actix-web.
|
||||||
//! Non-mTLS connections are silently dropped (no response).
|
//! Non-mTLS connections are silently dropped (no response).
|
||||||
|
//! Supports CRL-aware client certificate verification when CRL is available.
|
||||||
|
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||||
@ -11,14 +12,21 @@ use actix_web::{
|
|||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use futures_util::future::LocalBoxFuture;
|
use futures_util::future::LocalBoxFuture;
|
||||||
use rustls::{
|
use rustls::{
|
||||||
|
client::danger::HandshakeSignatureValid,
|
||||||
crypto::aws_lc_rs,
|
crypto::aws_lc_rs,
|
||||||
server::{ServerConfig, WebPkiClientVerifier},
|
pki_types::{CertificateDer, UnixTime},
|
||||||
|
server::{
|
||||||
|
danger::{ClientCertVerified, ClientCertVerifier},
|
||||||
|
ServerConfig, WebPkiClientVerifier,
|
||||||
|
},
|
||||||
version::TLS13,
|
version::TLS13,
|
||||||
RootCertStore,
|
DigitallySignedStruct, DistinguishedName, Error as RustlsError, RootCertStore, SignatureScheme,
|
||||||
};
|
};
|
||||||
use rustls_pemfile::{certs, private_key};
|
use rustls_pemfile::{certs, private_key};
|
||||||
use std::{fs::File, io::BufReader, sync::Arc};
|
use std::{fs::File, io::BufReader, sync::Arc};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use super::crl::{cert_serial_hex, SharedCrlState};
|
||||||
|
|
||||||
/// Check for duplicate critical headers (VULN-006)
|
/// Check for duplicate critical headers (VULN-006)
|
||||||
/// Returns true if duplicate headers are detected
|
/// Returns true if duplicate headers are detected
|
||||||
@ -45,6 +53,107 @@ fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CRL-aware client certificate verifier.
|
||||||
|
///
|
||||||
|
/// Wraps WebPkiClientVerifier for chain validation, then checks the
|
||||||
|
/// end-entity certificate serial against the in-memory CRL index.
|
||||||
|
/// If CRL is unavailable (Missing/Degraded), falls back to WebPKI-only.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CrlAwareVerifier {
|
||||||
|
inner: Arc<dyn ClientCertVerifier>,
|
||||||
|
crl_state: SharedCrlState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrlAwareVerifier {
|
||||||
|
fn new(inner: Arc<dyn ClientCertVerifier>, crl_state: SharedCrlState) -> Self {
|
||||||
|
Self { inner, crl_state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientCertVerifier for CrlAwareVerifier {
|
||||||
|
fn offer_client_auth(&self) -> bool {
|
||||||
|
self.inner.offer_client_auth()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_auth_mandatory(&self) -> bool {
|
||||||
|
self.inner.client_auth_mandatory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_hint_subjects(&self) -> &[DistinguishedName] {
|
||||||
|
self.inner.root_hint_subjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_client_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &CertificateDer<'_>,
|
||||||
|
intermediates: &[CertificateDer<'_>],
|
||||||
|
now: UnixTime,
|
||||||
|
) -> Result<ClientCertVerified, RustlsError> {
|
||||||
|
// 1. Delegate chain validation to WebPKI
|
||||||
|
self.inner
|
||||||
|
.verify_client_cert(end_entity, intermediates, now)?;
|
||||||
|
|
||||||
|
// 2. Check CRL revocation status
|
||||||
|
let crl = self.crl_state.load();
|
||||||
|
match crl.status {
|
||||||
|
super::crl::CrlStatus::Valid | super::crl::CrlStatus::Expired => {
|
||||||
|
// CRL is available -- check serial
|
||||||
|
if let Some(serial_hex) = cert_serial_hex(end_entity.as_ref()) {
|
||||||
|
if crl.is_revoked(&serial_hex) {
|
||||||
|
warn!(
|
||||||
|
serial = %serial_hex,
|
||||||
|
"Client certificate is revoked per CRL -- rejecting connection"
|
||||||
|
);
|
||||||
|
return Err(RustlsError::InvalidCertificate(
|
||||||
|
rustls::CertificateError::Revoked,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClientCertVerified::assertion())
|
||||||
|
}
|
||||||
|
super::crl::CrlStatus::Missing | super::crl::CrlStatus::Degraded => {
|
||||||
|
// No CRL available -- fall back to WebPKI-only (already passed above)
|
||||||
|
warn!(
|
||||||
|
status = %crl.status,
|
||||||
|
"CRL not available -- allowing connection with WebPKI-only verification"
|
||||||
|
);
|
||||||
|
Ok(ClientCertVerified::assertion())
|
||||||
|
}
|
||||||
|
super::crl::CrlStatus::Invalid => {
|
||||||
|
// Invalid CRL signature -- fail-closed
|
||||||
|
error!(
|
||||||
|
"CRL signature is invalid -- refusing all client certificates (fail-closed)"
|
||||||
|
);
|
||||||
|
Err(RustlsError::InvalidCertificate(
|
||||||
|
rustls::CertificateError::Revoked,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, RustlsError> {
|
||||||
|
self.inner.verify_tls12_signature(message, cert, dss)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, RustlsError> {
|
||||||
|
self.inner.verify_tls13_signature(message, cert, dss)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
self.inner.supported_verify_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// mTLS Configuration
|
/// mTLS Configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MtlsConfig {
|
pub struct MtlsConfig {
|
||||||
@ -71,12 +180,30 @@ impl MtlsMiddleware {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build rustls server configuration with client certificate verification
|
/// Build rustls server configuration with client certificate verification.
|
||||||
pub fn build_rustls_config(&self) -> Result<Arc<ServerConfig>, MtlsError> {
|
///
|
||||||
let client_verifier = WebPkiClientVerifier::builder(self.cert_store.clone())
|
/// When `crl_state` is provided and the CRL is available, wraps the
|
||||||
|
/// WebPkiClientVerifier with CrlAwareVerifier for revocation checking.
|
||||||
|
/// When CRL is missing/degraded, falls back to WebPKI-only verification.
|
||||||
|
pub fn build_rustls_config(
|
||||||
|
&self,
|
||||||
|
crl_state: Option<SharedCrlState>,
|
||||||
|
) -> Result<Arc<ServerConfig>, MtlsError> {
|
||||||
|
let webpki_verifier = WebPkiClientVerifier::builder(self.cert_store.clone())
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?;
|
.map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?;
|
||||||
|
|
||||||
|
let client_verifier: Arc<dyn ClientCertVerifier> = match crl_state {
|
||||||
|
Some(state) => {
|
||||||
|
info!("CRL-aware client verification enabled");
|
||||||
|
Arc::new(CrlAwareVerifier::new(webpki_verifier, state))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("No CRL state provided -- using WebPKI-only client verification");
|
||||||
|
webpki_verifier
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let server_cert = load_certs(&self.config.server_cert_path)?;
|
let server_cert = load_certs(&self.config.server_cert_path)?;
|
||||||
let server_key = load_private_key(&self.config.server_key_path)?;
|
let server_key = load_private_key(&self.config.server_key_path)?;
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,14 @@ pub struct TlsConfig {
|
|||||||
pub server_key: String,
|
pub server_key: String,
|
||||||
#[serde(default = "default_tls_version")]
|
#[serde(default = "default_tls_version")]
|
||||||
pub min_tls_version: String,
|
pub min_tls_version: String,
|
||||||
|
/// Path to persist the CRL fetched from the manager.
|
||||||
|
/// Defaults to /etc/linux_patch_api/certs/crl.pem
|
||||||
|
#[serde(default = "default_crl_path")]
|
||||||
|
pub crl_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_crl_path() -> String {
|
||||||
|
"/etc/linux_patch_api/certs/crl.pem".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
|
|||||||
52
src/main.rs
52
src/main.rs
@ -27,6 +27,7 @@ use std::sync::Arc;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use linux_patch_api::api::{configure_api_routes, configure_health_route};
|
use linux_patch_api::api::{configure_api_routes, configure_health_route};
|
||||||
|
use linux_patch_api::auth::crl::{self, CrlStatus};
|
||||||
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
|
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
|
||||||
use linux_patch_api::config::loader::{validate_certs, CertStatus};
|
use linux_patch_api::config::loader::{validate_certs, CertStatus};
|
||||||
use linux_patch_api::enroll;
|
use linux_patch_api::enroll;
|
||||||
@ -297,6 +298,10 @@ async fn main() -> Result<()> {
|
|||||||
let cache_state = web::Data::new(PackageCacheState::new());
|
let cache_state = web::Data::new(PackageCacheState::new());
|
||||||
info!("Package cache state initialized");
|
info!("Package cache state initialized");
|
||||||
|
|
||||||
|
// Initialize shared CRL state (available even when TLS is off for health reporting)
|
||||||
|
let shared_crl_state = crl::new_shared_state();
|
||||||
|
let crl_state_data = web::Data::new(shared_crl_state.clone());
|
||||||
|
|
||||||
// Configure bind address
|
// Configure bind address
|
||||||
let bind_address = format!("{}:{}", config.server.bind, config.server.port);
|
let bind_address = format!("{}:{}", config.server.bind, config.server.port);
|
||||||
|
|
||||||
@ -306,7 +311,8 @@ async fn main() -> Result<()> {
|
|||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
.app_data(job_manager_data.clone())
|
.app_data(job_manager_data.clone())
|
||||||
.app_data(backend_data.clone())
|
.app_data(backend_data.clone())
|
||||||
.app_data(cache_state.clone());
|
.app_data(cache_state.clone())
|
||||||
|
.app_data(crl_state_data.clone());
|
||||||
|
|
||||||
// Configure API routes
|
// Configure API routes
|
||||||
app = app.configure(|cfg| {
|
app = app.configure(|cfg| {
|
||||||
@ -345,6 +351,7 @@ async fn main() -> Result<()> {
|
|||||||
server_cert = %tls_config.server_cert,
|
server_cert = %tls_config.server_cert,
|
||||||
server_key = %tls_config.server_key,
|
server_key = %tls_config.server_key,
|
||||||
min_tls_version = %tls_config.min_tls_version,
|
min_tls_version = %tls_config.min_tls_version,
|
||||||
|
crl_path = %tls_config.crl_path,
|
||||||
"Initializing mTLS authentication with TLS binding"
|
"Initializing mTLS authentication with TLS binding"
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -355,11 +362,50 @@ async fn main() -> Result<()> {
|
|||||||
min_tls_version: tls_config.min_tls_version.clone(),
|
min_tls_version: tls_config.min_tls_version.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load CRL from disk into the shared CRL state
|
||||||
|
let crl_path = std::path::PathBuf::from(&tls_config.crl_path);
|
||||||
|
let ca_cert_der = std::fs::read(&tls_config.ca_cert).unwrap_or_default();
|
||||||
|
|
||||||
|
// Load initial CRL from disk (missing is OK -- degraded mode)
|
||||||
|
let initial_crl = crl::load_crl(&crl_path, &ca_cert_der);
|
||||||
|
match initial_crl.status {
|
||||||
|
CrlStatus::Invalid => {
|
||||||
|
error!("CRL signature is invalid -- refusing to start (fail-closed)");
|
||||||
|
std::process::exit(ExitCode::Error as i32);
|
||||||
|
}
|
||||||
|
CrlStatus::Valid | CrlStatus::Expired => {
|
||||||
|
info!(
|
||||||
|
status = %initial_crl.status,
|
||||||
|
revoked = initial_crl.revoked_serials.len(),
|
||||||
|
"CRL loaded from disk"
|
||||||
|
);
|
||||||
|
shared_crl_state.store(std::sync::Arc::new(initial_crl));
|
||||||
|
}
|
||||||
|
CrlStatus::Missing => {
|
||||||
|
info!("No CRL on disk -- starting in WebPKI-only mode");
|
||||||
|
}
|
||||||
|
CrlStatus::Degraded => {
|
||||||
|
warn!("CRL load failed -- starting in degraded (WebPKI-only) mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn CRL refresh background task if manager URL is configured
|
||||||
|
if let Some(manager_url) = config.enrollment_manager_url() {
|
||||||
|
crl::spawn_crl_refresh_task(
|
||||||
|
manager_url.to_string(),
|
||||||
|
crl_path,
|
||||||
|
ca_cert_der,
|
||||||
|
shared_crl_state.clone(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!("No manager URL configured -- CRL auto-refresh disabled");
|
||||||
|
}
|
||||||
|
|
||||||
match MtlsMiddleware::new(mtls_config.clone()) {
|
match MtlsMiddleware::new(mtls_config.clone()) {
|
||||||
Ok(middleware) => {
|
Ok(middleware) => {
|
||||||
// Build rustls server configuration
|
// Build rustls server configuration with CRL-aware verifier
|
||||||
let rustls_config = middleware
|
let rustls_config = middleware
|
||||||
.build_rustls_config()
|
.build_rustls_config(Some(shared_crl_state.clone()))
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
|
||||||
|
|
||||||
info!("mTLS middleware and rustls config initialized successfully");
|
info!("mTLS middleware and rustls config initialized successfully");
|
||||||
|
|||||||
@ -78,6 +78,7 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
min_tls_version: "1.3".to_string(),
|
min_tls_version: "1.3".to_string(),
|
||||||
|
crl_path: String::new(), // No CRL in E2E tests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user