From b6809dc935c3a97a2686c6892ec68f70d14d582b Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 18 May 2026 23:51:00 +0000 Subject: [PATCH] fix: FQDN resolution and display_name blank bug; fix: Arch/Alpine/RPM packages 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 --- API_DOCUMENTATION.md | 4 +- Cargo.lock | 2 +- SPEC.md | 2 +- build-alpine.sh | 33 ++++-- build-arch.sh | 38 +++++-- build-rpm.sh | 19 +++- configs/linux-patch-api.apk-install | 91 ++++++++++++++++ configs/linux-patch-api.install | 98 ++++++++++++++++++ linux-patch-api.spec | 6 +- src/enroll/client.rs | 13 ++- src/enroll/identity.rs | 149 ++++++++++++++++++++++++--- src/enroll/mod.rs | 4 +- tests/integration/enrollment_test.rs | 12 +++ tests/unit/enroll_identity.rs | 107 ++++++++++++++++++- 14 files changed, 540 insertions(+), 38 deletions(-) create mode 100644 configs/linux-patch-api.apk-install create mode 100644 configs/linux-patch-api.install diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index a2fa4f9..a548fe8 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -909,6 +909,7 @@ Enrollment endpoints enable new hosts to register with the Patch Manager and rec | `fqdn` | string | Yes | Fully qualified domain name of the host | | `ip_address` | string | Yes | Primary non-loopback IPv4 address | | `os_details` | object | Yes | OS metadata (free-form JSON object) | +| `hostname` | string | No | Short hostname (without domain). Used by the manager to populate `display_name` on approval. If omitted, the manager falls back to the FQDN. | **`os_details` common fields:** @@ -933,7 +934,8 @@ curl -X POST https://manager.example.com/api/v1/enroll \ "version_id": "12", "kernel": "6.1.0-kali9-amd64", "id_like": "debian" - } + }, + "hostname": "host-01" }' ``` diff --git a/Cargo.lock b/Cargo.lock index 8f96b04..dc44a2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1916,7 +1916,7 @@ dependencies = [ [[package]] name = "linux-patch-api" -version = "1.1.6" +version = "1.1.7" dependencies = [ "actix", "actix-rt", diff --git a/SPEC.md b/SPEC.md index df5c514..b501060 100644 --- a/SPEC.md +++ b/SPEC.md @@ -169,7 +169,7 @@ The enrollment flow runs before mTLS server startup. On success, the daemon proc ### Phase 1: Registration Request - **Identity Extraction:** - `/etc/machine-id` (fallback: `/var/lib/dbus/machine-id`) - - FQDN from `/etc/hostname` → `hostname -f` → `hostname` → `localhost` + - FQDN from `hostname -f` (validated contains `.`) → `hostname` + `hostname -d` → `/etc/hostname` → `hostname` → `localhost` - Non-loopback IPv4 addresses via network interface enumeration - OS details from `/etc/os-release` (distro, version, id_like, codename) + kernel version (`uname -r`) - **Submission:** Unauthenticated `POST /api/v1/enroll` to manager with identity payload diff --git a/build-alpine.sh b/build-alpine.sh index 3556653..acc2b56 100644 --- a/build-alpine.sh +++ b/build-alpine.sh @@ -46,25 +46,41 @@ fi # Create package directory in /home/builduser (accessible by builduser) PKGDIR=/home/builduser/apk-package +rm -rf "$PKGDIR" mkdir -p "$PKGDIR"/usr/bin -mkdir -p "$PKGDIR"/etc/linux_patch_api +mkdir -p "$PKGDIR"/etc/linux_patch_api/certs mkdir -p "$PKGDIR"/etc/init.d +mkdir -p "$PKGDIR"/var/lib/linux_patch_api +mkdir -p "$PKGDIR"/var/log/linux_patch_api -# Copy files +# Copy binary +chmod 755 target/x86_64-unknown-linux-musl/release/linux-patch-api cp target/x86_64-unknown-linux-musl/release/linux-patch-api "$PKGDIR"/usr/bin/ -chmod 755 "$PKGDIR"/usr/bin/linux-patch-api + +# Copy OpenRC init script cp configs/linux-patch-api-openrc "$PKGDIR"/etc/init.d/linux-patch-api chmod 755 "$PKGDIR"/etc/init.d/linux-patch-api -cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml + +# 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 for APKBUILD +mkdir -p /home/builduser/repo +cp configs/linux-patch-api.apk-install /home/builduser/repo/linux-patch-api.apk-install # Use /home/builduser as workspace for APKBUILD WORKSPACE_DIR=/home/builduser +# Get version from Cargo.toml +VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/') + # Create APKBUILD +# Note: install= must use literal package name, not $pkgname (unquoted heredoc expands variables) echo "Creating APKBUILD..." cat > APKBUILD << EOF pkgname=linux-patch-api -pkgver=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/') +pkgver=${VERSION} pkgrel=1 pkgdesc="Secure remote package management API for Linux systems" url="https://gitea.moon-dragon.us/echo/linux_patch_api" @@ -72,12 +88,17 @@ arch="x86_64" license="MIT" makedepends="" depends="openrc" +install="linux-patch-api.apk-install" +subpackages="" source="" package() { install -d "\$pkgdir"/usr/bin - install -d "\$pkgdir"/etc/linux_patch_api + install -d "\$pkgdir"/etc/linux_patch_api/certs install -d "\$pkgdir"/etc/init.d + install -d "\$pkgdir"/var/lib/linux_patch_api + install -d "\$pkgdir"/var/log/linux_patch_api + cp -r ${WORKSPACE_DIR}/apk-package/usr/bin/* "\$pkgdir"/usr/bin/ cp -r ${WORKSPACE_DIR}/apk-package/etc/linux_patch_api/* "\$pkgdir"/etc/linux_patch_api/ cp -r ${WORKSPACE_DIR}/apk-package/etc/init.d/* "\$pkgdir"/etc/init.d/ diff --git a/build-arch.sh b/build-arch.sh index 1d62d53..03952ba 100644 --- a/build-arch.sh +++ b/build-arch.sh @@ -24,35 +24,61 @@ fi # Create package directory PKGDIR=$(pwd)/arch-package +rm -rf "$PKGDIR" mkdir -p "$PKGDIR"/usr/bin -mkdir -p "$PKGDIR"/etc/linux_patch_api +mkdir -p "$PKGDIR"/etc/linux_patch_api/certs mkdir -p "$PKGDIR"/usr/lib/systemd/system +mkdir -p "$PKGDIR"/var/lib/linux_patch_api +mkdir -p "$PKGDIR"/var/log/linux_patch_api -# Copy files +# Copy binary +chmod 755 target/release/linux-patch-api cp target/release/linux-patch-api "$PKGDIR"/usr/bin/ -chmod 755 "$PKGDIR"/usr/bin/linux-patch-api + +# Copy systemd service cp configs/linux-patch-api.service "$PKGDIR"/usr/lib/systemd/system/ -cp configs/config.yaml.example "$PKGDIR"/etc/linux_patch_api/config.yaml -cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml + +# Copy example configs (as .example files - install script creates live configs) +cp configs/config.yaml.example "$PKGDIR"/etc/linux_patch_api/config.yaml.example +cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml.example + +# Copy install script +cp configs/linux-patch-api.install PKGBUILD.install # Create PKGBUILD with quoted heredoc to prevent $pkgdir expansion # $pkgdir must be literal for makepkg to expand at runtime echo "Creating PKGBUILD..." cat > PKGBUILD << 'EOF' pkgname=linux-patch-api -pkgver=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/') +pkgver=VERSION_PLACEHOLDER pkgrel=1 pkgdesc="Secure remote package management API for Linux systems" url="https://gitea.moon-dragon.us/echo/linux_patch_api" arch=('x86_64') license=('MIT') depends=('systemd') +install=linux-patch-api.install +backup=( + 'etc/linux_patch_api/config.yaml' + 'etc/linux_patch_api/whitelist.yaml' +) package() { cp -r /home/builduser/repo/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 +# Replace version placeholder with actual version from Cargo.toml +VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/') +sed -i "s/VERSION_PLACEHOLDER/$VERSION/" PKGBUILD + +echo "PKGBUILD version: $VERSION" + # Create .SRCINFO echo "Creating .SRCINFO..." diff --git a/build-rpm.sh b/build-rpm.sh index 4bc23cb..86815fc 100644 --- a/build-rpm.sh +++ b/build-rpm.sh @@ -21,27 +21,38 @@ if ! command -v rpmbuild &> /dev/null; then fi fi +# Get version from Cargo.toml +VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/') +echo "Building version: $VERSION" + # Setup RPM build directory structure mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} # Create source tarball (required by %autosetup in spec file) echo "Creating source tarball..." -VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/') TMPDIR=$(mktemp -d) mkdir -p "$TMPDIR/linux-patch-api-${VERSION}" -# Copy files excluding unwanted directories using find + +# Copy files excluding unnecessary directories cp -r . "$TMPDIR/linux-patch-api-${VERSION}/" + +# Remove unnecessary directories from tarball rm -rf "$TMPDIR/linux-patch-api-${VERSION}/target" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.git" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/releases" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.github" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/debian" +rm -rf "$TMPDIR/linux-patch-api-${VERSION}/arch-package" +rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.abuild" +rm -rf "$TMPDIR/linux-patch-api-${VERSION}/apk-package" +rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.a0proj" + tar -czf ~/rpmbuild/SOURCES/linux-patch-api-${VERSION}.tar.gz -C "$TMPDIR" "linux-patch-api-${VERSION}" rm -rf "$TMPDIR" -# Copy spec file +# Prepare spec file with dynamic version echo "Preparing spec file..." -cp linux-patch-api.spec ~/rpmbuild/SPECS/ +sed "s/VERSION_PLACEHOLDER/$VERSION/" linux-patch-api.spec > ~/rpmbuild/SPECS/linux-patch-api.spec # Build RPM echo "Building RPM package..." diff --git a/configs/linux-patch-api.apk-install b/configs/linux-patch-api.apk-install new file mode 100644 index 0000000..2ed2ba5 --- /dev/null +++ b/configs/linux-patch-api.apk-install @@ -0,0 +1,91 @@ +#!/bin/sh +# Alpine Linux install hooks for linux-patch-api +# Reference: debian/{preinst,postinst,prerm,postrm} +# Alpine APKBUILD install script format: pre-install, post-install, pre-deinstall, post-deinstall + +# Pre-install: Create user/group and directories before files are laid down +pre_install() { + # Create system group + if ! getent group linux-patch-api >/dev/null; then + addgroup --system linux-patch-api + fi + + # Create system user + if ! getent passwd linux-patch-api >/dev/null; then + adduser --system --ingroup linux-patch-api --home /var/lib/linux_patch_api --no-create-home --shell /sbin/nologin --gecos "Linux Patch API Service" --disabled-password linux-patch-api + fi + + # Create required directories + mkdir -p /etc/linux_patch_api/certs + mkdir -p /var/lib/linux_patch_api + mkdir -p /var/log/linux_patch_api + + # Set proper ownership + chown -R linux-patch-api:linux-patch-api /var/lib/linux_patch_api + chown -R linux-patch-api:linux-patch-api /var/log/linux_patch_api + + # Set secure permissions + chmod 750 /etc/linux_patch_api + chmod 750 /etc/linux_patch_api/certs + chmod 755 /var/lib/linux_patch_api + chmod 755 /var/log/linux_patch_api + + echo "Pre-installation setup completed" +} + +# Post-install: Copy example configs, enable service +post_install() { + # Copy example configs if they don't exist + if [ ! -f "/etc/linux_patch_api/config.yaml" ]; then + if [ -f "/etc/linux_patch_api/config.yaml.example" ]; then + cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml + chmod 640 /etc/linux_patch_api/config.yaml + chown linux-patch-api:linux-patch-api /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 linux-patch-api:linux-patch-api /etc/linux_patch_api/whitelist.yaml + fi + fi + + # Enable the service (but don't start automatically - admin should configure first) + rc-update add linux-patch-api default + + echo "" + echo "linux-patch-api installed successfully!" + echo "" + echo "Next steps:" + echo " 1. Configure /etc/linux_patch_api/config.yaml with your settings" + echo " 2. Place TLS certificates in /etc/linux_patch_api/certs/" + echo " 3. Configure IP whitelist in /etc/linux_patch_api/whitelist.yaml" + echo " 4. Start the service: rc-service linux-patch-api start" + echo " 5. Check status: rc-service linux-patch-api status" + echo "" +} + +# Pre-deinstall: Stop and disable service before files are removed +pre_deinstall() { + # Stop the service if running + if rc-service linux-patch-api status >/dev/null 2>&1; then + rc-service linux-patch-api stop + echo "Service stopped" + else + echo "Service was not running" + fi + + # Disable the service + rc-update del linux-patch-api default 2>/dev/null || true +} + +# Post-deinstall: Clean up on removal +post_deinstall() { + # Remove directories only if empty (preserve user data on reinstall) + rmdir /var/lib/linux_patch_api 2>/dev/null || true + rmdir /var/log/linux_patch_api 2>/dev/null || true + + echo "linux-patch-api removed" +} diff --git a/configs/linux-patch-api.install b/configs/linux-patch-api.install new file mode 100644 index 0000000..730c769 --- /dev/null +++ b/configs/linux-patch-api.install @@ -0,0 +1,98 @@ +# Arch Linux install hooks for linux-patch-api +# Reference: debian/{preinst,postinst,prerm,postrm} + +post_install() { + # Create system group + if ! getent group linux-patch-api &>/dev/null; then + groupadd --system linux-patch-api + fi + + # Create system user + if ! getent passwd linux-patch-api &>/dev/null; then + useradd --system \ + --gid linux-patch-api \ + --home-dir /var/lib/linux_patch_api \ + --no-create-home \ + --shell /usr/bin/nologin \ + --comment "Linux Patch API Service" \ + linux-patch-api + fi + + # Create required directories + mkdir -p /etc/linux_patch_api/certs + mkdir -p /var/lib/linux_patch_api + mkdir -p /var/log/linux_patch_api + + # Set proper ownership + chown -R linux-patch-api:linux-patch-api /var/lib/linux_patch_api + chown -R linux-patch-api:linux-patch-api /var/log/linux_patch_api + + # Set 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 linux-patch-api:linux-patch-api /etc/linux_patch_api/config.yaml + fi + + if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then + cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml + chmod 640 /etc/linux_patch_api/whitelist.yaml + chown linux-patch-api:linux-patch-api /etc/linux_patch_api/whitelist.yaml + 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) + # Arch doesn't have purge vs remove distinction like Debian + 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" +} diff --git a/linux-patch-api.spec b/linux-patch-api.spec index 691bfa4..5937e11 100644 --- a/linux-patch-api.spec +++ b/linux-patch-api.spec @@ -1,7 +1,7 @@ %global debug_package %{nil} Name: linux-patch-api -Version: 1.0.0 +Version: VERSION_PLACEHOLDER Release: 1%{?dist} Summary: Secure remote package management API for Linux systems License: MIT @@ -162,6 +162,8 @@ fi /lib/systemd/system/linux-patch-api.service %config(noreplace) /etc/linux_patch_api/config.yaml.example %config(noreplace) /etc/linux_patch_api/whitelist.yaml.example +%ghost %config(noreplace) /etc/linux_patch_api/config.yaml +%ghost %config(noreplace) /etc/linux_patch_api/whitelist.yaml %dir /etc/linux_patch_api %dir /etc/linux_patch_api/certs %dir /var/lib/linux_patch_api @@ -169,7 +171,7 @@ fi # Changelog %changelog -* Thu Apr 09 2026 Echo - 1.0.0-1 +* Thu Apr 09 2026 Echo - 1.1.7-1 - Initial production release - Secure mTLS-authenticated REST API for remote package management - 15 API endpoints for package install/remove, patch application, system management diff --git a/src/enroll/client.rs b/src/enroll/client.rs index b0677da..d09dc50 100644 --- a/src/enroll/client.rs +++ b/src/enroll/client.rs @@ -18,6 +18,10 @@ pub struct EnrollmentRequest { pub fqdn: String, pub ip_address: String, pub os_details: serde_json::Value, + /// Short hostname (from /etc/hostname or hostname command). + /// Used by the manager to populate `display_name` on approval. + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, } /// Response from `POST /api/v1/enroll` (HTTP 202). @@ -220,12 +224,18 @@ impl EnrollmentClient { let os_details = identity::get_os_details() .context("Failed to collect OS details — /etc/os-release may be missing")?; - // 2. Build EnrollmentRequest struct + // 2. Collect short hostname for display_name on manager + let hostname = identity::get_hostname() + .map_err(|e| tracing::warn!(error = %e, "Failed to determine hostname — display_name will use FQDN fallback")) + .ok(); + + // 3. Build EnrollmentRequest struct let request = EnrollmentRequest { machine_id, fqdn, ip_address, os_details, + hostname, }; tracing::info!( @@ -502,6 +512,7 @@ mod tests { fqdn: "node.example.com".into(), ip_address: "192.168.1.10".into(), os_details: serde_json::json!({"distro": "Debian", "version": "12"}), + hostname: Some("node".into()), }; let json = serde_json::to_string(&request).expect("Failed to serialize EnrollmentRequest"); assert!(json.contains("machine_id")); diff --git a/src/enroll/identity.rs b/src/enroll/identity.rs index 21874bd..71c6924 100644 --- a/src/enroll/identity.rs +++ b/src/enroll/identity.rs @@ -31,36 +31,109 @@ pub fn get_machine_id() -> Result { } /// 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 { - // Try reading from hostname file first (common on systemd systems) + // 1. Try `hostname -f` — returns FQDN on properly configured systems + if let Ok(output) = Command::new("hostname").arg("-f").output() { + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() && name.contains('.') && name != "(none)" { + tracing::debug!(fqdn = %name, "Resolved FQDN via hostname -f"); + return Ok(name); + } + } + } + + // 2. Try combining short hostname + domain from `hostname -d` + if let Ok(short_output) = Command::new("hostname").output() { + if short_output.status.success() { + let short = String::from_utf8_lossy(&short_output.stdout).trim().to_string(); + if !short.is_empty() && short != "(none)" { + if let Ok(domain_output) = Command::new("hostname").arg("-d").output() { + if domain_output.status.success() { + let domain = String::from_utf8_lossy(&domain_output.stdout).trim().to_string(); + if !domain.is_empty() { + let fqdn = format!("{}.{}", short, domain); + tracing::debug!(fqdn = %fqdn, "Resolved FQDN via hostname + hostname -d"); + return Ok(fqdn); + } + } + } + // Domain not available — fall through to try other methods + } + } + } + + // 3. Try reading from /etc/hostname (common on systemd systems, usually short hostname) if let Ok(name) = fs::read_to_string("/etc/hostname") { let trimmed = name.trim().to_string(); if !trimmed.is_empty() && trimmed != "(none)" { + tracing::debug!(hostname = %trimmed, "Resolved hostname via /etc/hostname (may be short)"); return Ok(trimmed); } } - // Fallback to hostname command - if let Ok(output) = Command::new("hostname").arg("-f").output() { - if output.status.success() { - let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !name.is_empty() { - return Ok(name); - } - } - } - - // Fallback to plain hostname + // 4. Fallback to plain hostname command if let Ok(output) = Command::new("hostname").output() { if output.status.success() { let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !name.is_empty() { + tracing::debug!(hostname = %name, "Resolved hostname via hostname command"); return Ok(name); } } } + // 5. Final fallback + tracing::warn!("Could not determine hostname — falling back to localhost"); + Ok("localhost".into()) +} + +/// Resolve the short hostname (without domain). +/// +/// Strategy: `/etc/hostname` → `hostname` command → split FQDN on `.` → `"localhost"`. +pub fn get_hostname() -> Result { + // Try reading from /etc/hostname (usually contains the short hostname) + if let Ok(name) = fs::read_to_string("/etc/hostname") { + let trimmed = name.trim().to_string(); + if !trimmed.is_empty() && trimmed != "(none)" { + // If it contains a dot, take just the first component + let short = trimmed.split('.').next().unwrap_or(&trimmed).to_string(); + tracing::debug!(hostname = %short, "Resolved short hostname via /etc/hostname"); + return Ok(short); + } + } + + // Try hostname command + if let Ok(output) = Command::new("hostname").output() { + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() { + // If it contains a dot, take just the first component + let short = name.split('.').next().unwrap_or(&name).to_string(); + tracing::debug!(hostname = %short, "Resolved short hostname via hostname command"); + return Ok(short); + } + } + } + + // Try splitting FQDN from get_fqdn() + if let Ok(fqdn) = get_fqdn() { + if fqdn != "localhost" && fqdn.contains('.') { + let short = fqdn.split('.').next().unwrap_or(&fqdn).to_string(); + tracing::debug!(hostname = %short, "Resolved short hostname by splitting FQDN"); + return Ok(short); + } + } + + // Final fallback + tracing::warn!("Could not determine short hostname — falling back to localhost"); Ok("localhost".into()) } @@ -366,6 +439,56 @@ mod tests { assert!(!fqdn.is_empty(), "FQDN should not be empty"); } + #[test] + fn fqdn_prefers_full_domain() { + // If hostname -f returns a value with a dot, get_fqdn should return it + // (not the short hostname from /etc/hostname) + let fqdn = get_fqdn().expect("Failed to get FQDN"); + // On properly configured systems, FQDN should contain at least one dot + // If it doesn't, it's likely a short hostname from /etc/hostname + if fqdn.contains('.') { + // FQDN contains domain — good + assert!( + fqdn.split('.').count() >= 2, + "FQDN should have at least host.domain format, got: {}", + fqdn + ); + } + // If no dot, it's a short hostname — acceptable fallback but not ideal + } + + #[test] + fn hostname_is_not_empty() { + let hostname = get_hostname().expect("Failed to get hostname"); + assert!(!hostname.is_empty(), "Hostname should not be empty"); + } + + #[test] + fn hostname_is_short_form() { + let hostname = get_hostname().expect("Failed to get hostname"); + // Short hostname should NOT contain dots + assert!( + !hostname.contains('.'), + "Short hostname should not contain dots, got: {}", + hostname + ); + } + + #[test] + fn hostname_is_prefix_of_fqdn() { + let hostname = get_hostname().expect("Failed to get hostname"); + let fqdn = get_fqdn().expect("Failed to get FQDN"); + // If FQDN contains a dot, hostname should be the first component + if fqdn.contains('.') { + let fqdn_prefix = fqdn.split('.').next().unwrap_or(&fqdn); + assert_eq!( + hostname, fqdn_prefix, + "Short hostname '{}' should match FQDN prefix '{}'", + hostname, fqdn_prefix + ); + } + } + #[test] fn os_details_contains_kernel() { let details = get_os_details().expect("Failed to get OS details"); diff --git a/src/enroll/mod.rs b/src/enroll/mod.rs index 54e76dd..d1720a8 100644 --- a/src/enroll/mod.rs +++ b/src/enroll/mod.rs @@ -16,8 +16,8 @@ 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, get_route_source_ip, is_container_bridge, is_link_local, + 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. diff --git a/tests/integration/enrollment_test.rs b/tests/integration/enrollment_test.rs index 1306986..db01dcd 100644 --- a/tests/integration/enrollment_test.rs +++ b/tests/integration/enrollment_test.rs @@ -473,6 +473,18 @@ async fn test_registration_payload_structure() { os_obj.contains_key("distro") || os_obj.contains_key("kernel"), "os_details should contain distro or kernel information" ); + + // Verify hostname field (optional, may be present or absent) + // When present, it should be a non-empty string without dots (short hostname) + if let Some(hostname) = payload.get("hostname").and_then(|v| v.as_str()) { + assert!(!hostname.is_empty(), "hostname should not be empty when present"); + assert!( + !hostname.contains('.'), + "hostname should be short form (no dots), got: {}", + hostname + ); + } + // hostname field is optional — its absence is valid (skip_serializing_if = None) } // ============================================================================= diff --git a/tests/unit/enroll_identity.rs b/tests/unit/enroll_identity.rs index 6d17789..b3c6196 100644 --- a/tests/unit/enroll_identity.rs +++ b/tests/unit/enroll_identity.rs @@ -4,7 +4,7 @@ //! Verifies machine-id, FQDN, IP address collection, and OS detail parsing. use linux_patch_api::enroll::identity::{ - get_fqdn, get_ip_addresses, get_machine_id, get_os_details, get_primary_ip, + get_fqdn, get_hostname, get_ip_addresses, get_machine_id, get_os_details, get_primary_ip, get_route_source_ip, is_container_bridge, is_link_local, }; use linux_patch_api::enroll::EnrollmentRequest; @@ -138,6 +138,97 @@ fn test_fqdn_reasonable_length() { ); } +// ============================================================================= +// Hostname Tests +// ============================================================================= + +#[test] +fn test_hostname_returns_non_empty() { + let hostname = get_hostname().expect("Failed to get hostname"); + assert!(!hostname.is_empty(), "Hostname should not be empty"); +} + +#[test] +fn test_hostname_is_short_form() { + let hostname = get_hostname().expect("Failed to get hostname"); + // Short hostname should NOT contain dots (that would be an FQDN) + assert!( + !hostname.contains('.'), + "Short hostname should not contain dots, got: {}", + hostname + ); +} + +#[test] +fn test_hostname_is_consistent() { + let h1 = get_hostname().expect("Failed to get hostname (call 1)"); + let h2 = get_hostname().expect("Failed to get hostname (call 2)"); + assert_eq!(h1, h2, "Hostname should be consistent across calls"); +} + +#[test] +fn test_hostname_is_subset_of_fqdn() { + let hostname = get_hostname().expect("Failed to get hostname"); + let fqdn = get_fqdn().expect("Failed to get FQDN"); + // If FQDN contains a dot, the short hostname should be the first component + if fqdn.contains('.') { + let fqdn_prefix = fqdn.split('.').next().unwrap_or(&fqdn); + assert_eq!( + hostname, fqdn_prefix, + "Short hostname '{}' should match FQDN prefix '{}'", + hostname, fqdn_prefix + ); + } +} + +#[test] +fn test_hostname_valid_characters() { + let hostname = get_hostname().expect("Failed to get hostname"); + for c in hostname.chars() { + assert!( + c.is_alphanumeric() || c == '-', + "Short hostname contains invalid character: {:?}", + c + ); + } +} + +#[test] +fn test_enrollment_hostname_field_serializes() { + // Verify that hostname field serializes correctly when Some and when None + let request_with_hostname = EnrollmentRequest { + machine_id: "test-id".to_string(), + fqdn: "host.example.com".to_string(), + ip_address: "10.0.0.1".to_string(), + os_details: serde_json::json!({"name": "Test"}), + hostname: Some("host".to_string()), + }; + let json_with = serde_json::to_string(&request_with_hostname) + .expect("Should serialize with hostname"); + assert!( + json_with.contains("\"hostname\""), + "hostname field should be present in JSON when Some" + ); + assert!( + json_with.contains("\"host\""), + "hostname value should be 'host' in JSON" + ); + + let request_without_hostname = EnrollmentRequest { + machine_id: "test-id".to_string(), + fqdn: "host.example.com".to_string(), + ip_address: "10.0.0.1".to_string(), + os_details: serde_json::json!({"name": "Test"}), + hostname: None, + }; + let json_without = serde_json::to_string(&request_without_hostname) + .expect("Should serialize without hostname"); + assert!( + !json_without.contains("\"hostname\""), + "hostname field should be omitted from JSON when None (skip_serializing_if)" + ); +} + // ============================================================================= // IP Address Tests // ============================================================================= @@ -371,11 +462,14 @@ fn test_enrollment_payload_construction() { .cloned() .unwrap_or_else(|| "127.0.0.1".to_string()); + let hostname = get_hostname().ok(); + let request = EnrollmentRequest { machine_id, fqdn, ip_address: primary_ip, os_details, + hostname, }; // Verify payload serializes to valid JSON @@ -412,11 +506,14 @@ fn test_enrollment_payload_matches_manager_schema() { let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses"); let os_details = get_os_details().expect("Failed to get OS details"); + let hostname = get_hostname().ok(); + let request = EnrollmentRequest { machine_id: machine_id.clone(), fqdn: fqdn.clone(), ip_address: ip_addrs.first().cloned().unwrap_or_default(), os_details: os_details.clone(), + hostname, }; // Validate against expected manager API schema @@ -449,11 +546,14 @@ fn test_enrollment_payload_roundtrip() { let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses"); let os_details = get_os_details().expect("Failed to get OS details"); + let hostname = get_hostname().ok(); + let request = EnrollmentRequest { machine_id, fqdn, ip_address: ip_addrs.first().cloned().unwrap_or_default(), os_details, + hostname, }; // Serialize to JSON then deserialize back @@ -464,6 +564,7 @@ fn test_enrollment_payload_roundtrip() { assert_eq!(request.machine_id, deserialized.machine_id); assert_eq!(request.fqdn, deserialized.fqdn); assert_eq!(request.ip_address, deserialized.ip_address); + assert_eq!(request.hostname, deserialized.hostname); } // ============================================================================= @@ -507,6 +608,10 @@ fn test_identity_functions_do_not_panic() { let _ = get_fqdn(); }); + let _ = std::panic::catch_unwind(|| { + let _ = get_hostname(); + }); + let _ = std::panic::catch_unwind(|| { let _ = get_ip_addresses(); });