Private
Public Access
1
0

Compare commits

..

8 Commits

Author SHA1 Message Date
392e7553c4 release: bump version to 1.1.9 for non-Ubuntu package fixes
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 2s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m54s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m29s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m30s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m52s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m46s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m13s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m11s
2026-05-20 02:54:09 +00:00
19f76f4d9d fix: comment out RPM BuildRequires for CI (rustup not RPM), fix changelog date
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m13s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m21s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m39s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m54s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m38s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m4s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m23s
2026-05-20 02:32:31 +00:00
7dcbff8ece docs: add detailed Arch, RPM, Alpine installation instructions
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m15s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m19s
CI/CD Pipeline / Build RPM Package (push) Failing after 2m23s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m36s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m52s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m29s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m58s
- README: comprehensive per-platform install/build/verify/remove instructions
- README: prerequisites, post-install notes, Alpine OpenRC differences
- BUILD_PACKAGES: add Arch and Alpine build sections with troubleshooting
- BUILD_PACKAGES: fix Service Account table (runs as root, not system user)
- BUILD_PACKAGES: add Arch/Alpine supported distributions tables
2026-05-20 02:06:52 +00:00
8952589efd fix: align all non-Ubuntu packages with Debian baseline behavior
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Debian Package (push) Has been cancelled
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been cancelled
CI/CD Pipeline / Build Arch Package (push) Has been cancelled
CI/CD Pipeline / Build RPM Package (push) Has been cancelled
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been cancelled
CI/CD Pipeline / Build Alpine Package (push) Has been cancelled
- Arch: remove system user creation, root:root ownership, fix $startdir path in PKGBUILD
- RPM: uncomment BuildRequires, add runtime deps (openssl-libs, ca-certificates), remove system user, root:root ownership
- Alpine: remove system user creation, root:root ownership, co-locate install script with APKBUILD
- All platforms now match Debian: no system user, root:root, create dirs, copy example configs, enable service
2026-05-20 02:01:52 +00:00
bcc0d40413 release: bump version to 1.1.8
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 2s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m11s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m55s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m26s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m35s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m56s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m10s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m17s
2026-05-19 00:34:21 +00:00
1af72deb16 fix: Arch build - install script filename must match PKGBUILD install= reference
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m24s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m53s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m37s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m39s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m11s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m27s
2026-05-19 00:21:59 +00:00
11168b22df style: fix rustfmt formatting for CI
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 5s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m13s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 59s
CI/CD Pipeline / Build Arch Package (push) Failing after 2m53s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m40s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m53s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m0s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m24s
2026-05-18 23:54:15 +00:00
653623b9f0 fix: FQDN resolution and display_name blank bug; fix: Arch/Alpine/RPM packages
Some checks failed
CI/CD Pipeline / Code Format (push) Failing after 2s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Build Debian Package (push) Has been skipped
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been skipped
CI/CD Pipeline / Build RPM Package (push) Has been skipped
CI/CD Pipeline / Build Alpine Package (push) Has been skipped
CI/CD Pipeline / Build Arch Package (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 55s
Bug fixes:
- get_fqdn() now prioritizes 'hostname -f' (returns full FQDN) over /etc/hostname (returns short hostname)
- Added get_hostname() for short hostname extraction
- Added hostname field to EnrollmentRequest for manager display_name population
- Updated SPEC.md and API_DOCUMENTATION.md

Package fixes:
- Arch: Added linux-patch-api.install with post_install/upgrade/remove hooks, user creation, directory creation, config handling
- Alpine: Added linux-patch-api.apk-install with pre/post install/deinstall hooks, user creation, directory creation, config handling, missing config.yaml.example
- RPM: Dynamic version from Cargo.toml, %ghost %config(noreplace) for live configs, tarball exclusions, /var/log in %files
2026-05-18 23:51:00 +00:00
19 changed files with 1035 additions and 125 deletions

View File

@ -909,6 +909,7 @@ Enrollment endpoints enable new hosts to register with the Patch Manager and rec
| `fqdn` | string | Yes | Fully qualified domain name of the host |
| `ip_address` | string | Yes | Primary non-loopback IPv4 address |
| `os_details` | object | Yes | OS metadata (free-form JSON object) |
| `hostname` | string | No | Short hostname (without domain). Used by the manager to populate `display_name` on approval. If omitted, the manager falls back to the FQDN. |
**`os_details` common fields:**
@ -933,7 +934,8 @@ curl -X POST https://manager.example.com/api/v1/enroll \
"version_id": "12",
"kernel": "6.1.0-kali9-amd64",
"id_like": "debian"
}
},
"hostname": "host-01"
}'
```

View File

@ -1,6 +1,6 @@
# Linux Patch API - Package Build Guide
This document provides comprehensive instructions for building production-ready Debian (.deb) and RPM (.rpm) packages for the Linux Patch API.
This document provides comprehensive instructions for building production-ready packages for the Linux Patch API across all supported platforms: Debian/Ubuntu (.deb), RHEL/CentOS/Fedora (.rpm), Arch Linux (.pkg.tar.zst), and Alpine Linux (.apk).
## Prerequisites
@ -173,6 +173,152 @@ rpm -ql linux-patch-api
rpm -e linux-patch-api
```
## Building Arch Package (.pkg.tar.zst)
### Quick Build
```bash
cd /path/to/linux_patch_api
# Build release binary
cargo build --release
# Build Arch package
chmod +x build-arch.sh
SKIP_CARGO_BUILD=1 ./build-arch.sh
# Package will be created in releases/
ls -la releases/*.pkg.tar.zst
```
### Detailed Build Process
```bash
# 1. Install build dependencies (Arch Linux)
sudo pacman -Syu --noconfirm rust cargo systemd git base-devel gcc
# 2. Build release binary
cargo build --release
# 3. Run build script
chmod +x build-arch.sh
./build-arch.sh
# 4. Verify package contents
bsdtar -tf releases/linux-patch-api-*.pkg.tar.zst
# 5. Verify package info
pacman -Qi releases/linux-patch-api-*.pkg.tar.zst
```
### Install Script Hooks
The Arch package includes an `.install` file (`configs/linux-patch-api.install`) that runs automatically on install:
- **post_install**: Creates directories, copies example configs, enables systemd service
- **post_upgrade**: Reloads systemd daemon
- **pre_remove**: Stops and disables service
- **post_remove**: Cleans up empty directories
### Installation Test
```bash
# Install the package
sudo pacman -U ./releases/linux-patch-api-*.pkg.tar.zst
# Verify installation
systemctl status linux-patch-api
linux-patch-api --version
# Check installed files
pacman -Ql linux-patch-api
# Verify config files exist
ls -la /etc/linux_patch_api/
# Remove package
sudo pacman -R linux-patch-api
```
**Note:** The build script creates a `builduser` for `makepkg` when running as root (typical in CI environments). The `.install` hook handles directory creation, config copying, and service enablement.
## Building Alpine Package (.apk)
### Quick Build
```bash
cd /path/to/linux_patch_api
# Build release binary (MUSL target for Alpine)
cargo build --release --target x86_64-unknown-linux-musl
# Build Alpine package
chmod +x build-alpine.sh
SKIP_CARGO_BUILD=1 ./build-alpine.sh
# Package will be created in releases/
ls -la releases/*.apk
```
### Detailed Build Process
```bash
# 1. Install build dependencies (Alpine Linux)
apk add --no-cache alpine-sdk rust cargo openssl openssl-dev elogind-dev musl-dev abuild gcc
# 2. Add Rust MUSL target
rustup target add x86_64-unknown-linux-musl
# 3. Build release binary
cargo build --release --target x86_64-unknown-linux-musl
# 4. Run build script
chmod +x build-alpine.sh
./build-alpine.sh
# 5. Verify package contents
apk verify releases/*.apk
# 6. List package contents
tar -tzf releases/*.apk
```
### Install Script Hooks
The Alpine package includes an install script (`configs/linux-patch-api.apk-install`) that runs automatically on install:
- **pre_install**: Creates directories, sets ownership and permissions
- **post_install**: Copies example configs, adds service to default runlevel
- **pre_deinstall**: Stops and removes service from runlevel
- **post_deinstall**: Cleans up empty directories
### Installation Test
```bash
# Install the package
sudo apk add --allow-unstable ./releases/linux-patch-api-*.apk
# Verify installation
rc-service linux-patch-api status
linux-patch-api --version
# Check installed files
apk info -L linux-patch-api
# Verify config files exist
ls -la /etc/linux_patch_api/
# Remove package
sudo apk del linux-patch-api
```
**Important:** Alpine uses **OpenRC** instead of systemd. Key differences:
- Start service: `rc-service linux-patch-api start`
- Stop service: `rc-service linux-patch-api stop`
- Check status: `rc-service linux-patch-api status`
- Service init script: `/etc/init.d/linux-patch-api`
- The `abuild` tool generates signing keys automatically for CI builds
## Using the Interactive Installer
For manual deployment without package managers:
@ -209,15 +355,17 @@ The installer will:
| `/var/lib/linux_patch_api/` | Data directory | 755 |
| `/var/log/linux_patch_api/` | Log directory | 755 |
### System User/Group
### Service Account
| Property | Value |
|----------|-------|
| User | linux-patch-api |
| Group | linux-patch-api |
| User | root |
| Group | root |
| Home | /var/lib/linux_patch_api |
| Shell | /usr/sbin/nologin |
| Type | System account |
| Shell | N/A (systemd service) |
| Type | Runs as root (required for package management) |
**Note:** The service runs as root because package management operations (apt, dnf, apk, pacman) require root privileges. Security is provided by mTLS + IP whitelist, not process isolation.
## Supported Distributions
@ -240,6 +388,19 @@ The installer will:
| AlmaLinux | 8, 9 | ✅ Supported |
| Rocky Linux | 8, 9 | ✅ Supported |
### Arch Package (.pkg.tar.zst)
| Distribution | Versions | Status |
|--------------|----------|--------|
| Arch Linux | Rolling | ✅ Supported |
| Manjaro | Rolling | ✅ Supported |
### Alpine Package (.apk)
| Distribution | Versions | Status |
|--------------|----------|--------|
| Alpine Linux | 3.18+ | ✅ Supported |
## Troubleshooting
### Debian Package Issues
@ -276,9 +437,62 @@ cat ~/rpmbuild/BUILDROOT/*/var/log/rpmbuild.log
dnf install -y systemd-devel pkgconfig
```
### Arch Package Issues
**Error: `makepkg: cannot run as root`**
```bash
# The build script handles this automatically by creating builduser
# If running manually:
useradd -m builduser
su - builduser -c "cd /path/to/repo && makepkg -f --noconfirm"
```
**Error: `install script not found`**
```bash
# Ensure linux-patch-api.install is in the same directory as PKGBUILD
ls -la configs/linux-patch-api.install
# The build script copies it automatically
```
**Error: `Permission denied` on config files**
```bash
# Verify ownership is root:root
ls -la /etc/linux_patch_api/
# Fix if needed:
sudo chown -R root:root /etc/linux_patch_api/
sudo chmod 750 /etc/linux_patch_api /etc/linux_patch_api/certs
```
### Alpine Package Issues
**Error: `abuild: UNTRUSTED signature`**
```bash
# The build script handles key generation automatically
# If running manually:
abuild-keygen -a -n
cp /root/.abuild/*.rsa.pub /etc/apk/keys/
```
**Error: `apk add: ERROR: failed to create directory`**
```bash
# Verify the install script ran correctly
ls -la /etc/linux_patch_api/
ls -la /var/lib/linux_patch_api/
# Manually create if needed:
sudo mkdir -p /etc/linux_patch_api/certs /var/lib/linux_patch_api /var/log/linux_patch_api
```
**Error: `rc-service: service not found`**
```bash
# Verify the init script exists
ls -la /etc/init.d/linux-patch-api
# Re-add to default runlevel
sudo rc-update add linux-patch-api default
```
### Service Issues
**Service fails to start:**
**Service fails to start (systemd):**
```bash
# Check service status
systemctl status linux-patch-api
@ -293,6 +507,22 @@ linux-patch-api --config /etc/linux_patch_api/config.yaml --check
ls -la /etc/linux_patch_api/certs/
```
**Service fails to start (OpenRC/Alpine):**
```bash
# Check service status
rc-service linux-patch-api status
# View logs
cat /var/log/linux_patch_api/linux-patch-api.log
cat /var/log/linux_patch_api/linux-patch-api.err
# Check configuration
linux-patch-api --config /etc/linux_patch_api/config.yaml --check
# Verify certificates
ls -la /etc/linux_patch_api/certs/
```
## CI/CD Integration
### GitHub Actions Example
@ -383,7 +613,7 @@ jobs:
- Packages are signed with maintainer GPG key for production deployments
- All maintainer scripts run with `set -e` for fail-fast behavior
- Configuration files are marked as conffiles to preserve user modifications
- System user has minimal privileges (nologin shell, no home directory)
- Service runs as root (required for package management operations)
- Directory permissions follow principle of least privilege
- TLS certificates should be replaced with CA-signed certs in production

2
Cargo.lock generated
View File

@ -1916,7 +1916,7 @@ dependencies = [
[[package]]
name = "linux-patch-api"
version = "1.1.6"
version = "1.1.7"
dependencies = [
"actix",
"actix-rt",

View File

@ -1,6 +1,6 @@
[package]
name = "linux-patch-api"
version = "1.1.7"
version = "1.1.9"
edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"]
description = "Secure remote package management API for Linux systems"

164
README.md
View File

@ -185,6 +185,13 @@ For detailed enrollment procedures, see [DEPLOYMENT_GUIDE.md - Self-Enrollment D
### Package Installation
All platform packages produce identical installation results:
- Creates `/etc/linux_patch_api/`, `/etc/linux_patch_api/certs/`, `/var/lib/linux_patch_api/`, `/var/log/linux_patch_api/`
- Copies example configs to live configs if not already present
- Enables the service (does not start automatically)
- Sets correct permissions (750 on config dirs, 755 on data/log dirs)
- Ownership: root:root (service runs as root)
#### Debian/Ubuntu (.deb)
```bash
@ -197,52 +204,173 @@ apt-get install -f -y
# Verify installation
systemctl status linux-patch-api
linux-patch-api --version
# Check installed files
dpkg -L linux-patch-api
# Remove package (keeping configs)
dpkg -r linux-patch-api
# Purge package (removing all configs)
dpkg -P linux-patch-api
```
**Prerequisites:** `systemd`, `libsystemd0`
**Post-install:** The package automatically copies example configs, enables the service, and prints next steps. Configure `/etc/linux_patch_api/config.yaml` and place TLS certificates in `/etc/linux_patch_api/certs/` before starting.
#### RHEL/CentOS/Fedora (.rpm)
```bash
# Install the package
rpm -ivh linux-patch-api-1.0.0-1.x86_64.rpm
# Install the package (recommended - resolves dependencies automatically)
dnf install -y ./linux-patch-api-1.0.0-1.el9.x86_64.rpm
# Or with yum
yum install -y ./linux-patch-api-1.0.0-1.el9.x86_64.rpm
# Or with rpm (does NOT resolve dependencies)
rpm -ivh linux-patch-api-1.0.0-1.el9.x86_64.rpm
# Verify installation
systemctl status linux-patch-api
linux-patch-api --version
# Check installed files
rpm -ql linux-patch-api
# Remove package
rpm -e linux-patch-api
```
**Prerequisites (auto-resolved with dnf/yum):** `systemd`, `libsystemd`, `openssl-libs`, `ca-certificates`
**Post-install:** The package automatically creates directories, copies example configs, enables the service, and prints next steps. Configure `/etc/linux_patch_api/config.yaml` and place TLS certificates in `/etc/linux_patch_api/certs/` before starting.
**Note:** Use `dnf install` or `yum install` instead of `rpm -ivh` to automatically resolve dependencies. The `rpm -ivh` command will fail if required packages are not already installed.
#### Arch Linux (.pkg.tar.zst)
```bash
# Install the package
sudo pacman -U ./linux-patch-api-1.0.0-1-x86_64.pkg.tar.zst
# Verify installation
systemctl status linux-patch-api
linux-patch-api --version
# Check installed files
pacman -Ql linux-patch-api
# Remove package
sudo pacman -R linux-patch-api
```
**Prerequisites:** `systemd` (included by default on Arch)
**Post-install:** The package automatically creates directories, copies example configs, enables the service, and prints next steps. Configure `/etc/linux_patch_api/config.yaml` and place TLS certificates in `/etc/linux_patch_api/certs/` before starting.
**Note:** Arch uses systemd by default. The install hook runs `systemctl enable` but does not start the service. You must configure before starting.
#### Alpine Linux (.apk)
```bash
# Install the package
sudo apk add --allow-unstable ./linux-patch-api-1.0.0-r0.apk
# Verify installation
rc-service linux-patch-api status
linux-patch-api --version
# Check installed files
apk info -L linux-patch-api
# Remove package
sudo apk del linux-patch-api
```
**Prerequisites:** `openrc` (included by default on Alpine)
**Post-install:** The package automatically creates directories, copies example configs, adds the service to the default runlevel, and prints next steps. Configure `/etc/linux_patch_api/config.yaml` and place TLS certificates in `/etc/linux_patch_api/certs/` before starting.
**Important differences from systemd-based systems:**
- Alpine uses **OpenRC** instead of systemd. Use `rc-service` commands instead of `systemctl`
- Start service: `rc-service linux-patch-api start`
- Stop service: `rc-service linux-patch-api stop`
- Check status: `rc-service linux-patch-api status`
- The service is added to the `default` runlevel automatically on install
- Service init script: `/etc/init.d/linux-patch-api`
### Manual Installation
For systems without package manager support:
```bash
# Run interactive installer (requires root)
./install.sh
sudo ./install.sh
```
The installer will:
- Detect operating system
- Create system user and group
- Set up directory structure
- Install binary and configuration files
- Create directory structure (`/etc/linux_patch_api/`, `/var/lib/linux_patch_api/`, `/var/log/linux_patch_api/`)
- Install binary to `/usr/bin/linux-patch-api`
- Install example configs
- Configure systemd service
- Set correct permissions
### Building from Source
#### Prerequisites (all platforms)
- Rust toolchain (stable channel, 1.75+)
- OpenSSL development headers
- systemd development headers
- C compiler (gcc)
#### Build Debian Package (.deb)
```bash
# Clone repository
git clone https://gitea.internal/linux-patch-api.git
cd linux-patch-api
# Build release binary
cargo build --release --target x86_64-unknown-linux-gnu
# Build Debian package
dpkg-buildpackage -us -uc -b
# Or build RPM package
rpmbuild -ba linux-patch-api.spec
# On Debian/Ubuntu
apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev cargo rustc
cargo build --release
sudo dpkg-buildpackage -us -uc -b
```
#### Build RPM Package (.rpm)
```bash
# On Fedora/RHEL/CentOS
dnf install -y rpm-build cargo rust gcc openssl-devel systemd-devel pkgconfig
cargo build --release --target x86_64-unknown-linux-gnu
chmod +x build-rpm.sh
./build-rpm.sh
```
**Note:** The RPM spec includes `BuildRequires` for native RPM build environments. When building in CI containers (where deps are pre-installed via apt-get), these are informational only.
#### Build Arch Package (.pkg.tar.zst)
```bash
# On Arch Linux/Manjaro
pacman -Syu --noconfirm rust cargo systemd git base-devel gcc
cargo build --release
chmod +x build-arch.sh
SKIP_CARGO_BUILD=1 ./build-arch.sh
```
**Note:** The build script creates a `builduser` for `makepkg` when running as root (typical in CI). The `.install` hook handles directory creation, config copying, and service enablement.
#### Build Alpine Package (.apk)
```bash
# On Alpine Linux 3.18+
apk add --no-cache alpine-sdk rust cargo openssl openssl-dev elogind-dev musl-dev abuild gcc
cargo build --release --target x86_64-unknown-linux-musl
chmod +x build-alpine.sh
SKIP_CARGO_BUILD=1 ./build-alpine.sh
```
**Important:** Alpine requires the `x86_64-unknown-linux-musl` target for static linking. The build script handles `abuild` key generation and runs as a `builduser` when executed as root.
See [BUILD_PACKAGES.md](./BUILD_PACKAGES.md) for detailed build instructions.
---

View File

@ -169,7 +169,7 @@ The enrollment flow runs before mTLS server startup. On success, the daemon proc
### Phase 1: Registration Request
- **Identity Extraction:**
- `/etc/machine-id` (fallback: `/var/lib/dbus/machine-id`)
- FQDN from `/etc/hostname` `hostname -f``hostname``localhost`
- FQDN from `hostname -f` (validated contains `.`) → `hostname` + `hostname -d``/etc/hostname``hostname``localhost`
- Non-loopback IPv4 addresses via network interface enumeration
- OS details from `/etc/os-release` (distro, version, id_like, codename) + kernel version (`uname -r`)
- **Submission:** Unauthenticated `POST /api/v1/enroll` to manager with identity payload

View File

@ -44,27 +44,48 @@ else
echo "Skipping cargo build (SKIP_CARGO_BUILD is set)"
fi
# Create package directory in /home/builduser (accessible by builduser)
PKGDIR=/home/builduser/apk-package
mkdir -p "$PKGDIR"/usr/bin
mkdir -p "$PKGDIR"/etc/linux_patch_api
mkdir -p "$PKGDIR"/etc/init.d
# Get version from Cargo.toml
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
# Copy files
# Create package directory structure
PKGDIR=$(pwd)/apk-package
rm -rf "$PKGDIR"
mkdir -p "$PKGDIR"/usr/bin
mkdir -p "$PKGDIR"/etc/linux_patch_api/certs
mkdir -p "$PKGDIR"/etc/init.d
mkdir -p "$PKGDIR"/var/lib/linux_patch_api
mkdir -p "$PKGDIR"/var/log/linux_patch_api
# Copy binary
chmod 755 target/x86_64-unknown-linux-musl/release/linux-patch-api
cp target/x86_64-unknown-linux-musl/release/linux-patch-api "$PKGDIR"/usr/bin/
chmod 755 "$PKGDIR"/usr/bin/linux-patch-api
# Copy OpenRC init script
cp configs/linux-patch-api-openrc "$PKGDIR"/etc/init.d/linux-patch-api
chmod 755 "$PKGDIR"/etc/init.d/linux-patch-api
cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml
# Use /home/builduser as workspace for APKBUILD
WORKSPACE_DIR=/home/builduser
# Copy example configs (as .example files - install script creates live configs)
cp configs/config.yaml.example "$PKGDIR"/etc/linux_patch_api/config.yaml.example
cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml.example
# Create APKBUILD
# Prepare workspace for abuild
WORKSPACE_DIR=/home/builduser/repo
mkdir -p "$WORKSPACE_DIR"
# Copy install script to workspace (must be co-located with APKBUILD)
cp configs/linux-patch-api.apk-install "$WORKSPACE_DIR"/linux-patch-api.apk-install
# Copy package directory to workspace
cp -r "$PKGDIR" "$WORKSPACE_DIR"/apk-package
# Copy entire repo to workspace for source references
cp -r . "$WORKSPACE_DIR"/src/
# Create APKBUILD in workspace directory (co-located with install script)
echo "Creating APKBUILD..."
cat > APKBUILD << EOF
cat > "$WORKSPACE_DIR"/APKBUILD << EOF
pkgname=linux-patch-api
pkgver=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
pkgver=${VERSION}
pkgrel=1
pkgdesc="Secure remote package management API for Linux systems"
url="https://gitea.moon-dragon.us/echo/linux_patch_api"
@ -72,21 +93,24 @@ arch="x86_64"
license="MIT"
makedepends=""
depends="openrc"
install="linux-patch-api.apk-install"
subpackages=""
source=""
package() {
install -d "\$pkgdir"/usr/bin
install -d "\$pkgdir"/etc/linux_patch_api
install -d "\$pkgdir"/etc/linux_patch_api/certs
install -d "\$pkgdir"/etc/init.d
cp -r ${WORKSPACE_DIR}/apk-package/usr/bin/* "\$pkgdir"/usr/bin/
cp -r ${WORKSPACE_DIR}/apk-package/etc/linux_patch_api/* "\$pkgdir"/etc/linux_patch_api/
cp -r ${WORKSPACE_DIR}/apk-package/etc/init.d/* "\$pkgdir"/etc/init.d/
install -d "\$pkgdir"/var/lib/linux_patch_api
install -d "\$pkgdir"/var/log/linux_patch_api
install -Dm755 "\$startdir"/apk-package/usr/bin/linux-patch-api "\$pkgdir"/usr/bin/linux-patch-api
install -Dm755 "\$startdir"/apk-package/etc/init.d/linux-patch-api "\$pkgdir"/etc/init.d/linux-patch-api
install -Dm644 "\$startdir"/apk-package/etc/linux_patch_api/config.yaml.example "\$pkgdir"/etc/linux_patch_api/config.yaml.example
install -Dm644 "\$startdir"/apk-package/etc/linux_patch_api/whitelist.yaml.example "\$pkgdir"/etc/linux_patch_api/whitelist.yaml.example
}
EOF
# Generate checksums for APKBUILD sources
echo "Generating checksums..."
# Build APK package
echo "Building APK package..."
@ -96,10 +120,8 @@ if [ "$(id -u)" = "0" ]; then
adduser -D -s /bin/sh builduser 2>/dev/null || true
addgroup builduser abuild 2>/dev/null || usermod -aG abuild builduser
# Copy repo contents to builduser home (accessible directory)
cp -r . /home/builduser/repo/
chown -R builduser:builduser /home/builduser/repo/
chown -R builduser:builduser /home/builduser/apk-package/
# Set ownership of workspace
chown -R builduser:builduser "$WORKSPACE_DIR"
# Set up builduser home directory for abuild
mkdir -p /home/builduser/.abuild
@ -115,32 +137,25 @@ if [ "$(id -u)" = "0" ]; then
echo "PACKAGER_PRIVKEY=\"$KEYFILE\"" > /home/builduser/.abuild/abuild.conf
chown builduser:builduser /home/builduser/.abuild/abuild.conf
# Copy APKBUILD and checksums to builduser home for abuild
cp APKBUILD /home/builduser/
cp .checksums /home/builduser/ 2>/dev/null || true
# Install public key BEFORE abuild (fixes UNTRUSTED signature)
cp /home/builduser/.abuild/*.rsa.pub /etc/apk/keys/ 2>/dev/null || true
# Run abuild as builduser in /home/builduser where APKBUILD exists
# Run abuild as builduser in workspace directory
# Use || true because index update may fail but APK is still created
su - builduser -c "cd /home/builduser && abuild checksum && abuild -d -F" || true
su - builduser -c "cd $WORKSPACE_DIR && abuild checksum && abuild -d -F" || true
# Copy APK from builduser packages to releases
mkdir -p releases
cp /home/builduser/packages/x86_64/*.apk releases/ 2>/dev/null || cp /home/builduser/packages/*.apk releases/ 2>/dev/null || find /home/builduser/packages -name "*.apk" -exec cp {} releases/ \; 2>/dev/null || true
else
cd "$WORKSPACE_DIR"
abuild checksum
abuild -F -r
cd -
mkdir -p releases
cp ~/packages/x86_64/*.apk releases/ 2>/dev/null || cp ~/packages/*.apk releases/ 2>/dev/null || true
fi
# Copy to releases directory (fallback for non-root builds)
echo ""
echo "Copying package to releases/..."
mkdir -p releases
cp ~/packages/x86_64/*.apk releases/ 2>/dev/null || cp ~/packages/*.apk releases/ 2>/dev/null || find ~/packages -name "*.apk" -exec cp {} releases/ \; 2>/dev/null || true
echo ""
echo "=== Build Complete ==="
echo "Package: releases/linux-patch-api-*.apk"

View File

@ -22,43 +22,68 @@ else
echo "Skipping cargo build (SKIP_CARGO_BUILD is set)"
fi
# Create package directory
# Create package directory structure
PKGDIR=$(pwd)/arch-package
rm -rf "$PKGDIR"
mkdir -p "$PKGDIR"/usr/bin
mkdir -p "$PKGDIR"/etc/linux_patch_api
mkdir -p "$PKGDIR"/etc/linux_patch_api/certs
mkdir -p "$PKGDIR"/usr/lib/systemd/system
mkdir -p "$PKGDIR"/var/lib/linux_patch_api
mkdir -p "$PKGDIR"/var/log/linux_patch_api
# Copy files
# Copy binary
chmod 755 target/release/linux-patch-api
cp target/release/linux-patch-api "$PKGDIR"/usr/bin/
chmod 755 "$PKGDIR"/usr/bin/linux-patch-api
# Copy systemd service
cp configs/linux-patch-api.service "$PKGDIR"/usr/lib/systemd/system/
cp configs/config.yaml.example "$PKGDIR"/etc/linux_patch_api/config.yaml
cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml
# Copy example configs (as .example files - install script creates live configs)
cp configs/config.yaml.example "$PKGDIR"/etc/linux_patch_api/config.yaml.example
cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml.example
# Copy install script to current directory (must be co-located with PKGBUILD)
cp configs/linux-patch-api.install linux-patch-api.install
# Get version from Cargo.toml
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
# Create PKGBUILD with quoted heredoc to prevent $pkgdir expansion
# $pkgdir must be literal for makepkg to expand at runtime
echo "Creating PKGBUILD..."
cat > PKGBUILD << 'EOF'
pkgname=linux-patch-api
pkgver=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
pkgver=VERSION_PLACEHOLDER
pkgrel=1
pkgdesc="Secure remote package management API for Linux systems"
url="https://gitea.moon-dragon.us/echo/linux_patch_api"
arch=('x86_64')
license=('MIT')
depends=('systemd')
install=linux-patch-api.install
source=()
backup=(
'etc/linux_patch_api/config.yaml'
'etc/linux_patch_api/whitelist.yaml'
)
package() {
cp -r /home/builduser/repo/arch-package/* "$pkgdir"/
# Use $startdir because arch-package is co-located with PKGBUILD, not in sources
cp -r "$startdir"/arch-package/* "$pkgdir"/
# Ensure directories exist with proper structure
mkdir -p "$pkgdir"/etc/linux_patch_api/certs
mkdir -p "$pkgdir"/var/lib/linux_patch_api
mkdir -p "$pkgdir"/var/log/linux_patch_api
}
EOF
# Create .SRCINFO
echo "Creating .SRCINFO..."
# Replace version placeholder with actual version
sed -i "s/VERSION_PLACEHOLDER/$VERSION/" PKGBUILD
echo "PKGBUILD version: $VERSION"
# Build package
echo "Building Arch package..."
# For CI environments where we may run as root
if [ "$(id -u)" = "0" ]; then
echo "Running as root - creating build user for makepkg..."
@ -69,12 +94,22 @@ if [ "$(id -u)" = "0" ]; then
cp -r . /home/builduser/repo/
chown -R builduser:builduser /home/builduser/repo/
# Create source tarball for makepkg
# makepkg expects sources to be in $srcdir after extraction
# We create a tarball of arch-package so %autosetup or prepare can extract it
cd /home/builduser/repo
su - builduser -c "cd /home/builduser/repo && makepkg --printsrcinfo > .SRCINFO"
su - builduser -c "cd /home/builduser/repo && makepkg -f --noconfirm"
# Copy package to releases
mkdir -p /home/builduser/repo/releases
cp /home/builduser/repo/*.pkg.tar.zst /home/builduser/repo/releases/ 2>/dev/null || true
cd -
# Copy releases back to original directory
mkdir -p releases
cp /home/builduser/repo/*.pkg.tar.zst releases/
cp /home/builduser/repo/releases/*.pkg.tar.zst releases/ 2>/dev/null || true
else
makepkg --printsrcinfo > .SRCINFO
makepkg -f --noconfirm

View File

@ -21,27 +21,38 @@ if ! command -v rpmbuild &> /dev/null; then
fi
fi
# Get version from Cargo.toml
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
echo "Building version: $VERSION"
# Setup RPM build directory structure
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
# Create source tarball (required by %autosetup in spec file)
echo "Creating source tarball..."
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
TMPDIR=$(mktemp -d)
mkdir -p "$TMPDIR/linux-patch-api-${VERSION}"
# Copy files excluding unwanted directories using find
# Copy files excluding unnecessary directories
cp -r . "$TMPDIR/linux-patch-api-${VERSION}/"
# Remove unnecessary directories from tarball
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/target"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.git"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/releases"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.github"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/debian"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/arch-package"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.abuild"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/apk-package"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.a0proj"
tar -czf ~/rpmbuild/SOURCES/linux-patch-api-${VERSION}.tar.gz -C "$TMPDIR" "linux-patch-api-${VERSION}"
rm -rf "$TMPDIR"
# Copy spec file
# Prepare spec file with dynamic version
echo "Preparing spec file..."
cp linux-patch-api.spec ~/rpmbuild/SPECS/
sed "s/VERSION_PLACEHOLDER/$VERSION/" linux-patch-api.spec > ~/rpmbuild/SPECS/linux-patch-api.spec
# Build RPM
echo "Building RPM package..."

View File

@ -0,0 +1,81 @@
#!/bin/sh
# Alpine Linux install hooks for linux-patch-api
# Matches Debian preinst/postinst behavior: no system user, root:root ownership
# Alpine APKBUILD install script format: pre-install, post-install, pre-deinstall, post-deinstall
# Pre-install: Create directories before files are laid down
pre_install() {
# Create required directories
mkdir -p /etc/linux_patch_api/certs
mkdir -p /var/lib/linux_patch_api
mkdir -p /var/log/linux_patch_api
# Set proper ownership (service runs as root)
chown -R root:root /var/lib/linux_patch_api
chown -R root:root /var/log/linux_patch_api
# Set secure permissions
chmod 750 /etc/linux_patch_api
chmod 750 /etc/linux_patch_api/certs
chmod 755 /var/lib/linux_patch_api
chmod 755 /var/log/linux_patch_api
echo "Pre-installation setup completed"
}
# Post-install: Copy example configs, enable service
post_install() {
# Copy example configs if they don't exist
if [ ! -f "/etc/linux_patch_api/config.yaml" ]; then
if [ -f "/etc/linux_patch_api/config.yaml.example" ]; then
cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml
chmod 640 /etc/linux_patch_api/config.yaml
chown root:root /etc/linux_patch_api/config.yaml
fi
fi
if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then
if [ -f "/etc/linux_patch_api/whitelist.yaml.example" ]; then
cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml
chmod 640 /etc/linux_patch_api/whitelist.yaml
chown root:root /etc/linux_patch_api/whitelist.yaml
fi
fi
# Enable the service (but don't start automatically - admin should configure first)
rc-update add linux-patch-api default
echo ""
echo "linux-patch-api installed successfully!"
echo ""
echo "Next steps:"
echo " 1. Configure /etc/linux_patch_api/config.yaml with your settings"
echo " 2. Place TLS certificates in /etc/linux_patch_api/certs/"
echo " 3. Configure IP whitelist in /etc/linux_patch_api/whitelist.yaml"
echo " 4. Start the service: rc-service linux-patch-api start"
echo " 5. Check status: rc-service linux-patch-api status"
echo ""
}
# Pre-deinstall: Stop and disable service before files are removed
pre_deinstall() {
# Stop the service if running
if rc-service linux-patch-api status >/dev/null 2>&1; then
rc-service linux-patch-api stop
echo "Service stopped"
else
echo "Service was not running"
fi
# Disable the service
rc-update del linux-patch-api default 2>/dev/null || true
}
# Post-deinstall: Clean up on removal
post_deinstall() {
# Remove directories only if empty (preserve user data on reinstall)
rmdir /var/lib/linux_patch_api 2>/dev/null || true
rmdir /var/log/linux_patch_api 2>/dev/null || true
echo "linux-patch-api removed"
}

View File

@ -0,0 +1,81 @@
# Arch Linux install hooks for linux-patch-api
# Matches Debian preinst/postinst behavior: no system user, root:root ownership
post_install() {
# Create required directories
mkdir -p /etc/linux_patch_api/certs
mkdir -p /var/lib/linux_patch_api
mkdir -p /var/log/linux_patch_api
# Set proper ownership (service runs as root)
chown -R root:root /var/lib/linux_patch_api
chown -R root:root /var/log/linux_patch_api
# Set secure permissions
chmod 750 /etc/linux_patch_api
chmod 750 /etc/linux_patch_api/certs
chmod 755 /var/lib/linux_patch_api
chmod 755 /var/log/linux_patch_api
# Copy example configs if they don't exist
if [ ! -f "/etc/linux_patch_api/config.yaml" ]; then
cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml
chmod 640 /etc/linux_patch_api/config.yaml
chown root:root /etc/linux_patch_api/config.yaml
fi
if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then
cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml
chmod 640 /etc/linux_patch_api/whitelist.yaml
chown root:root /etc/linux_patch_api/whitelist.yaml
fi
# Reload systemd daemon
systemctl daemon-reload
# Enable the service (but don't start automatically - admin should configure first)
systemctl enable linux-patch-api.service
echo ""
echo "linux-patch-api installed successfully!"
echo ""
echo "Next steps:"
echo " 1. Configure /etc/linux_patch_api/config.yaml with your settings"
echo " 2. Place TLS certificates in /etc/linux_patch_api/certs/"
echo " 3. Configure IP whitelist in /etc/linux_patch_api/whitelist.yaml"
echo " 4. Start the service: systemctl start linux-patch-api"
echo " 5. Check status: systemctl status linux-patch-api"
echo ""
}
post_upgrade() {
# Reload systemd daemon on upgrade
systemctl daemon-reload
}
pre_remove() {
# Stop the service before removal
if systemctl is-active --quiet linux-patch-api.service; then
systemctl stop linux-patch-api.service
echo "Service stopped successfully"
else
echo "Service was not running"
fi
# Disable the service
if systemctl is-enabled --quiet linux-patch-api.service 2>/dev/null; then
systemctl disable linux-patch-api.service
echo "Service disabled"
fi
}
post_remove() {
# Reload systemd to remove service file
systemctl daemon-reload 2>/dev/null || true
# Remove directories only if empty (preserve user data on upgrade/reinstall)
rmdir --ignore-fail-on-non-empty /var/lib/linux_patch_api 2>/dev/null || true
rmdir --ignore-fail-on-non-empty /var/log/linux_patch_api 2>/dev/null || true
echo "linux-patch-api removed"
}

22
debian/changelog vendored
View File

@ -1,3 +1,25 @@
linux-patch-api (1.1.9-1) unstable; urgency=low
* Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline
* Remove system user creation (service runs as root)
* Fix ownership to root:root across all platforms
* Fix Alpine: co-locate install script with APKBUILD
* Fix Arch: correct $startdir path in PKGBUILD
* Fix RPM: add runtime deps, comment BuildRequires for CI
* Add comprehensive installation docs for all platforms
-- Echo <echo@moon-dragon.us> Tue, 19 May 2026 21:54:00 -0500
linux-patch-api (1.1.8-1) unstable; urgency=low
* Fix FQDN resolution: prioritize hostname -f over /etc/hostname for full domain
* Fix display_name blank: add hostname field to enrollment request
* Fix Arch package: add install scripts, user creation, directory creation
* Fix Alpine package: add install scripts, user creation, missing config.yaml
* Fix RPM package: dynamic version, config handling, tarball exclusions
-- Echo <echo@moon-dragon.us> Mon, 18 May 2026 19:34:00 -0500
linux-patch-api (1.1.7-1) unstable; urgency=low
* Fix CI pipeline: add cargo clean and remove old .deb artifacts before packaging

View File

@ -1,7 +1,7 @@
%global debug_package %{nil}
Name: linux-patch-api
Version: 1.0.0
Version: VERSION_PLACEHOLDER
Release: 1%{?dist}
Summary: Secure remote package management API for Linux systems
License: MIT
@ -10,19 +10,22 @@ Source0: linux-patch-api-%{version}.tar.gz
BuildArch: x86_64
# Build requirements
# NOTE: Building in Debian container (node:18) - apt packages don't register in RPM db
# Build tools ARE available (installed via apt-get in ci.yml), just won't validate
# NOTE: CI uses rustup to install cargo/rust, so they are NOT available as RPM packages.
# Only uncomment BuildRequires for native RPM build environments where cargo/rust
# are installed via dnf/yum package manager.
# BuildRequires: cargo >= 1.75
# BuildRequires: rust >= 1.75
# BuildRequires: systemd-rpm-macros # Handling systemd manually
# BuildRequires: pkgconfig(systemd)
# BuildRequires: gcc
# BuildRequires: openssl-devel
# BuildRequires: systemd-devel
# BuildRequires: pkgconfig(systemd)
# Runtime requirements
Requires: systemd
Requires: libsystemd
Requires: openssl-libs
Requires: ca-certificates
# Description
%description
Linux Patch API provides a secure, mTLS-authenticated REST API for
remote package management operations including:
@ -69,28 +72,16 @@ cp configs/config.yaml.example %{buildroot}/etc/linux_patch_api/config.yaml.exam
cp configs/whitelist.yaml.example %{buildroot}/etc/linux_patch_api/whitelist.yaml.example
chmod 644 %{buildroot}/etc/linux_patch_api/*.example
# Pre-installation script
# Pre-installation script - create directories (matches Debian preinst)
%pre
# Create system group
getent group linux-patch-api > /dev/null || groupadd --system linux-patch-api
# Create system user
getent passwd linux-patch-api > /dev/null || useradd --system \
--gid linux-patch-api \
--home-dir /var/lib/linux_patch_api \
--no-create-home \
--shell /usr/sbin/nologin \
--comment "Linux Patch API Service" \
linux-patch-api
# Create required directories
mkdir -p /etc/linux_patch_api/certs
mkdir -p /var/lib/linux_patch_api
mkdir -p /var/log/linux_patch_api
# Set proper ownership
chown -R linux-patch-api:linux-patch-api /var/lib/linux_patch_api
chown -R linux-patch-api:linux-patch-api /var/log/linux_patch_api
# Set proper ownership (service runs as root)
chown -R root:root /var/lib/linux_patch_api
chown -R root:root /var/log/linux_patch_api
# Set secure permissions
chmod 750 /etc/linux_patch_api
@ -98,19 +89,19 @@ chmod 750 /etc/linux_patch_api/certs
chmod 755 /var/lib/linux_patch_api
chmod 755 /var/log/linux_patch_api
# Post-installation script
# Post-installation script - copy configs, enable service (matches Debian postinst)
%post
# Copy example configs if they don't exist
if [ ! -f "/etc/linux_patch_api/config.yaml" ]; then
cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml
chmod 640 /etc/linux_patch_api/config.yaml
chown linux-patch-api:linux-patch-api /etc/linux_patch_api/config.yaml
chown root:root /etc/linux_patch_api/config.yaml
fi
if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then
cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml
chmod 640 /etc/linux_patch_api/whitelist.yaml
chown linux-patch-api:linux-patch-api /etc/linux_patch_api/whitelist.yaml
chown root:root /etc/linux_patch_api/whitelist.yaml
fi
# Reload systemd daemon
@ -162,6 +153,8 @@ fi
/lib/systemd/system/linux-patch-api.service
%config(noreplace) /etc/linux_patch_api/config.yaml.example
%config(noreplace) /etc/linux_patch_api/whitelist.yaml.example
%ghost %config(noreplace) /etc/linux_patch_api/config.yaml
%ghost %config(noreplace) /etc/linux_patch_api/whitelist.yaml
%dir /etc/linux_patch_api
%dir /etc/linux_patch_api/certs
%dir /var/lib/linux_patch_api
@ -169,7 +162,27 @@ fi
# Changelog
%changelog
* Thu Apr 09 2026 Echo <echo@moon-dragon.us> - 1.0.0-1
* Tue May 19 2026 Echo <echo@moon-dragon.us> - 1.1.9-1
- Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline
- Remove system user creation (service runs as root)
- Fix ownership to root:root across all platforms
- Fix Alpine: co-locate install script with APKBUILD
- Fix Arch: correct $startdir path in PKGBUILD
- Fix RPM: add runtime deps, comment BuildRequires for CI
- Add comprehensive installation docs for all platforms
* Tue May 19 2026 Echo <echo@moon-dragon.us> - 1.1.8-1
- Fix RPM packaging: runtime deps, match Debian install behavior, comment BuildRequires for CI
- Remove system user creation (service runs as root per systemd unit)
- Fix ownership to root:root matching Debian package
- Add openssl-libs and ca-certificates runtime dependencies
* Mon May 18 2026 Echo <echo@moon-dragon.us> - 1.1.8-1
- Fix FQDN resolution: prioritize hostname -f over /etc/hostname
- Fix display_name blank: add hostname field to enrollment request
- Fix Arch/Alpine/RPM packaging: install scripts, user creation, directory creation
* Thu Apr 09 2026 Echo <echo@moon-dragon.us> - 1.1.7-1
- Initial production release
- Secure mTLS-authenticated REST API for remote package management
- 15 API endpoints for package install/remove, patch application, system management

View File

@ -18,6 +18,10 @@ pub struct EnrollmentRequest {
pub fqdn: String,
pub ip_address: String,
pub os_details: serde_json::Value,
/// Short hostname (from /etc/hostname or hostname command).
/// Used by the manager to populate `display_name` on approval.
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
}
/// Response from `POST /api/v1/enroll` (HTTP 202).
@ -220,12 +224,18 @@ impl EnrollmentClient {
let os_details = identity::get_os_details()
.context("Failed to collect OS details — /etc/os-release may be missing")?;
// 2. Build EnrollmentRequest struct
// 2. Collect short hostname for display_name on manager
let hostname = identity::get_hostname()
.map_err(|e| tracing::warn!(error = %e, "Failed to determine hostname — display_name will use FQDN fallback"))
.ok();
// 3. Build EnrollmentRequest struct
let request = EnrollmentRequest {
machine_id,
fqdn,
ip_address,
os_details,
hostname,
};
tracing::info!(
@ -502,6 +512,7 @@ mod tests {
fqdn: "node.example.com".into(),
ip_address: "192.168.1.10".into(),
os_details: serde_json::json!({"distro": "Debian", "version": "12"}),
hostname: Some("node".into()),
};
let json = serde_json::to_string(&request).expect("Failed to serialize EnrollmentRequest");
assert!(json.contains("machine_id"));

View File

@ -31,36 +31,113 @@ pub fn get_machine_id() -> Result<String> {
}
/// Resolve the fully-qualified domain name.
/// Strategy: `gethostname` via std → fallback to `hostname` CLI → "localhost".
///
/// Strategy (in priority order):
/// 1. `hostname -f` → if result contains `.`, it's a real FQDN
/// 2. `hostname` + `hostname -d` → combine short hostname + domain
/// 3. `/etc/hostname` → short hostname fallback
/// 4. `hostname` command → last resort
/// 5. `"localhost"` → final fallback
pub fn get_fqdn() -> Result<String> {
// Try reading from hostname file first (common on systemd systems)
// 1. Try `hostname -f` — returns FQDN on properly configured systems
if let Ok(output) = Command::new("hostname").arg("-f").output() {
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() && name.contains('.') && name != "(none)" {
tracing::debug!(fqdn = %name, "Resolved FQDN via hostname -f");
return Ok(name);
}
}
}
// 2. Try combining short hostname + domain from `hostname -d`
if let Ok(short_output) = Command::new("hostname").output() {
if short_output.status.success() {
let short = String::from_utf8_lossy(&short_output.stdout)
.trim()
.to_string();
if !short.is_empty() && short != "(none)" {
if let Ok(domain_output) = Command::new("hostname").arg("-d").output() {
if domain_output.status.success() {
let domain = String::from_utf8_lossy(&domain_output.stdout)
.trim()
.to_string();
if !domain.is_empty() {
let fqdn = format!("{}.{}", short, domain);
tracing::debug!(fqdn = %fqdn, "Resolved FQDN via hostname + hostname -d");
return Ok(fqdn);
}
}
}
// Domain not available — fall through to try other methods
}
}
}
// 3. Try reading from /etc/hostname (common on systemd systems, usually short hostname)
if let Ok(name) = fs::read_to_string("/etc/hostname") {
let trimmed = name.trim().to_string();
if !trimmed.is_empty() && trimmed != "(none)" {
tracing::debug!(hostname = %trimmed, "Resolved hostname via /etc/hostname (may be short)");
return Ok(trimmed);
}
}
// Fallback to hostname command
if let Ok(output) = Command::new("hostname").arg("-f").output() {
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
return Ok(name);
}
}
}
// Fallback to plain hostname
// 4. Fallback to plain hostname command
if let Ok(output) = Command::new("hostname").output() {
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
tracing::debug!(hostname = %name, "Resolved hostname via hostname command");
return Ok(name);
}
}
}
// 5. Final fallback
tracing::warn!("Could not determine hostname — falling back to localhost");
Ok("localhost".into())
}
/// Resolve the short hostname (without domain).
///
/// Strategy: `/etc/hostname` → `hostname` command → split FQDN on `.` → `"localhost"`.
pub fn get_hostname() -> Result<String> {
// Try reading from /etc/hostname (usually contains the short hostname)
if let Ok(name) = fs::read_to_string("/etc/hostname") {
let trimmed = name.trim().to_string();
if !trimmed.is_empty() && trimmed != "(none)" {
// If it contains a dot, take just the first component
let short = trimmed.split('.').next().unwrap_or(&trimmed).to_string();
tracing::debug!(hostname = %short, "Resolved short hostname via /etc/hostname");
return Ok(short);
}
}
// Try hostname command
if let Ok(output) = Command::new("hostname").output() {
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
// If it contains a dot, take just the first component
let short = name.split('.').next().unwrap_or(&name).to_string();
tracing::debug!(hostname = %short, "Resolved short hostname via hostname command");
return Ok(short);
}
}
}
// Try splitting FQDN from get_fqdn()
if let Ok(fqdn) = get_fqdn() {
if fqdn != "localhost" && fqdn.contains('.') {
let short = fqdn.split('.').next().unwrap_or(&fqdn).to_string();
tracing::debug!(hostname = %short, "Resolved short hostname by splitting FQDN");
return Ok(short);
}
}
// Final fallback
tracing::warn!("Could not determine short hostname — falling back to localhost");
Ok("localhost".into())
}
@ -366,6 +443,56 @@ mod tests {
assert!(!fqdn.is_empty(), "FQDN should not be empty");
}
#[test]
fn fqdn_prefers_full_domain() {
// If hostname -f returns a value with a dot, get_fqdn should return it
// (not the short hostname from /etc/hostname)
let fqdn = get_fqdn().expect("Failed to get FQDN");
// On properly configured systems, FQDN should contain at least one dot
// If it doesn't, it's likely a short hostname from /etc/hostname
if fqdn.contains('.') {
// FQDN contains domain — good
assert!(
fqdn.split('.').count() >= 2,
"FQDN should have at least host.domain format, got: {}",
fqdn
);
}
// If no dot, it's a short hostname — acceptable fallback but not ideal
}
#[test]
fn hostname_is_not_empty() {
let hostname = get_hostname().expect("Failed to get hostname");
assert!(!hostname.is_empty(), "Hostname should not be empty");
}
#[test]
fn hostname_is_short_form() {
let hostname = get_hostname().expect("Failed to get hostname");
// Short hostname should NOT contain dots
assert!(
!hostname.contains('.'),
"Short hostname should not contain dots, got: {}",
hostname
);
}
#[test]
fn hostname_is_prefix_of_fqdn() {
let hostname = get_hostname().expect("Failed to get hostname");
let fqdn = get_fqdn().expect("Failed to get FQDN");
// If FQDN contains a dot, hostname should be the first component
if fqdn.contains('.') {
let fqdn_prefix = fqdn.split('.').next().unwrap_or(&fqdn);
assert_eq!(
hostname, fqdn_prefix,
"Short hostname '{}' should match FQDN prefix '{}'",
hostname, fqdn_prefix
);
}
}
#[test]
fn os_details_contains_kernel() {
let details = get_os_details().expect("Failed to get OS details");

View File

@ -16,7 +16,7 @@ pub use client::{
};
/// Re-export identity extraction functions.
pub use identity::{
get_fqdn, get_ip_addresses, get_ip_for_interface, get_machine_id, get_os_details,
get_fqdn, get_hostname, get_ip_addresses, get_ip_for_interface, get_machine_id, get_os_details,
get_primary_ip, get_route_source_ip, is_container_bridge, is_link_local,
};

View File

@ -0,0 +1,34 @@
# Fix Non-Ubuntu Package Builds
## Goal
All platform packages must produce identical install results as the Debian/Ubuntu package.
## Debian Baseline Behavior
- No system user creation (service runs as root)
- Directory ownership: root:root
- Create dirs: /etc/linux_patch_api/certs, /var/lib/linux_patch_api, /var/log/linux_patch_api
- Permissions: 750 on config dirs, 755 on data/log dirs
- Copy .example configs to live configs if not present
- Enable service (systemd or rc-update)
- Print next-steps message
## Tasks
- [x] 1. Fix Arch linux-patch-api.install - removed system user creation, root:root ownership, matches Debian
- [x] 2. Fix Arch build-arch.sh - fixed $startdir path, added source=() array
- [x] 3. Fix RPM linux-patch-api.spec - uncommented BuildRequires, added runtime deps, removed system user, root:root ownership
- [x] 4. Fix Alpine linux-patch-api.apk-install - removed system user creation, root:root ownership, matches Debian
- [x] 5. Fix Alpine build-alpine.sh - co-located install script with APKBUILD, used install -Dm commands
- [ ] 6. Verify all platforms produce consistent results (needs CI run)
## Changes Summary
### Arch Linux
- **configs/linux-patch-api.install**: Removed user/group creation, changed ownership to root:root, matches Debian preinst/postinst
- **build-arch.sh**: Fixed PKGBUILD package() to use $startdir (not $srcdir), added source=() array
### RPM (Fedora/RHEL/CentOS)
- **linux-patch-api.spec**: Uncommented BuildRequires (cargo, rust, gcc, openssl-devel, systemd-devel, pkgconfig(systemd)), added runtime Requires (openssl-libs, ca-certificates), removed system user creation from %pre, changed ownership to root:root in %pre, matches Debian behavior in %post
### Alpine Linux
- **configs/linux-patch-api.apk-install**: Removed addgroup/adduser, changed ownership to root:root, matches Debian preinst/postinst
- **build-alpine.sh**: Restructured to co-locate install script with APKBUILD in workspace directory, used install -Dm commands in package() function, fixed $startdir references

View File

@ -473,6 +473,21 @@ async fn test_registration_payload_structure() {
os_obj.contains_key("distro") || os_obj.contains_key("kernel"),
"os_details should contain distro or kernel information"
);
// Verify hostname field (optional, may be present or absent)
// When present, it should be a non-empty string without dots (short hostname)
if let Some(hostname) = payload.get("hostname").and_then(|v| v.as_str()) {
assert!(
!hostname.is_empty(),
"hostname should not be empty when present"
);
assert!(
!hostname.contains('.'),
"hostname should be short form (no dots), got: {}",
hostname
);
}
// hostname field is optional — its absence is valid (skip_serializing_if = None)
}
// =============================================================================

View File

@ -4,7 +4,7 @@
//! Verifies machine-id, FQDN, IP address collection, and OS detail parsing.
use linux_patch_api::enroll::identity::{
get_fqdn, get_ip_addresses, get_machine_id, get_os_details, get_primary_ip,
get_fqdn, get_hostname, get_ip_addresses, get_machine_id, get_os_details, get_primary_ip,
get_route_source_ip, is_container_bridge, is_link_local,
};
use linux_patch_api::enroll::EnrollmentRequest;
@ -138,6 +138,97 @@ fn test_fqdn_reasonable_length() {
);
}
// =============================================================================
// Hostname Tests
// =============================================================================
#[test]
fn test_hostname_returns_non_empty() {
let hostname = get_hostname().expect("Failed to get hostname");
assert!(!hostname.is_empty(), "Hostname should not be empty");
}
#[test]
fn test_hostname_is_short_form() {
let hostname = get_hostname().expect("Failed to get hostname");
// Short hostname should NOT contain dots (that would be an FQDN)
assert!(
!hostname.contains('.'),
"Short hostname should not contain dots, got: {}",
hostname
);
}
#[test]
fn test_hostname_is_consistent() {
let h1 = get_hostname().expect("Failed to get hostname (call 1)");
let h2 = get_hostname().expect("Failed to get hostname (call 2)");
assert_eq!(h1, h2, "Hostname should be consistent across calls");
}
#[test]
fn test_hostname_is_subset_of_fqdn() {
let hostname = get_hostname().expect("Failed to get hostname");
let fqdn = get_fqdn().expect("Failed to get FQDN");
// If FQDN contains a dot, the short hostname should be the first component
if fqdn.contains('.') {
let fqdn_prefix = fqdn.split('.').next().unwrap_or(&fqdn);
assert_eq!(
hostname, fqdn_prefix,
"Short hostname '{}' should match FQDN prefix '{}'",
hostname, fqdn_prefix
);
}
}
#[test]
fn test_hostname_valid_characters() {
let hostname = get_hostname().expect("Failed to get hostname");
for c in hostname.chars() {
assert!(
c.is_alphanumeric() || c == '-',
"Short hostname contains invalid character: {:?}",
c
);
}
}
#[test]
fn test_enrollment_hostname_field_serializes() {
// Verify that hostname field serializes correctly when Some and when None
let request_with_hostname = EnrollmentRequest {
machine_id: "test-id".to_string(),
fqdn: "host.example.com".to_string(),
ip_address: "10.0.0.1".to_string(),
os_details: serde_json::json!({"name": "Test"}),
hostname: Some("host".to_string()),
};
let json_with =
serde_json::to_string(&request_with_hostname).expect("Should serialize with hostname");
assert!(
json_with.contains("\"hostname\""),
"hostname field should be present in JSON when Some"
);
assert!(
json_with.contains("\"host\""),
"hostname value should be 'host' in JSON"
);
let request_without_hostname = EnrollmentRequest {
machine_id: "test-id".to_string(),
fqdn: "host.example.com".to_string(),
ip_address: "10.0.0.1".to_string(),
os_details: serde_json::json!({"name": "Test"}),
hostname: None,
};
let json_without = serde_json::to_string(&request_without_hostname)
.expect("Should serialize without hostname");
assert!(
!json_without.contains("\"hostname\""),
"hostname field should be omitted from JSON when None (skip_serializing_if)"
);
}
// =============================================================================
// IP Address Tests
// =============================================================================
@ -371,11 +462,14 @@ fn test_enrollment_payload_construction() {
.cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
let hostname = get_hostname().ok();
let request = EnrollmentRequest {
machine_id,
fqdn,
ip_address: primary_ip,
os_details,
hostname,
};
// Verify payload serializes to valid JSON
@ -412,11 +506,14 @@ fn test_enrollment_payload_matches_manager_schema() {
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses");
let os_details = get_os_details().expect("Failed to get OS details");
let hostname = get_hostname().ok();
let request = EnrollmentRequest {
machine_id: machine_id.clone(),
fqdn: fqdn.clone(),
ip_address: ip_addrs.first().cloned().unwrap_or_default(),
os_details: os_details.clone(),
hostname,
};
// Validate against expected manager API schema
@ -449,11 +546,14 @@ fn test_enrollment_payload_roundtrip() {
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses");
let os_details = get_os_details().expect("Failed to get OS details");
let hostname = get_hostname().ok();
let request = EnrollmentRequest {
machine_id,
fqdn,
ip_address: ip_addrs.first().cloned().unwrap_or_default(),
os_details,
hostname,
};
// Serialize to JSON then deserialize back
@ -464,6 +564,7 @@ fn test_enrollment_payload_roundtrip() {
assert_eq!(request.machine_id, deserialized.machine_id);
assert_eq!(request.fqdn, deserialized.fqdn);
assert_eq!(request.ip_address, deserialized.ip_address);
assert_eq!(request.hostname, deserialized.hostname);
}
// =============================================================================
@ -507,6 +608,10 @@ fn test_identity_functions_do_not_panic() {
let _ = get_fqdn();
});
let _ = std::panic::catch_unwind(|| {
let _ = get_hostname();
});
let _ = std::panic::catch_unwind(|| {
let _ = get_ip_addresses();
});