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}")
|
||||
|
||||
Reference in New Issue
Block a user