Merge feature/block-history into master

This commit is contained in:
Nuno Duque Nunes 2026-05-28 01:54:02 +00:00
commit 91593b2576
11 changed files with 365 additions and 36 deletions

View file

@ -16,6 +16,7 @@ function cmd::block::on_load() {
flag::register --subnet flag::register --subnet
flag::register --block-name flag::register --block-name
flag::register --service flag::register --service
flag::register --reason
} }
# ============================================ # ============================================
@ -61,6 +62,7 @@ function cmd::block::run() {
local name="" identity="" type="" block_name="" local name="" identity="" type="" block_name=""
local ips=() subnets=() ports=() services=() local ips=() subnets=() ports=() services=()
local quiet=false force=false local quiet=false force=false
local reason=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@ -74,6 +76,7 @@ function cmd::block::run() {
--quiet) quiet=true; shift ;; --quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;; --subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;; --port) ports+=("$2"); shift 2 ;;
--reason) reason="$2"; shift 2 ;;
--help) cmd::block::help; return ;; --help) cmd::block::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
@ -110,6 +113,7 @@ function cmd::block::run() {
fi fi
monitor::update_endpoint_cache monitor::update_endpoint_cache
cmd::block::_block_all "$name" "$client_ip" "$quiet" cmd::block::_block_all "$name" "$client_ip" "$quiet"
cmd::block::_record_history "$name" "full" "manual" "$reason"
return 0 return 0
fi fi
@ -209,6 +213,14 @@ function cmd::block::run() {
fi fi
fi fi
# Record history — derive block type from what was blocked
local btype="specific"
[[ ${#services[@]} -gt 0 ]] && btype="${services[0]}"
[[ ${#ips[@]} -gt 0 ]] && btype="ip"
[[ ${#subnets[@]} -gt 0 ]] && btype="subnet"
[[ ${#ports[@]} -gt 0 ]] && btype="port"
cmd::block::_record_history "$name" "$btype" "manual" "$reason"
return 0 return 0
} }
@ -271,3 +283,24 @@ function cmd::block::_block_all() {
$quiet || log::wg_success "${name} has been blocked." $quiet || log::wg_success "${name} has been blocked."
} }
function cmd::block::_record_history() {
local name="${1:-}" block_type="${2:-full}" \
triggered_by="${3:-manual}" reason="${4:-}"
local endpoint
endpoint=$(json::peer_history_lookup "$name" 2>/dev/null || true)
# endpoint_cache lookup
local ep_cache
ep_cache=$(json::endpoint_cache_get "$(ctx::endpoint_cache)" "$name" 2>/dev/null || true)
json::block_history_record \
"$(ctx::block_history)" \
"$name" \
"$block_type" \
"$triggered_by" \
"$reason" \
"${ep_cache:-}" \
2>/dev/null > /dev/null || true
}

View file

@ -215,6 +215,7 @@ function cmd::export::_full() {
"$(ctx::identities)" \ "$(ctx::identities)" \
"$(ctx::groups)" \ "$(ctx::groups)" \
"$(ctx::blocks)" \ "$(ctx::blocks)" \
"$(ctx::block_history)" \
"$(ctx::config_file)" \ "$(ctx::config_file)" \
"$(ctx::policies)" \ "$(ctx::policies)" \
"$(ctx::subnets)" \ "$(ctx::subnets)" \

View file

@ -126,6 +126,7 @@ function cmd::test::run_all_integration_sections() {
cmd::test::section_config cmd::test::section_config
cmd::test::section_rules cmd::test::section_rules
cmd::test::section_groups cmd::test::section_groups
cmd::test::section_block_unblock
cmd::test::section_audit cmd::test::section_audit
cmd::test::section_logs cmd::test::section_logs
cmd::test::section_fw cmd::test::section_fw
@ -204,6 +205,92 @@ function cmd::test::section_groups() {
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
} }
# function cmd::test::section_blocks() {
# test::section "Blocks"
# cmd::test::run_cmd "block --reason records history" "block-history" block --name guest-test --reason "test block" --force
# cmd::test::run_cmd_succeeds "unblock clears history" unblock --name guest-test --force
# }
function cmd::test::section_block_unblock() {
test::section "Block / Unblock"
# ── Setup fixture ──
local fixture="phone-testblock"
wgctl unblock --name "$fixture" --force >/dev/null 2>&1 || true
wgctl remove --name "$fixture" --force >/dev/null 2>&1 || true
wgctl add --name testblock --type phone >/dev/null 2>&1 || true
local history_file
history_file="$(ctx::block_history)/${fixture}.json"
# ── Block ──
echo "DEBUG about to run: $WGCTL_BINARY block --name $fixture --force" >&2
cmd::test::run_cmd "block peer" "blocked" block --name "$fixture" --force
cmd::test::run_cmd "block already blocked" "already" block --name "$fixture" --force
wgctl unblock --name "$fixture" --force >/dev/null 2>&1 || true
cmd::test::run_cmd "block with reason" "blocked" block --name "$fixture" --force \
--reason "test reason"
# ── Block history file created ──
[[ -f "$history_file" ]] && test::pass "block history file created" \
|| test::fail "block history file not created"
# ── Block history fields ──
if [[ -f "$history_file" ]]; then
local has_id has_blocked_at has_endpoint has_reason
has_id=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and 'id' in d['history'][-1] else 'no')
" 2>/dev/null)
has_blocked_at=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and d['history'][-1].get('blocked_at') else 'no')
" 2>/dev/null)
has_endpoint=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if 'endpoint_at_block' in d['history'][-1] else 'no')
" 2>/dev/null)
has_reason=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'][-1].get('reason') == 'test reason' else 'no')
" 2>/dev/null)
cmd::test::assert "history has id" "$has_id" "yes"
cmd::test::assert "history has blocked_at" "$has_blocked_at" "yes"
cmd::test::assert "history has endpoint" "$has_endpoint" "yes"
cmd::test::assert "history has reason" "$has_reason" "yes"
fi
# ── Unblock ──
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name "$fixture" --force \
--reason "test cleanup"
cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name "$fixture" --force
# ── Unblock history updated ──
if [[ -f "$history_file" ]]; then
local has_unblocked has_unblock_reason
has_unblocked=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'] and d['history'][-1].get('unblocked_at') else 'no')
" 2>/dev/null)
has_unblock_reason=$(python3 -c "
import json
d=json.load(open('$history_file'))
print('yes' if d['history'][-1].get('unblock_reason') == 'test cleanup' else 'no')
" 2>/dev/null)
cmd::test::assert "history has unblocked_at" "$has_unblocked" "yes"
cmd::test::assert "history has unblock_reason" "$has_unblock_reason" "yes"
fi
# ── Teardown fixture ──
wgctl remove --name "$fixture" --force >/dev/null 2>&1 || true
rm -f "$history_file"
}
function cmd::test::section_audit() { function cmd::test::section_audit() {
test::section "Audit" test::section "Audit"
cmd::test::run_cmd_any "audit" "passed" audit cmd::test::run_cmd_any "audit" "passed" audit
@ -424,3 +511,36 @@ function cmd::test::section_display() {
# Test style via unit tests (see unit section) # Test style via unit tests (see unit section)
} }
# ============================================
# Helpers
# ============================================
function cmd::test::assert() {
local desc="${1:-}" result="${2:-}" expected="${3:-}"
if [[ "$result" == "$expected" ]]; then
test::pass "$desc"
else
test::fail "${desc} (expected '${expected}', got '${result}')"
fi
}
function cmd::test::assert_true() {
local desc="${1:-}"
shift
if "$@" 2>/dev/null; then
test::pass "$desc"
else
test::fail "$desc (expected true, got false)"
fi
}
function cmd::test::assert_false() {
local desc="${1:-}"
shift
if ! "$@" 2>/dev/null; then
test::pass "$desc"
else
test::fail "$desc (expected false, got true)"
fi
}

View file

@ -16,6 +16,7 @@ function cmd::unblock::on_load() {
flag::register --subnet flag::register --subnet
flag::register --all flag::register --all
flag::register --service flag::register --service
flag::register --reason
} }
# ============================================ # ============================================
@ -59,20 +60,22 @@ function cmd::unblock::run() {
local name="" identity="" type="" local name="" identity="" type=""
local ips=() subnets=() ports=() services=() local ips=() subnets=() ports=() services=()
local all=false quiet=false force=false local all=false quiet=false force=false
local reason=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--identity) identity="$2"; shift 2 ;; --identity) identity="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;; --ip) ips+=("$2"); shift 2 ;;
--force) force=true; shift ;; --force) force=true; shift ;;
--quiet) quiet=true; shift ;; --quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;; --subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;; --port) ports+=("$2"); shift 2 ;;
--service) services+=("$2"); shift 2 ;; --service) services+=("$2"); shift 2 ;;
--all) all=true; shift ;; --reason) reason="$2"; shift 2 ;;
--help) cmd::unblock::help; return ;; --all) all=true; shift ;;
--help) cmd::unblock::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
cmd::unblock::help cmd::unblock::help
@ -110,6 +113,7 @@ function cmd::unblock::run() {
if $all; then if $all; then
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet" cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
cmd::unblock::_record_history "$name" "manual" "$reason"
return 0 return 0
fi fi
@ -180,6 +184,9 @@ function cmd::unblock::run() {
done done
block::cleanup "$name" block::cleanup "$name"
# Record unblock for specific rules
cmd::unblock::_record_history "$name" "manual" "$reason"
return 0 return 0
} }
@ -249,3 +256,14 @@ function cmd::unblock::_unblock_all() {
$quiet || log::wg_success "${name} has been unblocked." $quiet || log::wg_success "${name} has been unblocked."
return 0 return 0
} }
function cmd::unblock::_record_history() {
local name="${1:-}" unblocked_by="${2:-manual}" reason="${3:-}"
json::block_history_unblock \
"$(ctx::block_history)" \
"$name" \
"$unblocked_by" \
"$reason" \
2>/dev/null > /dev/null || true
}

