From 3b2146bc7a1903cd9f56db0515d3761e90e66d36 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 12:11:41 -0400 Subject: [PATCH] feat(host_agent): agent reports its primary IP; mirror into Host.address (task 274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent now detects its own primary non-loopback IP (UDP-socket trick, no packets sent) and sends it in the metadata bag; bumps AGENT_VERSION 1.0.0->1.1.0 and re-detects on SIGHUP. The server persists it on HostAgentRegistration.host_ip and mirrors it into Host.address ONLY when that field is blank — an admin-typed address is never overwritten. The reported IP shows in the settings table so admins can see drift regardless. - agent.py: detect_primary_ip(); host_ip in collect_metadata(); refresh on reload - routes.py: pure pick_host_address() helper; ingest persists host_ip + mirrors - models.py + migration host_agent_002_host_ip: new String(45) column - settings_list.html: Reported IP column - plugin.yaml: 1.0.0 -> 1.1.0 - tests: pick_host_address (fill-when-blank / never-clobber / no-op), detect_primary_ip (never raises, non-loopback), metadata carries host_ip Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/host_agent/agent.py | 33 ++++++++++++++- .../versions/host_agent_002_host_ip.py | 24 +++++++++++ plugins/host_agent/models.py | 2 + plugins/host_agent/plugin.yaml | 2 +- plugins/host_agent/routes.py | 21 ++++++++++ .../host_agent/templates/settings_list.html | 5 ++- tests/plugins/host_agent/test_host_ip.py | 41 +++++++++++++++++++ 7 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 plugins/host_agent/migrations/versions/host_agent_002_host_ip.py create mode 100644 tests/plugins/host_agent/test_host_ip.py diff --git a/plugins/host_agent/agent.py b/plugins/host_agent/agent.py index 34f52d6..2051c60 100644 --- a/plugins/host_agent/agent.py +++ b/plugins/host_agent/agent.py @@ -18,7 +18,7 @@ import urllib.request from collections import deque from datetime import datetime, timezone -AGENT_VERSION = "1.0.0" +AGENT_VERSION = "1.1.0" class ConfigError(Exception): @@ -148,6 +148,29 @@ def collect_uptime() -> int: return int(float(_read_file(UPTIME_PATH).split()[0])) +def detect_primary_ip() -> str | None: + """Best-effort primary non-loopback IPv4 of this host. + + connect() on a SOCK_DGRAM socket only sets the default peer and selects the + outbound interface — no packets are sent — so getsockname() reveals the + source IP the kernel would use to reach the internet. This is the host's own + view of its address, so it survives reverse proxies / NAT (unlike the + server reading request.remote_addr). Returns None on any error (offline / no + route) and skips loopback. + """ + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + except OSError: + return None + finally: + s.close() + if not ip or ip.startswith("127."): + return None + return ip + + def collect_metadata() -> dict: u = os.uname() distro = "unknown" @@ -158,7 +181,12 @@ def collect_metadata() -> dict: break except (OSError, FileNotFoundError): pass - return {"kernel": u.release, "distro": distro, "arch": u.machine} + return { + "kernel": u.release, + "distro": distro, + "arch": u.machine, + "host_ip": detect_primary_ip(), + } def default_mounts() -> list[str]: @@ -319,6 +347,7 @@ def main_loop(conf_path: str) -> int: try: cfg = read_config(conf_path) mounts = cfg.get("mounts") or default_mounts() + metadata = collect_metadata() # refresh host_ip/distro on reload _log("INFO", "config reloaded") except ConfigError as e: _log("ERROR", f"reload failed: {e}") diff --git a/plugins/host_agent/migrations/versions/host_agent_002_host_ip.py b/plugins/host_agent/migrations/versions/host_agent_002_host_ip.py new file mode 100644 index 0000000..197dd2f --- /dev/null +++ b/plugins/host_agent/migrations/versions/host_agent_002_host_ip.py @@ -0,0 +1,24 @@ +# plugins/host_agent/migrations/versions/host_agent_002_host_ip.py +"""host_agent: add agent-reported host_ip column + +Revision ID: host_agent_002_host_ip +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "host_agent_002_host_ip" +down_revision: Union[str, None] = "host_agent_001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "host_agent_registrations", + sa.Column("host_ip", sa.String(45), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("host_agent_registrations", "host_ip") diff --git a/plugins/host_agent/models.py b/plugins/host_agent/models.py index b93d725..e8d87d0 100644 --- a/plugins/host_agent/models.py +++ b/plugins/host_agent/models.py @@ -26,6 +26,8 @@ class HostAgentRegistration(Base): kernel = Column(String(128), nullable=True) distro = Column(String(128), nullable=True) arch = Column(String(32), nullable=True) + # Agent-reported primary IP. 45 chars = max textual IPv6 (no zone suffix). + host_ip = Column(String(45), nullable=True) last_seen_at = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow) updated_at = Column(DateTime(timezone=True), nullable=False, diff --git a/plugins/host_agent/plugin.yaml b/plugins/host_agent/plugin.yaml index 09b9022..d6716e8 100644 --- a/plugins/host_agent/plugin.yaml +++ b/plugins/host_agent/plugin.yaml @@ -1,6 +1,6 @@ # plugins/host_agent/plugin.yaml name: host_agent -version: "1.0.0" +version: "1.1.0" description: "Remote Linux host resource monitoring via a lightweight Python push agent (CPU, memory, storage, load, uptime)" author: "Steward" license: "MIT" diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index f023811..584db94 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -34,6 +34,19 @@ def _parse_ts(ts: str) -> datetime: return datetime.fromisoformat(ts) +def pick_host_address(current: str | None, reported: str | None) -> str | None: + """Return the address to store on the Host, or None for no change. + + The agent-reported IP fills Host.address only when the current value is + blank — an admin-typed address (DNS name, management IP) is never + overwritten. The reported IP is always kept on the registration regardless, + so admins can still see drift. + """ + if reported and not (current or "").strip(): + return reported + return None + + def _expand_sample_to_metrics( sample: dict, host_name: str, recorded_at: datetime ) -> list[PluginMetric]: @@ -157,6 +170,14 @@ async def ingest(): if field in md and getattr(reg, field) != md[field]: setattr(reg, field, md[field]) changed = True + host_ip = md.get("host_ip") + if host_ip and reg.host_ip != host_ip: + reg.host_ip = host_ip + changed = True + # Mirror the reported IP into Host.address only when it's blank. + new_address = pick_host_address(host.address, host_ip) + if new_address is not None: + host.address = new_address if changed: reg.updated_at = datetime.now(timezone.utc) diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 7e4ca96..49684f7 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -32,7 +32,7 @@ - + @@ -40,6 +40,7 @@ {% for item in registrations %} + @@ -57,7 +58,7 @@ {% else %} - + {% endfor %}
HostAgent versionDistroHostReported IPAgent versionDistro Last seenActions
{{ item.host.name if item.host else item.reg.host_id }}{{ item.reg.host_ip or "—" }} {{ item.reg.agent_version or "—" }} {{ item.reg.distro or "—" }} {{ item.reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") if item.reg.last_seen_at else "never" }}
No hosts registered. Add one above.
No hosts registered. Add one above.
diff --git a/tests/plugins/host_agent/test_host_ip.py b/tests/plugins/host_agent/test_host_ip.py new file mode 100644 index 0000000..ee07c9a --- /dev/null +++ b/tests/plugins/host_agent/test_host_ip.py @@ -0,0 +1,41 @@ +"""Tests for agent-reported primary IP (task 274).""" +from plugins.host_agent.routes import pick_host_address +from plugins.host_agent import agent as a + + +# ── pick_host_address: fill only when blank, never clobber ──────────────────── + +def test_pick_fills_when_current_blank(): + assert pick_host_address("", "10.0.0.5") == "10.0.0.5" + assert pick_host_address(None, "10.0.0.5") == "10.0.0.5" + assert pick_host_address(" ", "10.0.0.5") == "10.0.0.5" + + +def test_pick_keeps_admin_value(): + # A non-blank current address is never overwritten. + assert pick_host_address("monitor.example.com", "10.0.0.5") is None + assert pick_host_address("192.168.1.9", "10.0.0.5") is None + + +def test_pick_no_reported_ip_is_noop(): + assert pick_host_address("", None) is None + assert pick_host_address("", "") is None + assert pick_host_address(None, None) is None + + +# ── detect_primary_ip: never raises; None or a non-loopback string ──────────── + +def test_detect_primary_ip_never_raises_and_is_sane(): + ip = a.detect_primary_ip() + assert ip is None or isinstance(ip, str) + if ip is not None: + assert not ip.startswith("127.") + assert ip # non-empty + + +# ── host_ip rides in the metadata bag the agent sends ───────────────────────── + +def test_collect_metadata_includes_host_ip_key(): + md = a.collect_metadata() + assert "host_ip" in md # present even if None when offline + assert set(md) >= {"kernel", "distro", "arch", "host_ip"}