From 48fb8752c9d8cb2c01f5a8ec8dd59eff9a73745a Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 18 May 2026 03:35:46 +0000 Subject: [PATCH] feat(enrollment): add route-based IP selection and fix package versioning for v1.1.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- build-alpine.sh | 2 +- build-arch.sh | 2 +- build-rpm.sh | 2 +- configs/config.yaml.example | 8 +- debian/changelog | 10 +++ src/enroll/client.rs | 9 +- src/enroll/identity.rs | 160 ++++++++++++++++++++++++++++++++-- src/enroll/mod.rs | 2 +- tests/unit/enroll_identity.rs | 68 +++++++++++++-- 11 files changed, 245 insertions(+), 22 deletions(-) mode change 100755 => 100644 build-rpm.sh diff --git a/Cargo.lock b/Cargo.lock index 6162503..d272224 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1916,7 +1916,7 @@ dependencies = [ [[package]] name = "linux-patch-api" -version = "0.3.12" +version = "1.1.5" dependencies = [ "actix", "actix-rt", diff --git a/Cargo.toml b/Cargo.toml index 1a96694..c9e73d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "linux-patch-api" -version = "0.3.12" +version = "1.1.5" edition = "2021" authors = ["Echo "] description = "Secure remote package management API for Linux systems" diff --git a/build-alpine.sh b/build-alpine.sh index 9ec5b23..3556653 100644 --- a/build-alpine.sh +++ b/build-alpine.sh @@ -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" diff --git a/build-arch.sh b/build-arch.sh index f761149..1d62d53 100644 --- a/build-arch.sh +++ b/build-arch.sh @@ -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" diff --git a/build-rpm.sh b/build-rpm.sh old mode 100755 new mode 100644 index 3e2196b..4bc23cb --- a/build-rpm.sh +++ b/build-rpm.sh @@ -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 diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 4f86ee6..e09d5e8 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -62,6 +62,12 @@ package_manager: # # Example: "eth0", "ens192", "enp0s3" # report_interface: "eth0" # # Explicit IPv4 address reported to the manager. -# # Highest priority — overrides both report_interface and auto-detect. +# # 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 ` +# 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 diff --git a/debian/changelog b/debian/changelog index ec62ece..2bd5114 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +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 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 diff --git a/src/enroll/client.rs b/src/enroll/client.rs index a337fbd..b0677da 100644 --- a/src/enroll/client.rs +++ b/src/enroll/client.rs @@ -103,7 +103,8 @@ impl EnrollmentClient { /// See [`identity::get_primary_ip`] for resolution priority: /// 1. `report_ip` — explicit IP (highest priority) /// 2. `report_interface` — IP from named interface - /// 3. Auto-detect — first routable IP (container bridge subnets filtered) + /// 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, @@ -202,7 +203,10 @@ 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 { - // 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() @@ -210,6 +214,7 @@ impl EnrollmentClient { 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() diff --git a/src/enroll/identity.rs b/src/enroll/identity.rs index 7e75152..21874bd 100644 --- a/src/enroll/identity.rs +++ b/src/enroll/identity.rs @@ -123,6 +123,60 @@ pub fn is_link_local(addr: &Ipv4Addr) -> bool { 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 `. +/// +/// 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 { + 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::().is_ok() { + let addr = part.parse::().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. @@ -158,8 +212,13 @@ pub fn get_ip_for_interface(interface_name: &str) -> Result { /// Resolution priority: /// 1. `report_ip` — explicit IP from config (highest priority) /// 2. `report_interface` — IP from a named interface -/// 3. 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>) -> Result { +/// 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 { // Priority 1: Explicit IP override if let Some(ip) = report_ip { // Validate it parses as IPv4 @@ -188,13 +247,34 @@ pub fn get_primary_ip(report_interface: Option<&str>, report_ip: Option<&str>) - tracing::warn!( interface = iface, error = %e, - "Configured report_interface lookup failed — falling back to auto-detect" + "Configured report_interface lookup failed — falling back to route-based or auto-detect" ); } } } - // Priority 3: Auto-detect (bridge subnets already filtered by get_ip_addresses) + // 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() @@ -368,7 +448,7 @@ mod tests { 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) { + 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"); @@ -386,7 +466,7 @@ mod tests { #[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")).expect("Failed with explicit IP"); + let ip = get_primary_ip(None, Some("10.99.99.1"), None).expect("Failed with explicit IP"); assert_eq!(ip, "10.99.99.1"); } @@ -394,7 +474,7 @@ mod tests { 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")) { + match get_primary_ip(None, Some("127.0.0.1"), None) { Ok(ip) => assert_ne!(ip, "127.0.0.1"), Err(_) => { eprintln!( @@ -408,7 +488,7 @@ mod tests { 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")) { + match get_primary_ip(None, Some("not-an-ip"), None) { Ok(ip) => assert!(!ip.is_empty()), Err(_) => { eprintln!( @@ -417,4 +497,68 @@ mod tests { } } } + + #[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 + ); + } + } + } } diff --git a/src/enroll/mod.rs b/src/enroll/mod.rs index 8e32f1a..54e76dd 100644 --- a/src/enroll/mod.rs +++ b/src/enroll/mod.rs @@ -17,7 +17,7 @@ pub use client::{ /// Re-export identity extraction functions. pub use identity::{ get_fqdn, get_ip_addresses, get_ip_for_interface, get_machine_id, get_os_details, - get_primary_ip, is_container_bridge, is_link_local, + 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. diff --git a/tests/unit/enroll_identity.rs b/tests/unit/enroll_identity.rs index 1e1e953..f1afbfa 100644 --- a/tests/unit/enroll_identity.rs +++ b/tests/unit/enroll_identity.rs @@ -5,7 +5,7 @@ use linux_patch_api::enroll::identity::{ get_fqdn, get_ip_addresses, get_machine_id, get_os_details, get_primary_ip, - is_container_bridge, is_link_local, + get_route_source_ip, is_container_bridge, is_link_local, }; use linux_patch_api::enroll::EnrollmentRequest; use serde_json::Value; @@ -586,7 +586,7 @@ fn test_get_ip_addresses_excludes_link_local() { #[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) { + 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"); @@ -603,14 +603,14 @@ fn test_get_primary_ip_auto_detect_no_bridge() { #[test] fn test_get_primary_ip_explicit_override() { - let ip = get_primary_ip(None, Some("10.99.99.1")).expect("Failed with explicit IP"); + 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")) { + match get_primary_ip(None, Some("127.0.0.1"), None) { Ok(ip) => assert_ne!(ip, "127.0.0.1"), Err(_) => { eprintln!( @@ -623,7 +623,7 @@ fn test_get_primary_ip_rejects_loopback_override() { #[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")) { + match get_primary_ip(None, Some("not-an-ip"), None) { Ok(ip) => assert!(!ip.is_empty()), Err(_) => { eprintln!( @@ -632,3 +632,61 @@ fn test_get_primary_ip_invalid_override_falls_back() { } } } + +#[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 + ); + } + } +}