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:
parent
c3cf5bc572
commit
8b47e55b4a
4 changed files with 158 additions and 27 deletions
23
core/json.sh
23
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 "$@" </dev/null; }
|
function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy "$@" </dev/null; }
|
||||||
|
|
||||||
# Activity Monitor
|
# Activity Monitor
|
||||||
function json::activity_aggregate() { python3 "$JSON_HELPER" activity_aggregate "$@" </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; }
|
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
|
||||||
|
|
||||||
# Hosts Resolution
|
# Hosts Resolution
|
||||||
function json::hosts_list() { python3 "$JSON_HELPER" hosts_list "$@" </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_show() { python3 "$JSON_HELPER" hosts_show "$@" </dev/null; }
|
||||||
function json::hosts_add() { python3 "$JSON_HELPER" hosts_add "$@" </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_remove() { python3 "$JSON_HELPER" hosts_remove "$@" </dev/null; }
|
||||||
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; }
|
||||||
|
|
||||||
function json::clean_handshakes() { python3 "$JSON_HELPER" clean_handshakes "$@" </dev/null; }
|
# Peer History
|
||||||
function json::batch_resolve() { python3 "$JSON_HELPER" batch_resolve "$(ctx::hosts)" "$(ctx::net)" "$@" </dev/null; }
|
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() {
|
function json::peer_transfer() {
|
||||||
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=""
|
||||||
|
[[ "$resolved" == "$ip" ]] && resolved=""
|
||||||
fi
|
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}"
|
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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue