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
This commit is contained in:
Nuno Duque Nunes 2026-05-26 15:51:53 +00:00
parent c3cf5bc572
commit 8b47e55b4a
4 changed files with 158 additions and 27 deletions

View file

@ -118,6 +118,9 @@ function json::hosts_remove() { python3 "$JSON_HELPER" hosts_remove "$@" </dev/n
function json::hosts_exists() { python3 "$JSON_HELPER" hosts_exists "$@" </dev/null; } function json::hosts_exists() { python3 "$JSON_HELPER" hosts_exists "$@" </dev/null; }
function json::hosts_lookup() { python3 "$JSON_HELPER" hosts_lookup "$@" </dev/null; } function json::hosts_lookup() { python3 "$JSON_HELPER" hosts_lookup "$@" </dev/null; }
# Peer History
function json::peer_history_lookup() { python3 "$JSON_HELPER" peer_history_lookup "$(ctx::data)/peer-history" "$1" </dev/null; }
function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; } function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; } function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }

View file

@ -1525,6 +1525,44 @@ def hosts_lookup(file, ip):
else: else:
print(str(entry)) print(str(entry))
# ======================================================
# Peer History
# ======================================================
def peer_history_lookup(history_dir, ip):
"""
Look up peer name for an endpoint IP using the index file.
Falls back to scanning peer files if index doesn't exist.
Returns peer name or empty string.
"""
import glob, os
index_file = os.path.join(history_dir, "endpoint_index.json")
try:
if os.path.exists(index_file):
with open(index_file) as f:
index = json.load(f)
result = index.get(ip, '')
if result:
print(result)
return
except Exception:
pass
# Fallback: scan peer files (rebuilds index implicitly)
try:
for hist_file in glob.glob(os.path.join(history_dir, "*.json")):
if os.path.basename(hist_file) == "endpoint_index.json":
continue
try:
with open(hist_file) as f:
data = json.load(f)
if ip in data.get("endpoints", {}):
print(data.get("peer", ""))
return
except Exception:
pass
except Exception:
pass
# ====================================================== # ======================================================
@ -1894,6 +1932,7 @@ commands = {
'hosts_lookup': lambda args: hosts_lookup(args[0], args[1]), 'hosts_lookup': lambda args: hosts_lookup(args[0], args[1]),
'clean_handshakes': lambda args: clean_handshakes(args[0], args[1] if len(args) > 1 else '300'), 'clean_handshakes': lambda args: clean_handshakes(args[0], args[1] if len(args) > 1 else '300'),
'batch_resolve': lambda args: batch_resolve(args[0], args[1], *args[2:]), '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 ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────

View file

@ -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 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") HS_CACHE_FILE = Path("/etc/wireguard/.wgctl/daemon/hs_cache.json")
ENDPOINT_CACHE_FILE = Path("/etc/wireguard/.wgctl/daemon/endpoint_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 # Logging
@ -167,13 +169,15 @@ def poll_handshakes():
Poll wg show latest-handshakes periodically. Poll wg show latest-handshakes periodically.
Log a handshake event only when gap > WG_HANDSHAKE_CHECK_SEC (new session). Log a handshake event only when gap > WG_HANDSHAKE_CHECK_SEC (new session).
""" """
global _hs_last_logged global _hs_last_logged, _endpoint_index
_hs_last_logged = load_hs_cache() _hs_last_logged = load_hs_cache()
_endpoint_index = load_endpoint_index()
pubkey_to_name = build_pubkey_to_name() pubkey_to_name = build_pubkey_to_name()
log.info(f"Handshake poller started — {len(pubkey_to_name)} peers, " log.info(f"Handshake poller started — {len(pubkey_to_name)} peers, "
f"session threshold {WG_HANDSHAKE_CHECK_SEC}s") f"session threshold {WG_HANDSHAKE_CHECK_SEC}s")
log.info(f"Endpoint index loaded — {len(_endpoint_index)} known endpoints")
while True: while True:
try: try:
@ -203,12 +207,8 @@ def poll_handshakes():
# Always update last seen # Always update last seen
_hs_last_logged[pubkey] = ts _hs_last_logged[pubkey] = ts
if gap < WG_HANDSHAKE_CHECK_SEC:
continue # keepalive, skip
# Get endpoint # Get endpoint
endpoint = get_endpoint(pubkey) or '' endpoint = get_endpoint(pubkey) or ''
if not endpoint: if not endpoint:
try: try:
cache = json.loads(ENDPOINT_CACHE_FILE.read_text()) cache = json.loads(ENDPOINT_CACHE_FILE.read_text())
@ -216,8 +216,14 @@ def poll_handshakes():
except Exception: except Exception:
pass pass
# Always update peer history + index
if endpoint:
update_peer_history(client, endpoint, ts)
# New session, log it if gap < WG_HANDSHAKE_CHECK_SEC:
continue # keepalive, skip
# New session — log it
entry = { entry = {
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(), "timestamp": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"ip": "", "ip": "",
@ -238,7 +244,75 @@ def poll_handshakes():
except Exception as e: except Exception as e:
log.error(f"Handshake poll error: {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 # Packet Handler

View file

@ -93,16 +93,31 @@ function resolve::endpoint_parts() {
[[ -z "$ip" ]] && echo "|" && return 0 [[ -z "$ip" ]] && echo "|" && return 0
[[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "${ip}|" && return 0 [[ "${_WGCTL_RAW:-false}" == "true" ]] && echo "${ip}|" && return 0
# Don't use cache for endpoint_parts — always resolve fresh
local resolved="" 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 if [[ -z "$resolved" ]]; then
resolved=$(net::reverse_lookup "$ip" "" "" 2>/dev/null) || resolved="" resolved=$(net::reverse_lookup "$ip" "" "" 2>/dev/null) || resolved=""
fi
[[ "$resolved" == "$ip" ]] && resolved="" [[ "$resolved" == "$ip" ]] && resolved=""
fi
# 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}" echo "${ip}|${resolved}"
} }
# resolve::clear_cache # resolve::clear_cache
# Clears the resolution cache — call between commands if needed. # Clears the resolution cache — call between commands if needed.
function resolve::clear_cache() { function resolve::clear_cache() {