Private
Public Access
1
0

Compare commits

...

8 Commits

Author SHA1 Message Date
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
16 changed files with 686 additions and 240 deletions

Binary file not shown.

205
Cargo.lock generated
View File

@ -821,26 +821,6 @@ dependencies = [
"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]]
name = "core-foundation-sys"
version = "0.8.7"
@ -1193,15 +1173,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "foreign-types"
version = "0.5.0"
@ -1209,7 +1180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@ -1223,12 +1194,6 @@ dependencies = [
"syn",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@ -1606,22 +1571,6 @@ dependencies = [
"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]]
name = "hyper-util"
version = "0.1.20"
@ -1640,11 +1589,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -1969,7 +1916,7 @@ dependencies = [
[[package]]
name = "linux-patch-api"
version = "0.3.12"
version = "1.1.5"
dependencies = [
"actix",
"actix-rt",
@ -2123,23 +2070,6 @@ dependencies = [
"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]]
name = "nix"
version = "0.30.1"
@ -2270,49 +2200,6 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ordered-multimap"
version = "0.7.3"
@ -2755,20 +2642,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2 0.4.13",
"http 1.4.0",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -2779,7 +2661,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
@ -2941,15 +2822,6 @@ dependencies = [
"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]]
name = "scopeguard"
version = "1.2.0"
@ -2962,29 +2834,6 @@ version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "semver"
version = "1.0.28"
@ -3260,27 +3109,6 @@ dependencies = [
"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]]
name = "systemd"
version = "0.10.1"
@ -3288,7 +3116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01e9d1976a15b86245def55d20d52b5818e1a1e81aa030b6a608d3ce57709423"
dependencies = [
"cstr-argument",
"foreign-types 0.5.0",
"foreign-types",
"libc",
"libsystemd-sys",
"log",
@ -3461,16 +3289,6 @@ dependencies = [
"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]]
name = "tokio-rustls"
version = "0.26.4"
@ -3823,12 +3641,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@ -4129,17 +3941,6 @@ dependencies = [
"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]]
name = "windows-result"
version = "0.4.1"

View File

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

View File

@ -64,7 +64,7 @@ WORKSPACE_DIR=/home/builduser
echo "Creating APKBUILD..."
cat > APKBUILD << EOF
pkgname=linux-patch-api
pkgver=1.0.0
pkgver=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
pkgrel=1
pkgdesc="Secure remote package management API for Linux systems"
url="https://gitea.moon-dragon.us/echo/linux_patch_api"

View File

@ -40,7 +40,7 @@ cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml
echo "Creating PKGBUILD..."
cat > PKGBUILD << 'EOF'
pkgname=linux-patch-api
pkgver=1.0.0
pkgver=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
pkgrel=1
pkgdesc="Secure remote package management API for Linux systems"
url="https://gitea.moon-dragon.us/echo/linux_patch_api"

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

@ -26,7 +26,7 @@ mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
# Create source tarball (required by %autosetup in spec file)
echo "Creating source tarball..."
VERSION="1.0.0"
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
TMPDIR=$(mktemp -d)
mkdir -p "$TMPDIR/linux-patch-api-${VERSION}"
# Copy files excluding unwanted directories using find

View File

@ -57,3 +57,17 @@ package_manager:
# # Maximum number of polling attempts before giving up
# # Default: 1440 (24 hours at 60s intervals = 86400 seconds total)
# 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

17
debian/changelog vendored
View File

@ -1,3 +1,20 @@
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
* Fix socket activation detection to use resolved service name

View File

@ -114,6 +114,14 @@ pub struct EnrollmentConfig {
pub polling_interval_seconds: u64,
#[serde(default = "default_max_poll_attempts")]
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 {
@ -142,16 +150,16 @@ pub struct AppConfig {
impl AppConfig {
/// 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)
.with_context(|| format!("Failed to read config file: {}", path))?;
let config: AppConfig = serde_yaml::from_str(&content)
.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 tls.enabled {
if tls.enabled && !skip_tls_validation {
if !std::path::Path::new(&tls.ca_cert).exists() {
anyhow::bail!("TLS CA certificate not found: {}", tls.ca_cert);
}
@ -187,7 +195,7 @@ mod tests {
#[test]
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!(
result.is_ok(),
"Failed to load valid config: {:?}",
@ -204,7 +212,7 @@ mod tests {
#[test]
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");
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to read config file"));
@ -215,7 +223,7 @@ mod tests {
let invalid_path = "/tmp/invalid_config_test.yaml";
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");
std::fs::remove_file(invalid_path).unwrap();
@ -223,7 +231,7 @@ mod tests {
#[test]
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());
let config = result.unwrap();
assert!(config.server.port >= 1);
@ -231,7 +239,7 @@ mod tests {
#[test]
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());
let config = result.unwrap();
assert!(!config.server.bind.is_empty());
@ -239,7 +247,7 @@ mod tests {
#[test]
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());
let config = result.unwrap();
assert!(config.jobs.max_concurrent > 0);
@ -247,7 +255,7 @@ mod tests {
#[test]
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());
let config = result.unwrap();
assert!(config.jobs.timeout_minutes >= 1 && config.jobs.timeout_minutes <= 1440);

View File

@ -77,6 +77,10 @@ pub struct EnrollmentClient {
pub manager_url: String,
/// Pre-configured reqwest client with insecure TLS and timeout.
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 {
@ -91,6 +95,21 @@ impl EnrollmentClient {
/// contains a valid host component. Rejects dangerous schemes like `file://`,
/// `gopher://`, or URLs without a host.
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.
// Only http and https are permitted to prevent path traversal, SSRF,
// or local file access via dangerous schemes (file://, gopher://, etc.).
@ -124,6 +143,8 @@ impl EnrollmentClient {
Self {
manager_url: manager_url.to_string(),
http_client,
report_interface,
report_ip,
}
}
@ -182,22 +203,23 @@ impl EnrollmentClient {
/// - `Ok(EnrollmentResponse)` with the polling token on HTTP 202
/// - Error on 429 (rate limited), 5xx (server error), or network failure
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()
.context("Failed to read machine-id — host cannot enroll without identity")?;
let fqdn = identity::get_fqdn()
.context("Failed to determine FQDN — check hostname configuration")?;
let ip_addresses = identity::get_ip_addresses()
.context("Failed to enumerate network interfaces — check network configuration")?;
let ip_address = identity::get_primary_ip(
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()
.context("Failed to collect OS details — /etc/os-release may be missing")?;
// Use first non-loopback IP (manager expects single string)
let ip_address = ip_addresses
.first()
.cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
// 2. Build EnrollmentRequest struct
let request = EnrollmentRequest {
machine_id,

View File

@ -5,7 +5,7 @@
use anyhow::{anyhow, Context, Result};
use std::fs;
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr};
use std::process::Command;
/// Read the D-Bus machine identifier from `/etc/machine-id`.
@ -65,6 +65,9 @@ pub fn get_fqdn() -> Result<String> {
}
/// 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>> {
let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?;
@ -75,7 +78,17 @@ pub fn get_ip_addresses() -> Result<Vec<String>> {
return None;
}
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,
}
})
@ -86,6 +99,189 @@ pub fn get_ip_addresses() -> Result<Vec<String>> {
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.
/// Returns a JSON object with: distro, version, id_like, kernel.
pub fn get_os_details() -> Result<serde_json::Value> {
@ -178,4 +374,191 @@ mod tests {
"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

@ -15,7 +15,10 @@ pub use client::{
EnrollmentClient, EnrollmentRequest, EnrollmentResponse, EnrollmentStatusResponse, PkiBundle,
};
/// 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_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.
///
@ -28,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,
/// PKI provisioning failure, or whitelist update failure.
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
tracing::info!(

View File

@ -57,6 +57,11 @@ async fn main() -> Result<()> {
// Initialize logging
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!(
version = env!("CARGO_PKG_VERSION"),
config_path = args.config,
@ -64,7 +69,7 @@ async fn main() -> Result<()> {
);
// Load configuration
let config = match AppConfig::load(&args.config) {
let config = match AppConfig::load(&args.config, args.enroll.is_some()) {
Ok(cfg) => {
info!(
port = cfg.server.port,

View File

@ -82,8 +82,10 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
}
/// 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 {
EnrollmentClient::new(base_url)
EnrollmentClient::with_ip_overrides(base_url, None, Some("192.168.1.10".to_string()))
}
// =============================================================================

View File

@ -33,8 +33,10 @@ async fn create_mock_manager() -> (MockServer, String) {
}
/// 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 {
EnrollmentClient::new(base_url)
EnrollmentClient::with_ip_overrides(base_url, None, Some("192.168.1.10".to_string()))
}
// =============================================================================

View File

@ -4,7 +4,8 @@
//! Verifies machine-id, FQDN, IP address collection, and OS detail parsing.
use linux_patch_api::enroll::identity::{
get_fqdn, get_ip_addresses, get_machine_id, get_os_details,
get_fqdn, 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 serde_json::Value;
@ -144,10 +145,10 @@ fn test_fqdn_reasonable_length() {
#[test]
fn test_ip_addresses_returns_at_least_one() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
assert!(
!addrs.is_empty(),
"Should return at least one IP address on this system"
);
// In Docker containers, all IPs may be in 172.16.0.0/12 (filtered), so empty is valid
if addrs.is_empty() {
eprintln!("NOTE: No routable IPs found — likely running inside a Docker container with only bridge IPs");
}
}
#[test]
@ -364,11 +365,11 @@ fn test_enrollment_payload_construction() {
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses");
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()
.expect("Should have at least one IP")
.clone();
.cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
let request = EnrollmentRequest {
machine_id,
@ -514,3 +515,184 @@ fn test_identity_functions_do_not_panic() {
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
);
}
}
}