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

@ -107,19 +107,22 @@ function json::policy_set_field() { python3 "$JSON_HELPER" policy_set_fiel
function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy "$@" </dev/null; }
# Activity Monitor
function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </dev/null; }
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </dev/null; }
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
# Hosts Resolution
function json::hosts_list() { python3 "$JSON_HELPER" hosts_list "$@" </dev/null; }
function json::hosts_show() { python3 "$JSON_HELPER" hosts_show "$@" </dev/null; }
function json::hosts_add() { python3 "$JSON_HELPER" hosts_add "$@" </dev/null; }
function json::hosts_remove() { python3 "$JSON_HELPER" hosts_remove "$@" </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_list() { python3 "$JSON_HELPER" hosts_list "$@" </dev/null; }
function json::hosts_show() { python3 "$JSON_HELPER" hosts_show "$@" </dev/null; }
function json::hosts_add() { python3 "$JSON_HELPER" hosts_add "$@" </dev/null; }
function json::hosts_remove() { python3 "$JSON_HELPER" hosts_remove "$@" </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::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </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::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }
function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \

View file

@ -1525,6 +1525,44 @@ def hosts_lookup(file, ip):
else:
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]),
'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:]),
'peer_history_lookup': lambda args: peer_history_lookup(args[0], args[1]),
}
# ── 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
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
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()
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:
@ -203,12 +207,8 @@ 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
# 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 = {
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"ip": "",
@ -238,7 +244,75 @@ def poll_handshakes():
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

View file

@ -93,16 +93,31 @@ function resolve::endpoint_parts() {
[[ -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.
function resolve::clear_cache() {