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 collections import deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
AGENT_VERSION = "1.0.0"
|
AGENT_VERSION = "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
@@ -148,6 +148,29 @@ def collect_uptime() -> int:
|
|||||||
return int(float(_read_file(UPTIME_PATH).split()[0]))
|
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:
|
def collect_metadata() -> dict:
|
||||||
u = os.uname()
|
u = os.uname()
|
||||||
distro = "unknown"
|
distro = "unknown"
|
||||||
@@ -158,7 +181,12 @@ def collect_metadata() -> dict:
|
|||||||
break
|
break
|
||||||
except (OSError, FileNotFoundError):
|
except (OSError, FileNotFoundError):
|
||||||
pass
|
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]:
|
def default_mounts() -> list[str]:
|
||||||
@@ -319,6 +347,7 @@ def main_loop(conf_path: str) -> int:
|
|||||||
try:
|
try:
|
||||||
cfg = read_config(conf_path)
|
cfg = read_config(conf_path)
|
||||||
mounts = cfg.get("mounts") or default_mounts()
|
mounts = cfg.get("mounts") or default_mounts()
|
||||||
|
metadata = collect_metadata() # refresh host_ip/distro on reload
|
||||||
_log("INFO", "config reloaded")
|
_log("INFO", "config reloaded")
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
_log("ERROR", f"reload failed: {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)
|
kernel = Column(String(128), nullable=True)
|
||||||
distro = Column(String(128), nullable=True)
|
distro = Column(String(128), nullable=True)
|
||||||
arch = Column(String(32), 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)
|
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||||
updated_at = Column(DateTime(timezone=True), nullable=False,
|
updated_at = Column(DateTime(timezone=True), nullable=False,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# plugins/host_agent/plugin.yaml
|
# plugins/host_agent/plugin.yaml
|
||||||
name: host_agent
|
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)"
|
description: "Remote Linux host resource monitoring via a lightweight Python push agent (CPU, memory, storage, load, uptime)"
|
||||||
author: "Steward"
|
author: "Steward"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ def _parse_ts(ts: str) -> datetime:
|
|||||||
return datetime.fromisoformat(ts)
|
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(
|
def _expand_sample_to_metrics(
|
||||||
sample: dict, host_name: str, recorded_at: datetime
|
sample: dict, host_name: str, recorded_at: datetime
|
||||||
) -> list[PluginMetric]:
|
) -> list[PluginMetric]:
|
||||||
@@ -157,6 +170,14 @@ async def ingest():
|
|||||||
if field in md and getattr(reg, field) != md[field]:
|
if field in md and getattr(reg, field) != md[field]:
|
||||||
setattr(reg, field, md[field])
|
setattr(reg, field, md[field])
|
||||||
changed = True
|
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:
|
if changed:
|
||||||
reg.updated_at = datetime.now(timezone.utc)
|
reg.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
<th>Last seen</th><th class="td-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
{% for item in registrations %}
|
{% for item in registrations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ item.host.name if item.host else item.reg.host_id }}</td>
|
<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.agent_version or "—" }}</td>
|
||||||
<td>{{ item.reg.distro 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>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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