diff --git a/commands/block.command.sh b/commands/block.command.sh index 48a2fa8..0c29c97 100644 --- a/commands/block.command.sh +++ b/commands/block.command.sh @@ -16,6 +16,7 @@ function cmd::block::on_load() { flag::register --subnet flag::register --block-name flag::register --service + flag::register --reason } # ============================================ @@ -61,7 +62,8 @@ function cmd::block::run() { local name="" identity="" type="" block_name="" local ips=() subnets=() ports=() services=() local quiet=false force=false - + local reason="" + while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; @@ -74,6 +76,7 @@ function cmd::block::run() { --quiet) quiet=true; shift ;; --subnet) subnets+=("$2"); shift 2 ;; --port) ports+=("$2"); shift 2 ;; + --reason) reason="$2"; shift 2 ;; --help) cmd::block::help; return ;; *) log::error "Unknown flag: $1" @@ -82,25 +85,25 @@ function cmd::block::run() { ;; esac done - + # --identity: block all peers for this identity if [[ -n "$identity" ]]; then cmd::block::_block_identity "$identity" "$quiet" \ "${ips[@]+"${ips[@]}"}" || return 1 return 0 fi - + [[ -z "$name" ]] && { log::error "Missing required flag: --name or --identity" cmd::block::help return 1 } - + name=$(peers::resolve_and_require "$name" "$type") || return 1 - + local client_ip client_ip=$(peers::get_ip "$name") || return 1 - + # Full block if no specific targets if [[ ${#ips[@]} -eq 0 && ${#ports[@]} -eq 0 && \ ${#subnets[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then @@ -110,9 +113,10 @@ function cmd::block::run() { fi monitor::update_endpoint_cache cmd::block::_block_all "$name" "$client_ip" "$quiet" + cmd::block::_record_history "$name" "full" "manual" "$reason" return 0 fi - + # Specific rules — check if already fully blocked if block::has_file "$name"; then local direct @@ -122,9 +126,9 @@ function cmd::block::run() { return 1 fi fi - + local changed=false - + # Block specific IPs for ip in "${ips[@]}"; do ip::require_valid "$ip" @@ -132,7 +136,7 @@ function cmd::block::run() { block::add_rule "$name" "$client_ip" "ip" "${block_name:-}" "$ip" $quiet || log::wg_success "${ip} has been blocked for ${name}" done - + # Block specific subnets for subnet in "${subnets[@]}"; do ip::require_valid "$subnet" @@ -140,7 +144,7 @@ function cmd::block::run() { block::add_rule "$name" "$client_ip" "subnet" "${block_name:-}" "$subnet" $quiet || log::wg_success "${subnet} has been blocked for ${name}" done - + # Block specific ports for entry in "${ports[@]}"; do local b_target b_port b_proto @@ -151,7 +155,7 @@ function cmd::block::run() { "$b_target" "$b_port" "${b_proto:-tcp}" $quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}" done - + # Block services for svc in "${services[@]}"; do local resolved_lines=() @@ -160,7 +164,7 @@ function cmd::block::run() { log::error "Service not found or has no ports: ${svc}" return 1 fi - + local already_blocked=true for resolved in "${resolved_lines[@]}"; do if [[ "$resolved" == *:*:* ]]; then @@ -173,12 +177,12 @@ function cmd::block::run() { { already_blocked=false; break; } fi done - + if $already_blocked; then $quiet || log::wg_warning "${svc} is already blocked for ${name}" continue fi - + for resolved in "${resolved_lines[@]}"; do if [[ "$resolved" == *:*:* ]]; then local b_ip b_port b_proto @@ -191,14 +195,14 @@ function cmd::block::run() { block::add_rule "$name" "$client_ip" "ip" "$svc" "$resolved" fi done - + changed=true $quiet || log::wg_success "${svc} has been blocked for ${name}" done - + [[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \ ${#subnets[@]} -gt 0 ]] && changed=true - + if $changed; then local peer_rule peer_rule=$(peers::get_meta "$name" "rule") @@ -208,7 +212,15 @@ function cmd::block::run() { block::restore_rules_for "$name" "$client_ip" 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 } @@ -270,4 +282,25 @@ function cmd::block::_block_all() { block::set_direct "$name" "$client_ip" "true" $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 } \ No newline at end of file diff --git a/commands/export.command.sh b/commands/export.command.sh index cf434c2..59307a1 100644 --- a/commands/export.command.sh +++ b/commands/export.command.sh @@ -215,6 +215,7 @@ function cmd::export::_full() { "$(ctx::identities)" \ "$(ctx::groups)" \ "$(ctx::blocks)" \ + "$(ctx::block_history)" \ "$(ctx::config_file)" \ "$(ctx::policies)" \ "$(ctx::subnets)" \ diff --git a/commands/test/integration.sh b/commands/test/integration.sh index 1af590d..9253e98 100644 --- a/commands/test/integration.sh +++ b/commands/test/integration.sh @@ -126,6 +126,7 @@ function cmd::test::run_all_integration_sections() { cmd::test::section_config cmd::test::section_rules cmd::test::section_groups + cmd::test::section_block_unblock cmd::test::section_audit cmd::test::section_logs 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 } +# 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() { test::section "Audit" cmd::test::run_cmd_any "audit" "passed" audit @@ -423,4 +510,37 @@ function cmd::test::section_display() { config show --name phone-nuno # just check wgctl works, not display directly # 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 } \ No newline at end of file diff --git a/commands/unblock.command.sh b/commands/unblock.command.sh index 164afd4..7d6c27b 100644 --- a/commands/unblock.command.sh +++ b/commands/unblock.command.sh @@ -16,6 +16,7 @@ function cmd::unblock::on_load() { flag::register --subnet flag::register --all flag::register --service + flag::register --reason } # ============================================ @@ -59,20 +60,22 @@ function cmd::unblock::run() { local name="" identity="" type="" local ips=() subnets=() ports=() services=() local all=false quiet=false force=false + local reason="" while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --identity) identity="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --ip) ips+=("$2"); shift 2 ;; - --force) force=true; shift ;; - --quiet) quiet=true; shift ;; - --subnet) subnets+=("$2"); shift 2 ;; - --port) ports+=("$2"); shift 2 ;; - --service) services+=("$2"); shift 2 ;; - --all) all=true; shift ;; - --help) cmd::unblock::help; return ;; + --name) name="$2"; shift 2 ;; + --identity) identity="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --ip) ips+=("$2"); shift 2 ;; + --force) force=true; shift ;; + --quiet) quiet=true; shift ;; + --subnet) subnets+=("$2"); shift 2 ;; + --port) ports+=("$2"); shift 2 ;; + --service) services+=("$2"); shift 2 ;; + --reason) reason="$2"; shift 2 ;; + --all) all=true; shift ;; + --help) cmd::unblock::help; return ;; *) log::error "Unknown flag: $1" cmd::unblock::help @@ -110,6 +113,7 @@ function cmd::unblock::run() { if $all; then cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet" + cmd::unblock::_record_history "$name" "manual" "$reason" return 0 fi @@ -180,6 +184,9 @@ function cmd::unblock::run() { done block::cleanup "$name" + + # Record unblock for specific rules + cmd::unblock::_record_history "$name" "manual" "$reason" return 0 } @@ -248,4 +255,15 @@ function cmd::unblock::_unblock_all() { $quiet || log::wg_success "${name} has been unblocked." 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 } \ No newline at end of file diff --git a/core/context.sh b/core/context.sh index 811a9d2..d3527dd 100644 --- a/core/context.sh +++ b/core/context.sh @@ -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::lib() { echo "${_CTX_CORE}/lib"; } +function ctx::block_history() { echo "${_CTX_DATA}/block-history"; } + # ============================================ # Path Helpers # ============================================ diff --git a/core/json.sh b/core/json.sh index cb8b53a..01bba77 100644 --- a/core/json.sh +++ b/core/json.sh @@ -147,6 +147,13 @@ function json::error_envelope() { # Config function json::config_load() { python3 "$JSON_HELPER" config_load "$1" 11 else 'false', + args[6], args[7], args[8], args[9], args[10], args[11], 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_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[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:]), + '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 ───────────────────────────────────────────────────────────────────── diff --git a/core/lib/__pycache__/block_history.cpython-311.pyc b/core/lib/__pycache__/block_history.cpython-311.pyc new file mode 100644 index 0000000..64bbfe7 Binary files /dev/null and b/core/lib/__pycache__/block_history.cpython-311.pyc differ diff --git a/core/lib/block_history.py b/core/lib/block_history.py new file mode 100644 index 0000000..368c1f2 --- /dev/null +++ b/core/lib/block_history.py @@ -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)) \ No newline at end of file diff --git a/core/lib/importer.py b/core/lib/importer.py index 52d33f1..4e2b9e8 100644 --- a/core/lib/importer.py +++ b/core/lib/importer.py @@ -161,6 +161,17 @@ def import_full(file, clients_dir, meta_dir, rules_dir, identities_dir, open(os.path.join(groups_dir, f"{name}.group"), 'w').write( json.dumps(grp, indent=2)) 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 for key, path in [('policies', policies_file), ('subnets', subnets_file), diff --git a/daemon/endpoint_cache.json b/daemon/endpoint_cache.json index 4e01a44..0250d3d 100644 --- a/daemon/endpoint_cache.json +++ b/daemon/endpoint_cache.json @@ -1,13 +1,13 @@ { "phone-fred": "176.223.61.130", "phone-helena": "148.69.46.73", - "phone-nuno": "148.69.48.20", + "phone-nuno": "94.63.0.129", "tablet-nuno": "148.69.202.5", "guest-zephyr": "86.120.152.74", "guest-zephyr-test": "94.63.0.129", "desktop-roboclean": "46.189.215.231", "laptop-nuno": "94.63.0.129", "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" } \ No newline at end of file