Private
Public Access
1
0

Compare commits

...

33 Commits

Author SHA1 Message Date
b932f6be38 docs: update changelog for v1.1.13
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 1s
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 1m48s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m25s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m33s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m54s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m7s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m13s
2026-05-20 18:54:33 +00:00
5fa7fd0f90 fix: detect apk at /sbin/apk on Alpine (not just /usr/bin/apk); v1.1.13 2026-05-20 18:54:10 +00:00
4d75bb0e29 feat: Add APK (Alpine Linux) package manager backend; machine-id generation; OpenRC fix; v1.1.12
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 1s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m48s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m22s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m32s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m51s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m55s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m8s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m13s
2026-05-20 17:25:21 +00:00
8d76b3ddfe docs: add Alpine packaging root cause analysis and access lesson
All checks were successful
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 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m10s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m39s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m43s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m53s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m3s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m19s
2026-05-20 15:59:49 +00:00
603c974116 fix: OpenRC init script - change ownership from linux-patch-api:linux-patch-api to root:root
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 2s
CI/CD Pipeline / Clippy Lints (push) Successful in 1m9s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m32s
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 1m19s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m38s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m53s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m36s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m8s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m22s
The system user was removed from all install scripts but the OpenRC init script
still referenced linux-patch-api:linux-patch-api in checkpath. This would cause
the service to fail on Alpine because the user does not exist.
2026-05-20 14:57:53 +00:00
e033cb8536 fix: Alpine install scripts - use separate files with valid abuild suffixes
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 1s
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 1m52s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m28s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m31s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m47s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m46s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m20s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m16s
Root cause: .apk-install is not a valid abuild suffix (lines 247-257 of abuild).
abuild expects SEPARATE files: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall.
The old single .apk-install file caused abuild to die with "unknown install script suffix",
but CI used || true which masked the failure, so APK was built WITHOUT install scripts.

Verified on actual Alpine runner: install script suffixes now pass abuild validation.

- configs/linux-patch-api.pre-install: create dirs, set permissions (matches Debian preinst)
- configs/linux-patch-api.post-install: copy example configs, enable service (matches Debian postinst)
- configs/linux-patch-api.pre-deinstall: stop and disable service (matches Debian prerm)
- configs/linux-patch-api.post-deinstall: clean up empty dirs (matches Debian postrm)
- Removed configs/linux-patch-api.apk-install (invalid format)
- Updated build-alpine.sh: copy 4 install scripts to workspace, updated install= line in APKBUILD
2026-05-20 12:43:37 +00:00
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
74288e1dfc fix(ci): add cargo clean and artifact removal before packaging; bump to 1.1.7
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 1s
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 1m56s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m25s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m29s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m55s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m10s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m15s
- Insert 'Clean previous build artifacts' step (cargo clean + rm old .deb)
  before Build Debian package in both build-deb and build-deb-u2204 jobs.
- Bump version to 1.1.7 to ensure a clean build from scratch.
- Update debian/changelog with 1.1.7-1 entry.
2026-05-18 17:18:11 +00:00
73a11e70e0 fix(certs): replace encrypted CA with unencrypted ECDSA P-256 CA
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
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 1m1s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m51s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m34s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m45s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m56s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m15s
- Replaced password-protected RSA CA with unencrypted ECDSA P-256 CA
  to prevent manager startup failures from encrypted keys.
- Regenerated server and client certificates (client001) with new CA.
- Updated CA_SETUP.md to use openssl genpkey (unencrypted) instead of
  openssl genrsa -aes256, with warning against encrypted keys.
2026-05-18 16:00:22 +00:00
fc0b42040e fix(server): add explicit rustls CryptoProvider initialization for v1.1.6
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 1s
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 1m52s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m23s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m21s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m47s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m57s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m15s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m19s
- Add rustls::crypto::aws_lc_rs::default_provider().install_default()
  in main() before any TLS operations to prevent startup panic
- Bump version from 1.1.5 to 1.1.6
- Update debian/changelog with 1.1.6-1 entry
2026-05-18 13:43:34 +00:00
0d8b9a4d94 style: fix cargo fmt in enroll_identity tests
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 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m53s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m23s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m21s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m48s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m55s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m10s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m14s
2026-05-18 12:29:22 +00:00
945febbe96 feat(enrollment): add route-based IP selection and fix package versioning for v1.1.5
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 1m12s
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
2026-05-18 03:35:46 +00:00
6b75d2ab01 fix(clippy): remove needless return in Docker-compatible test
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 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m13s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m55s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m26s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m37s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m8s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m34s
2026-05-18 02:11:45 +00:00
0d582f2fda style: apply cargo fmt formatting
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
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 5s
2026-05-18 02:06:25 +00:00
7c55c99e48 fix(enrollment): filter Docker bridge IPs and add report_interface/report_ip config
Some checks failed
CI/CD Pipeline / Code Format (push) Failing after 3s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
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 5s
- identity.rs: filter 172.16.0.0/12 (Docker bridge) and 169.254.0.0/16 (link-local)
  from get_ip_addresses() auto-detection
- identity.rs: add is_container_bridge(), is_link_local(),
  get_ip_for_interface(), get_primary_ip() functions
- client.rs: add report_interface/report_ip fields to EnrollmentClient,
  new with_ip_overrides() constructor, register() uses get_primary_ip()
- loader.rs: add report_interface/report_ip to EnrollmentConfig
- mod.rs: wire config overrides through to EnrollmentClient
- config.yaml.example: document new report_interface/report_ip options
- Tests: add 18 new bridge filtering/IP override tests, fix Docker
  container compatibility in existing tests
2026-05-18 02:02:54 +00:00
5b5791f52f fix(tests): update test suite for AppConfig::load signature change
All checks were successful
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 1m11s
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 1m14s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m27s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m55s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m38s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m11s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m34s
2026-05-17 22:28:17 +00:00
fed5e386ce fix(enroll): skip TLS validation during enrollment bootstrap to allow certificate acquisition
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Failing after 43s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Failing after 56s
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
2026-05-17 22:20:48 +00:00
f3555c1570 fix(ci): use github.ref_type for upload conditions to fix Gitea runner compatibility
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m12s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m17s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m22s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m37s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m8s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m27s
2026-05-17 21:05:43 +00:00
cea162b048 fix(ci): force IPv4 for rustup download on Alpine runner
Some checks failed
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 / Enrollment Tests (push) Has been cancelled
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been cancelled
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 RPM Package (push) Has been cancelled
CI/CD Pipeline / Build Alpine Package (push) Has been cancelled
CI/CD Pipeline / Build Arch Package (push) Has been cancelled
CI/CD Pipeline / Security Audit (push) Has been cancelled
2026-05-17 20:35:48 +00:00
08493fc782 fix(ci): add openssl runtime package for Alpine musl builds
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
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 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m1s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m29s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m54s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m36s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m55s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m45s
2026-05-17 18:40:47 +00:00
8b890625f6 fix(ci): disable reqwest default features to eliminate OpenSSL on musl builds
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 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 58s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m40s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m14s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m12s
CI/CD Pipeline / Build Alpine Package (push) Failing after 5m2s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m3s
Requiring default-features=false on reqwest prevents native-tls/openssl-sys
from being pulled in as transitive dependencies, which broke static linking
on Alpine musl target. Also reverts invalid openssl-static package from CI.

- Cargo.toml: add default-features = false to reqwest dependency
- ci.yml: revert non-existent openssl-static package
2026-05-17 17:18:35 +00:00
835c8d79cf fix(ci): add openssl-static for Alpine musl static linking
Some checks failed
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 1m15s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m10s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m31s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m17s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m25s
The Alpine build job links against musl which requires static OpenSSL
libraries. Adding openssl-static package to resolve -lssl and -lcrypto
linker errors.
2026-05-17 17:07:10 +00:00
8fd7d7620a fix(ci): add OpenSSL dev dependencies to all build jobs
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
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 1m16s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m9s
CI/CD Pipeline / Build Arch Package (push) Successful in 3m2s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m37s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m47s
CI/CD Pipeline / Build Alpine Package (push) Failing after 4m9s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m56s
Add libssl-dev to Ubuntu-based runners and openssl-devel to Fedora runner
to resolve openssl-sys crate compilation failures in CI pipeline.

- clippy, test, audit: +libssl-dev
- enrollment-tests, verify-enrollment-cli: +libssl-dev
- build-deb, build-deb-u2204: +libssl-dev
- build-rpm (Fedora): +openssl-devel
2026-05-17 16:48:43 +00:00
3e8eacab9a fix(tests): resolve all clippy warnings for CI compliance
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m15s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m4s
CI/CD Pipeline / Build RPM Package (push) Failing after 42s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m55s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m35s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m37s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m50s
- Remove needless borrows on format!() in set_body_string() calls (needless_borrows_for_generic_args)
- Replace assert!(false, ...) with collected assertion (assertions_on_constants + never_loop)
- Use direct Method::POST comparison instead of to_string() (cmp_owned)
- Simplify negated equality to != operator (nonminimal_bool)

CI pipeline now passes with -D warnings enabled
2026-05-17 16:02:57 +00:00
a09e3eaa68 fix: add truncate(true) to lock file OpenOptions for clippy compliance
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
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 5s
Resolves clippy::suspicious_open_options warning on whitelist lock file creation.
2026-05-17 15:21:52 +00:00
6cfef766a7 fix: apply cargo fmt to resolve CI formatting failures
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m15s
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
Format all enrollment module source files and tests per rustfmt standards.
Resolves Gitea CI workflow cargo fmt check failures.
2026-05-17 05:49:26 +00:00
44 changed files with 3055 additions and 891 deletions

Binary file not shown.

View File

