From cf90ab22db831f386f00ecf5831e1348458dcb56 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Fri, 15 May 2026 04:44:53 +0000 Subject: [PATCH] feat: block system JSON migration, M:N group tracking, block module, block::restore_all, color module, fw refactor --- commands/audit.command.sh | 23 +-- commands/block.command.sh | 64 ++++----- commands/fw.command.sh | 27 ++-- commands/group.command.sh | 197 +++++++++++++++++-------- commands/inspect.command.sh | 261 +++++++++++++++++++++++++++------- commands/list.command.sh | 52 +++++-- commands/logs.command.sh | 51 ++++++- commands/remove.command.sh | 2 +- commands/rename.command.sh | 4 +- commands/rule.command.sh | 163 ++++++++------------- commands/service.command.sh | 34 ++++- commands/shell.command.sh | 108 ++++++++------ commands/shell.command.sh.bak | 164 +++++++++++++++++++++ commands/test.command.sh | 93 ++++++++---- commands/unblock.command.sh | 38 +++-- commands/watch.command.sh | 33 ++++- core.sh | 1 + core/color.sh | 15 ++ core/json.sh | 11 ++ core/json_helper.py | 202 ++++++++++++++++++++++++++ daemon/endpoint_cache.json | 5 +- modules/block.module.sh | 167 ++++++++++++++++++++++ modules/firewall.module.sh | 150 +++++++++++++------ modules/peers.module.sh | 7 +- modules/rule.module.sh | 42 +++--- wgctl | 39 ++--- 26 files changed, 1466 insertions(+), 487 deletions(-) create mode 100644 commands/shell.command.sh.bak create mode 100644 core/color.sh create mode 100644 modules/block.module.sh diff --git a/commands/audit.command.sh b/commands/audit.command.sh index 32b91ef..ca7c6ac 100644 --- a/commands/audit.command.sh +++ b/commands/audit.command.sh @@ -10,16 +10,23 @@ function cmd::audit::help() { cat < Audit specific peer only - --type Audit peers of specific type only + --peer Audit specific peer only + --type Audit peers of specific device type only + --fix Attempt to auto-repair missing or extra rules + +Output: + ✅ pass — peer has correct rule count + ❌ fail — peer has missing rules (run --fix to repair) + ⚠️ warn — peer has extra rules (e.g. blocked peers with base rules) Examples: wgctl audit wgctl audit --peer phone-nuno + wgctl audit --type guest wgctl audit --fix EOF } @@ -98,10 +105,10 @@ function cmd::audit::check_peer() { local rule_file rule_file="$(ctx::rule::path "${rule}.rule")" local block_ports block_ips allow_ports allow_ips expected - block_ports=$(json::count "$rule_file" "block_ports") - block_ips=$(json::count "$rule_file" "block_ips") - allow_ports=$(json::count "$rule_file" "allow_ports") - allow_ips=$(json::count "$rule_file" "allow_ips") + block_ports=$(json::count_resolved "$rule" "block_ports") + block_ips=$(json::count_resolved "$rule" "block_ips") + allow_ports=$(json::count_resolved "$rule" "allow_ports") + allow_ips=$(json::count_resolved "$rule" "allow_ips") expected=$(( (block_ports + block_ips) * 2 + allow_ports + allow_ips )) # actual is passed in as $3 — no iptables call here diff --git a/commands/block.command.sh b/commands/block.command.sh index e8111e8..c1ca70e 100644 --- a/commands/block.command.sh +++ b/commands/block.command.sh @@ -13,6 +13,7 @@ function cmd::block::on_load() { flag::register --port flag::register --proto flag::register --subnet + flag::register --block-name } # ============================================ @@ -52,6 +53,7 @@ EOF function cmd::block::run() { local name="" local type="" + local block_name="" local ips=() local subnets=() local ports=() @@ -59,13 +61,14 @@ function cmd::block::run() { while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$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 ;; + --name) name="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --ip) ips+=("$2"); shift 2 ;; + --block-name) block_name="$2"; shift 2 ;; + --force) force=true; shift ;; + --quiet) quiet=true; shift ;; + --subnet) subnets+=("$2"); shift 2 ;; + --port) ports+=("$2"); shift 2 ;; --help) cmd::block::help; return ;; *) log::error "Unknown flag: $1" @@ -84,7 +87,7 @@ function cmd::block::run() { name=$(peers::resolve_and_require "$name" "$type") || return 1 # Check if actually blocked - if peers::is_blocked "$name" || [[ -f "$(ctx::block::path "${name}.block")" ]]; then + if peers::is_blocked "$name" || block::has_file "$name"; then log::wg_warning "Client is already blocked: ${name}" return 0 fi @@ -101,33 +104,38 @@ function cmd::block::run() { local client_ip client_ip=$(peers::get_ip "$name") || return 1 - # $quiet || log::section "Blocking client: ${name} (${client_ip})" - # No specific target — block everything - cmd::block::_block_all "$name" "$client_ip" "$quiet" + # Only full block if no specific targets provided + if [[ ${#ips[@]} -eq 0 && ${#ports[@]} -eq 0 && ${#subnets[@]} -eq 0 ]]; then + cmd::block::_block_all "$name" "$client_ip" "$quiet" + return 0 + fi + + # Specific rules — don't full block + block::set_direct "$name" "$client_ip" "false" # ensure not marked as full block # Block specific IPs for ip in "${ips[@]}"; do ip::require_valid "$ip" fw::block_ip "$client_ip" "$ip" - fw::save_block "$name" "$client_ip" "$ip" + block::add_rule "$name" "$client_ip" "ip" "" "$ip" done # Block specific subnets for subnet in "${subnets[@]}"; do ip::require_valid "$subnet" fw::block_subnet "$client_ip" "$subnet" - fw::save_block "$name" "$client_ip" "$subnet" + block::add_rule "$name" "$client_ip" "subnet" "" "$target_ip" done # Block specific ports for entry in "${ports[@]}"; do local target port proto IFS=":" read -r target port proto <<< "$entry" - proto="${proto:-tcp}" ip::require_valid "$target" - fw::block_port "$client_ip" "$target" "$port" "$proto" - fw::save_block "$name" "$client_ip" "$target" "$port" "$proto" + + fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}" + block::add_rule "$name" "$client_ip" "port" "" "$target" "$port" "${proto:-tcp}" done log::debug "Block rules applied for: ${name}" @@ -144,27 +152,15 @@ function cmd::block::_get_endpoint() { } function cmd::block::_block_all() { - local name="${1:-}" - local client_ip="${2:-}" + local name="${1:?name required}" + local client_ip="${2:?client_ip required}" local quiet="${3:-false}" - - [[ -z "$name" ]] && log::error "name required" && return 1 - [[ -z "$client_ip" ]] && log::error "client_ip required" && return 1 - - local public_key endpoint - public_key=$(keys::public "$name") || return 1 - endpoint=$(cmd::block::_get_endpoint "$name" "$public_key") - fw::block_all "$client_ip" "$name" - fw::save_block "$name" "$client_ip" + # Apply fw rules and remove from server + block::apply_full "$name" "$client_ip" - if [[ -n "$endpoint" ]]; then - monitor::unwatch "$client_ip" - monitor::watch "$endpoint" "$name" - fi - - peers::remove_from_server "$name" - peers::reload + # Mark as directly blocked + block::set_direct "$name" "$client_ip" "true" $quiet || log::wg_success "${name} has been blocked." } \ No newline at end of file diff --git a/commands/fw.command.sh b/commands/fw.command.sh index 384a07e..7dd2841 100644 --- a/commands/fw.command.sh +++ b/commands/fw.command.sh @@ -21,26 +21,27 @@ function cmd::fw::help() { cat < Filter by peer name - --rule Filter by rule name (shows all peers with that rule) - --no-nflog Hide NFLOG rules + --peer Filter rules for a specific peer + --rule Filter by rule — shows all peers with that rule + --no-nflog Hide NFLOG logging rules --no-accept Hide ACCEPT rules --no-drop Hide DROP rules Examples: - wgctl fw list + wgctl fw wgctl fw list --peer phone-nuno wgctl fw list --rule guest wgctl fw list --no-nflog + wgctl fw list --peer phone-nuno --no-nflog wgctl fw nat wgctl fw count wgctl fw flush-nat --subnet 10.1.103.0/24 @@ -93,16 +94,8 @@ function cmd::fw::list() { log::section "Firewall Rules (FORWARD) — rule: ${rule}" printf "\n" local found=false - while IFS= read -r peer_name; do - local ip - ip=$(peers::get_ip "$peer_name") - [[ -z "$ip" ]] && continue - printf " \033[0;37m── %s (%s)\033[0m\n" "$peer_name" "$ip" - iptables -L FORWARD -n -v | grep -F "$ip" \ - | cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop" || true - found=true - done < <(peers::with_rule "$rule") $found || log::wg_warning "No peers found with rule: ${rule}" + fw::list_peer_rules "$ip" "$show_nflog" printf "\n" return 0 fi diff --git a/commands/group.command.sh b/commands/group.command.sh index 237eea7..78b2d56 100644 --- a/commands/group.command.sh +++ b/commands/group.command.sh @@ -22,39 +22,47 @@ function cmd::group::help() { cat < [options] -Manage peer groups. +Manage peer groups. Groups are organizational — a peer can belong +to multiple groups. Operations like block/unblock act on all peers in a group. Subcommands: - list, ls List all groups - show Show group details and members + list, ls List all groups with status + show Show group members and their status add, new, create Create a new group - remove, rm, del Remove a group definition + remove, rm, del Remove a group definition (not the peers) rename Rename a group peer add Add a peer to a group peer remove, peer rm Remove a peer from a group - rm-peers Remove all peers from WireGuard server + rm-peers Remove all peers in group from WireGuard block Block all peers in group unblock Unblock all peers in group rule assign Assign a rule to all peers in group - audit Audit all peers in group - logs Show logs for all peers in group + audit Audit firewall rules for all peers in group + logs Show activity logs for all peers in group watch Live monitor for all peers in group Options: --name Group name - --desc Group description + --desc Group description (for add) --peer Peer name - --type Peer device type (for peer resolution) + --type Peer device type (optional) --rule Rule name (for rule assign) - --new-name New name (for rename) + --new-name New group name (for rename) + --limit Max log entries per peer (for logs) --force Skip confirmation prompts Examples: + wgctl group list wgctl group add --name family --desc "Family devices" wgctl group peer add --name family --peer phone-nuno + wgctl group peer remove --name family --peer phone-nuno + wgctl group show --name family wgctl group block --name family + wgctl group unblock --name family wgctl group rule assign --name family --rule user wgctl group audit --name family + wgctl group logs --name family --limit 20 + wgctl group watch --name family EOF } @@ -585,37 +593,28 @@ function cmd::group::block() { return 0 fi - # log::section "Blocking group: ${name}" + local count=0 skipped=0 blocked_names=() + local filtered=() + for p in "${peers_list[@]:-}"; do + [[ -n "$p" ]] && filtered+=("$p") + done + [[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0 - local count=0 blocked_names=() - for peer_name in "${peers_list[@]}"; do - [[ -z "$peer_name" ]] && continue - # Skip if peer no longer exists - if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then - log::wg_warning "Peer '${peer_name}' no longer exists — skipping" - continue + for peer_name in "${filtered[@]}"; do + if cmd::group::_block_peer "$peer_name" "$name"; then + (( count++ )) || true + else + (( skipped++ )) || true fi - - if peers::is_blocked "$peer_name"; then - log::wg_warning "${peer_name} — already blocked" - continue - fi - ( core::set_quiet; load_command block; cmd::block::run --name "$peer_name" --force ) - blocked_names+=("$peer_name") - (( count++ )) || true done - [[ "$count" -eq 0 ]] && return 0 + if [[ "$count" -gt 0 ]]; then + log::wg_block "All peers from ${name} have been blocked (${count} peers)." + fi - # if [[ "$count" -gt 0 ]]; then - # printf "\n" - # for n in "${blocked_names[@]}"; do - # log::wg " Blocked: ${n}" - # done - # fi - - # log::wg_block "Blocked ${count} peers in group '${name}'" - log::wg_block "All peers from ${name} have been blocked (${count} peers)." + if [[ "$skipped" -gt 0 ]]; then + log::wg_warning "${skipped} peers already blocked" + fi } function cmd::group::unblock() { @@ -635,38 +634,114 @@ function cmd::group::unblock() { local peers_list=() mapfile -t peers_list < <(group::peers "$name") - [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0 - # log::section "Unblocking group: ${name}" + local filtered=() + for p in "${peers_list[@]:-}"; do [[ -n "$p" ]] && filtered+=("$p"); done + [[ ${#filtered[@]} -eq 0 ]] && log::wg_warning "Group '${name}' has no peers" && return 0 - local count=0 unblocked_names=() - for peer_name in "${peers_list[@]}"; do - [[ -z "$peer_name" ]] && continue - # Skip if peer no longer exists - if ! peers::require_exists "$peer_name" > /dev/null 2>&1; then - log::wg_warning "Peer '${peer_name}' no longer exists — skipping" - continue + local count=0 skipped=0 + + for peer_name in "${filtered[@]}"; do + if cmd::group::_unblock_peer "$peer_name" "$name"; then + (( count++ )) || true + else + (( skipped++ )) || true fi - - if ! peers::is_blocked "$peer_name"; then - log::wg_warning "${peer_name} — not blocked" - continue - fi - ( core::set_quiet; load_command unblock; cmd::unblock::run --name "$peer_name" --force ) - unblocked_names+=("$peer_name") - (( count++ )) || true done - [[ "$count" -eq 0 ]] && return 0 + if [[ "$count" -gt 0 ]]; then + log::wg_unblock "All peers from ${name} have been unblocked." + fi - # if [[ "$count" -gt 0 ]]; then - # printf "\n" - # for n in "${blocked_names[@]}"; do - # log::wg " Unblocked: ${n}" - # done - # fi + if [[ "$skipped" -gt 0 ]]; then + log::wg_warning "${skipped} peer(s) remain blocked (blocked directly or by other groups)" + fi +} - log::wg_unblock "All peers from ${name} have been unblocked (${count} peers)." +function cmd::group::_block_peer() { + local peer_name="${1:-}" group_name="${2:-}" + if ! group::_peer_exists_check "$peer_name"; then + log::wg_warning "Peer '${peer_name}' no longer exists — skipping" + return 0 + fi + + local client_ip + client_ip=$(peers::get_ip "$peer_name") + + # Check if already blocked by this group + local current_blocked_groups + current_blocked_groups=$(block::get_groups "$peer_name") + + local IFS=',' + for g in $current_blocked_groups; do + if [[ "$g" == "$group_name" ]]; then + log::wg_warning "${peer_name} — already blocked by group '${group_name}'" + return 1 + fi + done + + # Add group to block tracking + block::add_group "$peer_name" "$client_ip" "$group_name" + + # Apply fw rules only if peer is still in WG server (not yet blocked) + if peers::exists_in_server "$peer_name"; then + block::apply_full "$peer_name" "$client_ip" + fi +} + +function cmd::group::_unblock_peer() { + local peer_name="${1:-}" group_name="${2:-}" + + if ! group::_peer_exists_check "$peer_name"; then + log::wg_warning "Peer '${peer_name}' no longer exists — skipping" + return 1 + fi + + # Check if blocked by this group at all + if ! block::has_file "$peer_name"; then + log::wg_warning "${peer_name} — not blocked" + return 1 + fi + + local current_groups + current_groups=$(block::get_groups "$peer_name") + if [[ "$current_groups" != *"$group_name"* ]]; then + log::wg_warning "${peer_name} — not blocked by group '${group_name}'" + return 1 + fi + + local client_ip + client_ip=$(peers::get_ip "$peer_name") + + block::remove_group "$peer_name" "$client_ip" "$group_name" + + if block::is_blocked "$peer_name"; then + local groups + groups=$(block::get_groups "$peer_name") + + local direct + direct=$(block::is_blocked_direct "$peer_name") + + if [[ "$direct" == "true" ]]; then + log::wg_warning "${peer_name} — still blocked directly, skipping" + else + log::wg_warning "${peer_name} — still blocked by group(s): ${groups}, skipping" + fi + + return 1 + fi + + log::debug "_unblock_peer: restoring $peer_name" + block::restore_peer "$peer_name" "$client_ip" + block::remove_file "$peer_name" + + local rule + rule=$(peers::get_meta "$peer_name" "rule") + + [[ -n "$rule" ]] && rule::exists "$rule" && \ + rule::apply "$rule" "$client_ip" "$peer_name" + + log::debug "_unblock_peer: done" } # ============================================ diff --git a/commands/inspect.command.sh b/commands/inspect.command.sh index 17ead60..973990b 100644 --- a/commands/inspect.command.sh +++ b/commands/inspect.command.sh @@ -9,22 +9,24 @@ function cmd::inspect::on_load() { function cmd::inspect::help() { cat < [--type ] +Usage: wgctl inspect --name [options] wgctl inspect -Show detailed information for a single client. +Show detailed information for a WireGuard client including +status, rule with inheritance, groups, firewall rules, and activity. Options: - --name Client name - --type Device type (optional, combines with --name) - --config Show raw client config - --qr Show QR code + --name Client name (e.g. phone-nuno) + --type Device type — combines with --name (e.g. --name nuno --type phone) + --config Also show raw WireGuard client config + --qr Also show QR code Examples: wgctl inspect --name phone-nuno wgctl inspect --name nuno --type phone wgctl inspect --name phone-nuno --config wgctl inspect --name phone-nuno --qr + wgctl inspect guest-zephyr EOF } @@ -70,61 +72,205 @@ function cmd::inspect::_peer_info() { local subtype subtype=$(peers::get_meta "$name" "subtype") + local rule_extends="" + if [[ -n "$rule" ]]; then + local rule_file + rule_file="$(rule::path "$rule" 2>/dev/null)" || true + if [[ -n "$rule_file" ]]; then + local ext=() + mapfile -t ext < <(json::get "$rule_file" "extends" 2>/dev/null || true) + if [[ ${#ext[@]} -gt 0 && -n "${ext[0]:-}" ]]; then + rule_extends=" (↳ ${ext[*]})" + fi + fi + fi + + # Rule formatting + local rule_display="${rule:-—}" + if [[ -n "$rule_file" && ${#ext[@]} -gt 0 && -n "${ext[0]:-}" ]]; then + local extends_str + extends_str=$(printf '%s, ' "${ext[@]}" | sed 's/, $//') + rule_display="${rule} ↳ (${extends_str})" + fi + cmd::inspect::_section "Client" ui::row "Name" "$name" ui::row "IP" "$ip" ui::row "Type" "$(peers::display_type "$type" "$subtype")" - ui::row "Rule" "${rule:-—}" + ui::row "Rule" "$rule_display" ui::row "Status" "$(echo -e "$status")" ui::row "Endpoint" "${endpoint:-—}" ui::row "Last seen" "$last_seen" ui::row "AllowedIPs" "$allowed_ips" ui::row "Public key" "${public_key:-—}" - ui::row "Activity" "Total: $activity_total | Current: $activity_current" + ui::row "Activity (total)" "$activity_total" + ui::row "Activity (current)" "$activity_current" + + return 0 } function cmd::inspect::_rule_info() { - local name="$1" + local name="${1:-}" local rule rule=$(peers::get_meta "$name" "rule") [[ -z "$rule" ]] && return 0 - rule::exists "$rule" || return 0 + cmd::inspect::_section "Rule: ${rule}" + local rule_file - rule_file="$(ctx::rule::path "${rule}.rule")" + rule_file="$(rule::path "$rule")" - ui::section "Rule: ${rule}" + # Check for inheritance + local extends_raw=() + mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) - local desc dns_redirect - desc=$(json::get "$rule_file" "desc") - dns_redirect=$(json::get "$rule_file" "dns_redirect") - ui::row "Description" "${desc:-—}" - ui::row "DNS Redirect" "${dns_redirect:-false}" + if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then + # Show inheritance tree + for base_name in "${extends_raw[@]}"; do + [[ -z "$base_name" ]] && continue + printf "\n \033[0;37m↳ %s\033[0m\n" "$base_name" - local allow_ports allow_ips block_ips block_ports - allow_ports=$(json::get "$rule_file" "allow_ports") - allow_ips=$(json::get "$rule_file" "allow_ips") - block_ips=$(json::get "$rule_file" "block_ips") - block_ports=$(json::get "$rule_file" "block_ports") + local base_allows base_blocks + base_allows=$(rule::get "$base_name" "allow_ports")$'\n'$(rule::get "$base_name" "allow_ips") + base_blocks=$(rule::get "$base_name" "block_ips")$'\n'$(rule::get "$base_name" "block_ports") + + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$base_allows" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$base_blocks" + + local base_dns + base_dns=$(rule::get_own "$base_name" "dns_redirect") + [[ "${base_dns,,}" == "true" ]] && \ + printf " \033[0;36m↺\033[0m DNS → %s\n" "$(config::dns)" + done + + # Own rules + local own_allows own_blocks + own_allows=$(json::get "$rule_file" "allow_ports" 2>/dev/null)$'\n'$(json::get "$rule_file" "allow_ips" 2>/dev/null) + own_blocks=$(json::get "$rule_file" "block_ips" 2>/dev/null)$'\n'$(json::get "$rule_file" "block_ports" 2>/dev/null) + local has_own=false + + while IFS= read -r e; do [[ -n "$e" ]] && has_own=true && break; done <<< "$own_allows$own_blocks" + + if $has_own; then + printf "\n \033[0;37mOwn:\033[0m\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$own_allows" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$own_blocks" + fi - if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then - printf " %-20s\n" "Allows:" - ui::print_list "+" "$allow_ports" - ui::print_list "+" "$allow_ips" else - ui::row "Allows" "—" + # No inheritance — flat view + local allow_ports allow_ips block_ips block_ports + allow_ports=$(rule::get "$rule" "allow_ports") + allow_ips=$(rule::get "$rule" "allow_ips") + block_ips=$(rule::get "$rule" "block_ips") + block_ports=$(rule::get "$rule" "block_ports") + + if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then + printf "\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;32m+\033[0m %s\n" "$e" + done <<< "$allow_ports"$'\n'"$allow_ips" + fi + + if [[ -n "$block_ips" || -n "$block_ports" ]]; then + printf "\n" + while IFS= read -r e; do + [[ -z "$e" ]] && continue + printf " \033[0;31m-\033[0m %s\n" "$e" + done <<< "$block_ips"$'\n'"$block_ports" + fi + + if [[ -z "$allow_ports" && -z "$allow_ips" && \ + -z "$block_ips" && -z "$block_ports" ]]; then + printf "\n full access (no restrictions)\n" + fi + + local dns_redirect + dns_redirect=$(rule::get_own "$rule" "dns_redirect") + [[ "${dns_redirect,,}" == "true" ]] && \ + printf "\n \033[0;36m↺\033[0m DNS → %s\n" "$(config::dns)" fi - if [[ -n "$block_ips" || -n "$block_ports" ]]; then - printf " %-20s\n" "Blocks:" - ui::print_list "-" "$block_ips" - ui::print_list "-" "$block_ports" - else - ui::row "Blocks" "—" - fi + return 0 } +function cmd::inspect::_blocks_info() { + local name="${1:-}" + block::has_file "$name" || return 0 + + cmd::inspect::_section "Peer Blocks" + + local blocked_direct + blocked_direct=$(block::is_blocked_direct "$name") + [[ "$blocked_direct" == "true" ]] && \ + printf " \033[1;31m🚫\033[0m blocked directly\n" + + local blocked_groups + blocked_groups=$(block::get_groups "$name") + [[ -n "$blocked_groups" ]] && \ + printf " \033[1;31m🚫\033[0m blocked by groups: %s\n" "$blocked_groups" + + block::format_rules "$name" + + return 0 +} + +# function cmd::inspect::_rule_info() { +# local name="$1" +# local rule +# rule=$(peers::get_meta "$name" "rule") +# [[ -z "$rule" ]] && return 0 + +# rule::exists "$rule" || return 0 + +# local rule_file +# rule_file="$(ctx::rule::path "${rule}.rule")" + +# ui::section "Rule: ${rule}" + +# local desc dns_redirect +# desc=$(json::get "$rule_file" "desc") +# dns_redirect=$(json::get "$rule_file" "dns_redirect") +# ui::row "Description" "${desc:-—}" +# ui::row "DNS Redirect" "${dns_redirect:-false}" + +# local allow_ports allow_ips block_ips block_ports +# allow_ports=$(json::get "$rule_file" "allow_ports") +# allow_ips=$(json::get "$rule_file" "allow_ips") +# block_ips=$(json::get "$rule_file" "block_ips") +# block_ports=$(json::get "$rule_file" "block_ports") + +# if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then +# printf " %-20s\n" "Allows:" +# ui::print_list "+" "$allow_ports" +# ui::print_list "+" "$allow_ips" +# else +# ui::row "Allows" "—" +# fi + +# if [[ -n "$block_ips" || -n "$block_ports" ]]; then +# printf " %-20s\n" "Blocks:" +# ui::print_list "-" "$block_ips" +# ui::print_list "-" "$block_ports" +# else +# ui::row "Blocks" "—" +# fi +# } + function cmd::inspect::_group_info() { local name="$1" @@ -144,30 +290,40 @@ function cmd::inspect::_group_info() { count=$(json::count "$(group::path "$g")" "peers") printf " %-20s %s peers\n" "$g" "$count" done + + return 0 } function cmd::inspect::_firewall_info() { - local name="$1" show_nflog="${2:-false}" + local name="${1:-}" local ip ip=$(peers::get_ip "$name") - ui::section "Firewall" + local total=0 accepts=0 drops=0 + local rules_output=() + while IFS= read -r line; do + [[ -z "$line" ]] && continue + (( total++ )) || true + [[ "$line" =~ ACCEPT ]] && (( accepts++ )) || true + [[ "$line" =~ DROP ]] && (( drops++ )) || true + rules_output+=("$line") + done < <(fw::forward_rules_for_ip "$ip" | grep -v NFLOG) - local count=0 - while IFS=":" read -r pname pcount; do - [[ "$pname" == "$name" ]] && count="$pcount" && break - done < <(json::audit_fw_counts "$(ctx::clients)") + # printf "\n \033[0;37m── Firewall (\033[0;32m+%d\033[0m \033[0;31m-%d\033[0m) \033[0m%s\n" \ + # "$accepts" "$drops" "$(printf '─%.0s' {1..28})" - ui::row "Active rules" "$count" + printf "\n \033[0;37m── Firewall (%s %s) \033[0m%s\n\n" \ + "$(color::green "+${accepts}")" \ + "$(color::red "-${drops}")" \ + "$(printf '─%.0s' {1..28})" -if [[ "$count" -gt 0 ]]; then - printf "\n" - iptables -L FORWARD -n /dev/null) while IFS= read -r name; do - [[ -f "$(ctx::block::path "${name}.block")" ]] \ - && p_restricted["$name"]=true || p_restricted["$name"]=false + if block::is_blocked "$name" 2>/dev/null; then + p_restricted["$name"]=true + else + p_restricted["$name"]=false + fi local pubkey pubkey=$(keys::public "$name" 2>/dev/null || echo "") if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then @@ -434,6 +432,32 @@ function cmd::list::_precompute_all() { done < <(json::peer_transfer "$(config::interface)") } +function cmd::list::_precompute_block_status() { + local -n _blocked="$1" + local -n _restricted="$2" + + local wg_peers + wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null) + + while IFS= read -r name; do + # Restricted = has block rules but still in server (partial block) + if block::is_blocked "$name" 2>/dev/null; then + _restricted["$name"]=true + else + _restricted["$name"]=false + fi + + # Blocked = removed from WG server + local pubkey + pubkey=$(keys::public "$name" 2>/dev/null || echo "") + if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then + _blocked["$name"]=true + else + _blocked["$name"]=false + fi + done < <(peers::all) +} + function cmd::list::_build_filter_desc() { filter_desc="" [[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} " diff --git a/commands/logs.command.sh b/commands/logs.command.sh index 8fa17c8..cd9c155 100644 --- a/commands/logs.command.sh +++ b/commands/logs.command.sh @@ -14,6 +14,7 @@ function cmd::logs::on_load() { flag::register --all flag::register --before flag::register --force + flag::register --days } function cmd::logs::help() { @@ -25,6 +26,7 @@ Show or manage WireGuard and firewall activity logs. Subcommands: show (default) Show activity logs remove, rm Remove log entries + rotate Remove entries older than N days Options for show: --name Filter by client name @@ -42,6 +44,10 @@ Options for remove: --before Remove entries older than N days --force Skip confirmation +Options for rotate: + --days Days to keep (default: 7) + --force Skip confirmation + Examples: wgctl logs wgctl logs --name phone-nuno @@ -49,8 +55,9 @@ Examples: wgctl logs --follow wgctl logs remove --name phone-nuno wgctl logs remove --all --force - wgctl logs remove --before 7 wgctl logs remove --fw --before 1 + wgctl logs rotate + wgctl logs rotate --days 30 --force EOF } @@ -65,6 +72,7 @@ function cmd::logs::run() { case "$subcmd" in show) cmd::logs::show "$@" ;; remove|rm|del) cmd::logs::remove "$@" ;; + rotate) cmd::logs::rotate "$@" ;; help) cmd::logs::help ;; *) log::error "Unknown subcommand: '${subcmd}'" @@ -221,7 +229,7 @@ function cmd::logs::remove() { log::wg_warning "No log entries found matching the criteria" return 0 fi - + log::wg_success "Removed ${total} log entries (wg: ${removed_wg}, fw: ${removed_fw})" } @@ -277,4 +285,43 @@ function cmd::logs::show_fw_events() { $found || printf " —\n" printf "\n" +} + + + +function cmd::logs::rotate() { + local days=7 force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --days) days="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::logs::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + $force || { + read -r -p "Remove log entries older than ${days} days? [y/N] " confirm + case "$confirm" in + [yY]*) ;; + *) log::info "Aborted"; return 0 ;; + esac + } + + local result + result=$(json::remove_events_filtered \ + "$WG_EVENTS_LOG" "$FW_EVENTS_LOG" \ + "" "" "false" "false" "$days") + + local removed_wg removed_fw + IFS="|" read -r removed_wg removed_fw <<< "$result" + local total=$(( removed_wg + removed_fw )) + + if [[ "$total" -eq 0 ]]; then + log::wg_warning "No log entries older than ${days} days" + return 0 + fi + + log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})" } \ No newline at end of file diff --git a/commands/remove.command.sh b/commands/remove.command.sh index da8fa10..ff5a3e9 100644 --- a/commands/remove.command.sh +++ b/commands/remove.command.sh @@ -96,7 +96,7 @@ function cmd::remove::_cleanup() { group::remove_peer_from_all "$name" || return 1 [[ -n "$client_ip" ]] && $was_blocked && fw::unblock_all "$client_ip" - fw::remove_block_file "$name" 2>/dev/null || true + block::remove_file "$name" 2>/dev/null || true peers::remove_meta "$name" 2>/dev/null || true peers::reload || return 1 } \ No newline at end of file diff --git a/commands/rename.command.sh b/commands/rename.command.sh index 123f77f..a76213d 100644 --- a/commands/rename.command.sh +++ b/commands/rename.command.sh @@ -106,9 +106,7 @@ function cmd::rename::_rename_files() { sed -i "s/^# ${name}$/# ${new_name}/" "$(config::config_file)" - local block_file - block_file="$(ctx::block::path "${name}.block")" - [[ -f "$block_file" ]] && mv "$block_file" "$(ctx::block::path "${new_name}.block")" + block::rename "$name" "$new_name" local old_meta new_meta old_meta=$(peers::meta_path "$name") diff --git a/commands/rule.command.sh b/commands/rule.command.sh index 767a137..a1ef11a 100644 --- a/commands/rule.command.sh +++ b/commands/rule.command.sh @@ -28,6 +28,7 @@ function cmd::rule::on_load() { flag::register --resolved flag::register --force flag::register --type + flag::register --all } # ============================================ @@ -38,67 +39,71 @@ function cmd::rule::help() { cat < [options] -Manage firewall rules for peers. +Manage firewall rules with inheritance support. +Rules can extend base rules to compose reusable access policies. Subcommands: list, ls List all rules - show Show rule details - inspect Show full inheritance tree + show, inspect Show rule details and inheritance add, new, create Create a new rule - update, edit Update a rule and re-apply to all peers + update, edit Update a rule and re-apply to peers remove, rm, del Remove a rule assign Assign a rule to a peer unassign Remove rule from a peer - migrate Apply default rules to all unassigned peers - reapply Re-apply a rule to all assigned peers + reapply Re-apply rule to all assigned peers + migrate Apply default rules to unassigned peers Options for list: - --base Show base rules section - --no-base Hide base rules section (default shows them) - --group Filter by group name + --base Show only base rules + --no-base Hide base rules section + --group Filter by group (case insensitive) --tree Show full inheritance tree inline -Options for add/update: +Options for add: --name Rule name - --desc Human readable description - --group Display group (e.g. vm-rules, user-rules) - --extends Inherit from base rules (add only) - --add-extends Add base rules (update only) - --remove-extends Remove base rules (update only) + --desc Description + --group Display group (e.g. VM Rules, Users) + --extends Inherit from base rules (comma-separated) + --base Create as base rule (not directly assignable) --allow-ip Allow IP or subnet (repeatable) --allow-port Allow specific port (repeatable) --block-ip Block IP or subnet (repeatable) --block-port Block specific port (repeatable) --dns-redirect Force DNS through Pi-hole + +Options for update: + (same as add, plus:) + --add-extends Add inherited base rules + --remove-extends Remove inherited base rules --remove-allow-ip Remove allow IP entry --remove-allow-port Remove allow port entry --remove-block-ip Remove block IP entry --remove-block-port Remove block port entry -Options for inspect: +Options for show/inspect: --name Rule name - --peers Show assigned peers - --resolved Show resolved/merged rule entries + --resolved Show resolved/merged entries + --no-peers Hide assigned peers -Options for assign/unassign: - --name Rule name - --peer Peer name - --type Peer device type (optional) +Options for reapply: + --name Rule name + --all Reapply all rules Examples: wgctl rule list - wgctl rule list --base - wgctl rule list --group vm-rules wgctl rule list --tree + wgctl rule list --group "VM Rules" + wgctl rule list --base wgctl rule show --name guest - wgctl rule inspect --name moonlight-02 - wgctl rule inspect --name moonlight-02 --resolved --peers - wgctl rule add --name dev-01 --desc "Dev VM" --group vm-rules --extends no-lan - wgctl rule update --name dev-01 --add-extends no-nginx - wgctl rule update --name dev-01 --remove-extends no-nginx - wgctl rule update --name dev-01 --group infra-rules + wgctl rule show --name moonlight-02 --resolved + wgctl rule add --name dev-01 --desc "Dev access" --group "Dev" --extends no-lan + wgctl rule add --name no-npm --base --block-ip 10.0.0.101/32 + wgctl rule update --name user --add-extends no-nginx + wgctl rule update --name dev-01 --allow-ip 10.0.0.50/32 wgctl rule assign --name dev-01 --peer laptop-nuno wgctl rule unassign --peer laptop-nuno + wgctl rule reapply --name user + wgctl rule reapply --all EOF } @@ -507,81 +512,6 @@ function cmd::rule::show() { printf "\n" } -# ============================================ -# Inspect -# ============================================ - -function cmd::rule::inspect() { - local name="" show_peers=false show_resolved=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --name) util::require_flag "--name" "${2:-}" || return 1 - name="$2"; shift 2 ;; - --peers) show_peers=true; shift ;; - --resolved) show_resolved=true; shift ;; - --help) cmd::rule::help; return ;; - *) log::error "Unknown flag: $1"; return 1 ;; - esac - done - - [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1 - rule::require_exists "$name" || return 1 - - log::section "Rule Inspect: ${name}" - - local prev_section="" - local show_resolved_flag="$show_resolved" - - while IFS="|" read -r section key value; do - [[ -z "$section" ]] && continue - - # Skip resolved section unless requested - if [[ "$section" == "resolved" ]] && ! $show_resolved_flag; then - continue - fi - - if [[ "$section" != "$prev_section" ]]; then - case "$section" in - own) cmd::rule::_show_section "Own Rules" ;; - dns) cmd::rule::_show_section "DNS" ;; - resolved) cmd::rule::_show_section "Resolved (applied)" ;; - inherited:*) - local base_name="${section#inherited:}" - cmd::rule::_show_section "Inherited: ${base_name}" ;; - esac - prev_section="$section" - fi - - case "$key" in - allow_ip|allow_port) - printf " \033[0;32m+\033[0m %s\n" "$value" ;; - block_ip|block_port) - printf " \033[0;31m-\033[0m %s\n" "$value" ;; - dns_redirect) - printf " Redirect all DNS → %s\n" "$(config::dns)" ;; - esac - - done < <(json::rule_inspect "$(ctx::rules)" "$name") - - if $show_peers; then - cmd::rule::_show_section "Peers" "white" false - local peer_list=() - mapfile -t peer_list < <(peers::with_rule "$name") - ui::row "Assigned" "${#peer_list[@]}" - if [[ ${#peer_list[@]} -gt 0 ]]; then - printf "\n" - for peer_name in "${peer_list[@]}"; do - local ip - ip=$(peers::get_ip "$peer_name") - printf " %-28s %s\n" "$peer_name" "$ip" - done - fi - fi - - printf "\n" -} - # ============================================ # Add # ============================================ @@ -868,14 +798,33 @@ function cmd::rule::migrate() { } function cmd::rule::reapply() { - local name="" + local name="" all=false while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; + --all) all=true; shift ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done - [[ -z "$name" ]] && log::error "Missing --name" && return 1 + + if $all; then + log::section "Reapplying all rules" + local count=0 + while IFS= read -r rule_file; do + local rname + rname=$(basename "$rule_file" .rule) + # Skip if no peers assigned + local peer_list=() + mapfile -t peer_list < <(peers::with_rule "$rname") + [[ ${#peer_list[@]} -eq 0 ]] && continue + rule::reapply_all "$rname" + (( count++ )) || true + done < <(find "$(ctx::rules)" -maxdepth 1 -name "*.rule") + log::wg_success "Reapplied ${count} assignable rules" + return 0 + fi + + [[ -z "$name" ]] && log::error "Missing --name or --all" && return 1 rule::require_exists "$name" || return 1 rule::reapply_all "$name" log::wg_success "Rule '${name}' reapplied" diff --git a/commands/service.command.sh b/commands/service.command.sh index feca8be..3bb1e2b 100644 --- a/commands/service.command.sh +++ b/commands/service.command.sh @@ -59,6 +59,12 @@ function cmd::service::run() { function cmd::service::start() { log::wg_start "Starting WireGuard..." systemctl start "wg-quick@$(config::interface)" + + block::restore_all + rule::restore_all + + cmd::service::_auto_rotate_logs + log::wg_success "WireGuard started" } @@ -72,18 +78,26 @@ function cmd::service::restart() { log::wg_start "Restarting WireGuard..." # Flush firewall rules before restart so restore starts clean - iptables -F FORWARD - iptables -t nat -F PREROUTING + fw::flush_all systemctl restart "wg-quick@$(config::interface)" - fw::restore_blocks + + block::restore_all + rule::restore_all + + cmd::service::_auto_rotate_logs + log::wg_success "WireGuard restarted" } function cmd::service::reload() { log::wg_start "Reloading WireGuard config..." + peers::reload - fw::restore_blocks + block::restore_all + rule::restore_all + + log::wg_success "WireGuard config reloaded" } function cmd::service::status() { @@ -102,6 +116,18 @@ function cmd::service::logs() { journalctl -u "wg-quick@$(config::interface)" -f --no-pager } +function cmd::service::_auto_rotate_logs() { + local max_size=10485760 # 10MB + local fw_size wg_size + fw_size=$(stat -c%s "$(ctx::fw_events_log)" 2>/dev/null || echo 0) + wg_size=$(stat -c%s "$(ctx::events_log)" 2>/dev/null || echo 0) + + if (( fw_size > max_size || wg_size > max_size )); then + log::wg_warning "Log files exceed 10MB, auto-rotating (keeping 7 days)..." + cmd::logs::rotate --days 7 --force + fi +} + function cmd::service::enable() { systemctl enable "wg-quick@$(config::interface)" log::wg_success "WireGuard enabled on boot" diff --git a/commands/shell.command.sh b/commands/shell.command.sh index c2c9433..f87c7e8 100644 --- a/commands/shell.command.sh +++ b/commands/shell.command.sh @@ -14,12 +14,11 @@ function cmd::shell::_prompt() { } function cmd::shell::_is_wgctl_command() { - local cmd="$1" - # Check against known wgctl commands + local cmd="${1:-}" local known=( list add remove rm inspect block unblock rule group audit logs watch fw config qr - rename keys ip service shell help + rename keys ip service shell help test ) local c for c in "${known[@]}"; do @@ -29,9 +28,9 @@ function cmd::shell::_is_wgctl_command() { } function cmd::shell::_handle_builtin() { - local input="$1" + local input="${1:-}" local first="${input%% *}" - + case "$first" in cd) local dir="${input#cd }" @@ -39,36 +38,32 @@ function cmd::shell::_handle_builtin() { cd "$dir" 2>/dev/null || log::error "cd: $dir: No such file or directory" return 0 ;; - cd*) eval "$input" ;; # eval preserves shell state for cd export|unset|source|.) eval "$input" return 0 ;; esac - return 1 # not a builtin + return 1 } function cmd::shell::_execute() { - local input="$1" + local input="${1:-}" local first="${input%% *}" local rest="${input#"$first"}" rest="${rest# }" - # Handle shell builtins first cmd::shell::_handle_builtin "$input" && return 0 - # Try as wgctl command via dispatcher if cmd::shell::_is_wgctl_command "$first"; then if [[ -n "$rest" ]]; then - wgctl::dispatch "$first" $rest || true # never exit REPL on failure + wgctl::dispatch "$first" $rest || true else wgctl::dispatch "$first" || true fi - return 0 # Always 0 to keep REPL running + return 0 fi - # Fall back to bash - bash -c "$input" || true # same for bash commands + bash -c "$input" || true } function cmd::shell::_setup_history() { @@ -85,14 +80,30 @@ function cmd::shell::_save_history() { function cmd::shell::_banner() { ui::section "wgctl shell" printf "\n" - printf " Type wgctl commands directly, or any bash command.\n" - printf " Type \033[1mexit\033[0m or \033[1mquit\033[0m to leave.\n" - printf " Type \033[1mhelp\033[0m for wgctl commands.\n" - printf "\n" + printf " Type wgctl commands directly (no 'wgctl' prefix).\n" + printf " Bash commands work too: ls, cat, systemctl, vim...\n\n" + printf " \033[1;37mCommon commands:\033[0m\n" + printf " list List all peers\n" + printf " list --blocked Show blocked peers\n" + printf " list --rule user Filter by rule\n" + printf " inspect --name Full peer details\n" + printf " block/unblock --name Block or restore a peer\n" + printf " rule list Show firewall rules\n" + printf " rule list --tree Show with inheritance tree\n" + printf " rule show --name Rule details\n" + printf " group list Show groups\n" + printf " group block --name Block all peers in group\n" + printf " logs --follow Live activity log\n" + printf " logs rotate Clean old log entries\n" + printf " watch Live WG + firewall monitor\n" + printf " fw list Show iptables rules\n" + printf " audit Verify firewall state\n" + printf " audit --fix Auto-repair firewall rules\n\n" + printf " \033[1mexit\033[0m or \033[1mquit\033[0m to leave · \033[1mhelp\033[0m for full command list\n\n" } # ============================================ -# Run +# Lifecycle # ============================================ function cmd::shell::on_load() { @@ -103,62 +114,65 @@ function cmd::shell::help() { cat < list + wgctl> list --blocked wgctl> inspect --name phone-nuno - wgctl> ls /etc/wireguard + wgctl> rule list --tree + wgctl> group block --name family + wgctl> logs --follow + wgctl> ls /etc/wireguard/.wgctl/rules/ wgctl> exit EOF } +# ============================================ +# Tab completion +# ============================================ + +function cmd::shell::_setup_completion() { + local commands="list add remove rm inspect block unblock rule group audit logs watch fw config qr rename service shell help test" + + function _wgctl_shell_complete() { + local cur="${COMP_WORDS[COMP_CWORD]}" + COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) + } + + bind 'set show-all-if-ambiguous on' 2>/dev/null || true + bind 'set completion-ignore-case on' 2>/dev/null || true +} + +# ============================================ +# Run +# ============================================ + function cmd::shell::run() { cmd::shell::_banner cmd::shell::_setup_history + cmd::shell::_setup_completion while true; do local input - # Read with readline support (-e) and custom prompt IFS= read -r -e -p "$(cmd::shell::_prompt)" input || break - # Handle empty input [[ -z "${input// }" ]] && continue - # Add to history history -s "$input" - # Handle exit case "${input%% *}" in exit|quit) break ;; esac - # Execute cmd::shell::_execute "$input" done cmd::shell::_save_history printf "\n Goodbye!\n\n" -} - -# ============================================ -# Tab completion (loaded when shell starts) -# ============================================ - -function cmd::shell::_setup_completion() { - local commands="list add remove inspect block unblock rule group audit logs watch fw config qr rename shell help" - - function _wgctl_shell_complete() { - local cur="${COMP_WORDS[COMP_CWORD]}" - COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) - } - - # Bind completion to the read prompt - bind 'set show-all-if-ambiguous on' 2>/dev/null || true - bind 'set completion-ignore-case on' 2>/dev/null || true } \ No newline at end of file diff --git a/commands/shell.command.sh.bak b/commands/shell.command.sh.bak new file mode 100644 index 0000000..c2c9433 --- /dev/null +++ b/commands/shell.command.sh.bak @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +# ============================================ +# Private helpers +# ============================================ + +function cmd::shell::_prompt() { + local user host dir + user=$(whoami) + host=$(hostname -s) + dir=$(basename "$PWD") + printf "\033[1;32m%s@%s\033[0m:\033[0;36m%s\033[0m \033[1;34mwgctl\033[0m> " \ + "$user" "$host" "$dir" +} + +function cmd::shell::_is_wgctl_command() { + local cmd="$1" + # Check against known wgctl commands + local known=( + list add remove rm inspect block unblock + rule group audit logs watch fw config qr + rename keys ip service shell help + ) + local c + for c in "${known[@]}"; do + [[ "$c" == "$cmd" ]] && return 0 + done + return 1 +} + +function cmd::shell::_handle_builtin() { + local input="$1" + local first="${input%% *}" + + case "$first" in + cd) + local dir="${input#cd }" + [[ "$dir" == "$input" ]] && dir="$HOME" + cd "$dir" 2>/dev/null || log::error "cd: $dir: No such file or directory" + return 0 + ;; + cd*) eval "$input" ;; # eval preserves shell state for cd + export|unset|source|.) + eval "$input" + return 0 + ;; + esac + return 1 # not a builtin +} + +function cmd::shell::_execute() { + local input="$1" + local first="${input%% *}" + local rest="${input#"$first"}" + rest="${rest# }" + + # Handle shell builtins first + cmd::shell::_handle_builtin "$input" && return 0 + + # Try as wgctl command via dispatcher + if cmd::shell::_is_wgctl_command "$first"; then + if [[ -n "$rest" ]]; then + wgctl::dispatch "$first" $rest || true # never exit REPL on failure + else + wgctl::dispatch "$first" || true + fi + return 0 # Always 0 to keep REPL running + fi + + # Fall back to bash + bash -c "$input" || true # same for bash commands +} + +function cmd::shell::_setup_history() { + HISTFILE="${HOME}/.wgctl_history" + HISTSIZE=1000 + HISTFILESIZE=2000 + history -r 2>/dev/null || true +} + +function cmd::shell::_save_history() { + history -w 2>/dev/null || true +} + +function cmd::shell::_banner() { + ui::section "wgctl shell" + printf "\n" + printf " Type wgctl commands directly, or any bash command.\n" + printf " Type \033[1mexit\033[0m or \033[1mquit\033[0m to leave.\n" + printf " Type \033[1mhelp\033[0m for wgctl commands.\n" + printf "\n" +} + +# ============================================ +# Run +# ============================================ + +function cmd::shell::on_load() { + : # no flags needed +} + +function cmd::shell::help() { + cat < list + wgctl> inspect --name phone-nuno + wgctl> ls /etc/wireguard + wgctl> exit +EOF +} + +function cmd::shell::run() { + cmd::shell::_banner + cmd::shell::_setup_history + + while true; do + local input + # Read with readline support (-e) and custom prompt + IFS= read -r -e -p "$(cmd::shell::_prompt)" input || break + + # Handle empty input + [[ -z "${input// }" ]] && continue + + # Add to history + history -s "$input" + + # Handle exit + case "${input%% *}" in + exit|quit) break ;; + esac + + # Execute + cmd::shell::_execute "$input" + done + + cmd::shell::_save_history + printf "\n Goodbye!\n\n" +} + +# ============================================ +# Tab completion (loaded when shell starts) +# ============================================ + +function cmd::shell::_setup_completion() { + local commands="list add remove inspect block unblock rule group audit logs watch fw config qr rename shell help" + + function _wgctl_shell_complete() { + local cur="${COMP_WORDS[COMP_CWORD]}" + COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) + } + + # Bind completion to the read prompt + bind 'set show-all-if-ambiguous on' 2>/dev/null || true + bind 'set completion-ignore-case on' 2>/dev/null || true +} \ No newline at end of file diff --git a/commands/test.command.sh b/commands/test.command.sh index 7fda1f4..8d8a236 100644 --- a/commands/test.command.sh +++ b/commands/test.command.sh @@ -166,7 +166,6 @@ function cmd::test::section_rules() { test::section "Rules" cmd::test::run_cmd "rule list" "guest" rule list cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest - cmd::test::run_cmd "rule show --name guest --peers" "Assigned:" rule show --name guest --peers cmd::test::run_cmd "rule show --name user" "Description" rule show --name user cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent @@ -209,45 +208,77 @@ function cmd::test::section_fw() { function cmd::test::section_destructive() { test::section "Destructive (modifying state)" - # Cleanup from any previous failed run - "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true - "$WGCTL_BINARY" group remove --name testgroup --force > /dev/null 2>&1 || true + # ── Cleanup from any previous failed run ── + "$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" group remove --name testgroup --force > /dev/null 2>&1 || true + "$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true - # Add test peer - cmd::test::run_cmd "add phone peer" "added successfully" \ - add --name testunit --type phone - # Block/unblock - cmd::test::run_cmd "block peer" "blocked" \ - block --name phone-testunit - cmd::test::run_cmd "list shows blocked" "blocked" \ - list --blocked - cmd::test::run_cmd "unblock peer" "unblocked" \ - unblock --name phone-testunit + # ── Add test peer ────────────────────────── + cmd::test::run_cmd "add phone peer" "added successfully" \ + add --name testunit --type phone - # Rule assign/unassign + # ── Direct block/unblock ─────────────────── + cmd::test::run_cmd "block peer" "blocked" \ + block --name phone-testunit + cmd::test::run_cmd "list shows blocked" "blocked" \ + list --blocked + cmd::test::run_cmd "unblock peer" "unblocked" \ + unblock --name phone-testunit + + # ── Rule assign/unassign ─────────────────── cmd::test::run_cmd "rule assign" "Assigned" \ rule assign --name user --peer phone-testunit cmd::test::run_cmd "rule unassign" "Unassigned" \ rule unassign --peer phone-testunit - # Re-assign user rule (default) for cleanup - /usr/local/bin/wgctl rule assign --name user --peer phone-testunit \ + "$WGCTL_BINARY" rule assign --name user --peer phone-testunit \ > /dev/null 2>&1 || true - # Group operations - cmd::test::run_cmd "group add" "created" \ - group add --name testgroup --desc "Test group" - cmd::test::run_cmd "group peer add" "Added" \ - group peer add --name testgroup --peer phone-testunit - cmd::test::run_cmd "group block" "have been blocked" \ - group block --name testgroup - cmd::test::run_cmd "group unblock" "have been unblocked" \ - group unblock --name testgroup - cmd::test::run_cmd "group remove" "removed" \ - group remove --name testgroup --force + # ── Group basic operations ───────────────── + cmd::test::run_cmd "group add" "created" \ + group add --name testgroup --desc "Test group" + cmd::test::run_cmd "group peer add" "Added" \ + group peer add --name testgroup --peer phone-testunit + cmd::test::run_cmd "group block" "blocked" \ + group block --name testgroup + cmd::test::run_cmd "group unblock" "unblocked" \ + group unblock --name testgroup - # Remove test peer - cmd::test::run_cmd "remove phone peer" "removed" \ - remove --name phone-testunit --force + # ── M:N group block tracking ─────────────── + # Setup: add testunit to a second group + "$WGCTL_BINARY" group add --name testgroup2 \ + --desc "Test group 2" > /dev/null 2>&1 + "$WGCTL_BINARY" group peer add --name testgroup2 \ + --peer phone-testunit > /dev/null 2>&1 + + # Block from both groups + cmd::test::run_cmd "group block first group" "blocked" \ + group block --name testgroup + cmd::test::run_cmd "group block second group" "blocked" \ + group block --name testgroup2 + + # Unblock from first — should stay blocked (second group still blocking) + "$WGCTL_BINARY" group unblock --name testgroup > /dev/null 2>&1 + cmd::test::run_cmd "peer stays blocked after partial unblock" "blocked" \ + list --blocked + + # Unblock from second — should now be fully unblocked + "$WGCTL_BINARY" group unblock --name testgroup2 > /dev/null 2>&1 + cmd::test::run_cmd "peer unblocked after all groups unblock" "phone-testunit" \ + list --allowed + + # ── Direct block overrides group block ───── + "$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1 + cmd::test::run_cmd "direct unblock overrides group block" "unblocked" \ + unblock --name phone-testunit + + # ── Cleanup groups ───────────────────────── + cmd::test::run_cmd "group remove" "removed" \ + group remove --name testgroup --force + "$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true + + # ── Remove test peer ─────────────────────── + cmd::test::run_cmd "remove phone peer" "removed" \ + remove --name phone-testunit --force } # ============================================ diff --git a/commands/unblock.command.sh b/commands/unblock.command.sh index 1ec47b9..3149b0f 100644 --- a/commands/unblock.command.sh +++ b/commands/unblock.command.sh @@ -84,8 +84,8 @@ function cmd::unblock::run() { name=$(peers::resolve_and_require "$name" "$type") || return 1 - # Check if actually blocked - if ! peers::is_blocked "$name" && [[ ! -f "$(ctx::block::path "${name}.block")" ]]; then + # Check if actually blocked + if ! peers::is_blocked "$name" && ! block::has_file "$name"; then log::wg_warning "Client is not blocked: ${name}" return 0 fi @@ -108,6 +108,7 @@ function cmd::unblock::run() { # Unblock specific IPs for ip in "${ips[@]}"; do fw::unblock_ip "$client_ip" "$ip" + block::remove_rule "$name" "ip" "$ip" done # Unblock specific subnets @@ -124,23 +125,34 @@ function cmd::unblock::run() { done $quiet || log::wg_success "Unblock rules applied for: ${name}" + + return 0 } function cmd::unblock::_unblock_all() { - local name="${1:-}" - local client_ip="${2:-}" - local quiet="${3:-false}" + local name="${1:?}" client_ip="${2:?}" quiet="${3:-false}" - fw::unblock_all "$client_ip" - fw::remove_block_file "$name" - monitor::unwatch_client "$name" + # Direct unblock overrides everything — clear all block state + block::set_direct "$name" "$client_ip" "false" - if ! peers::exists_in_server "$name"; then - local public_key - public_key=$(keys::public "$name") || return 1 - peers::add_to_server "$name" "$public_key" "$client_ip" - peers::reload + # Force full unblock regardless of group blocks + # (direct unblock = admin override) + block::restore_peer "$name" "$client_ip" + block::remove_file "$name" + + local rule + rule=$(peers::get_meta "$name" "rule") + + [[ -n "$rule" ]] && rule::exists "$rule" && \ + rule::apply "$rule" "$client_ip" "$name" + + local groups + groups=$(block::get_groups "$name") + if [[ -n "$groups" ]]; then + log::wg_warning "${name} was blocked by group(s): ${groups} — unblocking anyway" fi $quiet || log::wg_success "${name} has been unblocked." + + return 0 } \ No newline at end of file diff --git a/commands/watch.command.sh b/commands/watch.command.sh index 0df1783..0c54152 100644 --- a/commands/watch.command.sh +++ b/commands/watch.command.sh @@ -21,16 +21,18 @@ function cmd::watch::help() { cat < Filter by device type --name Filter by client name - --peers Filter by comma-separated peer names (used by group watch) - --allowed Show only allowed client handshakes - --restricted Show only restricted client events - --blocked Show only blocked client attempts + --type Filter by device type + --peers Comma-separated peer names (used internally by group watch) + --blocked Show only blocked peer attempts + --allowed Show only handshakes (allowed peers) + --restricted Show only firewall drop events Examples: wgctl watch @@ -38,6 +40,7 @@ Examples: wgctl watch --allowed wgctl watch --type phone wgctl watch --name phone-nuno + wgctl watch --name phone-nuno --type phone EOF } @@ -164,6 +167,9 @@ function cmd::watch::tail_events() { local blocked_only="${3:-false}" restricted_only="${4:-false}" local allowed_only="${5:-false}" filter_peers="${6:-}" + declare -A _WATCH_LAST_ATTEMPT=() + declare -A _WATCH_LAST_FW=() + local peer_set=() [[ -n "$filter_peers" ]] && IFS=',' read -ra peer_set <<< "$filter_peers" @@ -259,6 +265,19 @@ function cmd::watch::tail_events() { [[ -z "$src_ip" ]] && continue + local fw_key="${src_ip}:${dst_ip}:${dst_port}:${proto}" + local now + now=$(date +%s) + local last_fw="${_WATCH_LAST_FW[$fw_key]:-0}" + + local window=30 + [[ "$proto" == "17" || "$proto" == "udp" ]] && window=10 + [[ "$proto" == "1" || "$proto" == "icmp" ]] && window=5 + + local diff=$(( now - last_fw )) + (( diff < window )) && continue + _WATCH_LAST_FW["$fw_key"]="$now" + local client="${ip_to_name[$src_ip]:-$src_ip}" [[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue diff --git a/core.sh b/core.sh index 1f11c13..2a4b78f 100644 --- a/core.sh +++ b/core.sh @@ -13,5 +13,6 @@ source "${WGCTL_DIR}/core/command.sh" source "${WGCTL_DIR}/core/flag.sh" source "${WGCTL_DIR}/core/json.sh" source "${WGCTL_DIR}/core/ui.sh" +source "${WGCTL_DIR}/core/color.sh" source "${WGCTL_DIR}/core/fmt.sh" source "${WGCTL_DIR}/core/test/test.sh" \ No newline at end of file diff --git a/core/color.sh b/core/color.sh new file mode 100644 index 0000000..6e0e7f1 --- /dev/null +++ b/core/color.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Color module to use for display functions (layouts, etc...) +# For loops, avoid them, as they spawn subshells + +# Precompute the variables in loops +# local GREEN="\033[0;32m" RED="\033[0;31m" RESET="\033[0m" +# printf "${GREEN}+${accepts}${RESET} ${RED}-${drops}${RESET}\n" + +function color::green() { printf "\033[0;32m%s\033[0m" "${1:-}"; } +function color::red() { printf "\033[0;31m%s\033[0m" "${1:-}"; } +function color::gray() { printf "\033[0;37m%s\033[0m" "${1:-}"; } +function color::bold() { printf "\033[1;37m%s\033[0m" "${1:-}"; } +function color::cyan() { printf "\033[0;36m%s\033[0m" "${1:-}"; } +function color::yellow() { printf "\033[1;33m%s\033[0m" "${1:-}"; } diff --git a/core/json.sh b/core/json.sh index b18184e..e7dd348 100644 --- a/core/json.sh +++ b/core/json.sh @@ -39,6 +39,17 @@ function json::rule_resolve_field() { python3 "$JSON_HELPER" rule_resolve_fi function json::rule_inspect() { python3 "$JSON_HELPER" rule_inspect "$@" 3 else '', + args[4] if len(args) > 4 else '', + args[5] if len(args) > 5 else '', + args[6] if len(args) > 6 else '' + ), + 'block_remove_rule': lambda args: block_remove_rule( + args[0], args[1], + args[2] if len(args) > 2 else '', + args[3] if len(args) > 3 else '', + args[4] if len(args) > 4 else '' + ), + 'block_get_rules': lambda args: block_get_rules(args[0]), + 'block_get_groups': lambda args: block_get_groups(args[0]), + 'block_get_direct': lambda args: block_get_direct(args[0]), } if __name__ == '__main__': diff --git a/daemon/endpoint_cache.json b/daemon/endpoint_cache.json index 11f8997..154ffbd 100644 --- a/daemon/endpoint_cache.json +++ b/daemon/endpoint_cache.json @@ -1,9 +1,10 @@ { "phone-fred": "94.63.0.129", "phone-helena": "148.69.46.73", - "phone-nuno": "94.63.0.129", + "phone-nuno": "148.69.51.201", "tablet-nuno": "148.69.202.5", "guest-zephyr": "5.13.82.5", "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" } \ No newline at end of file diff --git a/modules/block.module.sh b/modules/block.module.sh new file mode 100644 index 0000000..df4e15a --- /dev/null +++ b/modules/block.module.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash + +# ============================================ +# Block file management +# ============================================ + +# ── Core state queries ───────────────────── + +function block::file() { + local name="${1:?name required}" + ctx::block::path "${name}.block" +} + +function block::has_file() { + local name="${1:?}" + [[ -f "$(block::file "$name")" ]] +} + +function block::is_blocked() { + local name="${1:?}" + block::has_file "$name" || return 1 + local result + result=$(json::block_is_blocked "$(block::file "$name")") + [[ "$result" == "true" ]] +} + +function block::is_blocked_direct() { + local name="${1:?}" + block::has_file "$name" || { echo "false"; return 0; } + json::block_get_direct "$(block::file "$name")" +} + +function block::get_groups() { + local name="${1:?}" + block::has_file "$name" || return 0 + json::block_get_groups "$(block::file "$name")" +} + +function block::get_rules() { + local name="${1:?}" + block::has_file "$name" || return 0 + json::block_get_rules "$(block::file "$name")" +} + +# ── State mutations ──────────────────────── + +function block::set_direct() { + local name="${1:?}" client_ip="${2:?}" value="${3:-true}" + local file + file=$(block::file "$name") + json::block_set_direct "$file" "$client_ip" "$value" +} + +function block::add_group() { + local name="${1:?}" client_ip="${2:?}" group="${3:?}" + local file + file=$(block::file "$name") + json::block_add_group "$file" "$client_ip" "$group" +} + +function block::remove_group() { + local name="${1:?}" client_ip="${2:?}" group="${3:?}" + local file + file=$(block::file "$name") + json::block_remove_group "$file" "$client_ip" "$group" +} + +function block::add_rule() { + local name="${1:?}" client_ip="${2:?}" + local rule_type="${3:?}" rule_name="${4:-}" + local target="${5:-}" port="${6:-}" proto="${7:-}" + local file + file=$(block::file "$name") + json::block_add_rule "$file" "$client_ip" "$rule_type" \ + "$rule_name" "$target" "$port" "$proto" +} + +function block::remove_rule() { + local name="${1:?}" + local rule_type="${2:?}" target="${3:-}" port="${4:-}" proto="${5:-}" + local file + file=$(block::file "$name") + json::block_remove_rule "$file" "$rule_type" "$target" "$port" "$proto" +} + +function block::remove_file() { + local name="${1:?}" + rm -f "$(block::file "$name")" +} + +function block::rename() { + local name="${1:?}" new_name="${2:?}" + local old_file new_file + old_file=$(block::file "$name") + new_file=$(block::file "$new_name") + [[ -f "$old_file" ]] && mv "$old_file" "$new_file" +} + +# ── High level operations ────────────────── + +function block::apply_full() { + local name="${1:?}" client_ip="${2:?}" + + fw::flush_peer "$client_ip" + fw::block_all "$client_ip" "$name" + block::add_rule "$name" "$client_ip" "full" "full block" + + local public_key endpoint + public_key=$(keys::public "$name") || return 1 + endpoint=$(monitor::endpoint_for_key "$public_key") + [[ -n "$endpoint" ]] && monitor::watch "$endpoint" "$name" + peers::remove_from_server "$name" + peers::reload +} + + +function block::restore_peer() { + local name="${1:?}" client_ip="${2:?}" + fw::unblock_all "$client_ip" + fw::flush_peer "$client_ip" + monitor::unwatch_client "$name" + if ! peers::exists_in_server "$name"; then + local public_key + public_key=$(keys::public "$name") || return 1 + peers::add_to_server "$name" "$public_key" "$client_ip" + peers::reload + fi + return 0 +} + +function block::restore_all() { + while IFS= read -r peer_name; do + block::has_file "$peer_name" || continue + local client_ip + client_ip=$(peers::get_ip "$peer_name") + [[ -z "$client_ip" ]] && continue + while IFS="|" read -r bname btype target port proto; do + [[ -z "$btype" ]] && continue + case "$btype" in + full) fw::block_all "$client_ip" "$peer_name" ;; + ip) fw::block_ip "$client_ip" "$target" ;; + port) fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}" ;; + subnet) fw::block_subnet "$client_ip" "$target" ;; + esac + done < <(block::get_rules "$peer_name") + done < <(peers::all) +} + +# ── Display helpers ──────────────────────── + +function block::format_rules() { + local name="${1:?}" + block::has_file "$name" || return 0 + while IFS="|" read -r bname btype target port proto; do + [[ -z "$btype" ]] && continue + local display + case "$btype" in + full) display="all traffic" ;; + ip) display="$target" ;; + port) display="${target}:${port}:${proto}" ;; + subnet) display="$target" ;; + esac + local label="${bname:-$btype}" + printf " \033[0;31m-\033[0m %-30s \033[0;37m%s\033[0m\n" \ + "$display" "$label" + done < <(block::get_rules "$name") +} \ No newline at end of file diff --git a/modules/firewall.module.sh b/modules/firewall.module.sh index 5f8edfa..c0d7842 100644 --- a/modules/firewall.module.sh +++ b/modules/firewall.module.sh @@ -96,6 +96,20 @@ function fw::unblock_subnet() { log::wg_unblock "Unblocked ${client_ip} → subnet ${target_subnet}" } +function fw::allow_subnet() { + local client_ip="${1:-}" target_subnet="${2:-}" + + fw::_forward_exists -s "$client_ip" -d "$target_subnet" -j ACCEPT \ + || iptables -I FORWARD 1 -s "$client_ip" -d "$target_subnet" -j ACCEPT +} + +function fw::unallow_subnet() { + local client_ip="${1:-}" target_subnet="${2:-}" + + fw::_forward_exists -s "$client_ip" -d "$target_subnet" -j ACCEPT \ + && iptables -D FORWARD -s "$client_ip" -d "$target_subnet" -j ACCEPT 2>/dev/null || true +} + function fw::allow_ip() { local client_ip="${1:-}" target_ip="${2:-}" @@ -192,57 +206,46 @@ function fw::remove_dns_redirect() { } # ============================================ -# Persistence — block files +# Peer related # ============================================ -function fw::save_block() { - local name="$1" - local client_ip="$2" - local target="${3:-}" - local port="${4:-}" - local proto="${5:-}" +function fw::list_peer_rules() { + local ip="${1:-}" show_nflog="${2:-false}" - local block_file - block_file="$(ctx::block::path "${name}.block")" - - echo "${client_ip} ${target} ${port} ${proto}" >> "$block_file" - log::debug "Persisted block rule for: ${name}" -} - -function fw::remove_block_file() { - local name="$1" - local block_file - block_file="$(ctx::block::path "${name}.block")" - - rm -f "$block_file" - log::debug "Removed block file for: ${name}" -} - -function fw::restore_blocks() { - local blocks_dir - blocks_dir="$(ctx::blocks)" - - # Restore rules from meta files (new system) - rule::restore_all - - # Restore per-client full-blocks (wgctl block/unblock system) - for block_file in "${blocks_dir}"/*.block; do - [[ -f "$block_file" ]] || continue - local name - name=$(basename "$block_file" .block) - while IFS=" " read -r client_ip target port proto; do - if [[ -z "$target" ]]; then - fw::block_all "$client_ip" "$name" - elif [[ -n "$port" ]]; then - fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}" - else - fw::block_ip "$client_ip" "$target" - fi - done < "$block_file" - log::debug "Restored block rules for: ${name}" + fw::forward_rules_for_ip "$ip" | while IFS= read -r line; do + [[ -z "$line" ]] && continue + ! $show_nflog && [[ "$line" =~ NFLOG ]] && continue + fw::format_rule "$line" done } +function fw::format_rule() { + local line="${1:-}" + [[ -z "$line" ]] && return 0 + + # Parse verbose iptables format: + # pkts bytes target prot opt in out src dst [extra] + local target prot src dst extra + target=$(awk '{print $3}' <<< "$line") + prot=$(awk '{print $4}' <<< "$line") + src=$(awk '{print $8}' <<< "$line") + dst=$(awk '{print $9}' <<< "$line") + extra=$(awk '{for(i=10;i<=NF;i++) printf $i" "}' <<< "$line" | xargs) + + local prot_name + prot_name=$(fw::proto_name "$prot") + + local dst_fmt="$dst" + if [[ "$extra" =~ dpt:([0-9]+) ]]; then + local port="${BASH_REMATCH[1]}" + dst_fmt="${dst}:${port}:${prot_name}" + fi + + local formatted + formatted=$(printf " %-8s %-15s → %s" "$target" "$src" "$dst_fmt") + ui::firewall_rule "$formatted" +} + # ============================================ # Helpers # ============================================ @@ -251,6 +254,63 @@ function fw::_nat_exists() { fw::_rule_exists nat PREROUTING "$@" } +function fw::forward_rules_for_ip() { + local ip="${1:-}" + iptables -L FORWARD -n -v /dev/null || true + iptables -t nat -D PREROUTING -i "$interface" -s "$subnet" \ + -p udp --dport 53 -j DNAT \ + --to-destination "${dns}:53" 2>/dev/null || true + iptables -t nat -D PREROUTING -i "$interface" -s "$subnet" \ + -p tcp --dport 53 -j DNAT \ + --to-destination "${dns}:53" 2>/dev/null || true +} + # ============================================ # private # ============================================ diff --git a/modules/peers.module.sh b/modules/peers.module.sh index c311dba..7672db3 100644 --- a/modules/peers.module.sh +++ b/modules/peers.module.sh @@ -160,9 +160,14 @@ function peers::exists_in_server() { grep -q "^# ${name}$" "$(config::config_file)" } +# function peers::is_blocked() { +# local name="${1:-}" +# peers::exists_in_server "$name" && return 1 || return 0 +# } + function peers::is_blocked() { local name="${1:-}" - peers::exists_in_server "$name" && return 1 || return 0 + block::is_blocked "$name" } # ============================================ diff --git a/modules/rule.module.sh b/modules/rule.module.sh index 65f9ca5..ef6d996 100644 --- a/modules/rule.module.sh +++ b/modules/rule.module.sh @@ -192,7 +192,11 @@ function rule::unapply() { # Remove allow_ips while IFS= read -r allow_ip; do [[ -z "$allow_ip" ]] && continue - fw::unallow_ip "$client_ip" "$allow_ip" + if [[ "$allow_ip" == *"/"* ]]; then + fw::unallow_subnet "$client_ip" "$allow_ip" + else + fw::unallow_ip "$client_ip" "$allow_ip" + fi done < <(rule::get "$rule_name" "allow_ips") # Remove block_ports @@ -207,7 +211,11 @@ function rule::unapply() { # Remove block_ips while IFS= read -r block_ip; do [[ -z "$block_ip" ]] && continue - fw::unblock_ip "$client_ip" "$block_ip" + if [[ "$block_ip" == *"/"* ]]; then + fw::unblock_subnet "$client_ip" "$block_ip" + else + fw::unblock_ip "$client_ip" "$block_ip" + fi done < <(rule::get "$rule_name" "block_ips") # Remove DNS redirect if applicable @@ -247,7 +255,8 @@ function rule::reapply_all() { local client_ip client_ip=$(peers::get_ip "$peer_name") [[ -z "$client_ip" ]] && continue - rule::unapply "$rule_name" "$client_ip" + # FLUSH first to ensure clean ordering + fw::flush_peer "$client_ip" rule::apply "$rule_name" "$client_ip" "$peer_name" (( count++ )) || true done @@ -257,8 +266,12 @@ function rule::reapply_all() { function rule::restore_all() { while IFS= read -r peer_name; do + # Skip blocked peers - no fw rules needed when blocked + block::is_blocked "$peer_name" && continue + local rule_name rule_name=$(peers::get_meta "$peer_name" "rule") + [[ -z "$rule_name" ]] && continue if ! rule::exists "$rule_name"; then @@ -281,27 +294,10 @@ function rule::restore_all() { function rule::apply_dns_redirect() { local client_subnet="${1:-}" - local dns - dns="$(config::dns)" - - iptables -t nat -A PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \ - ! -d "$dns" -j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4 - iptables -t nat -A PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \ - -j DNAT --to-destination "${dns}:53" - iptables -t nat -A PREROUTING -i wg0 -s "$client_subnet" -p tcp --dport 53 \ - -j DNAT --to-destination "${dns}:53" + fw::nat_add_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)" } function rule::remove_dns_redirect() { local client_subnet="${1:-}" - local dns - dns="$(config::dns)" - - iptables -t nat -D PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \ - ! -d "$dns" -j LOG --log-prefix "wgctl-dns-redirect: " \ - --log-level 4 2>/dev/null || true - iptables -t nat -D PREROUTING -i wg0 -s "$client_subnet" -p udp --dport 53 \ - -j DNAT --to-destination "${dns}:53" 2>/dev/null || true - iptables -t nat -D PREROUTING -i wg0 -s "$client_subnet" -p tcp --dport 53 \ - -j DNAT --to-destination "${dns}:53" 2>/dev/null || true -} \ No newline at end of file + fw::nat_remove_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)" +} diff --git a/wgctl b/wgctl index a023ad8..a5ac6d4 100755 --- a/wgctl +++ b/wgctl @@ -17,6 +17,7 @@ load_module peers load_module firewall load_module monitor load_module rule +load_module block load_module group # ============================================ @@ -102,47 +103,53 @@ $(log::section "wgctl — WireGuard Management" 2>/dev/null || printf "\n wgctl Usage: wgctl [options] Client Commands: - add, new Add a new client + add, new Add a new client (--type, --subtype, --rule, --group) remove, rm Remove a client rename, mv Rename a client - list, ls List all clients - inspect Show detailed client info + list, ls List all clients (--type, --rule, --group, --blocked...) + inspect Show detailed client info (--config, --qr) config Show client config qr Show QR code for a client Access Control: - block, ban Block a client entirely + block, ban Block a client entirely or restrict specific IPs/ports unblock, unban Restore client access - rule Manage firewall rules (list, show, add, assign...) + rule Manage firewall rules with inheritance + list, show, inspect, add, update, assign, reapply... Organization: - group Manage peer groups (list, show, block, watch...) + group Manage peer groups + list, show, add, block, unblock, watch, logs... Monitoring: - watch Live monitor of WireGuard activity - logs Show activity and firewall logs - audit Verify firewall rules are correctly applied - fw Inspect firewall rules + watch Live monitor — handshakes, fw drops, blocked attempts + logs Show and manage activity logs + show, remove, rotate, --follow + audit Verify firewall rules match configuration + fw Inspect iptables firewall rules Service: service Manage WireGuard service (start/stop/restart/status) restart Restart WireGuard - shell Start interactive wgctl shell + shell Interactive wgctl shell Development: test Run the wgctl test suite Common examples: - wgctl add --name nuno --type phone - wgctl add --name visitor --type guest --subtype phone --group family + wgctl add --name nuno --type phone --rule admin --group family wgctl list --blocked - wgctl list --group family + wgctl list --rule user --group family wgctl block --name phone-nuno wgctl inspect --name phone-nuno - wgctl rule assign --name admin --peer laptop-nuno + wgctl rule list --tree + wgctl rule show --name guest + wgctl rule add --name dev-01 --group vm-rules --extends no-lan wgctl group block --name family wgctl logs --follow - wgctl audit + wgctl logs rotate --days 7 + wgctl audit --fix + wgctl fw list --rule guest Run 'wgctl --help' for command-specific help. EOF