From 8b47e55b4ad62e90f83518a9fa086924ff54983f Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Tue, 26 May 2026 15:51:53 +0000 Subject: [PATCH] feat: peer endpoint history tracking and resolution - daemon: update_peer_history() tracks all endpoints per peer - daemon: endpoint_index.json for O(1) IP -> peer name lookup - daemon: poll_handshakes updates history on every cycle - json_helper: peer_history_lookup() uses index, falls back to scan - resolve::endpoint_parts: step 3 checks peer history index - json.sh: json::peer_history_lookup wrapper - resolve: mobile peer IPs now resolve to peer name via history --- core/json.sh | 23 +++++---- core/json_helper.py | 39 +++++++++++++++ daemon/wgctl-monitor.py | 100 +++++++++++++++++++++++++++++++++----- modules/resolve.module.sh | 23 +++++++-- 4 files changed, 158 insertions(+), 27 deletions(-) diff --git a/core/json.sh b/core/json.sh index 2ae6b61..f7895cd 100644 --- a/core/json.sh +++ b/core/json.sh @@ -107,19 +107,22 @@ function json::policy_set_field() { python3 "$JSON_HELPER" policy_set_fiel function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy "$@" 1 else '300'), 'batch_resolve': lambda args: batch_resolve(args[0], args[1], *args[2:]), + 'peer_history_lookup': lambda args: peer_history_lookup(args[0], args[1]), } # ── Main ───────────────────────────────────────────────────────────────────── diff --git a/daemon/wgctl-monitor.py b/daemon/wgctl-monitor.py index a4e3236..825bb41 100755 --- a/daemon/wgctl-monitor.py +++ b/daemon/wgctl-monitor.py @@ -26,6 +26,8 @@ WG_HANDSHAKE_CHECK_SEC = int(os.environ.get("WG_HANDSHAKE_CHECK_TIME_SEC", "300" WG_WG_INTERFACE = os.environ.get("WG_WG_INTERFACE", "wg0") # WireGuard interface, not capture interface HS_CACHE_FILE = Path("/etc/wireguard/.wgctl/daemon/hs_cache.json") ENDPOINT_CACHE_FILE = Path("/etc/wireguard/.wgctl/daemon/endpoint_cache.json") +PEER_HISTORY_DIR = Path("/etc/wireguard/.wgctl/peer-history") +ENDPOINT_INDEX_FILE = PEER_HISTORY_DIR / "endpoint_index.json" # ============================================ # Logging @@ -167,13 +169,15 @@ def poll_handshakes(): Poll wg show latest-handshakes periodically. Log a handshake event only when gap > WG_HANDSHAKE_CHECK_SEC (new session). """ - global _hs_last_logged - - _hs_last_logged = load_hs_cache() - + global _hs_last_logged, _endpoint_index + + _hs_last_logged = load_hs_cache() + _endpoint_index = load_endpoint_index() + pubkey_to_name = build_pubkey_to_name() log.info(f"Handshake poller started — {len(pubkey_to_name)} peers, " f"session threshold {WG_HANDSHAKE_CHECK_SEC}s") + log.info(f"Endpoint index loaded — {len(_endpoint_index)} known endpoints") while True: try: @@ -202,13 +206,9 @@ def poll_handshakes(): # Always update last seen _hs_last_logged[pubkey] = ts - - if gap < WG_HANDSHAKE_CHECK_SEC: - continue # keepalive, skip # Get endpoint endpoint = get_endpoint(pubkey) or '' - if not endpoint: try: cache = json.loads(ENDPOINT_CACHE_FILE.read_text()) @@ -216,8 +216,14 @@ def poll_handshakes(): except Exception: pass - - # New session, log it + # Always update peer history + index + if endpoint: + update_peer_history(client, endpoint, ts) + + if gap < WG_HANDSHAKE_CHECK_SEC: + continue # keepalive, skip + + # New session — log it entry = { "timestamp": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(), "ip": "", @@ -231,14 +237,82 @@ def poll_handshakes(): log.info(f"New session: {client} from {endpoint}") except Exception as e: log.error(f"Failed to write handshake event: {e}") - + log.debug(f"Gap for {client}: {gap}s (threshold: {WG_HANDSHAKE_CHECK_SEC}s)") save_hs_cache(_hs_last_logged) - + except Exception as e: log.error(f"Handshake poll error: {e}") - time.sleep(WG_HANDSHAKE_CHECK_SEC // 2) # poll at half the threshold + time.sleep(WG_HANDSHAKE_CHECK_SEC // 2) + + +# ============================================ +# Peer History +# ============================================ + +def load_endpoint_index() -> dict: + """Load endpoint -> peer name index.""" + try: + if ENDPOINT_INDEX_FILE.exists(): + return json.loads(ENDPOINT_INDEX_FILE.read_text()) + except Exception: + pass + return {} + +def save_endpoint_index(index: dict): + """Save endpoint -> peer name index.""" + try: + PEER_HISTORY_DIR.mkdir(parents=True, exist_ok=True) + ENDPOINT_INDEX_FILE.write_text(json.dumps(index, indent=2)) + except Exception as e: + log.error(f"Failed to save endpoint index: {e}") + +# In-memory index — loaded once, updated on each new endpoint +_endpoint_index: dict = {} + +def update_peer_history(client: str, endpoint: str, ts: int): + """ + Update peer endpoint history and endpoint index. + Called on every poll cycle to keep last_seen current. + """ + global _endpoint_index + if not endpoint: + return + try: + PEER_HISTORY_DIR.mkdir(parents=True, exist_ok=True) + history_file = PEER_HISTORY_DIR / f"{client}.json" + + if history_file.exists(): + try: + data = json.loads(history_file.read_text()) + except Exception: + data = {"peer": client, "endpoints": {}} + else: + data = {"peer": client, "endpoints": {}} + + ts_iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + eps = data.setdefault("endpoints", {}) + is_new = endpoint not in eps + + if is_new: + eps[endpoint] = { + "first_seen": ts_iso, + "last_seen": ts_iso, + "count": 1 + } + log.debug(f"New endpoint for {client}: {endpoint}") + # Update in-memory index and persist + _endpoint_index[endpoint] = client + save_endpoint_index(_endpoint_index) + else: + eps[endpoint]["last_seen"] = ts_iso + eps[endpoint]["count"] += 1 + + history_file.write_text(json.dumps(data, indent=2)) + except Exception as e: + log.error(f"Failed to update peer history for {client}: {e}") + # ============================================ # Packet Handler diff --git a/modules/resolve.module.sh b/modules/resolve.module.sh index f4c6e36..ea67024 100644 --- a/modules/resolve.module.sh +++ b/modules/resolve.module.sh @@ -92,16 +92,31 @@ function resolve::endpoint_parts() { local ip="${1:-}" [[ -z "$ip" ]] && echo "|" && return 0 [[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "${ip}|" && return 0 - - # Don't use cache for endpoint_parts — always resolve fresh + local resolved="" - [[ -f "$(ctx::hosts)" ]] && resolved=$(hosts::resolve_ip "$ip" 2>/dev/null || true) + + # 1. hosts.json exact IP match + [[ -f "$(ctx::hosts)" ]] && \ + resolved=$(hosts::resolve_ip "$ip" 2>/dev/null || true) + + # 2. services.json match if [[ -z "$resolved" ]]; then resolved=$(net::reverse_lookup "$ip" "" "" 2>/dev/null) || resolved="" + [[ "$resolved" == "$ip" ]] && resolved="" fi - [[ "$resolved" == "$ip" ]] && resolved="" + + # 3. Peer history index — O(1) lookup + if [[ -z "$resolved" ]]; then + local history_dir + history_dir="$(ctx::data)/peer-history" + if [[ -d "$history_dir" ]]; then + resolved=$(json::peer_history_lookup "$ip" 2>/dev/null || true) + fi + fi + echo "${ip}|${resolved}" } + # resolve::clear_cache # Clears the resolution cache — call between commands if needed.