@ -49,7 +49,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get -f install -y sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run clippy - name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings run: cargo clippy --all-targets --all-features -- -D warnings
@ -71,7 +71,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get -f install -y sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run tests - name: Run tests
run: cargo test --all-features run: cargo test --all-features
@ -93,7 +93,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get -f install -y sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run cargo-audit - name: Run cargo-audit
run: | run: |
cargo install cargo-audit cargo install cargo-audit
@ -118,7 +118,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get -f install -y sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run enrollment unit tests - name: Run enrollment unit tests
run: cargo test --test enroll_identity run: cargo test --test enroll_identity
- name: Run enrollment integration tests - name: Run enrollment integration tests
@ -145,7 +145,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get -f install -y sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Build binary - name: Build binary
run: cargo build run: cargo build
- name: Verify --enroll flag exists - name: Verify --enroll flag exists
@ -170,12 +170,16 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get -f install -y sudo apt-get -f install -y
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev
- name: Clean previous build artifacts
run: |
cargo clean
rm -f ../linux-patch-api_*.deb
- name: Build Debian package - name: Build Debian package
run: | run: |
sudo dpkg-buildpackage -us -uc -b -d sudo dpkg-buildpackage -us -uc -b -d
- name: Upload to Gitea Release - name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/') if: github.ref_type == 'tag'
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: | run: |
@ -203,12 +207,16 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get -f install -y sudo apt-get -f install -y
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev
- name: Clean previous build artifacts
run: |
cargo clean
rm -f ../linux-patch-api_*.deb
- name: Build Debian package - name: Build Debian package
run: | run: |
sudo dpkg-buildpackage -us -uc -b -d sudo dpkg-buildpackage -us -uc -b -d
- name: Upload to Gitea Release - name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/') if: github.ref_type == 'tag'
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: | run: |
@ -240,7 +248,7 @@ jobs:
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install build dependencies - name: Install build dependencies
run: | run: |
sudo dnf install -y gcc rpm-build systemd-devel pkg-config sudo dnf install -y gcc rpm-build systemd-devel pkg-config openssl-devel
- name: Build release binary - name: Build release binary
run: cargo build --release run: cargo build --release
- name: Build RPM package - name: Build RPM package
@ -248,7 +256,7 @@ jobs:
chmod +x build-rpm.sh chmod +x build-rpm.sh
./build-rpm.sh ./build-rpm.sh
- name: Upload to Gitea Release - name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/') if: github.ref_type == 'tag'
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: | run: |
@ -271,13 +279,13 @@ jobs:
- name: Install Rust - name: Install Rust
run: | run: |
apk add --no-cache curl bash apk add --no-cache curl bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal curl --ipv4 --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
rustup target add x86_64-unknown-linux-musl rustup target add x86_64-unknown-linux-musl
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install build dependencies - name: Install build dependencies
run: | run: |
apk add --no-cache alpine-sdk rust cargo openssl-dev elogind-dev musl-dev abuild gcc apk add --no-cache alpine-sdk rust cargo openssl openssl-dev elogind-dev musl-dev abuild gcc
- name: Build release binary - name: Build release binary
run: cargo build --release --target x86_64-unknown-linux-musl run: cargo build --release --target x86_64-unknown-linux-musl
- name: Build Alpine package - name: Build Alpine package
@ -285,7 +293,7 @@ jobs:
chmod +x build-alpine.sh chmod +x build-alpine.sh
SKIP_CARGO_BUILD=1 ./build-alpine.sh SKIP_CARGO_BUILD=1 ./build-alpine.sh
- name: Upload to Gitea Release - name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/') if: github.ref_type == 'tag'
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: | run: |

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 | | `fqdn` | string | Yes | Fully qualified domain name of the host |
| `ip_address` | string | Yes | Primary non-loopback IPv4 address | | `ip_address` | string | Yes | Primary non-loopback IPv4 address |
| `os_details` | object | Yes | OS metadata (free-form JSON object) | | `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:** **`os_details` common fields:**
@ -933,7 +934,8 @@ curl -X POST https://manager.example.com/api/v1/enroll \
"version_id": "12", "version_id": "12",
"kernel": "6.1.0-kali9-amd64", "kernel": "6.1.0-kali9-amd64",
"id_like": "debian" "id_like": "debian"
} },
"hostname": "host-01"
}' }'
``` ```

View File

@ -1,6 +1,6 @@
# Linux Patch API - Package Build Guide # 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 ## Prerequisites
@ -173,6 +173,152 @@ rpm -ql linux-patch-api
rpm -e 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 ## Using the Interactive Installer
For manual deployment without package managers: For manual deployment without package managers:
@ -209,15 +355,17 @@ The installer will:
| `/var/lib/linux_patch_api/` | Data directory | 755 | | `/var/lib/linux_patch_api/` | Data directory | 755 |
| `/var/log/linux_patch_api/` | Log directory | 755 | | `/var/log/linux_patch_api/` | Log directory | 755 |
### System User/Group ### Service Account
| Property | Value | | Property | Value |
|----------|-------| |----------|-------|
| User | linux-patch-api | | User | root |
| Group | linux-patch-api | | Group | root |
| Home | /var/lib/linux_patch_api | | Home | /var/lib/linux_patch_api |
| Shell | /usr/sbin/nologin | | Shell | N/A (systemd service) |
| Type | System account | | 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 ## Supported Distributions
@ -240,6 +388,19 @@ The installer will:
| AlmaLinux | 8, 9 | ✅ Supported | | AlmaLinux | 8, 9 | ✅ Supported |
| Rocky Linux | 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 ## Troubleshooting
### Debian Package Issues ### Debian Package Issues
@ -276,9 +437,62 @@ cat ~/rpmbuild/BUILDROOT/*/var/log/rpmbuild.log
dnf install -y systemd-devel pkgconfig 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 Issues
**Service fails to start:** **Service fails to start (systemd):**
```bash ```bash
# Check service status # Check service status
systemctl status linux-patch-api 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/ 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 ## CI/CD Integration
### GitHub Actions Example ### GitHub Actions Example
@ -383,7 +613,7 @@ jobs:
- Packages are signed with maintainer GPG key for production deployments - Packages are signed with maintainer GPG key for production deployments
- All maintainer scripts run with `set -e` for fail-fast behavior - All maintainer scripts run with `set -e` for fail-fast behavior
- Configuration files are marked as conffiles to preserve user modifications - 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 - Directory permissions follow principle of least privilege
- TLS certificates should be replaced with CA-signed certs in production - TLS certificates should be replaced with CA-signed certs in production

205
Cargo.lock generated
View File

@ -821,26 +821,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@ -1193,15 +1173,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@ -1209,7 +1180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared 0.3.1", "foreign-types-shared",
] ]
[[package]] [[package]]
@ -1223,12 +1194,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@ -1606,22 +1571,6 @@ dependencies = [
"webpki-roots", "webpki-roots",
] ]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@ -1640,11 +1589,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.3", "socket2 0.6.3",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@ -1969,7 +1916,7 @@ dependencies = [
[[package]] [[package]]
name = "linux-patch-api" name = "linux-patch-api"
version = "0.3.12" version = "1.1.13"
dependencies = [ dependencies = [
"actix", "actix",
"actix-rt", "actix-rt",
@ -2123,23 +2070,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.30.1" version = "0.30.1"
@ -2270,49 +2200,6 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "ordered-multimap" name = "ordered-multimap"
version = "0.7.3" version = "0.7.3"
@ -2755,20 +2642,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"h2 0.4.13",
"http 1.4.0", "http 1.4.0",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@ -2779,7 +2661,6 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls",
"tokio-rustls", "tokio-rustls",
"tower", "tower",
"tower-http", "tower-http",
@ -2941,15 +2822,6 @@ dependencies = [
"sdd", "sdd",
] ]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -2962,29 +2834,6 @@ version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -3260,27 +3109,6 @@ dependencies = [
"windows 0.52.0", "windows 0.52.0",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "systemd" name = "systemd"
version = "0.10.1" version = "0.10.1"
@ -3288,7 +3116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01e9d1976a15b86245def55d20d52b5818e1a1e81aa030b6a608d3ce57709423" checksum = "01e9d1976a15b86245def55d20d52b5818e1a1e81aa030b6a608d3ce57709423"
dependencies = [ dependencies = [
"cstr-argument", "cstr-argument",
"foreign-types 0.5.0", "foreign-types",
"libc", "libc",
"libsystemd-sys", "libsystemd-sys",
"log", "log",
@ -3461,16 +3289,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@ -3823,12 +3641,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -4129,17 +3941,6 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "linux-patch-api" name = "linux-patch-api"
version = "0.3.12" version = "1.1.13"
edition = "2021" edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"] authors = ["Echo <echo@moon-dragon.us>"]
description = "Secure remote package management API for Linux systems" description = "Secure remote package management API for Linux systems"
@ -64,7 +64,7 @@ addr = "0.15"
if-addrs = "0.13" if-addrs = "0.13"
# HTTP client for enrollment communication # HTTP client for enrollment communication
reqwest = { version = "0.12", features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# Clap for CLI arguments # Clap for CLI arguments
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }

164
README.md
View File

@ -185,6 +185,13 @@ For detailed enrollment procedures, see [DEPLOYMENT_GUIDE.md - Self-Enrollment D
### Package Installation ### 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) #### Debian/Ubuntu (.deb)
```bash ```bash
@ -197,52 +204,173 @@ apt-get install -f -y
# Verify installation # Verify installation
systemctl status linux-patch-api systemctl status linux-patch-api
linux-patch-api --version 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) #### RHEL/CentOS/Fedora (.rpm)
```bash ```bash
# Install the package # Install the package (recommended - resolves dependencies automatically)
rpm -ivh linux-patch-api-1.0.0-1.x86_64.rpm 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 # Verify installation
systemctl status linux-patch-api systemctl status linux-patch-api
linux-patch-api --version 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 ### Manual Installation
For systems without package manager support: For systems without package manager support:
```bash ```bash
# Run interactive installer (requires root) # Run interactive installer (requires root)
./install.sh sudo ./install.sh
``` ```
The installer will: The installer will:
- Detect operating system - Detect operating system
- Create system user and group - Create directory structure (`/etc/linux_patch_api/`, `/var/lib/linux_patch_api/`, `/var/log/linux_patch_api/`)
- Set up directory structure - Install binary to `/usr/bin/linux-patch-api`
- Install binary and configuration files - Install example configs
- Configure systemd service - Configure systemd service
- Set correct permissions
### Building from Source ### 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 ```bash
# Clone repository # On Debian/Ubuntu
git clone https://gitea.internal/linux-patch-api.git apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev cargo rustc
cd linux-patch-api cargo build --release
sudo dpkg-buildpackage -us -uc -b
# 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
``` ```
#### 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. 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 ### Phase 1: Registration Request
- **Identity Extraction:** - **Identity Extraction:**
- `/etc/machine-id` (fallback: `/var/lib/dbus/machine-id`) - `/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 - Non-loopback IPv4 addresses via network interface enumeration
- OS details from `/etc/os-release` (distro, version, id_like, codename) + kernel version (`uname -r`) - 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 - **Submission:** Unauthenticated `POST /api/v1/enroll` to manager with identity payload

View File

@ -44,27 +44,51 @@ else
echo "Skipping cargo build (SKIP_CARGO_BUILD is set)" echo "Skipping cargo build (SKIP_CARGO_BUILD is set)"
fi fi
# Create package directory in /home/builduser (accessible by builduser) # Get version from Cargo.toml
PKGDIR=/home/builduser/apk-package VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
mkdir -p "$PKGDIR"/usr/bin
mkdir -p "$PKGDIR"/etc/linux_patch_api
mkdir -p "$PKGDIR"/etc/init.d
# 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/ 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 cp configs/linux-patch-api-openrc "$PKGDIR"/etc/init.d/linux-patch-api
chmod 755 "$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 # Copy example configs (as .example files - install script creates live configs)
WORKSPACE_DIR=/home/builduser 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
rm -rf "$WORKSPACE_DIR"
mkdir -p "$WORKSPACE_DIR"
# Copy package directory to workspace
cp -r "$PKGDIR" "$WORKSPACE_DIR"/apk-package
# Copy install scripts to workspace (must be co-located with APKBUILD)
# Alpine abuild requires SEPARATE files with valid suffixes:
# pkgname.pre-install, pkgname.post-install, pkgname.pre-deinstall, pkgname.post-deinstall
cp configs/linux-patch-api.pre-install "$WORKSPACE_DIR"/linux-patch-api.pre-install
cp configs/linux-patch-api.post-install "$WORKSPACE_DIR"/linux-patch-api.post-install
cp configs/linux-patch-api.pre-deinstall "$WORKSPACE_DIR"/linux-patch-api.pre-deinstall
cp configs/linux-patch-api.post-deinstall "$WORKSPACE_DIR"/linux-patch-api.post-deinstall
# Create APKBUILD in workspace directory (co-located with install scripts)
echo "Creating APKBUILD..." echo "Creating APKBUILD..."
cat > APKBUILD << EOF cat > "$WORKSPACE_DIR"/APKBUILD << EOF
pkgname=linux-patch-api pkgname=linux-patch-api
pkgver=1.0.0 pkgver=${VERSION}
pkgrel=1 pkgrel=1
pkgdesc="Secure remote package management API for Linux systems" pkgdesc="Secure remote package management API for Linux systems"
url="https://gitea.moon-dragon.us/echo/linux_patch_api" url="https://gitea.moon-dragon.us/echo/linux_patch_api"
@ -72,21 +96,24 @@ arch="x86_64"
license="MIT" license="MIT"
makedepends="" makedepends=""
depends="openrc" depends="openrc"
install="linux-patch-api.pre-install linux-patch-api.post-install linux-patch-api.pre-deinstall linux-patch-api.post-deinstall"
subpackages=""
source="" source=""
package() { package() {
install -d "\$pkgdir"/usr/bin 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 install -d "\$pkgdir"/etc/init.d
cp -r ${WORKSPACE_DIR}/apk-package/usr/bin/* "\$pkgdir"/usr/bin/ install -d "\$pkgdir"/var/lib/linux_patch_api
cp -r ${WORKSPACE_DIR}/apk-package/etc/linux_patch_api/* "\$pkgdir"/etc/linux_patch_api/ install -d "\$pkgdir"/var/log/linux_patch_api
cp -r ${WORKSPACE_DIR}/apk-package/etc/init.d/* "\$pkgdir"/etc/init.d/
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 EOF
# Generate checksums for APKBUILD sources
echo "Generating checksums..."
# Build APK package # Build APK package
echo "Building APK package..." echo "Building APK package..."
@ -96,10 +123,8 @@ if [ "$(id -u)" = "0" ]; then
adduser -D -s /bin/sh builduser 2>/dev/null || true adduser -D -s /bin/sh builduser 2>/dev/null || true
addgroup builduser abuild 2>/dev/null || usermod -aG abuild builduser addgroup builduser abuild 2>/dev/null || usermod -aG abuild builduser
# Copy repo contents to builduser home (accessible directory) # Set ownership of workspace
cp -r . /home/builduser/repo/ chown -R builduser:builduser "$WORKSPACE_DIR"
chown -R builduser:builduser /home/builduser/repo/
chown -R builduser:builduser /home/builduser/apk-package/
# Set up builduser home directory for abuild # Set up builduser home directory for abuild
mkdir -p /home/builduser/.abuild mkdir -p /home/builduser/.abuild
@ -115,35 +140,27 @@ if [ "$(id -u)" = "0" ]; then
echo "PACKAGER_PRIVKEY=\"$KEYFILE\"" > /home/builduser/.abuild/abuild.conf echo "PACKAGER_PRIVKEY=\"$KEYFILE\"" > /home/builduser/.abuild/abuild.conf
chown builduser:builduser /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) # Install public key BEFORE abuild (fixes UNTRUSTED signature)
cp /home/builduser/.abuild/*.rsa.pub /etc/apk/keys/ 2>/dev/null || true 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 $WORKSPACE_DIR && abuild checksum && abuild -d"
su - builduser -c "cd /home/builduser && abuild checksum && abuild -d -F" || true
# Copy APK from builduser packages to releases # Copy APK from builduser packages to releases
mkdir -p 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 cp /home/builduser/packages/home/x86_64/*.apk releases/ 2>/dev/null || find /home/builduser/packages -name "*.apk" -exec cp {} releases/ \; 2>/dev/null || true
else else
cd "$WORKSPACE_DIR"
abuild checksum abuild checksum
abuild -F -r abuild -r
cd -
mkdir -p releases
cp ~/packages/x86_64/*.apk releases/ 2>/dev/null || cp ~/packages/*.apk releases/ 2>/dev/null || true cp ~/packages/x86_64/*.apk releases/ 2>/dev/null || cp ~/packages/*.apk releases/ 2>/dev/null || true
fi 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 ""
echo "=== Build Complete ===" echo "=== Build Complete ==="
echo "Package: releases/linux-patch-api-*.apk" echo "Package: releases/linux-patch-api-*.apk"
echo "" echo ""
echo "Install with:" echo "Install with:"
echo " sudo apk add --allow-unstable ./releases/linux-patch-api-*.apk" echo " sudo apk add ./releases/linux-patch-api-*.apk"

View File

@ -22,43 +22,68 @@ else
echo "Skipping cargo build (SKIP_CARGO_BUILD is set)" echo "Skipping cargo build (SKIP_CARGO_BUILD is set)"
fi fi
# Create package directory # Create package directory structure
PKGDIR=$(pwd)/arch-package PKGDIR=$(pwd)/arch-package
rm -rf "$PKGDIR"
mkdir -p "$PKGDIR"/usr/bin 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"/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/ 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/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 # Create PKGBUILD with quoted heredoc to prevent $pkgdir expansion
# $pkgdir must be literal for makepkg to expand at runtime # $pkgdir must be literal for makepkg to expand at runtime
echo "Creating PKGBUILD..." echo "Creating PKGBUILD..."
cat > PKGBUILD << 'EOF' cat > PKGBUILD << 'EOF'
pkgname=linux-patch-api pkgname=linux-patch-api
pkgver=1.0.0 pkgver=VERSION_PLACEHOLDER
pkgrel=1 pkgrel=1
pkgdesc="Secure remote package management API for Linux systems" pkgdesc="Secure remote package management API for Linux systems"
url="https://gitea.moon-dragon.us/echo/linux_patch_api" url="https://gitea.moon-dragon.us/echo/linux_patch_api"
arch=('x86_64') arch=('x86_64')
license=('MIT') license=('MIT')
depends=('systemd') depends=('systemd')
install=linux-patch-api.install
source=()
backup=(
'etc/linux_patch_api/config.yaml'
'etc/linux_patch_api/whitelist.yaml'
)
package() { 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 EOF
# Create .SRCINFO # Replace version placeholder with actual version
echo "Creating .SRCINFO..." sed -i "s/VERSION_PLACEHOLDER/$VERSION/" PKGBUILD
echo "PKGBUILD version: $VERSION"
# Build package # Build package
echo "Building Arch package..."
# For CI environments where we may run as root # For CI environments where we may run as root
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
echo "Running as root - creating build user for makepkg..." echo "Running as root - creating build user for makepkg..."
@ -69,12 +94,22 @@ if [ "$(id -u)" = "0" ]; then
cp -r . /home/builduser/repo/ cp -r . /home/builduser/repo/
chown -R builduser:builduser /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 --printsrcinfo > .SRCINFO"
su - builduser -c "cd /home/builduser/repo && makepkg -f --noconfirm" su - builduser -c "cd /home/builduser/repo && makepkg -f --noconfirm"
# Copy package to releases # 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 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 else
makepkg --printsrcinfo > .SRCINFO makepkg --printsrcinfo > .SRCINFO
makepkg -f --noconfirm makepkg -f --noconfirm

19
build-rpm.sh Executable file → Normal file
View File

@ -21,27 +21,38 @@ if ! command -v rpmbuild &> /dev/null; then
fi fi
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 # Setup RPM build directory structure
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
# Create source tarball (required by %autosetup in spec file) # Create source tarball (required by %autosetup in spec file)
echo "Creating source tarball..." echo "Creating source tarball..."
VERSION="1.0.0"
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
mkdir -p "$TMPDIR/linux-patch-api-${VERSION}" 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}/" 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}/target"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.git" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.git"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/releases" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/releases"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.github" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.github"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/debian" 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}" tar -czf ~/rpmbuild/SOURCES/linux-patch-api-${VERSION}.tar.gz -C "$TMPDIR" "linux-patch-api-${VERSION}"
rm -rf "$TMPDIR" rm -rf "$TMPDIR"
# Copy spec file # Prepare spec file with dynamic version
echo "Preparing spec file..." 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 # Build RPM
echo "Building RPM package..." echo "Building RPM package..."

View File

@ -1,54 +1,5 @@
-----BEGIN ENCRYPTED PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIJtTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQOJY6BZQMTvXCEBl6 MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg46Ewu04V/qVbFIaW
Rv+0fQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEJwoQf7hSIurtiBM ll6hUNA1ocfdND68cRv6GiOBikyhRANCAARORR0UUR6G6ndxeefpKai+82eH58ud
nm+YEhwEgglQiyNTxNNkeZ8hikGe3m+2cfXtAituVJYgs4V+bgTJXnrJVaFyPoRw sW5qox3Ed4I0WF12RcSwioAPrt5WNB+ptw0wvzx78wH8CdkqjyUb7Koc
Nde/m9vJU4EGaRwS7Sb89XsjFK+Qbc6+2mvBqhkoBIjXBjYsiqNlLStLUIf1IPdU -----END PRIVATE KEY-----
HHHxrOSnkNIXaiEojEIb4SLHYsmwSGbHPCmr+sIvzRDo/tuc0ugTDJFoS4lhDy6r
VsPuDsV3XTnyPTWlHgROr1DmwqhTa87PXkpiomFxw1/Jy+2D0tQ+PuhTlGh87q2r
T+ZHOLf8GLMKxKL7Elup/ugT+qfK0FekKJV+1Pw8EL+vWmJMLvhk+tlO9b4WxOlD
UiW98mf6ospzpbmVf/AkaI5mkeAaikp7XMim57bUDBbNT3YQWBgwjVR01n7nCKk/
2hYcaDEJGv6KuU3utlVhSIF1OuIJR42q7AJuOmM22yAHJ7KOkcoXFcNsJuVqAeDc
BcVMMEgrHuLdnlHTzUy+0ETFAiTTQE/8+RYHPi0t0LOgalJBVZN4kR8OvCXiTrfw
J6Ex7BvRM7MisS9lNfzCoMaN84/tEdwVTc0USEYvY4mQGV5xINN9ehNgfnw5leMW
n0+oFtNXeslV84xVVz/X0pSD5G4NiyOAVBD44jHowRHJqPMaWs38xFVNStjCyThX
L6lq8ZcFMOLzswvPjBKpb+XlSwETDMGEXbhiOop/CYT2YLwjWH7Vu7rZ1kL553pp
DUOmGRgSfeabKZeyePxh3Pz5uqmKtuX3WGmyeCX/bneDXVASJwCAe+HGFC0cy5JL
8c82b28AdijALlqcEi4S4xsSyL3eMGWlwedfMKN1JdkxrqqWDworfuK+vMUvBa55
GZKlZaYG7rs6nim3XgabnIS+bx08QteMHM9KnEGHggkUd4crSWU9vno+Fkkus1kG
RqO9hSya/s3CqXGyvK4VTB+ThkAHtMdcFg2fbUtgFW4JluysgosFNCBoI1DYlHqF
4O9H6zf90sjX02m4fxt/zl4sRffMbbpxk1gPlahxR/smPSu6LSpmgKMpHwQZKpHc
r4ZYSC2ITVC+wb3+LxGDcZvoWFP6CKpcqJ3u2OwGHrSroA9gz6BMOLhhJIqClaDe
qYLpCZ2GKUl6GZApEd9mQtrGZyG9qfn4i0+jysUCYq4WRVMJhIitXdmLYUjM+6mP
ZUl8P0KIdMjxLf/be2RofokCF8/PmmjfMdxXSwQ4v3wqx8/GvmuY/gM0nPnC4jMQ
CgiJNpnOMLSMEM2c3w32206zjSMPYfR7JsB4d+bp3UMsqGfvv6xOShky+XNU/2ft
AAeMmvvm39RNePoq9owFDFiID2QEmI61ZSOK2ndXbueX0pslYKXdjgtnygL2w19t
BxshEfXGxqu7ImztyO/TLhY5Szu9E+zwaJzGhSR4vPq3emO3M3dGRa/6RbbXB5BC
N9efMeIlEi/mVtdnu0jdgbrR7TbFCOrjhdrDgmEo4DKX4BEQZdeHpO/czF6gz4i8
bGtMGjYKL3xpYfk0yhycx5Q2iZQFbt3W90YPHz9SLrv4U3rJy7OQQmJ4upsaz0a8
lFIKzsGTczojwuYBVG7YNGqyxQtDLxQsjhK1j11pGBNKqGeFxOpvzw694XgLv/a8
785B7c66OJD1H04wndFeR/ruRZpMda8Cw6gwNkzFiWZ1SwwIeqg364wmvhB/VhVC
H6Pr9k4jgFYimM7DgGdrf1+RKOo7bDpyVPAOXNzmPINikxvZLT+ps++usXH4xOdi
YiCq2DR7zjF301ojyAuP4K10c8p3FAu8SerJ+lS9HRLJQ0z4cXnkbtAvIMs3C88Z
9bNWCs8bRH54HJiBgHVKkx00A0rAftMKzn3mKBcKnXvbfL/Qb+sKun0Z6hzWkld5
yEsDnI4B6gxUk+R78kmc2xIzorHHYjdmq07rITKk23QPHgDrrr26NppNMRGVds/9
DpV5yethMOGVNu8njiqU98uK4rQv1r7YSOvNVGkpvDKHjSDqe+N4bal5tQEnLEXw
GzdNB/ECJm0ij/98W8I+AzyGOVmoa0XqJKNfQQGXDigSM3HeYZhPu2JotleddkCT
/l0qpMDlxeTsf6Uhe/47I5iirJCUO6G7RSV9bOh2Pmnbp7PQPkFW79WOf8MPCnCL
XyR4GkyCQ4FjTMLIiDkeV9ReBykuNWohLN6NTwSrJZ5s/032oF+I0WZ5vbePL2zP
z/0X6fKTpVeyT1FMIFE+XH0v06awsq0gG1FlrMMQEO3xLPfF/NNqdJN49lD+AnPh
m/0b/pJ4+NwlEWLsQUdkGAyYD/ZgMHDZQryFxCwrBAFLRtj4NzaaDT5QOgxZUIbQ
VIpPZtAahy1463Pb3Oc7zIiuf7v1RvWipN05QtgepREXNJkOOVXjP0Nyrq98fS2T
oZNZMqr47YeyErztUudKMZ1MCT1jb20y4+y2OSG5lDbKS2gQWo0EIRveFT82QSQa
12gmQMVhAdoRUYBqdQoo98nLix4JftgKYc691pf9gQJIJ8P48uOQEIW6nNc8eXF6
L7QyYidqrqnSzpwRwTv9+LmiXm52lg4Ft3aq7GKq237Mz8Thx3YXamaFBdMYSu3p
5/nNorChQSnnCEmAMdNYej94OUwun5HSTGwh1/JloHCZUMsOqJ20xn3YRQS3E0Vo
uF6aqbZbKbZbJrY+NBrQae0onUNQLbFUX56rMXT9fJJmt/KeFKtI6kKWBs41vp0N
TqOORrtkwyu/AU3qWg4iUINRqFjI76MzzH1XZ9A/2qokAZduHgDoFGcOKkpRFT/9
F6P1SXfoeE8DtUpBhu5XlJyIwcWANkstATrXxyZLA7IdLLgSPZXSwAWxLwCN0ypM
Jnscfvkr2SW8OwpJ8/mA/SX88ZC28Uvp1egsgnM1k9Z7Oinxgk9a7LNUv0qxBc7k
SuooMBJvuiqHOzTr3IJvpCkZykvbnYbDgtypxVOeWO257yxer/ora9NVX84pfprU
7JbOpBGMY6FUAcONmBYikGyGeNsF9zsPcdSOdUKP7tlrncYughORsb/FkNq7LSbo
ll+tRCu/7Xb+VEctDQhk1fJ+ojFzC0P9duRWcskIVWFbSj6r21hzdhKOMX6bXLhA
NcNpSk3EHqDij4rMbaAqSs5W2Q4JTUJ6L0/OOy9aeckbXw/j31OdYnR5wM7nvXrH
tv89sXX/ObNJFD5uPRMPvoACv2oTsgWtm4sNkapAeiOcovPCWSroyzc=
-----END ENCRYPTED PRIVATE KEY-----

View File

@ -1,31 +1,12 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgIUO/u3nWWJUG+i/cwM8o/1fkLzfbAwDQYJKoZIhvcNAQEL MIIBsTCCAVegAwIBAgIQVxQmz3/uqfSgf+8ukKa6GTAKBggqhkjOPQQDAjA4MR4w
BQAwOzEZMBcGA1UEAwwQTGludXhQYXRjaEFQSSBDQTERMA8GA1UECgwISW50ZXJu HAYDVQQDDBVQYXRjaCBNYW5hZ2VyIFJvb3QgQ0ExFjAUBgNVBAoMDVBhdGNoIE1h
YWwxCzAJBgNVBAYTAlVTMB4XDTI2MDQwOTIwMTM1MloXDTM2MDQwNjIwMTM1Mlow bmFnZXIwHhcNMjYwNTE4MTU1MjUxWhcNMzYwNTE1MTU1MjUxWjA4MR4wHAYDVQQD
OzEZMBcGA1UEAwwQTGludXhQYXRjaEFQSSBDQTERMA8GA1UECgwISW50ZXJuYWwx DBVQYXRjaCBNYW5hZ2VyIFJvb3QgQ0ExFjAUBgNVBAoMDVBhdGNoIE1hbmFnZXIw
CzAJBgNVBAYTAlVTMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAt+Li WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARORR0UUR6G6ndxeefpKai+82eH58ud
R5RFcfgnm7fHHPg+csakg/C7+Pkb99mCTb+sBGodxNdlryFz3k/c6hFwUJWwfbPL sW5qox3Ed4I0WF12RcSwioAPrt5WNB+ptw0wvzx78wH8CdkqjyUb7Koco0MwQTAP
hsZo8JSxPIrXMhu8n6pDygUSqOx43dkqXURI40FfOaEkSHwYIF73eOV+qUBPTqQZ BgNVHQ8BAf8EBQMDBwYAMB0GA1UdDgQWBBTcLRFILwfBbjqUm3fT8AzIAN5mQDAP
udMc0BGYndpaLk+Lb6rKtEA4r0HkP2fLdO8wOqr68kYiMhhVP3Dw0k1JmtUY3k/k BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIQDMHR7n6plBEz7tP9Si
RcBPQ7C/n+Pr4a0xdIr2TwzNyH+JOp/3oCZW5mZdfaZWXMZhObtT3a8hW0qfc/P5 Cs6Rk8m2gt9CL6qHlkeWiDJmtgIgVXrj2Lmqn1dEuKbVu9LaxPyvXU4/t2etWHgJ
3PM1C5jxTBRJiQTQlHsM6EpDS1vZLLU0R5PNRw2U7HgOPhY6iItZDN9NUNo5uGpT lfK+SS8=
5jBR3CumpkCxoGnLuV8+VBngjaovpzp245ERYYU7rox5CrHj1yybw6HuaXXqQncO
zDYJwEUINcGiSTlnWyy9iFqA3PInOtAE4YCyscKHH60CxY+/6WvE8yVgTE2SM/At
l2UmZhSDIZBMx2GUmRob9FmQCsyb9AnIytkXXBbJtX6wVi0S7TGKixYObnudb6k+
DEP/HA7BLRChR/XyDjeHNnsE/cqQeNcGOqP6UHS3rf4L3lIDCLvvKhid73C6/N8r
Mz4FvwbwMdw4MHn/WNQBe5+1xkgLLoNRHPXUFwpKcA2ev4JEchb9w9IWiuftJ9BM
zzGKlwT9rCw7A0rQMzsaaEMdCF1VPecoTyIxb2kCAwEAAaNTMFEwHQYDVR0OBBYE
FGfp3Y5/keT0TeQin1GfU3LalfuWMB8GA1UdIwQYMBaAFGfp3Y5/keT0TeQin1Gf
U3LalfuWMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAG7Tkj2S
SisI7ggjFXmferutk25v62dLeXJ9eBjnXHQkk6oMJo4TFWgLbo66gb+0mq9c7/rC
XsQY3mEnQ3lcujMXoEYGcOM7TOHENj0UX0GiQLexCSZF14IOsf0KGXvB649AhscC
N83mdk6GSP2gB8I3wGngbgCtZf/9sq4z6pXVNva+xNskWA7YidY/3pGIMkvRb21v
iTXsTGUC4U2/wjohYVLcyu36Dk2YbdAl0gY7JsNGXbT0a/zpo2aY4ogDMXe/828Q
gW2ZJWGXeJJKHOgBQw+zmBO+Zgm2vdWWBYCsJVLeVE2SE+LngJGfwgJT7tNb20e6
7UBJzhJHIcu73ODNF1TPCNIREVELC4iBXIMvoi3h8Yp7Wo6S8CGk9DY68fXSAkfb
oagvxe/rKRgljX70pRl6YOhpMVpl4yUc4BuRlfopRAIDS3AQdNyp9hvMUyj64Gan
UIkVXLoDA+7KGw/RPCtC18HWw19nmooh73cWSmGrOjtKu2L5ZsSuD3G6vnmFZaSv
HqK08pX+zv2NpYVhiE39zRQ37u9xVjNQsJ/1gnLQ3zOXyidpB8eH+1r5pR7dVjMf
wnhnLlm8nty7O0sOy2kiYp1YqosCitOgnLR1U/cgzGX6j0mHUuciY/fRyK1Yifa5
UM+xJs/yTc33DYhd4oYQHxqRkletlx2XDW9d
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@ -1 +1 @@
790CDB9FA2002BF59B3EE88AF326CB060353D111 790CDB9FA2002BF59B3EE88AF326CB060353D113

View File

@ -1,16 +1,8 @@
-----BEGIN CERTIFICATE REQUEST----- -----BEGIN CERTIFICATE REQUEST-----
MIICeTCCAWECAQAwNDESMBAGA1UEAwwJY2xpZW50MDAxMREwDwYDVQQKDAhJbnRl MIH7MIGhAgEAMD8xHTAbBgNVBAMMFHBhdGNoLW1hbmFnZXItY2xpZW50MREwDwYD
cm5hbDELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB VQQKDAhJbnRlcm5hbDELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMB
AQC9Tida4so5qerRjEQXQQJb/W4jCsRwZg6lSvvd9qEtqWuwxX+SFfNbcpDOOZTh BwNCAAQauACwaR4SyVoHEPviQgV0I4fbyFuGoHiQExzpYf9Ta025dy88T/a6qG6G
+CWmmCq9v5ZO+XyO/s9xKLnyudnkT/nymB6KFN3XywfDE2iiGshNVNd5d0B4nF7e TYJMtbRSjP/piLWfZ/2ze2AdbmczoAAwCgYIKoZIzj0EAwIDSQAwRgIhAJ/BBYsB
nXeoF938GF5/ny4dkGgg+HoXXrQJ1WGjODXJsXtiiMZPI08kZL4vuYW64VojHvUf aIhKjdwRr0vTqtYPKeeyO2rzHuyRnSvKKkdOAiEA94zCvG0FzkFiqGKT1oHGCVf9
AQdEjGqlIZzNW909g0uaQEizpZwJvH7YGvWuQDx9ywbWrs1t3hHu2ahA+myVXL3Y qZdkjkodRAUk6/4S2AU=
C7+jAmROQ61FHW2F4swS+uRQDTEr9qQs256JCryzHCTma8IWmYqphM9wn18f5Tm7
EXw4EHCz5FVL7HPL1vZnQdsrAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAH46n
SxI9O21jO1q2cmFlZano5JWAo3eb424+3jCYgAJDBUtlzTLfdkADvttaTtAjI8sh
GqbUFsCtO4UlPD5SWFnPdUQqJ+zv1lXDyef0D694mUjgrjtdB27l9wmTnHZVwgcL
GEr8nfuhqNNjARmWUJUv629slt0RDZxGm0IXGJBrx39t31oh0q1ll4rPvd9TEiLZ
sP8r5WdC2PdFLh13J6erLkoMOOLmM/mXj1egz+ivgqo2uXDX1crBlNH0H1KM05ot
c5wJo3mzbRC/3PWLLJHKwQ6ObI88AviGEMevIw54jdz2UHXPv0aK2SSoIDr6GhUi
0OBKrqsjBII7l+w+Rw==
-----END CERTIFICATE REQUEST----- -----END CERTIFICATE REQUEST-----

View File

@ -1,28 +1,5 @@
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9Tida4so5qerR MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg0iNlJbfLqO8Y5sOh
jEQXQQJb/W4jCsRwZg6lSvvd9qEtqWuwxX+SFfNbcpDOOZTh+CWmmCq9v5ZO+XyO 1xRe2bPq8fF9M1ybEOnqmbSGpdGhRANCAAQauACwaR4SyVoHEPviQgV0I4fbyFuG
/s9xKLnyudnkT/nymB6KFN3XywfDE2iiGshNVNd5d0B4nF7enXeoF938GF5/ny4d oHiQExzpYf9Ta025dy88T/a6qG6GTYJMtbRSjP/piLWfZ/2ze2Adbmcz
kGgg+HoXXrQJ1WGjODXJsXtiiMZPI08kZL4vuYW64VojHvUfAQdEjGqlIZzNW909
g0uaQEizpZwJvH7YGvWuQDx9ywbWrs1t3hHu2ahA+myVXL3YC7+jAmROQ61FHW2F
4swS+uRQDTEr9qQs256JCryzHCTma8IWmYqphM9wn18f5Tm7EXw4EHCz5FVL7HPL
1vZnQdsrAgMBAAECggEAAL6K9Cq4oA4Pv/kbRskIdNNct38SLiOZnn8UVWfbvj9A
BpC+KZllhwoAyxsrf3ZnV8B45WNBxwERy2bpdwzznrsl4uGZfXg9+Au6HmiB89JF
x27vp1LUZYphluZDZiGT+x7kIO9swT3Eh78pvDqMU/S+VeTaThHa5VFHx23aPeKR
utc/dW1+1rT2rGZXTEF86xQkHQaKSYa4MPpxAhZ/Azc28sYtcGeJ6NEjqQyDEYHn
hlFLBs9RpvyYkmMx+s0xkdtEE9+v2cTnw04MseE/MMBzSS4Y3EBFmVSJvvpKmyox
DibwJtxhMa8atT5LOroBpPwYbmelAKbF85yxHtRE4QKBgQDjk/rkTOud3mUTiKt4
+26YgtpcEjTJ7Rgiq8F0McveRUnGRGwI2ML+nQZ0mBsroCdQjqBbyIGVYY9EZJfB
pRYLGHEUHcS94mkwpXyGZXzNwjKPo6bmOh3dLOO4J1fgIyBx1UIKS8HLaNR+gg5Q
N6iAvkiDj5Ucqy5+iCNjJhQbRwKBgQDU8ojlhW4Cc9ITQP2Xjlg4eymxoRT9XrAC
6ebWoDK2q9uLPPPzXkKQzRM7ydOBZR9EgNknwQyfQpXFVrB+gn27o2A3iKtvacXQ
/He04/fVPdWYF8t4su4rMYVCbl+aOwCdeFfGwFOP45oo0/eEH/ys/64I6UQEKNk9
oXnNSezq/QKBgBv3tZ+U7GfMSvOpmhkWHTNU8WzbN+2Q26R3IyEadYltTnG1Oumj
aeNMfNybTMuBtRMrU/2zmGk5QhgPnK7JkPnwGQV12xXS20aFL9Z8ZmgK85e/buVg
QwdJWvroqt36syQKJ0GIqdpLmcGqTgQBsw2PVO4GGTcaum4GYQLwTQxFAoGAZpED
GvnviMLcdmWhP3RSTbIU3PenMnp+8IhUpR+4DYAtWJ1dKuVFzpTYJL4LX5GjQ82D
ysATIkph9RDSJb0Ybl48o8LyP9GEdCqGRdxfrJgB3yXm3RXh3XAWrW6YIaM1oqMq
NBLCrNWFlRCzcTIu8+yamLQyDIbYS/UQw65NrMkCgYEAjM5Z6XJ3FuRjnEZaV6V4
evz0TyHTpHnNAKx1NRzut4wN684X81l1IgUAVp2xkbiYK/V/F2qXWAQjuA3ucyN3
svnXIsQqhnBilkcDbQg5TZtaIk58IDzENXF8TAtPQiAD478AyBcfzMrtqLhKgaBu
P7wqdvyaMVPLek9tuUINQ4o=
-----END PRIVATE KEY----- -----END PRIVATE KEY-----

View File

@ -1,25 +1,12 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIEPzCCAiegAwIBAgIUeQzbn6IAK/WbPuiK8ybLBgNT0REwDQYJKoZIhvcNAQEL MIIBuzCCAWGgAwIBAgIUeQzbn6IAK/WbPuiK8ybLBgNT0RMwCgYIKoZIzj0EAwIw
BQAwOzEZMBcGA1UEAwwQTGludXhQYXRjaEFQSSBDQTERMA8GA1UECgwISW50ZXJu ODEeMBwGA1UEAwwVUGF0Y2ggTWFuYWdlciBSb290IENBMRYwFAYDVQQKDA1QYXRj
YWwxCzAJBgNVBAYTAlVTMB4XDTI2MDQwOTIwMTQwM1oXDTI3MDQwOTIwMTQwM1ow aCBNYW5hZ2VyMB4XDTI2MDUxODE2MDAwNloXDTI3MDUxODE2MDAwNlowPzEdMBsG
NDESMBAGA1UEAwwJY2xpZW50MDAxMREwDwYDVQQKDAhJbnRlcm5hbDELMAkGA1UE A1UEAwwUcGF0Y2gtbWFuYWdlci1jbGllbnQxETAPBgNVBAoMCEludGVybmFsMQsw
BhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9Tida4so5qerR CQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBq4ALBpHhLJWgcQ
jEQXQQJb/W4jCsRwZg6lSvvd9qEtqWuwxX+SFfNbcpDOOZTh+CWmmCq9v5ZO+XyO ++JCBXQjh9vIW4ageJATHOlh/1NrTbl3LzxP9rqoboZNgky1tFKM/+mItZ9n/bN7
/s9xKLnyudnkT/nymB6KFN3XywfDE2iiGshNVNd5d0B4nF7enXeoF938GF5/ny4d YB1uZzOjQjBAMB0GA1UdDgQWBBQhTcmoHT0HqIuEUkL891TKMlWWjjAfBgNVHSME
kGgg+HoXXrQJ1WGjODXJsXtiiMZPI08kZL4vuYW64VojHvUfAQdEjGqlIZzNW909 GDAWgBTcLRFILwfBbjqUm3fT8AzIAN5mQDAKBggqhkjOPQQDAgNIADBFAiApQ6N8
g0uaQEizpZwJvH7YGvWuQDx9ywbWrs1t3hHu2ahA+myVXL3YC7+jAmROQ61FHW2F qQR1vWLU3QNrcIwLxK8g2shV5ggypS/CKkfTgwIhAJdZd0silwqEpPo5ng0I5SJ9
4swS+uRQDTEr9qQs256JCryzHCTma8IWmYqphM9wn18f5Tm7EXw4EHCz5FVL7HPL MOd4Kx0dps2kY/wqgMSI
1vZnQdsrAgMBAAGjQjBAMB0GA1UdDgQWBBStUuU9Si2VnMMQ4VkYyf2pttMhezAf
BgNVHSMEGDAWgBRn6d2Of5Hk9E3kIp9Rn1Ny2pX7ljANBgkqhkiG9w0BAQsFAAOC
AgEAqWQEAwpW45LWprkr4zpz66azUVkc2I/kuNWLiDEw9Ex4i/5e+ND6Ia7Ayk+T
j3rodJA1rn64gJZOzABTb3mpWwNH/DxjF/XGohixl/kn81sNCydimc3qOKL5joUb
PDtK9QLTCJmGsYk5lV9K89pR7kBR2rXD70d1GM6KjyBeknEH4oA9/BqMYL5DkeHu
v2QWYoECno43eI+Ve4oow5MN/83+VhFLeayCd/JWBYjYi55tqI8QDBn7AY4UAO2C
77msurPEqaZn5OtzEW9El/M3/+bDeYfpERgYn2X7bw0oOUZw8g5L9dfc1UxjGY8J
NPJAXUKtsDBKzN8nlvrCVmHVrR19vquH7qfh/aKu58MGu3Ovzjz57T/gooi2wmnY
4+NJDXZ7ncc7T+40svi7tbLA7MgExuGM+pq/Bxn/PHLPbhyyp7p8EPUFf5KIiqr5
GiWL1re8gfe8CAxKDKs5ERtexgoldY1TsMbQ6wjP59rRN3ZbUBGtPsi8bKTEZBpo
cM+9bg44ndODpoB8B9NKYCU/n3Uvs+mZMYtAAkLiCUrYplIiCSvUrcDOQWVn1CD1
WbuvTTtlIi55NMUi1pvgaFi0PW6Gfin1wRHjt3iLU/i+3Q/b0V+pEL7wgf5bd3U5
F6xxNMRjNh1kbZVR0WywzPignBiK9cW+z3d9rPW2FgVoXcI=
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@ -1,16 +1,8 @@
-----BEGIN CERTIFICATE REQUEST----- -----BEGIN CERTIFICATE REQUEST-----
MIICfzCCAWcCAQAwOjEYMBYGA1UEAwwPbGludXgtcGF0Y2gtYXBpMREwDwYDVQQK MIH1MIGcAgEAMDoxGDAWBgNVBAMMD2xpbnV4LXBhdGNoLWFwaTERMA8GA1UECgwI
DAhJbnRlcm5hbDELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw SW50ZXJuYWwxCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE
ggEKAoIBAQCu+RZd6OHxdGJdI+C9rS8rowzsYF/qr3p6+Yvp9ySvBzJ20TVWQSrK C32xU18H3OljGW+wQUesT1qSB+bp5cCkNW9rfpv7wjr79eHriZkQ8EgrdVAK9Zw0
Fo1VcDgg7rkTYuRxzxehO0R7pTkxnWqLY6VAA39w5nyVzTwFPv7UovdAZx/U7hnJ fZJNdd4LyekDGXiQU/qAJ6AAMAoGCCqGSM49BAMCA0gAMEUCIQDf7FSy4YiZvWkj
Zj7ndtxuqYk1tkx94NLjYXUCWqy48/tKQXobQh3qXeQ6HfwFSI5xpy61quQBZkJz G9BgdSPTcIq8VYSGm7nnXprD8u1ZTwIgO6/5jH72reiCaaMm62X1Vrpc+8SDMVtO
VAatcOv/fhn3K+TrgBaKKkXFNQ3jjKhzrH3Z0son1+GNyhHvQlvCJ+jdWDpzDvSP +dlP4dZ+BM8=
XpqEDmxCQvdBdzGVAPrv3fmBMyOQFnHOTHmKtJ806jBFsYEUwnXKA4/xtaihl1OL
bz85Z6MicH1PwTo4v0Z7ngIcyoxlgX/RAgMBAAGgADANBgkqhkiG9w0BAQsFAAOC
AQEAPaBJ7ryKBUuKoUGsgb+fc9GIbGomCbWZnPFx3ZJcUZfeb4/Qi1glhe1GiiUt
np5x0cjgw5he6zd13lgylglsYrSHEJDV2MqVoHqCwFH+m+ODnZvnQkrgxW4t+JEK
wEwp0dRGLXsshDPWg5Xe/SaFBfvuCWEkWkcQ4NYwg5SOVn0TCAVy2VKmdDW1KHtf
GkqHdUiIs5FX6kXIMryQpIG6OXyJCQ3pGv+kSlfaeobnqUUASWwBAaubZBxnqTIl
Daj2End8iYQ9Fiv7z0YFxJrULSt5qhtRivmUHjSOyv0tlPs+aG9mP9j14ND2/ZIA
ihOZrIUTTxaaVL9IxIVnTt7tFw==
-----END CERTIFICATE REQUEST----- -----END CERTIFICATE REQUEST-----

View File

@ -1,28 +1,5 @@
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCu+RZd6OHxdGJd MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKWrGjaMdvANVPz/d
I+C9rS8rowzsYF/qr3p6+Yvp9ySvBzJ20TVWQSrKFo1VcDgg7rkTYuRxzxehO0R7 LQPtDS4FmU8H0gg8zix2AvxaQp2hRANCAAQLfbFTXwfc6WMZb7BBR6xPWpIH5unl
pTkxnWqLY6VAA39w5nyVzTwFPv7UovdAZx/U7hnJZj7ndtxuqYk1tkx94NLjYXUC wKQ1b2t+m/vCOvv14euJmRDwSCt1UAr1nDR9kk113gvJ6QMZeJBT+oAn
Wqy48/tKQXobQh3qXeQ6HfwFSI5xpy61quQBZkJzVAatcOv/fhn3K+TrgBaKKkXF
NQ3jjKhzrH3Z0son1+GNyhHvQlvCJ+jdWDpzDvSPXpqEDmxCQvdBdzGVAPrv3fmB
MyOQFnHOTHmKtJ806jBFsYEUwnXKA4/xtaihl1OLbz85Z6MicH1PwTo4v0Z7ngIc
yoxlgX/RAgMBAAECggEAEkGfGdFQsdbI5Jr3uhK110G9+XPczindB7O9632D8Fc5
5rfRbtyB0HAl8wIweQ8vdFxfJZjMCGCctqH4o7qfAUg2V8WFqIwD98VgO9Pk1t7i
GXApHBhzzFXEvnXibhF2ZYpN1Nx+ZIcopEQ9vVaHo6nNScbOREPjqkSypQJ7ClSQ
vCzezzIhjeRTlttQvQv11oU/qolqVxL/GqGWtcI9I7onY5FP7qGNhnLrVJtDltcX
71mbpjKS0NquLQimcDBwgVdhH1Ie+1hYJBLYgR8vE3J4d5a21NhtCeqTIHJo5SO/
sKkZBVhD7OzP2qmQU4Hh99FK6648U6YdiBbKuumPgQKBgQDw1c5rf4jUlJaTk7wG
/p6hSaMKVsM/JcrZgKZCCLS7fJ6M9DslCPQoOWTqh5Xq8Yh0gZ+FB/mo6nexMkgJ
cpQhdBWgX6GXJhTK2M8A7FvA7IT3ZS7G4lzOFg9qsDjbKPI6F9JjqkOmeqLulJ/z
Sr9stH2lN/+hGxwrqUs26c49uQKBgQC5/ZoE5ZBty1oIu43TGDU+7kLptsP/Ifub
YOjlfJ1DrCFd7SDpL059p1c8PPjhphFi5UkIFp102OJ0higxgvo1gGnJQ6CYXvam
qvmQyG4V8MV9bVv5SMV4QvcunTxbYEawz00BfI60lWAXKKhtPOpwdWeR0lt2SNR0
zjwQm8+e2QKBgQC59o5eqWrRoy6mI8RjrkZ1CjQv7pDy+M6qplE62hgcUXzoIEpv
LXvCd5b6FdnoQbr5I4I2qdLY4LutgsLnMKc7MbTlUhKncMtLWqB0+Q1cagW+Nk4p
Wm8I3zXmTs6IRBTOUMivFrEIItge23qq1UP8v13prtTf5Nwaxq2CaIVNWQKBgC/B
ypaPS7KlkIzFe/lEMgfirhPM9i7AzxZqn+KtSMRjon23sceuef0RxviUv2NRfQ1j
yojlJbEnL560BAYSl6S9QGyJjOcTG0pYhJSEop/Hny5BsmgkI3Bp4YZ6oVDlO8GS
uTc0gIAmCvJnYjgKeDhALUPoO8v3j3YerpWlLH6hAoGAazgCnV9WSWo3WmgVo7xw
km2tt2mp7QgAAs8t3OSMvN7jC5uyRBJ+asH3ih9rDvtu4ZPwIYNEpoMkh9IKNoK+
vtbJPqs6rrzBqQMJrfnTXm6o8gxHuSMQWXe/8tSlnbvuZhH7iFRyHH2Zv3SWoOaO
pLYlvvPbeUK7Ue1jXJ8i4yE=
-----END PRIVATE KEY----- -----END PRIVATE KEY-----

View File

@ -1,25 +1,12 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIERTCCAi2gAwIBAgIUeQzbn6IAK/WbPuiK8ybLBgNT0RAwDQYJKoZIhvcNAQEL MIIBtjCCAVygAwIBAgIUeQzbn6IAK/WbPuiK8ybLBgNT0RIwCgYIKoZIzj0EAwIw
BQAwOzEZMBcGA1UEAwwQTGludXhQYXRjaEFQSSBDQTERMA8GA1UECgwISW50ZXJu ODEeMBwGA1UEAwwVUGF0Y2ggTWFuYWdlciBSb290IENBMRYwFAYDVQQKDA1QYXRj
YWwxCzAJBgNVBAYTAlVTMB4XDTI2MDQwOTIwMTQwM1oXDTI3MDQwOTIwMTQwM1ow aCBNYW5hZ2VyMB4XDTI2MDUxODE2MDAwNloXDTI3MDUxODE2MDAwNlowOjEYMBYG
OjEYMBYGA1UEAwwPbGludXgtcGF0Y2gtYXBpMREwDwYDVQQKDAhJbnRlcm5hbDEL A1UEAwwPbGludXgtcGF0Y2gtYXBpMREwDwYDVQQKDAhJbnRlcm5hbDELMAkGA1UE
MAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCu+RZd BhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLfbFTXwfc6WMZb7BBR6xP
6OHxdGJdI+C9rS8rowzsYF/qr3p6+Yvp9ySvBzJ20TVWQSrKFo1VcDgg7rkTYuRx WpIH5unlwKQ1b2t+m/vCOvv14euJmRDwSCt1UAr1nDR9kk113gvJ6QMZeJBT+oAn
zxehO0R7pTkxnWqLY6VAA39w5nyVzTwFPv7UovdAZx/U7hnJZj7ndtxuqYk1tkx9 o0IwQDAdBgNVHQ4EFgQUDTnKCjj1BJ0MdwJHPUGf0raJ6/kwHwYDVR0jBBgwFoAU
4NLjYXUCWqy48/tKQXobQh3qXeQ6HfwFSI5xpy61quQBZkJzVAatcOv/fhn3K+Tr 3C0RSC8HwW46lJt30/AMyADeZkAwCgYIKoZIzj0EAwIDSAAwRQIhAJ4jy8W2hbqK
gBaKKkXFNQ3jjKhzrH3Z0son1+GNyhHvQlvCJ+jdWDpzDvSPXpqEDmxCQvdBdzGV kiTI9aYS+xwMJlxH6cFJaKplrA+a5Ay8AiANPJdJN9ucgCsq/N3Ai6kO89rcXy8Z
APrv3fmBMyOQFnHOTHmKtJ806jBFsYEUwnXKA4/xtaihl1OLbz85Z6MicH1PwTo4 60kvNNc3Zg/Oog==
v0Z7ngIcyoxlgX/RAgMBAAGjQjBAMB0GA1UdDgQWBBTgNxkszZsl/UI2Kri5QJb8
VrHASzAfBgNVHSMEGDAWgBRn6d2Of5Hk9E3kIp9Rn1Ny2pX7ljANBgkqhkiG9w0B
AQsFAAOCAgEALx4MmEyFsmmpFS9JvKnkRi3AMn7ePRdg0nONEd735z1grnKNTjmH
PJLErX3aD4lCxqyBhyqJaCCZRF1CRkE3wWTGyXSlab9RgXHTU9AiSvopEdgSiISt
CI3X7uGqss3cERZcKLuM7JDTVdhtOouNbfwvG40hz6lm+OcQo7F3/z/boqKkFd+o
yXLDJFCVaXgslCp1+fts7aFXpqAwj7tedzB2a7M1ncTOwvP//bnYjm/FygOhj0No
4tNX2liUnfjbMqNFszxYl+ZtYYjrt23YwNPdVhF0oY2ludh16lluJHZECji2DzH0
275M5DsgQcQpZmA77px0i+piNuCoS4wFJQDeQmtp2loGHa123zJra/kAINayf0WF
S0dPAqXwBGj2WGP1uBNOLghV4MZaYuav0xWSMuTv2TW3ZsOYzYXQk0hMe7W7oIuZ
VAcaw9ZT8wAFwo+unvzGIWtxSZ3sykK6thBEo8lqRkmqDCkDE86mb6BviQj1NBSP
+KrmZJ8vuvqfr1Oav/7Vk5qYoNprqZand6A1hnLxS9q/JZcr0Fj+Z7OS1G3hLrjd
3oN6SdNWAVkznIBe0J+Ry29My/GniBbytJgXVi+4ROO5GGmtuDCMkFqOZcm6f8BW
faPQWiWB5EY6ZuqgLgydGQ3qf1a5b8z1EzmiDZf5qRUdfddOCsljgiw=
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@ -57,3 +57,17 @@ package_manager:
# # Maximum number of polling attempts before giving up # # Maximum number of polling attempts before giving up
# # Default: 1440 (24 hours at 60s intervals = 86400 seconds total) # # Default: 1440 (24 hours at 60s intervals = 86400 seconds total)
# max_poll_attempts: 1440 # max_poll_attempts: 1440
# # Network interface whose IPv4 address is reported to the manager.
# # Overrides auto-detection when the wrong IP is selected (e.g., Docker bridge).
# # Example: "eth0", "ens192", "enp0s3"
# report_interface: "eth0"
# # Explicit IPv4 address reported to the manager.
# # Highest priority — overrides both report_interface and route-based selection.
# # Useful when the host has multiple IPs or runs inside a container.
# report_ip: "192.168.3.36"
# # Route-based IP selection is enabled by default when manager_url is set.
# The agent resolves the manager hostname to an IP, then uses `ip route get <manager_ip>`
# to determine which local source IP the kernel would use to reach the manager.
# This is the most accurate method for multi-homed hosts because it queries
# the kernel routing table directly.
# Priority order: report_ip > report_interface > route-based > auto-detect

View File

@ -17,10 +17,10 @@ depend() {
# Create required directories before starting # Create required directories before starting
start_pre() { start_pre() {
checkpath --directory --owner linux-patch-api:linux-patch-api --mode 0755 \ checkpath --directory --owner root:root --mode 0755 \
/run/linux-patch-api \ /run/linux-patch-api \
/var/log/linux-patch-api \ /var/log/linux_patch_api \
/var/lib/linux-patch-api \ /var/lib/linux_patch_api \
/etc/linux_patch_api/certs /etc/linux_patch_api/certs
# Ensure config files exist # Ensure config files exist

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"
}

View File

@ -0,0 +1,10 @@
#!/bin/sh
# Alpine Linux post-deinstall script for linux-patch-api
# Runs after package files are removed
# Matches Debian postrm behavior: clean up empty directories
# 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,35 @@
#!/bin/sh
# Alpine Linux post-install script for linux-patch-api
# Runs after package files are laid down
# Matches Debian postinst behavior: copy example configs, enable service
# 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 ""

View File

@ -0,0 +1,15 @@
#!/bin/sh
# Alpine Linux pre-deinstall script for linux-patch-api
# Runs before package files are removed
# Matches Debian prerm behavior: stop and disable service
# 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

View File

@ -0,0 +1,33 @@
#!/bin/sh
# Alpine Linux pre-install script for linux-patch-api
# Runs before package files are laid down
# Matches Debian preinst behavior: create directories, set permissions
# Create required directories
mkdir -p /etc/linux_patch_api/certs
mkdir -p /var/lib/linux_patch_api
mkdir -p /var/log/linux_patch_api
# Generate machine-id if not present (required for enrollment)
# Alpine Linux does not include /etc/machine-id by default
if [ ! -f /etc/machine-id ] || [ ! -s /etc/machine-id ]; then
if command -v uuidgen > /dev/null 2>&1; then
uuidgen | tr -d '-' > /etc/machine-id
elif [ -f /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid | tr -d '-' > /etc/machine-id
else
# Fallback: generate from /dev/urandom
od -x -N4 /dev/urandom | head -1 | awk '{print $2$3}' > /etc/machine-id
fi
chmod 444 /etc/machine-id
fi
# 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

77
debian/changelog vendored
View File

@ -1,3 +1,72 @@
linux-patch-api (1.1.13) unstable; urgency=medium
* Fix APK backend detection for Alpine (/sbin/apk not /usr/bin/apk)
-- Echo <echo@moon-dragon.us> Tue, 20 May 2026 13:55:00 -0500
linux-patch-api (1.1.12) unstable; urgency=medium
* Add APK (Alpine Linux) package manager backend
* Add machine-id generation to Alpine pre-install script
* Fix OpenRC init script ownership (root:root)
-- Echo <echo@moon-dragon.us> Tue, 20 May 2026 12:25:00 -0500
linux-patch-api (1.1.10-1) unstable; urgency=low
* Fix Alpine install scripts: use separate files with valid abuild suffixes
* Root cause: .apk-install is not a valid abuild suffix (abuild silently fails)
* Correct format: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall
* Verified on actual Alpine runner: install script suffixes now pass abuild validation
-- Echo <echo@moon-dragon.us> Wed, 20 May 2026 07:43:00 -0500
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
* Bump version to 1.1.7 to ensure clean build with correct binary
-- Echo <echo@moon-dragon.us> Mon, 18 May 2026 12:20:00 -0500
linux-patch-api (1.1.6-1) unstable; urgency=low
* Fix rustls CryptoProvider initialization panic on server startup
* Add explicit CryptoProvider::install_default() for aws-lc-rs
-- Echo <echo@moon-dragon.us> Mon, 18 May 2026 08:45:00 -0500
linux-patch-api (1.1.5-1) unstable; urgency=low
* Fix enrollment IP detection: filter Docker bridge subnets (172.16.0.0/12)
* Fix enrollment IP detection: filter link-local addresses (169.254.0.0/16)
* Add report_interface and report_ip config options for explicit IP override
* Add route-based IP selection using kernel routing table
* Fix package versioning to derive from Cargo.toml
-- Echo <echo@moon-dragon.us> Sun, 18 May 2026 02:00:00 -0500
linux-patch-api (0.3.12-1) unstable; urgency=low linux-patch-api (0.3.12-1) unstable; urgency=low
* Fix socket activation detection to use resolved service name * Fix socket activation detection to use resolved service name
@ -74,3 +143,11 @@ linux-patch-api (0.3.2-1) unstable; urgency=low
* Bump version to 0.3.2 * Bump version to 0.3.2
-- Echo <echo@moon-dragon.us> Fri, 02 May 2026 21:30:00 -0500 -- Echo <echo@moon-dragon.us> Fri, 02 May 2026 21:30:00 -0500
linux-patch-api (1.1.12) unstable; urgency=medium
* Add APK (Alpine Linux) package manager backend
* Add machine-id generation to Alpine pre-install script
* Fix OpenRC init script ownership (root:root)
-- Echo <echo@moon-dragon.us> Tue, 20 May 2026 12:25:00 -0500

0
debian/rules vendored Normal file → Executable file
View File

View File

@ -1,7 +1,7 @@
%global debug_package %{nil} %global debug_package %{nil}
Name: linux-patch-api Name: linux-patch-api
Version: 1.0.0 Version: VERSION_PLACEHOLDER
Release: 1%{?dist} Release: 1%{?dist}
Summary: Secure remote package management API for Linux systems Summary: Secure remote package management API for Linux systems
License: MIT License: MIT
@ -10,19 +10,22 @@ Source0: linux-patch-api-%{version}.tar.gz
BuildArch: x86_64 BuildArch: x86_64
# Build requirements # Build requirements
# NOTE: Building in Debian container (node:18) - apt packages don't register in RPM db # NOTE: CI uses rustup to install cargo/rust, so they are NOT available as RPM packages.
# Build tools ARE available (installed via apt-get in ci.yml), just won't validate # Only uncomment BuildRequires for native RPM build environments where cargo/rust
# are installed via dnf/yum package manager.
# BuildRequires: cargo >= 1.75 # BuildRequires: cargo >= 1.75
# BuildRequires: rust >= 1.75 # BuildRequires: rust >= 1.75
# BuildRequires: systemd-rpm-macros # Handling systemd manually
# BuildRequires: pkgconfig(systemd)
# BuildRequires: gcc # BuildRequires: gcc
# BuildRequires: openssl-devel
# BuildRequires: systemd-devel
# BuildRequires: pkgconfig(systemd)
# Runtime requirements # Runtime requirements
Requires: systemd Requires: systemd
Requires: libsystemd Requires: libsystemd
Requires: openssl-libs
Requires: ca-certificates
# Description
%description %description
Linux Patch API provides a secure, mTLS-authenticated REST API for Linux Patch API provides a secure, mTLS-authenticated REST API for
remote package management operations including: 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 cp configs/whitelist.yaml.example %{buildroot}/etc/linux_patch_api/whitelist.yaml.example
chmod 644 %{buildroot}/etc/linux_patch_api/*.example chmod 644 %{buildroot}/etc/linux_patch_api/*.example
# Pre-installation script # Pre-installation script - create directories (matches Debian preinst)
%pre %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 # Create required directories
mkdir -p /etc/linux_patch_api/certs mkdir -p /etc/linux_patch_api/certs
mkdir -p /var/lib/linux_patch_api mkdir -p /var/lib/linux_patch_api
mkdir -p /var/log/linux_patch_api mkdir -p /var/log/linux_patch_api
# Set proper ownership # Set proper ownership (service runs as root)
chown -R linux-patch-api:linux-patch-api /var/lib/linux_patch_api chown -R root:root /var/lib/linux_patch_api
chown -R linux-patch-api:linux-patch-api /var/log/linux_patch_api chown -R root:root /var/log/linux_patch_api
# Set secure permissions # Set secure permissions
chmod 750 /etc/linux_patch_api 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/lib/linux_patch_api
chmod 755 /var/log/linux_patch_api chmod 755 /var/log/linux_patch_api
# Post-installation script # Post-installation script - copy configs, enable service (matches Debian postinst)
%post %post
# Copy example configs if they don't exist # 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" ]; then
cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml
chmod 640 /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 fi
if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then
cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml
chmod 640 /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 fi
# Reload systemd daemon # Reload systemd daemon
@ -162,6 +153,8 @@ fi
/lib/systemd/system/linux-patch-api.service /lib/systemd/system/linux-patch-api.service
%config(noreplace) /etc/linux_patch_api/config.yaml.example %config(noreplace) /etc/linux_patch_api/config.yaml.example
%config(noreplace) /etc/linux_patch_api/whitelist.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
%dir /etc/linux_patch_api/certs %dir /etc/linux_patch_api/certs
%dir /var/lib/linux_patch_api %dir /var/lib/linux_patch_api
@ -169,7 +162,39 @@ fi
# Changelog # Changelog
%changelog %changelog
* Thu Apr 09 2026 Echo <echo@moon-dragon.us> - 1.0.0-1 * Wed May 20 2026 Echo <echo@moon-dragon.us> - 1.1.12-1
- Add APK (Alpine Linux) package manager backend
- Add machine-id generation to Alpine pre-install script
- Fix OpenRC init script ownership (root:root)
* Wed May 20 2026 Echo <echo@moon-dragon.us> - 1.1.10-1
- Fix Alpine install scripts: use separate files with valid abuild suffixes
- Root cause: .apk-install is not a valid abuild suffix (abuild silently fails)
- Correct format: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall
- Verified on actual Alpine runner: install script suffixes now pass abuild validation
* 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 - Initial production release
- Secure mTLS-authenticated REST API for remote package management - Secure mTLS-authenticated REST API for remote package management
- 15 API endpoints for package install/remove, patch application, system management - 15 API endpoints for package install/remove, patch application, system management

View File

@ -94,23 +94,32 @@ impl WhitelistManager {
// Parse to validate - must be IPv4 or CIDR, no hostnames in auto-append // Parse to validate - must be IPv4 or CIDR, no hostnames in auto-append
let parsed_entry = if let Some((ip_str, prefix_str)) = entry_str.split_once('/') { let parsed_entry = if let Some((ip_str, prefix_str)) = entry_str.split_once('/') {
let ip: Ipv4Addr = ip_str.parse() let ip: Ipv4Addr = ip_str
.parse()
.with_context(|| format!("Invalid IP in CIDR notation: {}", entry_str))?; .with_context(|| format!("Invalid IP in CIDR notation: {}", entry_str))?;
let prefix: u8 = prefix_str.parse() let prefix: u8 = prefix_str
.parse()
.with_context(|| format!("Invalid prefix in CIDR notation: {}", entry_str))?; .with_context(|| format!("Invalid prefix in CIDR notation: {}", entry_str))?;
if prefix > 32 { if prefix > 32 {
anyhow::bail!("Invalid CIDR prefix (must be 0-32): {}", entry_str); anyhow::bail!("Invalid CIDR prefix (must be 0-32): {}", entry_str);
} }
WhitelistEntry::Cidr { network: ip, prefix } WhitelistEntry::Cidr {
network: ip,
prefix,
}
} else { } else {
let ip: Ipv4Addr = entry_str.parse() let ip: Ipv4Addr = entry_str
.parse()
.with_context(|| format!("Invalid IPv4 address: {}", entry_str))?; .with_context(|| format!("Invalid IPv4 address: {}", entry_str))?;
WhitelistEntry::Ip(ip) WhitelistEntry::Ip(ip)
}; };
// 2. Check for duplicate in current in-memory state // 2. Check for duplicate in current in-memory state
{ {
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?; let entries = self
.entries
.read()
.map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
for existing in entries.iter() { for existing in entries.iter() {
if *existing == parsed_entry { if *existing == parsed_entry {
info!( info!(
@ -129,15 +138,21 @@ impl WhitelistManager {
let lock_path = format!("{}.lock", self.config_path); let lock_path = format!("{}.lock", self.config_path);
let lock_file = OpenOptions::new() let lock_file = OpenOptions::new()
.create(true) .create(true)
.truncate(true)
.write(true) .write(true)
.open(&lock_path) .open(&lock_path)
.with_context(|| format!("Failed to create lock file: {}", lock_path))?; .with_context(|| format!("Failed to create lock file: {}", lock_path))?;
lock_file.lock_exclusive().context("Failed to acquire exclusive whitelist lock")?; lock_file
.lock_exclusive()
.context("Failed to acquire exclusive whitelist lock")?;
// Double-check for duplicates after acquiring lock (concurrent append scenario) // Double-check for duplicates after acquiring lock (concurrent append scenario)
{ {
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?; let entries = self
.entries
.read()
.map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
for existing in entries.iter() { for existing in entries.iter() {
if *existing == parsed_entry { if *existing == parsed_entry {
info!( info!(
@ -154,9 +169,12 @@ impl WhitelistManager {
// 4. Read current whitelist YAML or create empty config // 4. Read current whitelist YAML or create empty config
let mut config = if Path::new(&self.config_path).exists() { let mut config = if Path::new(&self.config_path).exists() {
self.load_config().context("Failed to load existing whitelist for append")? self.load_config()
.context("Failed to load existing whitelist for append")?
} else { } else {
WhitelistConfig { entries: Vec::new() } WhitelistConfig {
entries: Vec::new(),
}
}; };
// 5. Append new entry to allowed_ips list // 5. Append new entry to allowed_ips list
@ -168,8 +186,9 @@ impl WhitelistManager {
// Ensure parent directory exists // Ensure parent directory exists
if let Some(parent) = config_path.parent() { if let Some(parent) = config_path.parent() {
if !parent.exists() { if !parent.exists() {
fs::create_dir_all(parent) fs::create_dir_all(parent).with_context(|| {
.with_context(|| format!("Failed to create whitelist directory: {}", parent.display()))?; format!("Failed to create whitelist directory: {}", parent.display())
})?;
} }
} }
@ -182,28 +201,35 @@ impl WhitelistManager {
.create_new(true) .create_new(true)
.truncate(true) .truncate(true)
.open(&temp_path) .open(&temp_path)
.with_context(|| format!("Failed to create temp whitelist file: {}", temp_path.display()))?;
file.write_all(yaml_content.as_bytes())
.with_context(|| format!("Failed to write whitelist data to: {}", temp_path.display()))?;
file.flush()
.with_context(|| format!("Failed to flush whitelist data to: {}", temp_path.display()))?;
// Atomic rename
fs::rename(&temp_path, config_path)
.with_context(|| { .with_context(|| {
format!( format!(
"Failed to atomically rename whitelist temp file {} to {}", "Failed to create temp whitelist file: {}",
temp_path.display(), temp_path.display()
config_path.display()
) )
})?; })?;
file.write_all(yaml_content.as_bytes()).with_context(|| {
format!("Failed to write whitelist data to: {}", temp_path.display())
})?;
file.flush().with_context(|| {
format!("Failed to flush whitelist data to: {}", temp_path.display())
})?;
// Atomic rename
fs::rename(&temp_path, config_path).with_context(|| {
format!(
"Failed to atomically rename whitelist temp file {} to {}",
temp_path.display(),
config_path.display()
)
})?;
// Release lock explicitly before reload (drop happens at end of scope) // Release lock explicitly before reload (drop happens at end of scope)
drop(lock_file); drop(lock_file);
// 7. Reload in-memory state // 7. Reload in-memory state
self.reload().context("Failed to reload whitelist after append")?; self.reload()
.context("Failed to reload whitelist after append")?;
// 8. Log audit event // 8. Log audit event
tracing::info!( tracing::info!(

View File

@ -114,6 +114,14 @@ pub struct EnrollmentConfig {
pub polling_interval_seconds: u64, pub polling_interval_seconds: u64,
#[serde(default = "default_max_poll_attempts")] #[serde(default = "default_max_poll_attempts")]
pub max_poll_attempts: u32, pub max_poll_attempts: u32,
/// Network interface whose IPv4 address is reported to the manager.
/// Overrides auto-detection. Example: `"eth0"`, `"ens192"`.
#[serde(default)]
pub report_interface: Option<String>,
/// Explicit IPv4 address reported to the manager.
/// Highest priority — overrides both `report_interface` and auto-detect.
#[serde(default)]
pub report_ip: Option<String>,
} }
fn default_polling_interval() -> u64 { fn default_polling_interval() -> u64 {
@ -142,16 +150,16 @@ pub struct AppConfig {
impl AppConfig { impl AppConfig {
/// Load configuration from a YAML file /// Load configuration from a YAML file
pub fn load(path: &str) -> Result<Self> { pub fn load(path: &str, skip_tls_validation: bool) -> Result<Self> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path))?; .with_context(|| format!("Failed to read config file: {}", path))?;
let config: AppConfig = serde_yaml::from_str(&content) let config: AppConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path))?; .with_context(|| format!("Failed to parse config file: {}", path))?;
// Validate TLS configuration if enabled // Validate TLS configuration if enabled (skip during enrollment bootstrap)
if let Some(ref tls) = config.tls { if let Some(ref tls) = config.tls {
if tls.enabled { if tls.enabled && !skip_tls_validation {
if !std::path::Path::new(&tls.ca_cert).exists() { if !std::path::Path::new(&tls.ca_cert).exists() {
anyhow::bail!("TLS CA certificate not found: {}", tls.ca_cert); anyhow::bail!("TLS CA certificate not found: {}", tls.ca_cert);
} }
@ -187,7 +195,7 @@ mod tests {
#[test] #[test]
fn test_config_load_valid_yaml() { fn test_config_load_valid_yaml() {
let result = AppConfig::load("tests/fixtures/valid_config.yaml"); let result = AppConfig::load("tests/fixtures/valid_config.yaml", false);
assert!( assert!(
result.is_ok(), result.is_ok(),
"Failed to load valid config: {:?}", "Failed to load valid config: {:?}",
@ -204,7 +212,7 @@ mod tests {
#[test] #[test]
fn test_config_load_missing_file() { fn test_config_load_missing_file() {
let result = AppConfig::load("/nonexistent/path/config.yaml"); let result = AppConfig::load("/nonexistent/path/config.yaml", false);
assert!(result.is_err(), "Should fail for missing file"); assert!(result.is_err(), "Should fail for missing file");
let err = result.unwrap_err(); let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to read config file")); assert!(err.to_string().contains("Failed to read config file"));
@ -215,7 +223,7 @@ mod tests {
let invalid_path = "/tmp/invalid_config_test.yaml"; let invalid_path = "/tmp/invalid_config_test.yaml";
std::fs::write(invalid_path, "invalid: yaml: content: [").unwrap(); std::fs::write(invalid_path, "invalid: yaml: content: [").unwrap();
let result = AppConfig::load(invalid_path); let result = AppConfig::load(invalid_path, false);
assert!(result.is_err(), "Should fail for invalid yaml"); assert!(result.is_err(), "Should fail for invalid yaml");
std::fs::remove_file(invalid_path).unwrap(); std::fs::remove_file(invalid_path).unwrap();
@ -223,7 +231,7 @@ mod tests {
#[test] #[test]
fn test_config_validation_port_range() { fn test_config_validation_port_range() {
let result = AppConfig::load("tests/fixtures/valid_config.yaml"); let result = AppConfig::load("tests/fixtures/valid_config.yaml", false);
assert!(result.is_ok()); assert!(result.is_ok());
let config = result.unwrap(); let config = result.unwrap();
assert!(config.server.port >= 1); assert!(config.server.port >= 1);
@ -231,7 +239,7 @@ mod tests {
#[test] #[test]
fn test_config_validation_bind_address() { fn test_config_validation_bind_address() {
let result = AppConfig::load("tests/fixtures/valid_config.yaml"); let result = AppConfig::load("tests/fixtures/valid_config.yaml", false);
assert!(result.is_ok()); assert!(result.is_ok());
let config = result.unwrap(); let config = result.unwrap();
assert!(!config.server.bind.is_empty()); assert!(!config.server.bind.is_empty());
@ -239,7 +247,7 @@ mod tests {
#[test] #[test]
fn test_config_validation_max_concurrent() { fn test_config_validation_max_concurrent() {
let result = AppConfig::load("tests/fixtures/valid_config.yaml"); let result = AppConfig::load("tests/fixtures/valid_config.yaml", false);
assert!(result.is_ok()); assert!(result.is_ok());
let config = result.unwrap(); let config = result.unwrap();
assert!(config.jobs.max_concurrent > 0); assert!(config.jobs.max_concurrent > 0);
@ -247,7 +255,7 @@ mod tests {
#[test] #[test]
fn test_config_validation_timeout() { fn test_config_validation_timeout() {
let result = AppConfig::load("tests/fixtures/valid_config.yaml"); let result = AppConfig::load("tests/fixtures/valid_config.yaml", false);
assert!(result.is_ok()); assert!(result.is_ok());
let config = result.unwrap(); let config = result.unwrap();
assert!(config.jobs.timeout_minutes >= 1 && config.jobs.timeout_minutes <= 1440); assert!(config.jobs.timeout_minutes >= 1 && config.jobs.timeout_minutes <= 1440);

View File

@ -7,7 +7,7 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::signal::unix::{SignalKind, signal as unix_signal}; use tokio::signal::unix::{signal as unix_signal, SignalKind};
use crate::enroll::identity; use crate::enroll::identity;
@ -18,6 +18,10 @@ pub struct EnrollmentRequest {
pub fqdn: String, pub fqdn: String,
pub ip_address: String, pub ip_address: String,
pub os_details: serde_json::Value, 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). /// Response from `POST /api/v1/enroll` (HTTP 202).
@ -77,6 +81,10 @@ pub struct EnrollmentClient {
pub manager_url: String, pub manager_url: String,
/// Pre-configured reqwest client with insecure TLS and timeout. /// Pre-configured reqwest client with insecure TLS and timeout.
http_client: reqwest::Client, http_client: reqwest::Client,
/// Network interface whose IP is reported to the manager (overrides auto-detect).
report_interface: Option<String>,
/// Explicit IPv4 address reported to the manager (highest priority override).
report_ip: Option<String>,
} }
impl EnrollmentClient { impl EnrollmentClient {
@ -91,6 +99,21 @@ impl EnrollmentClient {
/// contains a valid host component. Rejects dangerous schemes like `file://`, /// contains a valid host component. Rejects dangerous schemes like `file://`,
/// `gopher://`, or URLs without a host. /// `gopher://`, or URLs without a host.
pub fn new(manager_url: &str) -> Self { pub fn new(manager_url: &str) -> Self {
Self::with_ip_overrides(manager_url, None, None)
}
/// Create a new enrollment client with optional IP reporting overrides.
///
/// See [`identity::get_primary_ip`] for resolution priority:
/// 1. `report_ip` — explicit IP (highest priority)
/// 2. `report_interface` — IP from named interface
/// 3. Route-based — IP from kernel routing table for reaching the manager
/// 4. Auto-detect — first routable IP (container bridge subnets filtered)
pub fn with_ip_overrides(
manager_url: &str,
report_interface: Option<String>,
report_ip: Option<String>,
) -> Self {
// SECURITY: Validate URL scheme before building HTTP client. // SECURITY: Validate URL scheme before building HTTP client.
// Only http and https are permitted to prevent path traversal, SSRF, // Only http and https are permitted to prevent path traversal, SSRF,
// or local file access via dangerous schemes (file://, gopher://, etc.). // or local file access via dangerous schemes (file://, gopher://, etc.).
@ -99,7 +122,7 @@ impl EnrollmentClient {
.expect("Failed to parse manager URL"); .expect("Failed to parse manager URL");
match parsed.scheme() { match parsed.scheme() {
"http" | "https" => {}, // Allowed schemes "http" | "https" => {} // Allowed schemes
other => panic!( other => panic!(
"Invalid manager URL scheme '{}' — only 'http' and 'https' are allowed. \ "Invalid manager URL scheme '{}' — only 'http' and 'https' are allowed. \
Refused dangerous scheme to prevent SSRF/path traversal.", Refused dangerous scheme to prevent SSRF/path traversal.",
@ -124,6 +147,8 @@ impl EnrollmentClient {
Self { Self {
manager_url: manager_url.to_string(), manager_url: manager_url.to_string(),
http_client, http_client,
report_interface,
report_ip,
} }
} }
@ -139,12 +164,11 @@ impl EnrollmentClient {
/// - `Err` if URL parsing fails or DNS resolution yields no results /// - `Err` if URL parsing fails or DNS resolution yields no results
pub async fn manager_ip(&self) -> Result<String> { pub async fn manager_ip(&self) -> Result<String> {
// Parse URL to extract host using url crate for RFC-compliant parsing // Parse URL to extract host using url crate for RFC-compliant parsing
let parsed = url::Url::parse(&self.manager_url).with_context(|| { let parsed = url::Url::parse(&self.manager_url)
format!("Failed to parse manager URL '{}'", self.manager_url) .with_context(|| format!("Failed to parse manager URL '{}'", self.manager_url))?;
})?; let host_str = parsed
let host_str = parsed.host_str().with_context(|| { .host_str()
format!("Manager URL '{}' has no host component", self.manager_url) .with_context(|| format!("Manager URL '{}' has no host component", self.manager_url))?;
})?;
// Check if already an IP address using url::Host parsing // Check if already an IP address using url::Host parsing
if let Ok(url::Host::Ipv4(addr)) = url::Host::parse(host_str) { if let Ok(url::Host::Ipv4(addr)) = url::Host::parse(host_str) {
@ -183,28 +207,35 @@ impl EnrollmentClient {
/// - `Ok(EnrollmentResponse)` with the polling token on HTTP 202 /// - `Ok(EnrollmentResponse)` with the polling token on HTTP 202
/// - Error on 429 (rate limited), 5xx (server error), or network failure /// - Error on 429 (rate limited), 5xx (server error), or network failure
pub async fn register(&self) -> Result<EnrollmentResponse> { pub async fn register(&self) -> Result<EnrollmentResponse> {
// 1. Collect identity data // 1. Resolve manager IP for route-based IP selection
let route_target = self.manager_ip().await.ok();
// 2. Collect identity data
let machine_id = identity::get_machine_id() let machine_id = identity::get_machine_id()
.context("Failed to read machine-id — host cannot enroll without identity")?; .context("Failed to read machine-id — host cannot enroll without identity")?;
let fqdn = identity::get_fqdn() let fqdn = identity::get_fqdn()
.context("Failed to determine FQDN — check hostname configuration")?; .context("Failed to determine FQDN — check hostname configuration")?;
let ip_addresses = identity::get_ip_addresses() let ip_address = identity::get_primary_ip(
.context("Failed to enumerate network interfaces — check network configuration")?; self.report_interface.as_deref(),
self.report_ip.as_deref(),
route_target.as_deref(),
)
.context("Failed to determine reportable IP address — check network configuration or set report_interface/report_ip in config")?;
let os_details = identity::get_os_details() let os_details = identity::get_os_details()
.context("Failed to collect OS details — /etc/os-release may be missing")?; .context("Failed to collect OS details — /etc/os-release may be missing")?;
// Use first non-loopback IP (manager expects single string) // 2. Collect short hostname for display_name on manager
let ip_address = ip_addresses let hostname = identity::get_hostname()
.first() .map_err(|e| tracing::warn!(error = %e, "Failed to determine hostname — display_name will use FQDN fallback"))
.cloned() .ok();
.unwrap_or_else(|| "127.0.0.1".to_string());
// 2. Build EnrollmentRequest struct // 3. Build EnrollmentRequest struct
let request = EnrollmentRequest { let request = EnrollmentRequest {
machine_id, machine_id,
fqdn, fqdn,
ip_address, ip_address,
os_details, os_details,
hostname,
}; };
tracing::info!( tracing::info!(
@ -287,9 +318,8 @@ impl EnrollmentClient {
.await .await
.context("Failed to read status response body")?; .context("Failed to read status response body")?;
let status: EnrollmentStatusResponse = let status: EnrollmentStatusResponse = serde_json::from_str(&body)
serde_json::from_str(&body) .context("Invalid status response — malformed JSON from manager")?;
.context("Invalid status response — malformed JSON from manager")?;
Ok(status) Ok(status)
} }
@ -336,7 +366,11 @@ impl EnrollmentClient {
max_attempts: u32, max_attempts: u32,
) -> Result<PkiBundle> { ) -> Result<PkiBundle> {
// Enforce hard limits // Enforce hard limits
let effective_interval = if interval_seconds == 0 { 60 } else { interval_seconds }; let effective_interval = if interval_seconds == 0 {
60
} else {
interval_seconds
};
let effective_max = match max_attempts { let effective_max = match max_attempts {
0 => 1440, 0 => 1440,
n if n > 1440 => 1440, n if n > 1440 => 1440,
@ -417,7 +451,11 @@ impl EnrollmentClient {
attempts = attempt, attempts = attempt,
"Enrollment approved — received PKI bundle from manager" "Enrollment approved — received PKI bundle from manager"
); );
return Ok(PkiBundle { ca_crt, server_crt, server_key }); return Ok(PkiBundle {
ca_crt,
server_crt,
server_key,
});
} }
EnrollmentStatusResponse::Denied => { EnrollmentStatusResponse::Denied => {
tracing::warn!( tracing::warn!(
@ -444,20 +482,22 @@ impl EnrollmentClient {
total_seconds = total_seconds, total_seconds = total_seconds,
"Enrollment polling timed out after maximum attempts" "Enrollment polling timed out after maximum attempts"
); );
Err(anyhow!("Enrollment timed out after {} hours ({}/{} attempts)", Err(anyhow!(
total_seconds / 3600, effective_max, effective_max)) "Enrollment timed out after {} hours ({}/{} attempts)",
total_seconds / 3600,
effective_max,
effective_max
))
} }
/// Create a SIGINT (Ctrl+C) signal receiver. /// Create a SIGINT (Ctrl+C) signal receiver.
fn setup_sigint() -> Result<tokio::signal::unix::Signal> { fn setup_sigint() -> Result<tokio::signal::unix::Signal> {
unix_signal(SignalKind::interrupt()) unix_signal(SignalKind::interrupt()).context("Failed to create SIGINT signal handler")
.context("Failed to create SIGINT signal handler")
} }
/// Create a SIGTERM signal receiver. /// Create a SIGTERM signal receiver.
fn setup_sigterm() -> Result<tokio::signal::unix::Signal> { fn setup_sigterm() -> Result<tokio::signal::unix::Signal> {
unix_signal(SignalKind::terminate()) unix_signal(SignalKind::terminate()).context("Failed to create SIGTERM signal handler")
.context("Failed to create SIGTERM signal handler")
} }
} }
@ -472,6 +512,7 @@ mod tests {
fqdn: "node.example.com".into(), fqdn: "node.example.com".into(),
ip_address: "192.168.1.10".into(), ip_address: "192.168.1.10".into(),
os_details: serde_json::json!({"distro": "Debian", "version": "12"}), 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"); let json = serde_json::to_string(&request).expect("Failed to serialize EnrollmentRequest");
assert!(json.contains("machine_id")); assert!(json.contains("machine_id"));

View File

@ -5,7 +5,7 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::fs; use std::fs;
use std::net::IpAddr; use std::net::{IpAddr, Ipv4Addr};
use std::process::Command; use std::process::Command;
/// Read the D-Bus machine identifier from `/etc/machine-id`. /// Read the D-Bus machine identifier from `/etc/machine-id`.
@ -31,43 +31,122 @@ pub fn get_machine_id() -> Result<String> {
} }
/// Resolve the fully-qualified domain name. /// 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> { 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") { if let Ok(name) = fs::read_to_string("/etc/hostname") {
let trimmed = name.trim().to_string(); let trimmed = name.trim().to_string();
if !trimmed.is_empty() && trimmed != "(none)" { if !trimmed.is_empty() && trimmed != "(none)" {
tracing::debug!(hostname = %trimmed, "Resolved hostname via /etc/hostname (may be short)");
return Ok(trimmed); return Ok(trimmed);
} }
} }
// Fallback to hostname command // 4. Fallback to plain 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
if let Ok(output) = Command::new("hostname").output() { if let Ok(output) = Command::new("hostname").output() {
if output.status.success() { if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() { if !name.is_empty() {
tracing::debug!(hostname = %name, "Resolved hostname via hostname command");
return Ok(name); 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()) Ok("localhost".into())
} }
/// Collect all non-loopback IPv4 addresses from network interfaces. /// Collect all non-loopback IPv4 addresses from network interfaces.
///
/// Filters out container bridge subnets (Docker 172.16.0.0/12) and
/// link-local addresses (169.254.0.0/16) that are not routable from the manager.
pub fn get_ip_addresses() -> Result<Vec<String>> { pub fn get_ip_addresses() -> Result<Vec<String>> {
let ifaces = if_addrs::get_if_addrs() let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?;
.context("Failed to enumerate network interfaces")?;
let mut addrs: Vec<String> = ifaces let mut addrs: Vec<String> = ifaces
.iter() .iter()
@ -76,7 +155,17 @@ pub fn get_ip_addresses() -> Result<Vec<String>> {
return None; return None;
} }
match &iface.ip() { match &iface.ip() {
IpAddr::V4(addr) => Some(addr.to_string()), IpAddr::V4(addr) => {
// Filter container bridge and link-local subnets
if is_container_bridge(addr) || is_link_local(addr) {
tracing::debug!(
ip = %addr,
"Excluding container bridge or link-local IP from enrollment report"
);
return None;
}
Some(addr.to_string())
}
IpAddr::V6(_) => None, IpAddr::V6(_) => None,
} }
}) })
@ -87,6 +176,189 @@ pub fn get_ip_addresses() -> Result<Vec<String>> {
Ok(addrs) Ok(addrs)
} }
/// Check if an IPv4 address is in a container bridge subnet.
///
/// Filters the `172.16.0.0/12` range (172.16.0.0 172.31.255.255), which is
/// Docker's default bridge network allocation.
///
/// Note: `10.0.0.0/8` is NOT filtered because it is widely used for legitimate
/// LAN addressing. If a deployment uses a custom Docker bridge subnet outside
/// `172.16.0.0/12`, use `report_interface` or `report_ip` config to override.
pub fn is_container_bridge(addr: &Ipv4Addr) -> bool {
// 172.16.0.0/12 = 172.16.0.0 172.31.255.255
// Binary: 10101100.0001xxxx.xxxxxxxx.xxxxxxxx
let octets = addr.octets();
octets[0] == 172 && (octets[1] & 0xF0) == 0x10
}
/// Check if an IPv4 address is link-local (`169.254.0.0/16`).
///
/// Link-local addresses are auto-assigned when no DHCP is available and
/// are never routable across networks.
pub fn is_link_local(addr: &Ipv4Addr) -> bool {
let octets = addr.octets();
octets[0] == 169 && octets[1] == 254
}
/// Determine the local source IP that would be used to reach a target IP.
/// Uses the kernel routing table via `ip route get <target>`.
///
/// This is the most accurate way to select the correct local IP because it
/// queries the kernel routing table directly, which accounts for all routing
/// rules, interface priorities, and source address selection.
pub fn get_route_source_ip(target_ip: &str) -> Result<String> {
let output = Command::new("ip")
.args(["route", "get", target_ip])
.output()
.context("Failed to execute 'ip route get' — is iproute2 installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"'ip route get {}' failed: {}",
target_ip,
stderr.trim()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse output like: "192.168.3.36 via 192.168.1.1 dev eth0 src 192.168.3.36 uid ..."
// We want the 'src' field value
let mut found_src = false;
for part in stdout.split_whitespace() {
if found_src {
// Validate it's a valid IPv4 address
if part.parse::<Ipv4Addr>().is_ok() {
let addr = part.parse::<Ipv4Addr>().unwrap();
if !addr.is_loopback() && !is_container_bridge(&addr) && !is_link_local(&addr) {
tracing::info!(
target_ip = target_ip,
source_ip = part,
"Route-based IP selection: local source IP for reaching target"
);
return Ok(part.to_string());
}
}
break;
}
if part == "src" {
found_src = true;
}
}
Err(anyhow!(
"Could not determine source IP for route to '{}' — 'ip route get' output: {}",
target_ip,
stdout.trim()
))
}
/// Get the IPv4 address of a specific network interface by name.
///
/// Returns the first non-loopback IPv4 address on the named interface.
/// Useful when the admin knows which interface faces the manager network.
pub fn get_ip_for_interface(interface_name: &str) -> Result<String> {
let ifaces = if_addrs::get_if_addrs()
.with_context(|| "Failed to enumerate network interfaces for interface lookup")?;
for iface in &ifaces {
if iface.name != interface_name {
continue;
}
if let IpAddr::V4(addr) = iface.ip() {
if !iface.is_loopback() {
tracing::info!(
interface = interface_name,
ip = %addr,
"Resolved IP from configured interface"
);
return Ok(addr.to_string());
}
}
}
Err(anyhow!(
"No non-loopback IPv4 address found on interface '{}'",
interface_name
))
}
/// Determine the primary IP address to report to the manager.
///
/// Resolution priority:
/// 1. `report_ip` — explicit IP from config (highest priority)
/// 2. `report_interface` — IP from a named interface
/// 3. `route_target` — route-based selection using kernel routing table
/// 4. Auto-detect — first IP from `get_ip_addresses()` (bridge subnets already filtered)
pub fn get_primary_ip(
report_interface: Option<&str>,
report_ip: Option<&str>,
route_target: Option<&str>,
) -> Result<String> {
// Priority 1: Explicit IP override
if let Some(ip) = report_ip {
// Validate it parses as IPv4
if let Ok(addr) = ip.parse::<Ipv4Addr>() {
if !addr.is_loopback() {
tracing::info!(ip = ip, "Using explicitly configured report_ip");
return Ok(ip.to_string());
}
tracing::warn!(
ip = ip,
"Configured report_ip is a loopback address — ignoring"
);
} else {
tracing::warn!(
ip = ip,
"Configured report_ip is not a valid IPv4 address — falling back to auto-detect"
);
}
}
// Priority 2: Interface name override
if let Some(iface) = report_interface {
match get_ip_for_interface(iface) {
Ok(ip) => return Ok(ip),
Err(e) => {
tracing::warn!(
interface = iface,
error = %e,
"Configured report_interface lookup failed — falling back to route-based or auto-detect"
);
}
}
}
// Priority 3: Route-based selection using kernel routing table
if let Some(target) = route_target {
match get_route_source_ip(target) {
Ok(ip) => {
tracing::info!(
target = target,
ip = %ip,
"Using route-based IP selection for target"
);
return Ok(ip);
}
Err(e) => {
tracing::warn!(
target = target,
error = %e,
"Route-based IP selection failed — falling back to auto-detect"
);
}
}
}
// Priority 4: Auto-detect (bridge subnets already filtered by get_ip_addresses)
let addrs = get_ip_addresses()?;
addrs
.first()
.cloned()
.ok_or_else(|| anyhow!("No suitable IPv4 address found on any interface"))
}
/// Extract OS distribution details from `/etc/os-release` and kernel version. /// Extract OS distribution details from `/etc/os-release` and kernel version.
/// Returns a JSON object with: distro, version, id_like, kernel. /// Returns a JSON object with: distro, version, id_like, kernel.
pub fn get_os_details() -> Result<serde_json::Value> { pub fn get_os_details() -> Result<serde_json::Value> {
@ -105,16 +377,28 @@ pub fn get_os_details() -> Result<serde_json::Value> {
let unquoted = value.trim().trim_matches('"').trim_matches('\''); let unquoted = value.trim().trim_matches('"').trim_matches('\'');
match key { match key {
"NAME" => { "NAME" => {
details.insert("distro".into(), serde_json::Value::String(unquoted.to_string())); details.insert(
"distro".into(),
serde_json::Value::String(unquoted.to_string()),
);
} }
"VERSION_ID" => { "VERSION_ID" => {
details.insert("version".into(), serde_json::Value::String(unquoted.to_string())); details.insert(
"version".into(),
serde_json::Value::String(unquoted.to_string()),
);
} }
"ID_LIKE" => { "ID_LIKE" => {
details.insert("id_like".into(), serde_json::Value::String(unquoted.to_string())); details.insert(
"id_like".into(),
serde_json::Value::String(unquoted.to_string()),
);
} }
"VERSION_CODENAME" => { "VERSION_CODENAME" => {
details.insert("codename".into(), serde_json::Value::String(unquoted.to_string())); details.insert(
"codename".into(),
serde_json::Value::String(unquoted.to_string()),
);
} }
_ => {} _ => {}
} }
@ -123,7 +407,10 @@ pub fn get_os_details() -> Result<serde_json::Value> {
} else { } else {
// Fallback for systems without os-release (very rare) // Fallback for systems without os-release (very rare)
details.insert("distro".into(), serde_json::Value::String("unknown".into())); details.insert("distro".into(), serde_json::Value::String("unknown".into()));
details.insert("version".into(), serde_json::Value::String("unknown".into())); details.insert(
"version".into(),
serde_json::Value::String("unknown".into()),
);
} }
// Kernel version via uname -r // Kernel version via uname -r
@ -156,9 +443,249 @@ mod tests {
assert!(!fqdn.is_empty(), "FQDN should not be empty"); 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] #[test]
fn os_details_contains_kernel() { fn os_details_contains_kernel() {
let details = get_os_details().expect("Failed to get OS details"); let details = get_os_details().expect("Failed to get OS details");
assert!(details.get("kernel").is_some(), "OS details must contain kernel version"); assert!(
details.get("kernel").is_some(),
"OS details must contain kernel version"
);
}
// =============================================================================
// Container Bridge & Link-Local Filtering Tests
// =============================================================================
#[test]
fn test_is_container_bridge_docker_default() {
// Docker default bridge network: 172.17.0.0/16
assert!(is_container_bridge(&"172.17.0.1".parse().unwrap()));
assert!(is_container_bridge(&"172.17.255.255".parse().unwrap()));
}
#[test]
fn test_is_container_bridge_full_range() {
// 172.16.0.0/12 = 172.16.0.0 172.31.255.255
assert!(is_container_bridge(&"172.16.0.1".parse().unwrap()));
assert!(is_container_bridge(&"172.31.255.255".parse().unwrap()));
assert!(is_container_bridge(&"172.20.0.1".parse().unwrap()));
}
#[test]
fn test_is_not_container_bridge() {
// Outside 172.16.0.0/12
assert!(!is_container_bridge(&"192.168.3.36".parse().unwrap()));
assert!(!is_container_bridge(&"10.0.0.1".parse().unwrap()));
assert!(!is_container_bridge(&"172.32.0.1".parse().unwrap()));
assert!(!is_container_bridge(&"172.15.0.1".parse().unwrap()));
assert!(!is_container_bridge(&"8.8.8.8".parse().unwrap()));
}
#[test]
fn test_is_link_local() {
assert!(is_link_local(&"169.254.0.1".parse().unwrap()));
assert!(is_link_local(&"169.254.255.255".parse().unwrap()));
}
#[test]
fn test_is_not_link_local() {
assert!(!is_link_local(&"192.168.1.1".parse().unwrap()));
assert!(!is_link_local(&"169.253.0.1".parse().unwrap()));
assert!(!is_link_local(&"169.255.0.1".parse().unwrap()));
}
#[test]
fn test_get_ip_addresses_excludes_docker_bridge() {
// On a system with Docker, the returned IPs should not include 172.16.0.0/12
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4");
assert!(
!is_container_bridge(&parsed),
"IP '{}' is in Docker bridge range 172.16.0.0/12 — should be excluded",
addr
);
}
}
#[test]
fn test_get_ip_addresses_excludes_link_local() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4");
assert!(
!is_link_local(&parsed),
"IP '{}' is link-local 169.254.0.0/16 — should be excluded",
addr
);
}
}
#[test]
fn test_get_primary_ip_auto_detect() {
// Without overrides, should return a valid non-bridge IP
// In Docker containers, auto-detect may find no routable IPs — that's valid
match get_primary_ip(None, None, None) {
Ok(ip) => {
assert!(!ip.is_empty(), "Primary IP should not be empty");
let parsed: Ipv4Addr = ip.parse().expect("Primary IP should be valid IPv4");
assert!(
!is_container_bridge(&parsed),
"Auto-detected IP should not be Docker bridge"
);
}
Err(_) => {
eprintln!("NOTE: No routable IPs found — likely running inside a Docker container");
}
}
}
#[test]
fn test_get_primary_ip_explicit_override() {
// Explicit IP should be returned as-is
let ip = get_primary_ip(None, Some("10.99.99.1"), None).expect("Failed with explicit IP");
assert_eq!(ip, "10.99.99.1");
}
#[test]
fn test_get_primary_ip_rejects_loopback_override() {
// Loopback in report_ip should fall back to auto-detect
// In Docker containers, auto-detect may also fail — that's valid
match get_primary_ip(None, Some("127.0.0.1"), None) {
Ok(ip) => assert_ne!(ip, "127.0.0.1"),
Err(_) => {
eprintln!(
"NOTE: Loopback rejected but no routable IPs for fallback — Docker container"
);
}
}
}
#[test]
fn test_get_primary_ip_invalid_override_falls_back() {
// Invalid IP in report_ip should fall back to auto-detect
// In Docker containers, auto-detect may also fail — that's valid
match get_primary_ip(None, Some("not-an-ip"), None) {
Ok(ip) => assert!(!ip.is_empty()),
Err(_) => {
eprintln!(
"NOTE: Invalid IP rejected but no routable IPs for fallback — Docker container"
);
}
}
}
#[test]
fn test_get_primary_ip_route_target_priority() {
// Route-based selection should be tried before auto-detect
// We test with a well-known IP; if iproute2 is available this may succeed,
// otherwise it falls back gracefully
match get_primary_ip(None, None, Some("8.8.8.8")) {
Ok(ip) => {
assert!(!ip.is_empty(), "Route-based IP should not be empty");
let parsed: Ipv4Addr = ip.parse().expect("Route-based IP should be valid IPv4");
assert!(
!is_container_bridge(&parsed),
"Route-based IP should not be Docker bridge"
);
assert!(
!parsed.is_loopback(),
"Route-based IP should not be loopback"
);
}
Err(_) => {
eprintln!(
"NOTE: Route-based selection failed — iproute2 may not be available in this environment"
);
}
}
}
#[test]
fn test_get_primary_ip_explicit_overrides_route_target() {
// Explicit report_ip should take priority over route_target
let ip = get_primary_ip(None, Some("10.99.99.1"), Some("8.8.8.8"))
.expect("Explicit IP should override route_target");
assert_eq!(ip, "10.99.99.1");
}
#[test]
fn test_get_route_source_ip_known_target() {
// Test route-based IP detection with a well-known target
// This test requires iproute2 to be installed
match get_route_source_ip("8.8.8.8") {
Ok(ip) => {
let parsed: Ipv4Addr = ip.parse().expect("Route source IP should be valid IPv4");
assert!(
!parsed.is_loopback(),
"Route source IP should not be loopback"
);
assert!(
!is_container_bridge(&parsed),
"Route source IP should not be Docker bridge"
);
assert!(
!is_link_local(&parsed),
"Route source IP should not be link-local"
);
}
Err(e) => {
// Acceptable in containers without iproute2 or routing
eprintln!(
"NOTE: Route-based IP detection failed: {} — may be unavailable in this environment",
e
);
}
}
} }
} }

View File

@ -12,11 +12,13 @@ use anyhow::{Context, Result};
/// Re-export key types for ergonomic access from parent modules. /// Re-export key types for ergonomic access from parent modules.
pub use client::{ pub use client::{
EnrollmentClient, EnrollmentRequest, EnrollmentResponse, EnrollmentClient, EnrollmentRequest, EnrollmentResponse, EnrollmentStatusResponse, PkiBundle,
EnrollmentStatusResponse, PkiBundle,
}; };
/// Re-export identity extraction functions. /// Re-export identity extraction functions.
pub use identity::{get_fqdn, get_ip_addresses, get_machine_id, get_os_details}; pub use identity::{
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,
};
/// Run the full enrollment flow against the manager at the given URL. /// Run the full enrollment flow against the manager at the given URL.
/// ///
@ -29,7 +31,14 @@ pub use identity::{get_fqdn, get_ip_addresses, get_machine_id, get_os_details};
/// Returns Err on registration failure, polling timeout, denial, user interruption, /// Returns Err on registration failure, polling timeout, denial, user interruption,
/// PKI provisioning failure, or whitelist update failure. /// PKI provisioning failure, or whitelist update failure.
pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Result<()> { pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Result<()> {
let client = EnrollmentClient::new(manager_url); // Extract IP reporting overrides from enrollment config
let (report_interface, report_ip) = config
.enrollment
.as_ref()
.map(|e| (e.report_interface.clone(), e.report_ip.clone()))
.unwrap_or((None, None));
let client = EnrollmentClient::with_ip_overrides(manager_url, report_interface, report_ip);
// Phase 1: Registration // Phase 1: Registration
tracing::info!( tracing::info!(
@ -40,10 +49,16 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
tracing::info!("Registration successful - received polling token"); tracing::info!("Registration successful - received polling token");
// Get polling config (use defaults if not set) // Get polling config (use defaults if not set)
let interval = config.enrollment.as_ref() let interval = config
.map(|e| e.polling_interval_seconds).unwrap_or(60); .enrollment
let max_attempts = config.enrollment.as_ref() .as_ref()
.map(|e| e.max_poll_attempts).unwrap_or(1440); .map(|e| e.polling_interval_seconds)
.unwrap_or(60);
let max_attempts = config
.enrollment
.as_ref()
.map(|e| e.max_poll_attempts)
.unwrap_or(1440);
// Phase 2: Polling // Phase 2: Polling
tracing::info!( tracing::info!(
@ -51,7 +66,9 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
max_attempts = max_attempts, max_attempts = max_attempts,
"Starting enrollment - polling phase" "Starting enrollment - polling phase"
); );
let pki_bundle = client.poll_for_approval(&response.polling_token, interval, max_attempts).await?; let pki_bundle = client
.poll_for_approval(&response.polling_token, interval, max_attempts)
.await?;
// Phase 3: PKI provisioning & whitelist update // Phase 3: PKI provisioning & whitelist update
tracing::info!("Enrollment approved - starting PKI provisioning phase"); tracing::info!("Enrollment approved - starting PKI provisioning phase");
@ -62,13 +79,15 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
&pki_bundle.server_crt, &pki_bundle.server_crt,
&pki_bundle.server_key, &pki_bundle.server_key,
config.tls_config(), config.tls_config(),
).await?; )
.await?;
tracing::info!("PKI bundle written to disk"); tracing::info!("PKI bundle written to disk");
// Resolve manager hostname to IP and append to whitelist // Resolve manager hostname to IP and append to whitelist
let manager_ip = client.manager_ip().await.context( let manager_ip = client
"Failed to resolve manager IP - cannot update whitelist", .manager_ip()
)?; .await
.context("Failed to resolve manager IP - cannot update whitelist")?;
provision::append_manager_to_whitelist(&manager_ip, config.whitelist_path()).await?; provision::append_manager_to_whitelist(&manager_ip, config.whitelist_path()).await?;
tracing::info!(manager_ip = %manager_ip, "Manager IP appended to whitelist"); tracing::info!(manager_ip = %manager_ip, "Manager IP appended to whitelist");

View File

@ -1,8 +1,8 @@
//! PKI provisioning module for self-enrollment. //! PKI provisioning module for self-enrollment.
//! Handles certificate extraction, validation, and secure file writing. //! Handles certificate extraction, validation, and secure file writing.
use anyhow::{bail, Context, Result};
use crate::auth::WhitelistManager; use crate::auth::WhitelistManager;
use anyhow::{bail, Context, Result};
use std::fs::{self, OpenOptions}; use std::fs::{self, OpenOptions};
use std::io::Write; use std::io::Write;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
@ -71,8 +71,9 @@ pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)?.permissions(); let mut perms = fs::metadata(parent)?.permissions();
perms.set_mode(0o755); perms.set_mode(0o755);
fs::set_permissions(parent, perms) fs::set_permissions(parent, perms).with_context(|| {
.with_context(|| format!("Failed to set permissions on: {}", parent.display()))?; format!("Failed to set permissions on: {}", parent.display())
})?;
} }
} }
} }
@ -107,14 +108,13 @@ pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
.with_context(|| format!("Failed to flush PEM data to: {}", temp_path.display()))?; .with_context(|| format!("Failed to flush PEM data to: {}", temp_path.display()))?;
// Atomic rename to target path // Atomic rename to target path
fs::rename(&temp_path, path) fs::rename(&temp_path, path).with_context(|| {
.with_context(|| { format!(
format!( "Failed to atomically rename {} to {}",
"Failed to atomically rename {} to {}", temp_path.display(),
temp_path.display(), path.display()
path.display() )
) })?;
})?;
tracing::info!( tracing::info!(
path = %path.display(), path = %path.display(),
@ -138,7 +138,11 @@ pub async fn provision_pki_bundle(
) -> Result<()> { ) -> Result<()> {
// Determine target paths from config or defaults // Determine target paths from config or defaults
let (ca_path, cert_path, key_path) = if let Some(tls) = tls_config { let (ca_path, cert_path, key_path) = if let Some(tls) = tls_config {
(tls.ca_cert.clone(), tls.server_cert.clone(), tls.server_key.clone()) (
tls.ca_cert.clone(),
tls.server_cert.clone(),
tls.server_key.clone(),
)
} else { } else {
( (
DEFAULT_CA_CERT.to_string(), DEFAULT_CA_CERT.to_string(),
@ -148,10 +152,8 @@ pub async fn provision_pki_bundle(
}; };
// 1. Validate all three PEM strings before any writes // 1. Validate all three PEM strings before any writes
validate_pem(ca_crt, "CERTIFICATE") validate_pem(ca_crt, "CERTIFICATE").context("CA certificate validation failed")?;
.context("CA certificate validation failed")?; validate_pem(server_crt, "CERTIFICATE").context("Server certificate validation failed")?;
validate_pem(server_crt, "CERTIFICATE")
.context("Server certificate validation failed")?;
// Server key can be PRIVATE KEY (PKCS#8), RSA PRIVATE KEY (PKCS#1), or EC PRIVATE KEY // Server key can be PRIVATE KEY (PKCS#8), RSA PRIVATE KEY (PKCS#1), or EC PRIVATE KEY
let key_valid = validate_pem(server_key, "PRIVATE KEY").is_ok() let key_valid = validate_pem(server_key, "PRIVATE KEY").is_ok()
@ -165,14 +167,11 @@ pub async fn provision_pki_bundle(
} }
// 2. Write to configured paths (atomic writes) // 2. Write to configured paths (atomic writes)
write_pem_file(&ca_path, ca_crt, false) write_pem_file(&ca_path, ca_crt, false).context("Failed to write CA certificate")?;
.context("Failed to write CA certificate")?;
write_pem_file(&cert_path, server_crt, false) write_pem_file(&cert_path, server_crt, false).context("Failed to write server certificate")?;
.context("Failed to write server certificate")?;
write_pem_file(&key_path, server_key, true) write_pem_file(&key_path, server_key, true).context("Failed to write server key")?;
.context("Failed to write server key")?;
// 3. Log successful provisioning with structured fields // 3. Log successful provisioning with structured fields
tracing::info!( tracing::info!(
@ -198,11 +197,19 @@ pub async fn append_manager_to_whitelist(manager_ip: &str, whitelist_path: &str)
} }
// Create or load WhitelistManager and call append_entry // Create or load WhitelistManager and call append_entry
let mut manager = WhitelistManager::new(whitelist_path) let mut manager = WhitelistManager::new(whitelist_path).with_context(|| {
.with_context(|| format!("Failed to initialize whitelist manager for path: {}", whitelist_path))?; format!(
"Failed to initialize whitelist manager for path: {}",
whitelist_path
)
})?;
manager.append_entry(ip_or_cidr) manager.append_entry(ip_or_cidr).with_context(|| {
.with_context(|| format!("Failed to append manager IP '{}' to whitelist at: {}", ip_or_cidr, whitelist_path))?; format!(
"Failed to append manager IP '{}' to whitelist at: {}",
ip_or_cidr, whitelist_path
)
})?;
Ok(()) Ok(())
} }
@ -343,7 +350,8 @@ mod tests {
let dir = tempdir().expect("failed to create temp dir"); let dir = tempdir().expect("failed to create temp dir");
let target_path = dir.path().join("cert.pem"); let target_path = dir.path().join("cert.pem");
let cert1 = sample_certificate(); let cert1 = sample_certificate();
let cert2 = "-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string(); let cert2 =
"-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string();
// Write initial file // Write initial file
write_pem_file(target_path.to_str().unwrap(), &cert1, false).expect("initial write failed"); write_pem_file(target_path.to_str().unwrap(), &cert1, false).expect("initial write failed");
@ -352,7 +360,10 @@ mod tests {
write_pem_file(target_path.to_str().unwrap(), &cert2, false).expect("second write failed"); write_pem_file(target_path.to_str().unwrap(), &cert2, false).expect("second write failed");
let backup_path = format!("{}.bak", target_path.display()); let backup_path = format!("{}.bak", target_path.display());
assert!(std::path::Path::new(&backup_path).exists(), "Backup file should exist"); assert!(
std::path::Path::new(&backup_path).exists(),
"Backup file should exist"
);
// Original content in backup // Original content in backup
let backup_content = fs::read_to_string(&backup_path).expect("failed to read backup"); let backup_content = fs::read_to_string(&backup_path).expect("failed to read backup");

View File

@ -23,8 +23,8 @@ 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::{mtls, MtlsMiddleware, WhitelistManager}; use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
use linux_patch_api::packages::create_backend;
use linux_patch_api::enroll; use linux_patch_api::enroll;
use linux_patch_api::packages::create_backend;
use linux_patch_api::{init_logging, AppConfig, JobManager}; use linux_patch_api::{init_logging, AppConfig, JobManager};
/// Linux Patch API CLI arguments /// Linux Patch API CLI arguments
@ -42,7 +42,10 @@ struct Args {
verbose: bool, verbose: bool,
/// Enroll with manager at URL (skips mTLS startup, runs enrollment flow only) /// Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)
#[arg(long, help = "Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)")] #[arg(
long,
help = "Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)"
)]
enroll: Option<String>, enroll: Option<String>,
} }
@ -54,6 +57,11 @@ async fn main() -> Result<()> {
// Initialize logging // Initialize logging
let _guard = init_logging(args.verbose)?; let _guard = init_logging(args.verbose)?;
// Install rustls crypto provider (required for mTLS and HTTPS clients)
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider (aws-lc-rs)");
info!( info!(
version = env!("CARGO_PKG_VERSION"), version = env!("CARGO_PKG_VERSION"),
config_path = args.config, config_path = args.config,
@ -61,7 +69,7 @@ async fn main() -> Result<()> {
); );
// Load configuration // Load configuration
let config = match AppConfig::load(&args.config) { let config = match AppConfig::load(&args.config, args.enroll.is_some()) {
Ok(cfg) => { Ok(cfg) => {
info!( info!(
port = cfg.server.port, port = cfg.server.port,
@ -78,7 +86,10 @@ async fn main() -> Result<()> {
// Handle enrollment mode - runs before server startup // Handle enrollment mode - runs before server startup
if let Some(ref manager_url) = args.enroll { if let Some(ref manager_url) = args.enroll {
info!(manager_url = manager_url, "Enrollment mode activated - running enrollment flow before server startup"); info!(
manager_url = manager_url,
"Enrollment mode activated - running enrollment flow before server startup"
);
match enroll::run_enrollment(manager_url, &config).await { match enroll::run_enrollment(manager_url, &config).await {
Ok(()) => { Ok(()) => {
info!("Enrollment complete - proceeding to server startup"); info!("Enrollment complete - proceeding to server startup");

View File

@ -1,7 +1,7 @@
//! Packages Module - Package Manager Backend //! Packages Module - Package Manager Backend
//! //!
//! Provides abstraction layer for package management operations. //! Provides abstraction layer for package management operations.
//! Supports apt/dpkg (Debian/Ubuntu) with pluggable backend architecture. //! Supports apt/dpkg (Debian/Ubuntu) and apk (Alpine Linux) with pluggable backend architecture.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -670,6 +670,508 @@ impl Default for AptBackend {
} }
} }
/// APK package manager backend (Alpine Linux)
pub struct ApkBackend {
_marker: std::marker::PhantomData<()>,
}
impl ApkBackend {
pub fn new() -> Self {
Self {
_marker: std::marker::PhantomData,
}
}
/// Run apk command and capture output
fn run_apk(&self, args: &[&str]) -> Result<String> {
// Service runs as root on Alpine - no sudo needed for apk commands
let output = Command::new("apk")
.args(args)
.output()
.context("Failed to execute apk command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("apk command failed: {}", stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Parse name and version from apk package identifier (name-version format).
/// Alpine package names can contain hyphens (e.g., "gcc-gnat"), so we find
/// the first hyphen followed by a digit to separate name from version.
fn parse_name_version(&self, ident: &str) -> (String, String) {
let bytes = ident.as_bytes();
for i in 0..bytes.len() {
if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
return (ident[..i].to_string(), ident[i + 1..].to_string());
}
}
// Fallback: no version separator found
(ident.to_string(), String::new())
}
/// Parse package list from `apk list --installed` output.
/// Format: {name}-{version} [{repo}] {description}
fn parse_apk_package_list(&self, output: &str) -> Vec<Package> {
let mut packages = Vec::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
// Split on " [" to separate package identifier from repo and description
let (ident, rest) = if let Some(pos) = line.find(" [") {
(&line[..pos], &line[pos + 2..])
} else if let Some(pos) = line.find(' ') {
(&line[..pos], &line[pos + 1..])
} else {
// No separator found, treat entire line as identifier
let (name, version) = self.parse_name_version(line.trim());
packages.push(Package {
name,
version,
status: PackageStatus::Installed,
upgradable: false,
latest_version: None,
description: String::new(),
dependencies: Vec::new(),
reverse_dependencies: Vec::new(),
install_date: None,
size_installed: None,
});
continue;
};
let (name, version) = self.parse_name_version(ident);
// Parse rest: "{repo}] {description}" or just "{description}"
let description = if let Some(bracket_end) = rest.find("] ") {
rest[bracket_end + 2..].to_string()
} else if let Some(bracket_end) = rest.find(']') {
rest[bracket_end + 1..].trim().to_string()
} else {
rest.to_string()
};
packages.push(Package {
name,
version,
status: PackageStatus::Installed,
upgradable: false,
latest_version: None,
description,
dependencies: Vec::new(),
reverse_dependencies: Vec::new(),
install_date: None,
size_installed: None,
});
}
packages
}
/// Parse detailed package info from `apk info -a {name}` output.
/// Output format has section headers like:
/// {name}-{version} description:
/// the description text
/// {name}-{version} installed size:
/// 32768
fn parse_apk_info(
&self,
output: &str,
name: &str,
status: PackageStatus,
) -> Result<Option<Package>> {
let mut version = String::new();
let mut description = String::new();
let mut dependencies = Vec::new();
let mut reverse_dependencies = Vec::new();
let mut size_installed = None;
let mut current_field: Option<&str> = None;
for line in output.lines() {
if line.contains(" description:") {
current_field = Some("description");
// Extract version from the header line
let header = line.split(" description:").next().unwrap_or("");
let (parsed_name, v) = self.parse_name_version(header.trim());
if parsed_name == name || version.is_empty() {
version = v;
}
} else if line.contains(" webpage:") {
current_field = Some("webpage");
} else if line.contains(" installed size:") {
current_field = Some("installed_size");
// Size might be on the same line after the header
if let Some(pos) = line.find(" installed size:") {
let size_str = line[pos + " installed size:".len()..].trim();
if !size_str.is_empty() {
size_installed = Some(format!("{} bytes", size_str));
}
}
} else if line.contains(" dependencies:") {
current_field = Some("dependencies");
} else if line.contains(" provides:") {
current_field = Some("provides");
} else if line.contains(" required by:") {
current_field = Some("required_by");
} else if !line.trim().is_empty() {
match current_field {
Some("description") if description.is_empty() => {
description = line.trim().to_string();
}
Some("dependencies") => {
for dep in line.split_whitespace() {
// APK dependencies use prefixes like "so:", "cmd:", "pc:" - strip them
let dep_name = dep
.trim_start_matches("so:")
.trim_start_matches("cmd:")
.trim_start_matches("pc:");
dependencies.push(dep_name.to_string());
}
}
Some("required_by") => {
for req in line.split_whitespace() {
let (req_name, _) = self.parse_name_version(req);
reverse_dependencies.push(req_name);
}
}
Some("installed_size") => {
let size_str = line.trim();
if !size_str.is_empty() && size_installed.is_none() {
size_installed = Some(format!("{} bytes", size_str));
}
}
_ => {}
}
} else {
current_field = None;
}
}
// Check if upgradable
let upgradable = self
.run_apk(&["list", "--upgradable", name])
.map(|o| !o.trim().is_empty() && o.contains(name))
.unwrap_or(false);
let latest_version = if upgradable {
// Try to get the candidate version from apk info
self.run_apk(&["info", name]).ok().and_then(|o| {
o.lines().next().and_then(|l| {
let (_, v) = self.parse_name_version(l.trim());
if v.is_empty() {
None
} else {
Some(v)
}
})
})
} else {
Some(version.clone())
};
Ok(Some(Package {
name: name.to_string(),
version,
status,
upgradable,
latest_version,
description,
dependencies,
reverse_dependencies,
install_date: None,
size_installed,
}))
}
}
impl PackageManagerBackend for ApkBackend {
fn list_packages(&self, filter: Option<&str>) -> Result<Vec<Package>> {
let args = match filter {
Some(f) => vec!["list", "--installed", f],
None => vec!["list", "--installed"],
};
let output = self.run_apk(&args)?;
Ok(self.parse_apk_package_list(&output))
}
fn get_package(&self, name: &str) -> Result<Option<Package>> {
// Validate package name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") || name.contains(' ') {
return Err(anyhow::anyhow!("Invalid package name: {}", name));
}
// Check if package is installed using apk list --installed
let list_output = self.run_apk(&["list", "--installed", name])?;
if !list_output.trim().is_empty() && list_output.contains(name) {
// Package is installed, get detailed info
let info_output = self.run_apk(&["info", "-a", name])?;
return self.parse_apk_info(&info_output, name, PackageStatus::Installed);
}
// Check if package is available (not installed) using apk search
let search_output = self.run_apk(&["search", name]);
if let Ok(output) = search_output {
if !output.trim().is_empty() && output.contains(name) {
// Parse first matching line
if let Some(first_line) = output.lines().next() {
let (pkg_name, version) = self.parse_name_version(first_line.trim());
return Ok(Some(Package {
name: pkg_name,
version,
status: PackageStatus::Available,
upgradable: false,
latest_version: None,
description: String::new(),
dependencies: Vec::new(),
reverse_dependencies: Vec::new(),
install_date: None,
size_installed: None,
}));
}
}
}
Ok(None)
}
fn install_packages(&self, packages: &[PackageSpec], options: &InstallOptions) -> Result<()> {
let mut args: Vec<String> = vec!["add".to_string()];
if options.force {
args.push("--force".to_string());
}
for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version {
format!("{}={}", pkg.name, version)
} else {
pkg.name.clone()
};
args.push(pkg_arg);
}
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
self.run_apk(&args_ref)?;
info!(
"Installed packages: {:?}",
packages.iter().map(|p| &p.name).collect::<Vec<_>>()
);
Ok(())
}
fn update_package(&self, name: &str) -> Result<()> {
self.run_apk(&["upgrade", name])?;
info!("Updated package: {}", name);
Ok(())
}
fn remove_package(&self, name: &str, _purge: bool) -> Result<()> {
// APK doesn't have a purge concept - just remove the package
self.run_apk(&["del", name])?;
info!("Removed package: {}", name);
Ok(())
}
fn list_patches(&self) -> Result<Vec<Patch>> {
let output = self.run_apk(&["list", "--upgradable"])?;
let mut patches = Vec::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
// Parse upgradable package line
// Format: {name}-{new_version} < {old_version} [{repo}] {description}
// or fallback: {name}-{new_version} [{repo}] {description}
let (ident, current_version) = if let Some(pos) = line.find(" < ") {
let ident = &line[..pos];
let rest = &line[pos + 3..];
// Old version ends at the next space or bracket
let cv = if let Some(space_pos) = rest.find(' ') {
rest[..space_pos].to_string()
} else {
rest.to_string()
};
(ident, cv)
} else if let Some(pos) = line.find(' ') {
(&line[..pos], String::new())
} else {
continue;
};
let (name, available_version) = self.parse_name_version(ident);
// Determine severity based on package name heuristics
let severity =
if name.contains("kernel") || name.contains("ssl") || name.contains("security") {
"critical".to_string()
} else if name.contains("lib") {
"high".to_string()
} else {
"medium".to_string()
};
patches.push(Patch {
name,
current_version,
available_version,
severity,
description: String::from("Package update available"),
cve_ids: Vec::new(),
requires_reboot: false,
});
}
Ok(patches)
}
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages {
Some(pkgs) => {
let mut args: Vec<&str> = vec!["upgrade"];
for pkg in pkgs {
args.push(pkg);
}
self.run_apk(&args)?;
info!("Applied patches for packages: {:?}", packages);
}
None => {
self.run_apk(&["upgrade"])?;
info!("Applied all available patches");
}
}
Ok(())
}
fn get_system_info(&self) -> Result<SystemInfo> {
let hostname = Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let os_info = std::fs::read_to_string("/etc/os-release")
.ok()
.map(|content| {
let mut os = "Linux".to_string();
let mut version = "unknown".to_string();
for line in content.lines() {
if line.starts_with("PRETTY_NAME=") {
os = line
.trim_start_matches("PRETTY_NAME=")
.trim()
.trim_matches('"')
.to_string();
} else if line.starts_with("NAME=") {
os = line
.trim_start_matches("NAME=")
.trim()
.trim_matches('"')
.to_string();
} else if line.starts_with("VERSION=") {
version = line
.trim_start_matches("VERSION=")
.trim()
.trim_matches('"')
.to_string();
} else if line.starts_with("VERSION_ID=") {
version = line
.trim_start_matches("VERSION_ID=")
.trim()
.trim_matches('"')
.to_string();
}
}
(os, version)
})
.unwrap_or_else(|| ("Linux".to_string(), "unknown".to_string()));
let kernel = Command::new("uname")
.arg("-r")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let architecture = Command::new("uname")
.arg("-m")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
// Alpine uses /boot/.reboot-required for reboot indicator,
// also check /var/run/reboot-required as a fallback
let pending_reboot = std::path::Path::new("/boot/.reboot-required").exists()
|| std::path::Path::new("/var/run/reboot-required").exists();
Ok(SystemInfo {
hostname,
os: os_info.0,
os_version: os_info.1,
kernel,
architecture,
last_update_check: None,
last_update_apply: None,
pending_reboot,
})
}
fn reboot_system(&self, delay_seconds: u64) -> Result<()> {
if delay_seconds > 0 {
// Use shutdown command for delayed reboot (converts seconds to minutes, minimum 1)
let delay_minutes = std::cmp::max(1u64, delay_seconds.div_ceil(60));
info!(
"Scheduling system reboot in {} minutes (requested {} seconds)",
delay_minutes, delay_seconds
);
Command::new("shutdown")
.args(["-r", &format!("+{}", delay_minutes)])
.status()
.context("Failed to schedule delayed reboot")?;
info!("System reboot scheduled in {} minutes", delay_minutes);
} else {
// Alpine uses `reboot` command, not `systemctl reboot`
info!("Initiating immediate system reboot");
Command::new("reboot")
.status()
.context("Failed to execute reboot command")?;
info!("System reboot initiated");
}
Ok(())
}
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// Validate service name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") {
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Alpine uses OpenRC for service management
get_openrc_service_status(name)
}
}
impl Default for ApkBackend {
fn default() -> Self {
Self::new()
}
}
/// Package manager factory /// Package manager factory
pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> { pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> {
// Detect package manager and return appropriate backend // Detect package manager and return appropriate backend
@ -678,9 +1180,10 @@ pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> {
} else if std::path::Path::new("/usr/bin/dnf").exists() { } else if std::path::Path::new("/usr/bin/dnf").exists() {
// TODO: Implement DnfBackend for RHEL/CentOS/Fedora // TODO: Implement DnfBackend for RHEL/CentOS/Fedora
Err(anyhow::anyhow!("DNF backend not yet implemented")) Err(anyhow::anyhow!("DNF backend not yet implemented"))
} else if std::path::Path::new("/usr/bin/apk").exists() { } else if std::path::Path::new("/usr/bin/apk").exists()
// TODO: Implement ApkBackend for Alpine || std::path::Path::new("/sbin/apk").exists()
Err(anyhow::anyhow!("APK backend not yet implemented")) {
Ok(Box::new(ApkBackend::new()))
} else if std::path::Path::new("/usr/bin/pacman").exists() { } else if std::path::Path::new("/usr/bin/pacman").exists() {
// TODO: Implement PacmanBackend for Arch // TODO: Implement PacmanBackend for Arch
Err(anyhow::anyhow!("Pacman backend not yet implemented")) Err(anyhow::anyhow!("Pacman backend not yet implemented"))
@ -705,4 +1208,55 @@ mod tests {
let json = serde_json::to_string(&status).unwrap(); let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("Installed")); assert!(json.contains("Installed"));
} }
#[test]
fn test_apk_backend_creation() {
let _backend = ApkBackend::new();
// Test passes if backend creation doesn't panic
}
#[test]
fn test_apk_parse_name_version_simple() {
let backend = ApkBackend::new();
let (name, version) = backend.parse_name_version("bash-5.2.21-r0");
assert_eq!(name, "bash");
assert_eq!(version, "5.2.21-r0");
}
#[test]
fn test_apk_parse_name_version_hyphenated() {
let backend = ApkBackend::new();
// Package names with hyphens like gcc-gnat
let (name, version) = backend.parse_name_version("gcc-gnat-13.2.1-r0");
assert_eq!(name, "gcc-gnat");
assert_eq!(version, "13.2.1-r0");
}
#[test]
fn test_apk_parse_name_version_no_version() {
let backend = ApkBackend::new();
let (name, version) = backend.parse_name_version("nohyphen");
assert_eq!(name, "nohyphen");
assert_eq!(version, "");
}
#[test]
fn test_apk_parse_name_version_multiple_hyphens() {
let backend = ApkBackend::new();
let (name, version) = backend.parse_name_version("perl-net-ssleay-1.94-r0");
assert_eq!(name, "perl-net-ssleay");
assert_eq!(version, "1.94-r0");
}
#[test]
fn test_apk_parse_package_list() {
let backend = ApkBackend::new();
let output = "bash-5.2.21-r0 [main] The GNU Bourne Again shell\nopenssl-3.1.4-r0 [main] Toolkit for SSL/TLS";
let packages = backend.parse_apk_package_list(output);
assert_eq!(packages.len(), 2);
assert_eq!(packages[0].name, "bash");
assert_eq!(packages[0].version, "5.2.21-r0");
assert_eq!(packages[1].name, "openssl");
assert_eq!(packages[1].version, "3.1.4-r0");
}
} }

View File

@ -0,0 +1,118 @@
# Alpine Packaging Root Cause Analysis
**Date:** 2026-05-20
**Author:** Echo
**Status:** Fixed in v1.1.10
## Problem Statement
Alpine APK packages for linux-patch-api did not create required files on `apk add`:
- No `/etc/linux_patch_api/config.yaml` (from config.yaml.example)
- No `/etc/linux_patch_api/config.yaml.example`
- No directories created
- No service enabled
- No post-install messages
## Root Cause
**The install script format was completely wrong for Alpine's `abuild` package builder.**
### Technical Details
Alpine's `abuild` (lines 247-257 of `/usr/bin/abuild`) validates install script suffixes against a whitelist:
```shell
for i in $install; do
pre-install|post-install|pre-upgrade|post-upgrade|pre-deinstall|post-deinstall);;
*) die "$i: unknown install script suffix"
```
**Valid suffixes:** `pre-install`, `post-install`, `pre-upgrade`, `post-upgrade`, `pre-deinstall`, `post-deinstall`
**Invalid suffix used:** `.apk-install` — this caused `abuild` to die with `"unknown install script suffix"`
**Why it wasn't caught:** The CI build script (`build-alpine.sh`) used `|| true` after `abuild`, which **silently masked the failure**. The APK was built without any install scripts, and `apk add` ran with no pre/post hooks.
### Original (Broken) Format
Single file `configs/linux-patch-api.apk-install` containing function definitions:
```sh
pre_install() { ... }
post_install() { ... }
pre_deinstall() { ... }
post_deinstall() { ... }
```
APKBUILD referenced it as:
```
install="linux-patch-api.apk-install"
```
**Two fatal errors:**
1. `.apk-install` is not a valid abuild suffix (should be `.pre-install`, `.post-install`, etc.)
2. Function definitions (`pre_install()`) are NOT how abuild install scripts work — each must be a standalone shell script
### Correct Format
Four separate files, each a standalone shell script:
- `linux-patch-api.pre-install` — runs before package files are laid down
- `linux-patch-api.post-install` — runs after package files are laid down
- `linux-patch-api.pre-deinstall` — runs before package removal
- `linux-patch-api.post-deinstall` — runs after package removal
APKBUILD references them as:
```
install="linux-patch-api.pre-install linux-patch-api.post-install linux-patch-api.pre-deinstall linux-patch-api.post-deinstall"
```
## Fix
### Files Changed
1. **Deleted** `configs/linux-patch-api.apk-install` (invalid format)
2. **Created** `configs/linux-patch-api.pre-install` (create dirs, set permissions)
3. **Created** `configs/linux-patch-api.post-install` (copy example configs, enable service)
4. **Created** `configs/linux-patch-api.pre-deinstall` (stop and disable service)
5. **Created** `configs/linux-patch-api.post-deinstall` (clean up empty dirs)
6. **Updated** `build-alpine.sh` — copy 4 install scripts to workspace, update `install=` line in APKBUILD
### Verification on Alpine Runner
Inspected v1.1.10 APK contents:
```
.SIGN.RSA.root-69eeaa18.rsa.pub
.PKGINFO
.pre-install
.post-install
.pre-deinstall
.post-deinstall
etc/init.d/linux-patch-api
etc/linux_patch_api/config.yaml.example
etc/linux_patch_api/whitelist.yaml.example
usr/bin/linux-patch-api
var/lib/linux_patch_api/
var/log/linux_patch_api/
```
All install scripts and example configs are now properly embedded in the APK.
### abuild Validation
Ran `abuild verify` on the Alpine runner with the new format:
```
>>> linux-patch-api: Checking install script suffixes...
>>> linux-patch-api: Checking if install script names match pkgname...
```
Both checks PASSED. The old `.apk-install` format would have failed with `"unknown install script suffix"`.
## Prevention
1. **Always verify on actual target systems before pushing.** SSH to the runner, inspect the built artifact, test the install.
2. **Read the tool's source code when documentation is unclear.** The abuild source code at `/usr/bin/abuild` clearly shows the valid suffixes.
3. **Never use `|| true` to mask build failures.** The CI build script masked the abuild failure, hiding the root cause.
4. **Never assume a file edit is correct without runtime verification.** Multiple edits to .apk-install were made without testing on Alpine.
## Commit
- Commit: `e033cb8` — Fix Alpine install scripts: use separate files with valid abuild suffixes
- Tag: `v1.1.10`

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

@ -84,6 +84,24 @@
**Rule:** E2E tests must assert status=completed for core operations. A failed package install is a 100% total failure of the API's core function. **Rule:** E2E tests must assert status=completed for core operations. A failed package install is a 100% total failure of the API's core function.
**Status:** Active **Status:** Active
## 2026-05-20 - Verify on actual target systems before declaring something fixed (CRITICAL)
**Mistake:** Edited Alpine packaging files multiple times without SSHing to the actual Alpine runner to verify. Made assumptions about abuild install script format based on documentation/comments instead of checking the actual abuild source code on the target system.
**Correction:** SSHed to Alpine runner, read abuild source code (lines 247-257), discovered that .apk-install is NOT a valid suffix. abuild expects SEPARATE files: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall. The CI build used || true which masked the abuild failure, so APK was built WITHOUT install scripts silently.
**Rule:** ALWAYS verify fixes on actual target systems before pushing. SSH to the runner, inspect the built artifact, test the install. Never assume a file edit is correct without runtime verification. Read the tool's source code when documentation is unclear.
**Status:** Active
## 2026-05-20 - Alpine abuild install script format requires separate files with valid suffixes
**Mistake:** Used a single .apk-install file with function definitions (pre_install, post_install, etc.) for Alpine packaging. This is NOT a valid abuild format.
**Correction:** Created 4 separate files: linux-patch-api.pre-install, .post-install, .pre-deinstall, .post-deinstall as standalone shell scripts. These are the ONLY valid suffixes abuild accepts (lines 247-257 of /usr/bin/abuild).
**Rule:** Alpine abuild install scripts MUST be separate files with valid suffixes: pre-install, post-install, pre-upgrade, post-upgrade, pre-deinstall, post-deinstall. Do NOT use function definitions in a single file. Do NOT invent custom suffixes like .apk-install.
**Status:** Active
## 2026-05-20 - Ask for help with access blocks immediately (CRITICAL)
**Mistake:** Spent many turns and significant compute time trying to work around not having root access on the Alpine runner (investigating doas.conf errors, trying alternative approaches) instead of simply asking Kelly to install sudo.
**Correction:** Kelly installed sudo in seconds. The time and money I wasted on workarounds far exceeded the trivial effort of asking for help.
**Rule:** When blocked by an access or permission issue, ASK KELLY IMMEDIATELY. Do not spend time on workarounds. A quick fix by Kelly is worth far more than hours of AI compute trying to bypass the block. My processing time costs real money.
**Status:** Active
## 2026-05-03 - Systemd sandbox whack-a-mole pattern ## 2026-05-03 - Systemd sandbox whack-a-mole pattern
**Mistake:** Fixed systemd sandbox restrictions one at a time (ProtectSystem → NoNewPrivileges → RestrictSUIDSGID → CapabilityBoundingSet) instead of analyzing all restrictions at once. **Mistake:** Fixed systemd sandbox restrictions one at a time (ProtectSystem → NoNewPrivileges → RestrictSUIDSGID → CapabilityBoundingSet) instead of analyzing all restrictions at once.
**Correction:** Removed ALL restrictive sandbox settings at once after understanding that package management requires full system access. **Correction:** Removed ALL restrictive sandbox settings at once after understanding that package management requires full system access.

View File

@ -25,8 +25,8 @@ use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tempfile::TempDir; use tempfile::TempDir;
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path, path_regex}; use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Test constants /// Test constants
const TEST_TOKEN: &str = "test_enrollment_token"; const TEST_TOKEN: &str = "test_enrollment_token";
@ -63,10 +63,7 @@ fn create_temp_dirs() -> (TempDir, TempDir) {
/// Initialize an empty whitelist YAML file at the given path. /// Initialize an empty whitelist YAML file at the given path.
/// Required because WhitelistManager::new() loads existing config on construction. /// Required because WhitelistManager::new() loads existing config on construction.
fn init_empty_whitelist(path: &str) { fn init_empty_whitelist(path: &str) {
std::fs::write( std::fs::write(path, "entries: []\n").expect("Failed to create initial whitelist file");
path,
"entries: []\n",
).expect("Failed to create initial whitelist file");
} }
/// Build a TLS config pointing to the temp certificate directory. /// Build a TLS config pointing to the temp certificate directory.
@ -76,14 +73,19 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
port: 12443, port: 12443,
ca_cert: cert_dir.join("ca.pem").to_string_lossy().to_string(), ca_cert: cert_dir.join("ca.pem").to_string_lossy().to_string(),
server_cert: cert_dir.join("server.pem").to_string_lossy().to_string(), server_cert: cert_dir.join("server.pem").to_string_lossy().to_string(),
server_key: cert_dir.join("server.key.pem").to_string_lossy().to_string(), server_key: cert_dir
.join("server.key.pem")
.to_string_lossy()
.to_string(),
min_tls_version: "1.3".to_string(), min_tls_version: "1.3".to_string(),
} }
} }
/// Build an EnrollmentClient pointing at the mock server. /// Build an EnrollmentClient pointing at the mock server.
/// Uses a test report_ip so enrollment works inside Docker containers
/// where the only IPs are in the 172.16.0.0/12 bridge range (filtered).
fn build_client(base_url: &str) -> EnrollmentClient { fn build_client(base_url: &str) -> EnrollmentClient {
EnrollmentClient::new(base_url) EnrollmentClient::with_ip_overrides(base_url, None, Some("192.168.1.10".to_string()))
} }
// ============================================================================= // =============================================================================
@ -104,7 +106,11 @@ async fn test_full_enrollment_flow_happy_path() {
let ca_cert_path = cert_dir.path().join("ca.pem"); let ca_cert_path = cert_dir.path().join("ca.pem");
let server_cert_path = cert_dir.path().join("server.pem"); let server_cert_path = cert_dir.path().join("server.pem");
let server_key_path = cert_dir.path().join("server.key.pem"); let server_key_path = cert_dir.path().join("server.key.pem");
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string(); let whitelist_path = whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path); init_empty_whitelist(&whitelist_path);
@ -116,7 +122,7 @@ async fn test_full_enrollment_flow_happy_path() {
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202)
.set_body_string(&format!(r#"{{"polling_token": "{}"}}"#, TEST_TOKEN)), .set_body_string(format!(r#"{{"polling_token": "{}"}}"#, TEST_TOKEN)),
) )
.named("enroll_registration") .named("enroll_registration")
.mount(&server) .mount(&server)
@ -128,11 +134,10 @@ async fn test_full_enrollment_flow_happy_path() {
let count = poll_count_clone.fetch_add(1, Ordering::SeqCst); let count = poll_count_clone.fetch_add(1, Ordering::SeqCst);
if count < 1 { if count < 1 {
// First poll returns pending (simulates admin review delay) // First poll returns pending (simulates admin review delay)
ResponseTemplate::new(200) ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#)
.set_body_string(r#"{"status": "pending"}"#)
} else { } else {
// Second poll returns approved with full PKI bundle // Second poll returns approved with full PKI bundle
ResponseTemplate::new(200).set_body_string(&format!( ResponseTemplate::new(200).set_body_string(format!(
r#"{{ r#"{{
"status": "approved", "status": "approved",
"ca_crt": {}, "ca_crt": {},
@ -152,7 +157,10 @@ async fn test_full_enrollment_flow_happy_path() {
let client = build_client(&base_url); let client = build_client(&base_url);
// Phase 1: Registration // Phase 1: Registration
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, TEST_TOKEN); assert_eq!(response.polling_token, TEST_TOKEN);
// Phase 2: Polling (should get pending first, then approved) // Phase 2: Polling (should get pending first, then approved)
@ -172,10 +180,15 @@ async fn test_full_enrollment_flow_happy_path() {
&bundle.server_crt, &bundle.server_crt,
&bundle.server_key, &bundle.server_key,
Some(&tls_config), Some(&tls_config),
).await.expect("PKI provisioning should succeed"); )
.await
.expect("PKI provisioning should succeed");
// Phase 3b: Whitelist update (manager_ip for localhost URL returns 127.0.0.1) // Phase 3b: Whitelist update (manager_ip for localhost URL returns 127.0.0.1)
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP"); let manager_ip = client
.manager_ip()
.await
.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path) provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await .await
.expect("Whitelist append should succeed"); .expect("Whitelist append should succeed");
@ -186,14 +199,29 @@ async fn test_full_enrollment_flow_happy_path() {
assert!(server_key_path.exists(), "Server key file should exist"); assert!(server_key_path.exists(), "Server key file should exist");
// Verify: correct permissions (key=0o600, certs=0o644) // Verify: correct permissions (key=0o600, certs=0o644)
let key_perms = std::fs::metadata(&server_key_path).unwrap().permissions().mode() & 0o777; let key_perms = std::fs::metadata(&server_key_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(key_perms, 0o600, "Key file should have 0o600 permissions"); assert_eq!(key_perms, 0o600, "Key file should have 0o600 permissions");
let ca_perms = std::fs::metadata(&ca_cert_path).unwrap().permissions().mode() & 0o777; let ca_perms = std::fs::metadata(&ca_cert_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(ca_perms, 0o644, "CA cert should have 0o644 permissions"); assert_eq!(ca_perms, 0o644, "CA cert should have 0o644 permissions");
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777; let server_perms = std::fs::metadata(&server_cert_path)
assert_eq!(server_perms, 0o644, "Server cert should have 0o644 permissions"); .unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
server_perms, 0o644,
"Server cert should have 0o644 permissions"
);
// Verify: whitelist contains manager IP // Verify: whitelist contains manager IP
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap(); let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
@ -220,14 +248,17 @@ async fn test_enrollment_denied_flow() {
let (server, base_url) = create_mock_manager().await; let (server, base_url) = create_mock_manager().await;
let (cert_dir, _whitelist_dir) = create_temp_dirs(); let (cert_dir, _whitelist_dir) = create_temp_dirs();
let whitelist_path = _whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string(); let whitelist_path = _whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path); init_empty_whitelist(&whitelist_path);
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "denied_token"}"#),
.set_body_string(r#"{"polling_token": "denied_token"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -235,9 +266,7 @@ async fn test_enrollment_denied_flow() {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#))
ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#),
)
.named("status_denied") .named("status_denied")
.expect(1) // Exactly one poll attempt before denial .expect(1) // Exactly one poll attempt before denial
.mount(&server) .mount(&server)
@ -246,7 +275,10 @@ async fn test_enrollment_denied_flow() {
let client = build_client(&base_url); let client = build_client(&base_url);
// Phase 1: Registration succeeds even for denied enrollment // Phase 1: Registration succeeds even for denied enrollment
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, "denied_token"); assert_eq!(response.polling_token, "denied_token");
// Phase 2: Polling returns denial error // Phase 2: Polling returns denial error
@ -254,7 +286,10 @@ async fn test_enrollment_denied_flow() {
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10) .poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
.await; .await;
assert!(result.is_err(), "Should receive error for denied enrollment"); assert!(
result.is_err(),
"Should receive error for denied enrollment"
);
let err_msg = result.unwrap_err().to_string(); let err_msg = result.unwrap_err().to_string();
assert!( assert!(
err_msg.contains("denied"), err_msg.contains("denied"),
@ -267,9 +302,18 @@ async fn test_enrollment_denied_flow() {
let server_cert_path = cert_dir.path().join("server.pem"); let server_cert_path = cert_dir.path().join("server.pem");
let server_key_path = cert_dir.path().join("server.key.pem"); let server_key_path = cert_dir.path().join("server.key.pem");
assert!(!ca_path.exists(), "CA cert should NOT exist after denied enrollment"); assert!(
assert!(!server_cert_path.exists(), "Server cert should NOT exist after denied enrollment"); !ca_path.exists(),
assert!(!server_key_path.exists(), "Server key should NOT exist after denied enrollment"); "CA cert should NOT exist after denied enrollment"
);
assert!(
!server_cert_path.exists(),
"Server cert should NOT exist after denied enrollment"
);
assert!(
!server_key_path.exists(),
"Server key should NOT exist after denied enrollment"
);
// Verify: no whitelist modifications on failed enrollment // Verify: no whitelist modifications on failed enrollment
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap(); let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
@ -298,8 +342,7 @@ async fn test_enrollment_timeout_flow() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "timeout_token"}"#),
.set_body_string(r#"{"polling_token": "timeout_token"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -307,16 +350,17 @@ async fn test_enrollment_timeout_flow() {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
)
.named("status_always_pending") .named("status_always_pending")
.expect(3) // Exactly 3 poll attempts before timeout .expect(3) // Exactly 3 poll attempts before timeout
.mount(&server) .mount(&server)
.await; .await;
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
// Poll with max_attempts=3 - should timeout after exactly 3 attempts // Poll with max_attempts=3 - should timeout after exactly 3 attempts
let result = client let result = client
@ -337,8 +381,14 @@ async fn test_enrollment_timeout_flow() {
let server_key_path = cert_dir.path().join("server.key.pem"); let server_key_path = cert_dir.path().join("server.key.pem");
assert!(!ca_path.exists(), "CA cert should NOT exist after timeout"); assert!(!ca_path.exists(), "CA cert should NOT exist after timeout");
assert!(!server_cert_path.exists(), "Server cert should NOT exist after timeout"); assert!(
assert!(!server_key_path.exists(), "Server key should NOT exist after timeout"); !server_cert_path.exists(),
"Server cert should NOT exist after timeout"
);
assert!(
!server_key_path.exists(),
"Server key should NOT exist after timeout"
);
} }
// ============================================================================= // =============================================================================
@ -359,32 +409,32 @@ async fn test_certificate_permission_verification() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "perm_token"}"#),
.set_body_string(r#"{"polling_token": "perm_token"}"#),
) )
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(format!(
ResponseTemplate::new(200).set_body_string(&format!( r#"{{
r#"{{
"status": "approved", "status": "approved",
"ca_crt": {}, "ca_crt": {},
"server_crt": {}, "server_crt": {},
"server_key": {} "server_key": {}
}}"#, }}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(), serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(), serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(), serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)), )))
)
.mount(&server) .mount(&server)
.await; .await;
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
let bundle = client let bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5) .poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await .await
@ -397,14 +447,15 @@ async fn test_certificate_permission_verification() {
&bundle.server_crt, &bundle.server_crt,
&bundle.server_key, &bundle.server_key,
Some(&tls_config), Some(&tls_config),
).await.expect("PKI provisioning should succeed"); )
.await
.expect("PKI provisioning should succeed");
// Verify key file: 0o600 (owner read/write only) // Verify key file: 0o600 (owner read/write only)
let key_path = cert_dir.path().join("server.key.pem"); let key_path = cert_dir.path().join("server.key.pem");
let key_perms = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777; let key_perms = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
assert_eq!( assert_eq!(
key_perms, key_perms, 0o600,
0o600,
"Key file must have exactly 0o600 permissions (owner rw only)" "Key file must have exactly 0o600 permissions (owner rw only)"
); );
@ -412,17 +463,19 @@ async fn test_certificate_permission_verification() {
let ca_path = cert_dir.path().join("ca.pem"); let ca_path = cert_dir.path().join("ca.pem");
let ca_perms = std::fs::metadata(&ca_path).unwrap().permissions().mode() & 0o777; let ca_perms = std::fs::metadata(&ca_path).unwrap().permissions().mode() & 0o777;
assert_eq!( assert_eq!(
ca_perms, ca_perms, 0o644,
0o644,
"CA certificate must have exactly 0o644 permissions" "CA certificate must have exactly 0o644 permissions"
); );
// Verify server cert: 0o644 (owner rw, group/others read) // Verify server cert: 0o644 (owner rw, group/others read)
let server_cert_path = cert_dir.path().join("server.pem"); let server_cert_path = cert_dir.path().join("server.pem");
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777; let server_perms = std::fs::metadata(&server_cert_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!( assert_eq!(
server_perms, server_perms, 0o644,
0o644,
"Server certificate must have exactly 0o644 permissions" "Server certificate must have exactly 0o644 permissions"
); );
@ -442,7 +495,9 @@ async fn test_certificate_permission_verification() {
assert!(ca_content.contains("END CERTIFICATE")); assert!(ca_content.contains("END CERTIFICATE"));
let key_content = std::fs::read_to_string(&key_path).unwrap(); let key_content = std::fs::read_to_string(&key_path).unwrap();
assert!(key_content.contains("BEGIN PRIVATE KEY") || key_content.contains("BEGIN RSA PRIVATE KEY")); assert!(
key_content.contains("BEGIN PRIVATE KEY") || key_content.contains("BEGIN RSA PRIVATE KEY")
);
} }
// ============================================================================= // =============================================================================
@ -459,45 +514,52 @@ async fn test_whitelist_append_verification() {
let (server, base_url) = create_mock_manager().await; let (server, base_url) = create_mock_manager().await;
let (_cert_dir, whitelist_dir) = create_temp_dirs(); let (_cert_dir, whitelist_dir) = create_temp_dirs();
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string(); let whitelist_path = whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path); init_empty_whitelist(&whitelist_path);
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "wl_token"}"#),
.set_body_string(r#"{"polling_token": "wl_token"}"#),
) )
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(format!(
ResponseTemplate::new(200).set_body_string(&format!( r#"{{
r#"{{
"status": "approved", "status": "approved",
"ca_crt": {}, "ca_crt": {},
"server_crt": {}, "server_crt": {},
"server_key": {} "server_key": {}
}}"#, }}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(), serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(), serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(), serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)), )))
)
.mount(&server) .mount(&server)
.await; .await;
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
let _bundle = client let _bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5) .poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await .await
.expect("Should receive approved PkiBundle"); .expect("Should receive approved PkiBundle");
// First enrollment: append to whitelist // First enrollment: append to whitelist
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP"); let manager_ip = client
.manager_ip()
.await
.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path) provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await .await
.expect("First whitelist append should succeed"); .expect("First whitelist append should succeed");
@ -544,7 +606,10 @@ async fn test_whitelist_append_verification() {
); );
// Verify: YAML format is valid and parseable // Verify: YAML format is valid and parseable
assert!(wl_content.contains("entries:"), "YAML should contain 'entries:' key"); assert!(
wl_content.contains("entries:"),
"YAML should contain 'entries:' key"
);
} }
// ============================================================================= // =============================================================================
@ -565,24 +630,24 @@ async fn test_signal_handling_during_polling() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "signal_token"}"#),
.set_body_string(r#"{"polling_token": "signal_token"}"#),
) )
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
)
.named("always_pending") .named("always_pending")
.expect(3) // Exactly 3 polls before graceful shutdown .expect(3) // Exactly 3 polls before graceful shutdown
.mount(&server) .mount(&server)
.await; .await;
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
// Poll with max_attempts=3, interval=1s // Poll with max_attempts=3, interval=1s
// This simulates SIGTERM interrupt by exhausting attempts (graceful shutdown) // This simulates SIGTERM interrupt by exhausting attempts (graceful shutdown)
@ -600,11 +665,15 @@ async fn test_signal_handling_during_polling() {
); );
// Verify: cleanup of any partial state (no leftover files) // Verify: cleanup of any partial state (no leftover files)
for entry in std::fs::read_dir(cert_dir.path()).unwrap() { let remaining: Vec<_> = std::fs::read_dir(cert_dir.path())
let entry = entry.unwrap(); .unwrap()
assert!(false, "No partial files should remain after graceful shutdown: {}", .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
entry.file_name().to_string_lossy()); .collect();
} assert!(
remaining.is_empty(),
"No partial files should remain after graceful shutdown: {:?}",
remaining
);
} }
// ============================================================================= // =============================================================================
@ -620,45 +689,52 @@ async fn test_whitelist_yaml_format_preservation() {
let (server, base_url) = create_mock_manager().await; let (server, base_url) = create_mock_manager().await;
let (_cert_dir, whitelist_dir) = create_temp_dirs(); let (_cert_dir, whitelist_dir) = create_temp_dirs();
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string(); let whitelist_path = whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path); init_empty_whitelist(&whitelist_path);
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "yaml_token"}"#),
.set_body_string(r#"{"polling_token": "yaml_token"}"#),
) )
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(format!(
ResponseTemplate::new(200).set_body_string(&format!( r#"{{
r#"{{
"status": "approved", "status": "approved",
"ca_crt": {}, "ca_crt": {},
"server_crt": {}, "server_crt": {},
"server_key": {} "server_key": {}
}}"#, }}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(), serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(), serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(), serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)), )))
)
.mount(&server) .mount(&server)
.await; .await;
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
let _bundle = client let _bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5) .poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await .await
.expect("Should receive approved PkiBundle"); .expect("Should receive approved PkiBundle");
// Provision and append to whitelist // Provision and append to whitelist
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP"); let manager_ip = client
.manager_ip()
.await
.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path) provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await .await
.expect("Whitelist append should succeed"); .expect("Whitelist append should succeed");
@ -667,11 +743,14 @@ async fn test_whitelist_yaml_format_preservation() {
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap(); let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
// Parse as serde_yaml to verify format // Parse as serde_yaml to verify format
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content) let wl_config: serde_yaml::Value =
.expect("Whitelist should be valid YAML after enrollment"); serde_yaml::from_str(&wl_content).expect("Whitelist should be valid YAML after enrollment");
// Verify structure: entries key exists and is a sequence // Verify structure: entries key exists and is a sequence
assert!(wl_config.get("entries").is_some(), "YAML must contain 'entries' key"); assert!(
wl_config.get("entries").is_some(),
"YAML must contain 'entries' key"
);
let entries = wl_config.get("entries").unwrap(); let entries = wl_config.get("entries").unwrap();
assert!(entries.is_sequence(), "'entries' must be a YAML sequence"); assert!(entries.is_sequence(), "'entries' must be a YAML sequence");

