feat(host_agent): agent reports its primary IP; mirror into Host.address (task 274)
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:
@@ -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")
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user