feat(host_agent): agent reports its primary IP; mirror into Host.address (task 274)
CI / lint (push) Successful in 3s
CI / unit (push) Failing after 8s
CI / integration (push) Successful in 2m9s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:11:41 -04:00
parent af60ca446d
commit 3b2146bc7a
7 changed files with 123 additions and 5 deletions
+31 -2
View File
@@ -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}")
@@ -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")
+2
View File
@@ -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,
+1 -1
View File
@@ -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"
+21
View File
@@ -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)
@@ -32,7 +32,7 @@
<table class="table">
<thead>
<tr>
<th>Host</th><th>Agent version</th><th>Distro</th>
<th>Host</th><th>Reported IP</th><th>Agent version</th><th>Distro</th>
<th>Last seen</th><th class="td-actions">Actions</th>
</tr>
</thead>
@@ -40,6 +40,7 @@
{% for item in registrations %}
<tr>
<td>{{ item.host.name if item.host else item.reg.host_id }}</td>
<td>{{ item.reg.host_ip or "—" }}</td>
<td>{{ item.reg.agent_version or "—" }}</td>
<td>{{ item.reg.distro or "—" }}</td>
<td>{{ item.reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") if item.reg.last_seen_at else "never" }}</td>
@@ -57,7 +58,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No hosts registered. Add one above.</td></tr>
<tr><td colspan="6" class="empty">No hosts registered. Add one above.</td></tr>
{% endfor %}
</tbody>
</table>
+41
View File
@@ -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"}