View file

@ -86,6 +86,8 @@ function ctx::json_helper() { echo "${_CTX_CORE}/json_helper.py"; }
function ctx::monitor_script() { echo "${_CTX_ROOT}/daemon/wgctl-monitor.py"; } function ctx::monitor_script() { echo "${_CTX_ROOT}/daemon/wgctl-monitor.py"; }
function ctx::lib() { echo "${_CTX_CORE}/lib"; } function ctx::lib() { echo "${_CTX_CORE}/lib"; }
function ctx::block_history() { echo "${_CTX_DATA}/block-history"; }
# ============================================ # ============================================
# Path Helpers # Path Helpers
# ============================================ # ============================================

View file

@ -147,6 +147,13 @@ function json::error_envelope() {
# Config # Config
function json::config_load() { python3 "$JSON_HELPER" config_load "$1" </dev/null; } function json::config_load() { python3 "$JSON_HELPER" config_load "$1" </dev/null; }
function json::block_history_record() { python3 "$JSON_HELPER" block_history_record "$@" </dev/null; }
function json::block_history_unblock() { python3 "$JSON_HELPER" block_history_unblock "$@" </dev/null; }
function json::block_history_list() { python3 "$JSON_HELPER" block_history_list "$@" </dev/null; }
function json::block_history_list_all() { python3 "$JSON_HELPER" block_history_list_all "$@" </dev/null; }
function json::endpoint_cache_get() { python3 "$JSON_HELPER" endpoint_cache_get "$@" </dev/null; }
function json::peer_transfer() { function json::peer_transfer() {
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \ ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \ ACTIVITY_TOTAL_MED="$(config::activity_total_med)" \

View file

@ -1713,7 +1713,7 @@ def _export_peer_data(name, clients_dir, meta_dir, identities_dir, groups_dir, b
def export_full(clients_dir, meta_dir, rules_dir, identities_dir, def export_full(clients_dir, meta_dir, rules_dir, identities_dir,
groups_dir, blocks_dir, config_file, groups_dir, blocks_dir, block_history_dir, config_file,
policies_file, subnets_file, net_file, hosts_file, policies_file, subnets_file, net_file, hosts_file,
no_config, no_peers, version): no_config, no_peers, version):
"""Build full wgctl export as JSON.""" """Build full wgctl export as JSON."""
@ -1778,6 +1778,17 @@ def export_full(clients_dir, meta_dir, rules_dir, identities_dir,
pass pass
data['groups'] = groups data['groups'] = groups
# Block history
block_histories = []
for path in sorted(glob.glob(f"{block_history_dir}/*.json")):
try:
with open(path) as f:
block_histories.append(json.load(f))
except Exception:
pass
data['block_history'] = block_histories
# Flat JSON files # Flat JSON files
for key, path in [ for key, path in [
('policies', policies_file), ('policies', policies_file),
@ -1801,6 +1812,15 @@ def export_full(clients_dir, meta_dir, rules_dir, identities_dir,
} }
print(json.dumps(result)) print(json.dumps(result))
def endpoint_cache_get(cache_file, peer):
"""Get cached endpoint IP for a peer."""
try:
with open(cache_file) as f:
cache = json.load(f)
print(cache.get(peer, ''))
except Exception:
print('')
# ====================================================== # ======================================================
def _net_read(file): def _net_read(file):
@ -2174,10 +2194,10 @@ commands = {
'display_load': lambda args: display_load(args[0]), 'display_load': lambda args: display_load(args[0]),
'export_full': lambda args: export_full( 'export_full': lambda args: export_full(
args[0], args[1], args[2], args[3], args[4], args[5], args[0], args[1], args[2], args[3], args[4], args[5],
args[6], args[7], args[8], args[9], args[10], args[6], args[7], args[8], args[9], args[10], args[11],
args[11] if len(args) > 11 else 'false',
args[12] if len(args) > 12 else 'false', args[12] if len(args) > 12 else 'false',
args[13] if len(args) > 13 else 'unknown'), args[13] if len(args) > 13 else 'false',
args[14] if len(args) > 14 else 'unknown'),
# Import # Import
'import_peer': lambda args: __import__('lib.importer', fromlist=['import_peer']).import_peer( 'import_peer': lambda args: __import__('lib.importer', fromlist=['import_peer']).import_peer(
@ -2191,6 +2211,20 @@ commands = {
args[6], args[7], args[8], args[9], args[10], args[6], args[7], args[8], args[9], args[10],
args[11] if len(args) > 11 else 'false'), args[11] if len(args) > 11 else 'false'),
'import_get_field': lambda args: __import__('lib.importer', fromlist=['import_get_field']).import_get_field(args[0], *args[1:]), 'import_get_field': lambda args: __import__('lib.importer', fromlist=['import_get_field']).import_get_field(args[0], *args[1:]),
'block_history_record': lambda args: __import__('lib.block_history',
fromlist=['block_history_record']).block_history_record(
args[0], args[1], args[2], args[3],
args[4] if len(args) > 4 else '',
args[5] if len(args) > 5 else ''),
'block_history_unblock': lambda args: __import__('lib.block_history',
fromlist=['block_history_unblock']).block_history_unblock(
args[0], args[1], args[2],
args[3] if len(args) > 3 else ''),
'block_history_list': lambda args: __import__('lib.block_history',
fromlist=['block_history_list']).block_history_list(args[0], args[1]),
'block_history_list_all': lambda args: __import__('lib.block_history',
fromlist=['block_history_list_all']).block_history_list_all(args[0]),
'endpoint_cache_get': lambda args: endpoint_cache_get(args[0], args[1]),
} }
# ── Main ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────

Binary file not shown.

103
core/lib/block_history.py Normal file
View file

@ -0,0 +1,103 @@
# core/lib/block_history.py
import json
import os
from datetime import datetime, timezone
BLOCK_HISTORY_VERSION = 1
def _history_file(history_dir, peer):
return os.path.join(history_dir, f"{peer}.json")
def _load(history_dir, peer):
path = _history_file(history_dir, peer)
if os.path.exists(path):
try:
return json.load(open(path))
except Exception:
pass
return {"peer": peer, "version": BLOCK_HISTORY_VERSION, "history": []}
def _save(history_dir, peer, data):
os.makedirs(history_dir, exist_ok=True)
path = _history_file(history_dir, peer)
open(path, 'w').write(json.dumps(data, indent=2))
def _next_id(history):
if not history:
return 1
return max(int(e.get("id", 0)) for e in history) + 1
def _now():
return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def _get_endpoint(clients_dir, peer):
"""Try to get current endpoint from endpoint cache."""
cache_file = os.path.join(
os.path.dirname(os.path.dirname(clients_dir)),
'.wgctl', 'daemon', 'endpoint_cache.json')
try:
cache = json.load(open(cache_file))
return cache.get(peer, '')
except Exception:
return ''
def block_history_record(history_dir, peer, block_type,
triggered_by, reason, endpoint_at_block):
"""Record a new block event."""
data = _load(history_dir, peer)
entry = {
"id": _next_id(data["history"]),
"blocked_at": _now(),
"unblocked_at": None,
"block_type": block_type,
"triggered_by": triggered_by,
"reason": reason or '',
"endpoint_at_block": endpoint_at_block or '',
"unblocked_by": None,
"unblock_reason": None,
}
data["history"].append(entry)
_save(history_dir, peer, data)
print(entry["id"])
def block_history_unblock(history_dir, peer, unblocked_by, unblock_reason):
"""Update the most recent open block event with unblock timestamp."""
data = _load(history_dir, peer)
# Find most recent entry without unblocked_at
for entry in reversed(data["history"]):
if entry.get("unblocked_at") is None:
entry["unblocked_at"] = _now()
entry["unblocked_by"] = unblocked_by
entry["unblock_reason"] = unblock_reason or ''
_save(history_dir, peer, data)
print(entry["id"])
return
# No open block found — not an error, peer may have been unblocked externally
def block_history_list(history_dir, peer):
"""Output block history for a peer as JSON."""
data = _load(history_dir, peer)
print(json.dumps(data))
def block_history_list_all(history_dir):
"""Output block history for all peers as JSON array."""
import glob
results = []
for path in sorted(glob.glob(os.path.join(history_dir, '*.json'))):
try:
results.append(json.load(open(path)))
except Exception:
pass
print(json.dumps(results))

View file

@ -162,6 +162,17 @@ def import_full(file, clients_dir, meta_dir, rules_dir, identities_dir,
json.dumps(grp, indent=2)) json.dumps(grp, indent=2))
results.append('groups') results.append('groups')
# Block history
bh_dir = os.path.join(os.path.dirname(groups_dir), 'block-history')
os.makedirs(bh_dir, exist_ok=True)
for bh in data.get('block_history', []):
peer_name = bh.get('peer', '')
if peer_name:
open(os.path.join(bh_dir, f"{peer_name}.json"), 'w').write(
json.dumps(bh, indent=2))
if data.get('block_history'):
results.append('block_history')
# Flat JSON files # Flat JSON files
for key, path in [('policies', policies_file), ('subnets', subnets_file), for key, path in [('policies', policies_file), ('subnets', subnets_file),
('services', net_file), ('hosts', hosts_file)]: ('services', net_file), ('hosts', hosts_file)]:

View file

@ -1,13 +1,13 @@
{ {
"phone-fred": "176.223.61.130", "phone-fred": "176.223.61.130",
"phone-helena": "148.69.46.73", "phone-helena": "148.69.46.73",
"phone-nuno": "148.69.48.20", "phone-nuno": "94.63.0.129",
"tablet-nuno": "148.69.202.5", "tablet-nuno": "148.69.202.5",
"guest-zephyr": "86.120.152.74", "guest-zephyr": "86.120.152.74",
"guest-zephyr-test": "94.63.0.129", "guest-zephyr-test": "94.63.0.129",
"desktop-roboclean": "46.189.215.231", "desktop-roboclean": "46.189.215.231",
"laptop-nuno": "94.63.0.129", "laptop-nuno": "94.63.0.129",
"phone-luis": "176.223.61.15", "phone-luis": "176.223.61.15",
"phone-helena-2": "148.69.202.234", "phone-helena-2": "148.69.193.234",
"desktop-zephyr": "86.120.152.74" "desktop-zephyr": "86.120.152.74"
} }