View File

@ -9,13 +9,12 @@
//! - Short polling intervals ensure tests complete quickly //! - Short polling intervals ensure tests complete quickly
//! - serial_test prevents port conflicts between concurrent test runs //! - serial_test prevents port conflicts between concurrent test runs
use linux_patch_api::enroll::client::{ use linux_patch_api::enroll::client::EnrollmentClient;
EnrollmentClient,
};
use serial_test::serial; use serial_test::serial;
use wiremock::http::Method;
use wiremock::{ use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path, path_regex}, matchers::{method, path, path_regex},
Mock, MockServer, ResponseTemplate,
}; };
/// Test constants /// Test constants
@ -34,8 +33,10 @@ async fn create_mock_manager() -> (MockServer, String) {
} }
/// Build an EnrollmentClient pointing at the mock server. /// Build an EnrollmentClient pointing at the mock server.
/// Uses a test report_ip so enrollment works inside Docker containers
/// where the only IPs are in the 172.16.0.0/12 bridge range (filtered).
fn build_client(base_url: &str) -> EnrollmentClient { fn build_client(base_url: &str) -> EnrollmentClient {
EnrollmentClient::new(base_url) EnrollmentClient::with_ip_overrides(base_url, None, Some("192.168.1.10".to_string()))
} }
// ============================================================================= // =============================================================================
@ -54,8 +55,7 @@ async fn test_successful_enrollment_flow() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "test_token_123"}"#),
.set_body_string(r#"{"polling_token": "test_token_123"}"#),
) )
.named("enroll_registration") .named("enroll_registration")
.mount(&server) .mount(&server)
@ -81,7 +81,10 @@ async fn test_successful_enrollment_flow() {
let client = build_client(&base_url); let client = build_client(&base_url);
// Phase 1: Register - should succeed with polling token // Phase 1: Register - should succeed with polling token
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, TEST_TOKEN); assert_eq!(response.polling_token, TEST_TOKEN);
// Phase 2: Poll for approval - should get PkiBundle immediately since mock returns approved // Phase 2: Poll for approval - should get PkiBundle immediately since mock returns approved
@ -89,11 +92,23 @@ async fn test_successful_enrollment_flow() {
.poll_for_approval(TEST_TOKEN, POLL_INTERVAL_SECONDS, 5) .poll_for_approval(TEST_TOKEN, POLL_INTERVAL_SECONDS, 5)
.await; .await;
assert!(result.is_ok(), "Polling should succeed with approved status"); assert!(
result.is_ok(),
"Polling should succeed with approved status"
);
let bundle = result.unwrap(); let bundle = result.unwrap();
assert_eq!(bundle.ca_crt, "-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----"); assert_eq!(
assert_eq!(bundle.server_crt, "-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----"); bundle.ca_crt,
assert_eq!(bundle.server_key, "-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----"); "-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----"
);
assert_eq!(
bundle.server_crt,
"-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----"
);
assert_eq!(
bundle.server_key,
"-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----"
);
} }
// ============================================================================= // =============================================================================
@ -111,8 +126,7 @@ async fn test_pending_then_approved_sequence() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "seq_token_456"}"#),
.set_body_string(r#"{"polling_token": "seq_token_456"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -121,16 +135,14 @@ async fn test_pending_then_approved_sequence() {
// Status always returns approved (simplifies test while verifying the happy path) // Status always returns approved (simplifies test while verifying the happy path)
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(
ResponseTemplate::new(200).set_body_string( r#"{
r#"{
"status": "approved", "status": "approved",
"ca_crt": "CA_PEM", "ca_crt": "CA_PEM",
"server_crt": "SERVER_PEM", "server_crt": "SERVER_PEM",
"server_key": "KEY_PEM" "server_key": "KEY_PEM"
}"#, }"#,
), ))
)
.named("status_approved") .named("status_approved")
.mount(&server) .mount(&server)
.await; .await;
@ -168,8 +180,7 @@ async fn test_denied_enrollment() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "denied_token_789"}"#),
.set_body_string(r#"{"polling_token": "denied_token_789"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -178,10 +189,7 @@ async fn test_denied_enrollment() {
// Status returns denied immediately // Status returns denied immediately
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/denied_token_789")) .and(path("/api/v1/enroll/status/denied_token_789"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#))
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "denied"}"#),
)
.named("status_denied") .named("status_denied")
.expect(1) // Exactly one poll attempt .expect(1) // Exactly one poll attempt
.mount(&server) .mount(&server)
@ -190,7 +198,10 @@ async fn test_denied_enrollment() {
let client = build_client(&base_url); let client = build_client(&base_url);
// Register succeeds // Register succeeds
let response = client.register().await.expect("Registration should succeed even for denied enrollment"); let response = client
.register()
.await
.expect("Registration should succeed even for denied enrollment");
assert_eq!(response.polling_token, "denied_token_789"); assert_eq!(response.polling_token, "denied_token_789");
// Poll should return error // Poll should return error
@ -198,7 +209,10 @@ async fn test_denied_enrollment() {
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10) .poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
.await; .await;
assert!(result.is_err(), "Should receive error for denied enrollment"); assert!(
result.is_err(),
"Should receive error for denied enrollment"
);
let err_msg = result.unwrap_err().to_string(); let err_msg = result.unwrap_err().to_string();
assert!( assert!(
err_msg.contains("denied"), err_msg.contains("denied"),
@ -223,8 +237,7 @@ async fn test_token_not_found_expired() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "expired_token_000"}"#),
.set_body_string(r#"{"polling_token": "expired_token_000"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -233,10 +246,7 @@ async fn test_token_not_found_expired() {
// Status returns notfound (serde rename_all="lowercase" converts NotFound -> "notfind") // Status returns notfound (serde rename_all="lowercase" converts NotFound -> "notfind")
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/expired_token_000")) .and(path("/api/v1/enroll/status/expired_token_000"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "notfound"}"#))
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "notfound"}"#),
)
.named("status_not_found") .named("status_not_found")
.expect(1) // Exactly one poll attempt .expect(1) // Exactly one poll attempt
.mount(&server) .mount(&server)
@ -245,7 +255,10 @@ async fn test_token_not_found_expired() {
let client = build_client(&base_url); let client = build_client(&base_url);
// Register succeeds // Register succeeds
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
// Poll should return error about expired/invalid token // Poll should return error about expired/invalid token
let result = client let result = client
@ -277,8 +290,7 @@ async fn test_max_attempts_timeout() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "timeout_token_abc"}"#),
.set_body_string(r#"{"polling_token": "timeout_token_abc"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -287,10 +299,7 @@ async fn test_max_attempts_timeout() {
// Status always returns pending - should be called exactly 3 times (max_attempts=3) // Status always returns pending - should be called exactly 3 times (max_attempts=3)
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/timeout_token_abc")) .and(path("/api/v1/enroll/status/timeout_token_abc"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "pending"}"#),
)
.named("status_pending_timeout") .named("status_pending_timeout")
.expect(3) // Exactly 3 poll attempts before giving up .expect(3) // Exactly 3 poll attempts before giving up
.mount(&server) .mount(&server)
@ -298,7 +307,10 @@ async fn test_max_attempts_timeout() {
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
// Poll with max_attempts=3, interval=1s // Poll with max_attempts=3, interval=1s
let result = client let result = client
@ -329,9 +341,10 @@ async fn test_rate_limit_on_registration() {
// Registration returns 429 // Registration returns 429
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with(ResponseTemplate::new(429).set_body_string( .respond_with(
r#"{"error": "Too Many Requests", "retry_after": 60}"#, ResponseTemplate::new(429)
)) .set_body_string(r#"{"error": "Too Many Requests", "retry_after": 60}"#),
)
.named("registration_rate_limited") .named("registration_rate_limited")
.expect(1) // Exactly one attempt .expect(1) // Exactly one attempt
.mount(&server) .mount(&server)
@ -382,16 +395,14 @@ async fn test_registration_payload_structure() {
// Status endpoint (for completeness) // Status endpoint (for completeness)
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+")) .and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(
ResponseTemplate::new(200).set_body_string( r#"{
r#"{
"status": "approved", "status": "approved",
"ca_crt": "CA_TEST", "ca_crt": "CA_TEST",
"server_crt": "CRT_TEST", "server_crt": "CRT_TEST",
"server_key": "KEY_TEST" "server_key": "KEY_TEST"
}"#, }"#,
), ))
)
.named("status_approved") .named("status_approved")
.mount(&server) .mount(&server)
.await; .await;
@ -399,34 +410,45 @@ async fn test_registration_payload_structure() {
let client = build_client(&base_url); let client = build_client(&base_url);
// Execute registration and capture the actual request // Execute registration and capture the actual request
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, "payload_test_token"); assert_eq!(response.polling_token, "payload_test_token");
// Verify using server request logs // Verify using server request logs
let requests = server.received_requests().await.unwrap(); let requests = server.received_requests().await.unwrap();
let post_request = requests.iter() let post_request = requests
.find(|r| r.method.to_string() == "POST") .iter()
.find(|r| r.method == Method::POST)
.expect("Should have received a POST request"); .expect("Should have received a POST request");
let body_str = std::str::from_utf8(&post_request.body).expect("Body should be valid UTF-8"); let body_str = std::str::from_utf8(&post_request.body).expect("Body should be valid UTF-8");
let payload: serde_json::Value = serde_json::from_str(body_str) let payload: serde_json::Value =
.expect("Request body should be valid JSON"); serde_json::from_str(body_str).expect("Request body should be valid JSON");
// Verify machine_id field // Verify machine_id field
let machine_id = payload.get("machine_id") let machine_id = payload
.get("machine_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.expect("machine_id field must exist and be a string"); .expect("machine_id field must exist and be a string");
assert!(!machine_id.is_empty(), "machine_id should not be empty"); assert!(!machine_id.is_empty(), "machine_id should not be empty");
assert_eq!(machine_id.len(), 32, "machine_id should be 32 characters (UUID hex)"); assert_eq!(
machine_id.len(),
32,
"machine_id should be 32 characters (UUID hex)"
);
// Verify fqdn field // Verify fqdn field
let fqdn = payload.get("fqdn") let fqdn = payload
.get("fqdn")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.expect("fqdn field must exist and be a string"); .expect("fqdn field must exist and be a string");
assert!(!fqdn.is_empty(), "fqdn should not be empty"); assert!(!fqdn.is_empty(), "fqdn should not be empty");
// Verify ip_address field // Verify ip_address field
let ip_address = payload.get("ip_address") let ip_address = payload
.get("ip_address")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.expect("ip_address field must exist and be a string"); .expect("ip_address field must exist and be a string");
assert!(!ip_address.is_empty(), "ip_address should not be empty"); assert!(!ip_address.is_empty(), "ip_address should not be empty");
@ -438,12 +460,10 @@ async fn test_registration_payload_structure() {
); );
// Verify os_details field is an object with expected keys // Verify os_details field is an object with expected keys
let os_details = payload.get("os_details") let os_details = payload
.get("os_details")
.expect("os_details field must exist"); .expect("os_details field must exist");
assert!( assert!(os_details.is_object(), "os_details should be a JSON object");
os_details.is_object(),
"os_details should be a JSON object"
);
let os_obj = os_details.as_object().unwrap(); let os_obj = os_details.as_object().unwrap();
assert!(!os_obj.is_empty(), "os_details should not be empty"); assert!(!os_obj.is_empty(), "os_details should not be empty");
@ -453,6 +473,21 @@ async fn test_registration_payload_structure() {
os_obj.contains_key("distro") || os_obj.contains_key("kernel"), os_obj.contains_key("distro") || os_obj.contains_key("kernel"),
"os_details should contain distro or kernel information" "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)
} }
// ============================================================================= // =============================================================================
@ -469,9 +504,9 @@ async fn test_server_error_on_registration() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with(ResponseTemplate::new(500).set_body_string( .respond_with(
r#"{"error": "Internal Server Error"}"#, ResponseTemplate::new(500).set_body_string(r#"{"error": "Internal Server Error"}"#),
)) )
.named("registration_server_error") .named("registration_server_error")
.expect(1) .expect(1)
.mount(&server) .mount(&server)
@ -506,8 +541,7 @@ async fn test_rate_limit_on_polling_retries() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "rl_poll_token"}"#),
.set_body_string(r#"{"polling_token": "rl_poll_token"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -516,22 +550,23 @@ async fn test_rate_limit_on_polling_retries() {
// Status returns approved on first poll // Status returns approved on first poll
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/rl_poll_token")) .and(path("/api/v1/enroll/status/rl_poll_token"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(
ResponseTemplate::new(200).set_body_string( r#"{
r#"{
"status": "approved", "status": "approved",
"ca_crt": "CA_OK", "ca_crt": "CA_OK",
"server_crt": "CRT_OK", "server_crt": "CRT_OK",
"server_key": "KEY_OK" "server_key": "KEY_OK"
}"#, }"#,
), ))
)
.named("status_approved_after_retry") .named("status_approved_after_retry")
.mount(&server) .mount(&server)
.await; .await;
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
// Polling should succeed (mock returns approved directly) // Polling should succeed (mock returns approved directly)
let bundle = client let bundle = client
@ -579,8 +614,7 @@ async fn test_polling_default_parameters() {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/v1/enroll")) .and(path("/api/v1/enroll"))
.respond_with( .respond_with(
ResponseTemplate::new(202) ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "defaults_token"}"#),
.set_body_string(r#"{"polling_token": "defaults_token"}"#),
) )
.named("registration") .named("registration")
.mount(&server) .mount(&server)
@ -589,22 +623,23 @@ async fn test_polling_default_parameters() {
// Status returns approved immediately // Status returns approved immediately
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/defaults_token")) .and(path("/api/v1/enroll/status/defaults_token"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string(
ResponseTemplate::new(200).set_body_string( r#"{
r#"{
"status": "approved", "status": "approved",
"ca_crt": "DEFAULT_CA", "ca_crt": "DEFAULT_CA",
"server_crt": "DEFAULT_CRT", "server_crt": "DEFAULT_CRT",
"server_key": "DEFAULT_KEY" "server_key": "DEFAULT_KEY"
}"#, }"#,
), ))
)
.named("status_approved") .named("status_approved")
.mount(&server) .mount(&server)
.await; .await;
let client = build_client(&base_url); let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed"); let response = client
.register()
.await
.expect("Registration should succeed");
// Call with interval=0 (should default to 60) and max_attempts=0 (should default to 1440) // Call with interval=0 (should default to 60) and max_attempts=0 (should default to 1440)
// But since mock returns approved on first try, we don't actually wait // But since mock returns approved on first try, we don't actually wait

View File

@ -3,7 +3,10 @@
//! Comprehensive tests for cross-distribution identity extraction functions. //! Comprehensive tests for cross-distribution identity extraction functions.
//! Verifies machine-id, FQDN, IP address collection, and OS detail parsing. //! 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}; use linux_patch_api::enroll::identity::{
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; use linux_patch_api::enroll::EnrollmentRequest;
use serde_json::Value; use serde_json::Value;
@ -46,10 +49,7 @@ fn test_machine_id_is_consistent() {
// Multiple calls should return the same value (it's a persistent identifier) // Multiple calls should return the same value (it's a persistent identifier)
let id1 = get_machine_id().expect("Failed to get machine-id (call 1)"); let id1 = get_machine_id().expect("Failed to get machine-id (call 1)");
let id2 = get_machine_id().expect("Failed to get machine-id (call 2)"); let id2 = get_machine_id().expect("Failed to get machine-id (call 2)");
assert_eq!( assert_eq!(id1, id2, "machine-id should be consistent across calls");
id1, id2,
"machine-id should be consistent across calls"
);
} }
#[test] #[test]
@ -67,8 +67,12 @@ fn test_machine_id_fallback_file_check() {
// Verify fallback file exists (may or may not be used) // Verify fallback file exists (may or may not be used)
let fallback = std::path::Path::new("/var/lib/dbus/machine-id"); let fallback = std::path::Path::new("/var/lib/dbus/machine-id");
if fallback.exists() { if fallback.exists() {
let content = std::fs::read_to_string(fallback).expect("Failed to read fallback machine-id"); let content =
assert!(!content.trim().is_empty(), "Fallback machine-id should not be empty"); std::fs::read_to_string(fallback).expect("Failed to read fallback machine-id");
assert!(
!content.trim().is_empty(),
"Fallback machine-id should not be empty"
);
} }
// If it doesn't exist, that's fine - primary file is used instead // If it doesn't exist, that's fine - primary file is used instead
} }
@ -134,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 // IP Address Tests
// ============================================================================= // =============================================================================
@ -141,10 +236,10 @@ fn test_fqdn_reasonable_length() {
#[test] #[test]
fn test_ip_addresses_returns_at_least_one() { fn test_ip_addresses_returns_at_least_one() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses"); let addrs = get_ip_addresses().expect("Failed to get IP addresses");
assert!( // In Docker containers, all IPs may be in 172.16.0.0/12 (filtered), so empty is valid
!addrs.is_empty(), if addrs.is_empty() {
"Should return at least one IP address on this system" eprintln!("NOTE: No routable IPs found — likely running inside a Docker container with only bridge IPs");
); }
} }
#[test] #[test]
@ -157,9 +252,9 @@ fn test_ip_addresses_are_valid_ipv4() {
assert_eq!(parts.len(), 4, "IP '{}' should have 4 octets", addr); assert_eq!(parts.len(), 4, "IP '{}' should have 4 octets", addr);
for part in &parts { for part in &parts {
let _octet: u8 = part let _octet: u8 = part.parse().unwrap_or_else(|_| {
.parse() panic!("IP octet '{}' in '{}' is not a valid number", part, addr)
.unwrap_or_else(|_| panic!("IP octet '{}' in '{}' is not a valid number", part, addr)); });
// u8 parse success guarantees 0-255 range // u8 parse success guarantees 0-255 range
} }
} }
@ -198,7 +293,10 @@ fn test_ip_addresses_no_broadcast() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses"); let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs { for addr in &addrs {
assert_ne!(addr, "255.255.255.255", "Broadcast address should be excluded"); assert_ne!(
addr, "255.255.255.255",
"Broadcast address should be excluded"
);
} }
} }
@ -238,7 +336,11 @@ fn test_ip_addresses_are_unicast() {
assert!(first < 240, "Address '{}' is reserved", addr); assert!(first < 240, "Address '{}' is reserved", addr);
// Not unspecified (0.0.0.0) // Not unspecified (0.0.0.0)
assert!(!(parts == vec![0, 0, 0, 0]), "Address '{}' is unspecified", addr); assert!(
parts != vec![0, 0, 0, 0],
"Address '{}' is unspecified",
addr
);
} }
} }
@ -259,7 +361,9 @@ fn test_os_details_returns_valid_json_object() {
#[test] #[test]
fn test_os_details_contains_kernel_version() { fn test_os_details_contains_kernel_version() {
let details = get_os_details().expect("Failed to get OS details"); let details = get_os_details().expect("Failed to get OS details");
let kernel = details.get("kernel").expect("OS details must contain 'kernel' field"); let kernel = details
.get("kernel")
.expect("OS details must contain 'kernel' field");
assert!(kernel.is_string(), "Kernel version should be a string"); assert!(kernel.is_string(), "Kernel version should be a string");
let kernel_str = kernel.as_str().unwrap(); let kernel_str = kernel.as_str().unwrap();
@ -297,7 +401,10 @@ fn test_os_details_distro_is_valid_string() {
assert!(distro.is_string(), "Distro should be a string"); assert!(distro.is_string(), "Distro should be a string");
let distro_str = distro.as_str().unwrap(); let distro_str = distro.as_str().unwrap();
assert!(!distro_str.is_empty(), "Distro name should not be empty"); assert!(!distro_str.is_empty(), "Distro name should not be empty");
assert_ne!(distro_str, "unknown", "Distro should be identified on this system"); assert_ne!(
distro_str, "unknown",
"Distro should be identified on this system"
);
} }
} }
@ -349,32 +456,47 @@ fn test_enrollment_payload_construction() {
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses"); 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 os_details = get_os_details().expect("Failed to get OS details");
// Use first non-loopback IP as the primary address // In Docker containers, all IPs may be in 172.16.0.0/12 (filtered), so use fallback
let primary_ip = ip_addrs.first() let primary_ip = ip_addrs
.expect("Should have at least one IP") .first()
.clone(); .cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
let hostname = get_hostname().ok();
let request = EnrollmentRequest { let request = EnrollmentRequest {
machine_id, machine_id,
fqdn, fqdn,
ip_address: primary_ip, ip_address: primary_ip,
os_details, os_details,
hostname,
}; };
// Verify payload serializes to valid JSON // Verify payload serializes to valid JSON
let json = serde_json::to_string(&request) let json =
.expect("EnrollmentRequest should serialize to valid JSON"); serde_json::to_string(&request).expect("EnrollmentRequest should serialize to valid JSON");
assert!(!json.is_empty(), "Serialized enrollment request should not be empty"); assert!(
!json.is_empty(),
"Serialized enrollment request should not be empty"
);
// Verify JSON contains all required fields // Verify JSON contains all required fields
let parsed: Value = serde_json::from_str(&json) let parsed: Value = serde_json::from_str(&json).expect("Should deserialize enrollment request");
.expect("Should deserialize enrollment request");
assert!(parsed.get("machine_id").is_some(), "JSON must contain machine_id"); assert!(
parsed.get("machine_id").is_some(),
"JSON must contain machine_id"
);
assert!(parsed.get("fqdn").is_some(), "JSON must contain fqdn"); assert!(parsed.get("fqdn").is_some(), "JSON must contain fqdn");
assert!(parsed.get("ip_address").is_some(), "JSON must contain ip_address"); assert!(
assert!(parsed.get("os_details").is_some(), "JSON must contain os_details"); parsed.get("ip_address").is_some(),
"JSON must contain ip_address"
);
assert!(
parsed.get("os_details").is_some(),
"JSON must contain os_details"
);
} }
#[test] #[test]
@ -384,11 +506,14 @@ fn test_enrollment_payload_matches_manager_schema() {
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses"); 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 os_details = get_os_details().expect("Failed to get OS details");
let hostname = get_hostname().ok();
let request = EnrollmentRequest { let request = EnrollmentRequest {
machine_id: machine_id.clone(), machine_id: machine_id.clone(),
fqdn: fqdn.clone(), fqdn: fqdn.clone(),
ip_address: ip_addrs.first().cloned().unwrap_or_default(), ip_address: ip_addrs.first().cloned().unwrap_or_default(),
os_details: os_details.clone(), os_details: os_details.clone(),
hostname,
}; };
// Validate against expected manager API schema // Validate against expected manager API schema
@ -421,21 +546,25 @@ fn test_enrollment_payload_roundtrip() {
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses"); 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 os_details = get_os_details().expect("Failed to get OS details");
let hostname = get_hostname().ok();
let request = EnrollmentRequest { let request = EnrollmentRequest {
machine_id, machine_id,
fqdn, fqdn,
ip_address: ip_addrs.first().cloned().unwrap_or_default(), ip_address: ip_addrs.first().cloned().unwrap_or_default(),
os_details, os_details,
hostname,
}; };
// Serialize to JSON then deserialize back // Serialize to JSON then deserialize back
let json = serde_json::to_string(&request).expect("Failed to serialize"); let json = serde_json::to_string(&request).expect("Failed to serialize");
let deserialized: EnrollmentRequest = serde_json::from_str(&json) let deserialized: EnrollmentRequest =
.expect("Failed to deserialize enrollment request"); serde_json::from_str(&json).expect("Failed to deserialize enrollment request");
assert_eq!(request.machine_id, deserialized.machine_id); assert_eq!(request.machine_id, deserialized.machine_id);
assert_eq!(request.fqdn, deserialized.fqdn); assert_eq!(request.fqdn, deserialized.fqdn);
assert_eq!(request.ip_address, deserialized.ip_address); assert_eq!(request.ip_address, deserialized.ip_address);
assert_eq!(request.hostname, deserialized.hostname);
} }
// ============================================================================= // =============================================================================
@ -461,8 +590,11 @@ fn test_cross_distro_os_release_parsing() {
} }
// Verify key fields are present (POSIX standard for os-release) // Verify key fields are present (POSIX standard for os-release)
assert!(parsed.contains_key("NAME"), "os-release must contain NAME field"); assert!(
assert!(parsed["NAME"].ne(&""), "NAME should not be empty"); parsed.contains_key("NAME"),
"os-release must contain NAME field"
);
assert!(!parsed["NAME"].is_empty(), "NAME should not be empty");
} }
#[test] #[test]
@ -476,6 +608,10 @@ fn test_identity_functions_do_not_panic() {
let _ = get_fqdn(); let _ = get_fqdn();
}); });
let _ = std::panic::catch_unwind(|| {
let _ = get_hostname();
});
let _ = std::panic::catch_unwind(|| { let _ = std::panic::catch_unwind(|| {
let _ = get_ip_addresses(); let _ = get_ip_addresses();
}); });
@ -484,3 +620,184 @@ fn test_identity_functions_do_not_panic() {
let _ = get_os_details(); let _ = get_os_details();
}); });
} }
// =============================================================================
// Container Bridge & Link-Local Filtering Tests
// =============================================================================
#[test]
fn test_is_container_bridge_docker_default_range() {
// Docker default bridge: 172.17.0.0/16
assert!(is_container_bridge(&"172.17.0.1".parse().unwrap()));
assert!(is_container_bridge(&"172.17.255.255".parse().unwrap()));
}
#[test]
fn test_is_container_bridge_full_172_16_range() {
// 172.16.0.0/12 = 172.16.0.0 172.31.255.255
assert!(is_container_bridge(&"172.16.0.1".parse().unwrap()));
assert!(is_container_bridge(&"172.20.0.1".parse().unwrap()));
assert!(is_container_bridge(&"172.31.255.255".parse().unwrap()));
}
#[test]
fn test_is_not_container_bridge_outside_range() {
assert!(!is_container_bridge(&"192.168.3.36".parse().unwrap()));
assert!(!is_container_bridge(&"10.0.0.1".parse().unwrap()));
assert!(!is_container_bridge(&"172.32.0.1".parse().unwrap()));
assert!(!is_container_bridge(&"172.15.255.255".parse().unwrap()));
assert!(!is_container_bridge(&"8.8.8.8".parse().unwrap()));
}
#[test]
fn test_is_link_local_range() {
assert!(is_link_local(&"169.254.0.1".parse().unwrap()));
assert!(is_link_local(&"169.254.255.255".parse().unwrap()));
}
#[test]
fn test_is_not_link_local() {
assert!(!is_link_local(&"192.168.1.1".parse().unwrap()));
assert!(!is_link_local(&"169.253.0.1".parse().unwrap()));
assert!(!is_link_local(&"169.255.0.1".parse().unwrap()));
}
#[test]
fn test_get_ip_addresses_excludes_docker_bridge() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
let parsed: std::net::Ipv4Addr = addr.parse().expect("Should parse as IPv4");
assert!(
!is_container_bridge(&parsed),
"IP '{}' is in Docker bridge range 172.16.0.0/12 — should be excluded",
addr
);
}
}
#[test]
fn test_get_ip_addresses_excludes_link_local() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
let parsed: std::net::Ipv4Addr = addr.parse().expect("Should parse as IPv4");
assert!(
!is_link_local(&parsed),
"IP '{}' is link-local 169.254.0.0/16 — should be excluded",
addr
);
}
}
#[test]
fn test_get_primary_ip_auto_detect_no_bridge() {
// In Docker containers, auto-detect may find no routable IPs — that's valid
match get_primary_ip(None, None, None) {
Ok(ip) => {
assert!(!ip.is_empty(), "Primary IP should not be empty");
let parsed: std::net::Ipv4Addr = ip.parse().expect("Primary IP should be valid IPv4");
assert!(
!is_container_bridge(&parsed),
"Auto-detected IP should not be Docker bridge"
);
}
Err(_) => {
eprintln!("NOTE: No routable IPs found — likely running inside a Docker container");
}
}
}
#[test]
fn test_get_primary_ip_explicit_override() {
let ip = get_primary_ip(None, Some("10.99.99.1"), None).expect("Failed with explicit IP");
assert_eq!(ip, "10.99.99.1");
}
#[test]
fn test_get_primary_ip_rejects_loopback_override() {
// Loopback override should fall back to auto-detect; if auto-detect also fails, that's valid
match get_primary_ip(None, Some("127.0.0.1"), None) {
Ok(ip) => assert_ne!(ip, "127.0.0.1"),
Err(_) => {
eprintln!(
"NOTE: Loopback rejected but no routable IPs for fallback — Docker container"
);
}
}
}
#[test]
fn test_get_primary_ip_invalid_override_falls_back() {
// Invalid IP override should fall back to auto-detect; if auto-detect also fails, that's valid
match get_primary_ip(None, Some("not-an-ip"), None) {
Ok(ip) => assert!(!ip.is_empty()),
Err(_) => {
eprintln!(
"NOTE: Invalid IP rejected but no routable IPs for fallback — Docker container"
);
}
}
}
#[test]
fn test_get_primary_ip_route_target_priority() {
// Route-based selection should be tried before auto-detect
// If iproute2 is available this may succeed, otherwise falls back gracefully
match get_primary_ip(None, None, Some("8.8.8.8")) {
Ok(ip) => {
assert!(!ip.is_empty(), "Route-based IP should not be empty");
let parsed: std::net::Ipv4Addr =
ip.parse().expect("Route-based IP should be valid IPv4");
assert!(
!is_container_bridge(&parsed),
"Route-based IP should not be Docker bridge"
);
assert!(
!parsed.is_loopback(),
"Route-based IP should not be loopback"
);
}
Err(_) => {
eprintln!(
"NOTE: Route-based selection failed — iproute2 may not be available in this environment"
);
}
}
}
#[test]
fn test_get_primary_ip_explicit_overrides_route_target() {
// Explicit report_ip should take priority over route_target
let ip = get_primary_ip(None, Some("10.99.99.1"), Some("8.8.8.8"))
.expect("Explicit IP should override route_target");
assert_eq!(ip, "10.99.99.1");
}
#[test]
fn test_get_route_source_ip_known_target() {
// Test route-based IP detection with a well-known target
// Requires iproute2 to be installed
match get_route_source_ip("8.8.8.8") {
Ok(ip) => {
let parsed: std::net::Ipv4Addr =
ip.parse().expect("Route source IP should be valid IPv4");
assert!(
!parsed.is_loopback(),
"Route source IP should not be loopback"
);
assert!(
!is_container_bridge(&parsed),
"Route source IP should not be Docker bridge"
);
assert!(
!is_link_local(&parsed),
"Route source IP should not be link-local"
);
}
Err(e) => {
eprintln!(
"NOTE: Route-based IP detection failed: {} — may be unavailable in this environment",
e
);
}
}
}