feat: date format config, batch optimizations, list refactor, fw:: rename, .wgctl data dir

This commit is contained in:
Nuno Duque Nunes 2026-05-11 22:27:33 +00:00
parent 78f9caaf17
commit 0efa6c3a9e
44 changed files with 5338 additions and 6002 deletions

View file

@ -7,6 +7,8 @@
function cmd::add::on_load() { function cmd::add::on_load() {
flag::register --name flag::register --name
flag::register --type flag::register --type
flag::register --subtype
flag::register --rule
flag::register --ip flag::register --ip
flag::register --preset flag::register --preset
flag::register --guest flag::register --guest
@ -114,6 +116,8 @@ function cmd::add::is_mobile() {
function cmd::add::run() { function cmd::add::run() {
local name="" local name=""
local type="" local type=""
local subtype=""
local rule=""
local ip="" local ip=""
local tunnel="" local tunnel=""
local guest=false local guest=false
@ -126,6 +130,8 @@ function cmd::add::run() {
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--subtype) subtype="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;;
--ip) ip="$2"; shift 2 ;; --ip) ip="$2"; shift 2 ;;
--preset) presets+=("$2"); shift 2 ;; --preset) presets+=("$2"); shift 2 ;;
--guest) guest=true; shift ;; --guest) guest=true; shift ;;
@ -146,6 +152,18 @@ function cmd::add::run() {
type="guest" type="guest"
fi fi
# Resolve guest subtype
local effective_type="$type"
if [[ "$type" == "guest" && -n "$subtype" ]]; then
# Validate subtype
local valid_subtypes="desktop laptop phone tablet"
if ! echo "$valid_subtypes" | grep -qw "$subtype"; then
log::error "Invalid subtype: ${subtype} (valid: desktop, laptop, phone, tablet)"
return 1
fi
effective_type="guest-${subtype}"
fi
# Build full client name # Build full client name
local full_name="${type}-${name}" local full_name="${type}-${name}"
@ -157,14 +175,29 @@ function cmd::add::run() {
tunnel=$(config::default_tunnel_for "$type") tunnel=$(config::default_tunnel_for "$type")
fi fi
# Determine rule — explicit flag > type default
if [[ -z "$rule" ]]; then
if config::is_guest_type "$type"; then
rule="guest"
else
rule="user"
fi
fi
# Validate rule if specified
if ! rule::exists "$rule"; then
log::error "Rule not found: ${rule}"
return 1
fi
local allowed_ips local allowed_ips
allowed_ips=$(config::allowed_ips_for "$type" "$tunnel") || return 1 allowed_ips=$(config::allowed_ips_for "$effective_type" "$tunnel") || return 1
log::section "Adding client: ${full_name}" log::section "Adding client: ${full_name}"
# Auto-assign IP if not provided # Auto-assign IP if not provided
if [[ -z "$ip" ]]; then if [[ -z "$ip" ]]; then
ip=$(ip::next_for_type "$type") || return 1 ip=$(ip::next_for_type "$effective_type") || return 1
fi fi
log::wg_add "Name: ${full_name}" log::wg_add "Name: ${full_name}"
@ -176,21 +209,28 @@ function cmd::add::run() {
keys::generate_pair "$full_name" || return 1 keys::generate_pair "$full_name" || return 1
# Create client config # Create client config
peers::create_client_config "$full_name" "$type" "$ip" "$allowed_ips" || return 1 peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1
# Store subtype in meta
if [[ -n "$subtype" ]]; then
peers::set_meta "$full_name" "subtype" "$subtype"
fi
# Add peer to server config # Add peer to server config
local public_key local public_key
public_key=$(keys::public "$full_name") || return 1 public_key=$(keys::public "$full_name") || return 1
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1 peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
log::wg_add "Rule: ${rule:-none}"
# Apply presets # Apply presets
for preset in "${presets[@]}"; do for preset in "${presets[@]}"; do
firewall::apply_preset "$preset" "${ip}" || return 1 fw::apply_preset "$preset" "${ip}" || return 1
done done
# Apply guest rules if guest type # Apply rule
if [[ "$type" == "guest" ]]; then if [[ -n "$rule" ]]; then
firewall::apply_guest_rules rule::apply "$rule" "$ip" || return 1
fi fi
# Reload WireGuard # Reload WireGuard
@ -199,13 +239,12 @@ function cmd::add::run() {
log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]" log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]"
# Show QR for mobile by default, config for desktop/laptop # Show QR for mobile by default, config for desktop/laptop
# --show-config overrides to always show config local display_type="${subtype:-$type}"
if $show_qr; then
if cmd::add::is_mobile "$display_type"; then
keys::qr "$full_name" keys::qr "$full_name"
elif $show_config || ! cmd::add::is_mobile "$type"; then else
log::section "Client Config" log::section "Client Config"
cat "$(ctx::clients)/${full_name}.conf" cat "$(ctx::clients)/${full_name}.conf"
else
keys::qr "$full_name"
fi fi
} }

182
commands/audit.command.sh Normal file
View file

@ -0,0 +1,182 @@
#!/usr/bin/env bash
function cmd::audit::on_load() {
flag::register --fix
flag::register --peer
flag::register --type
}
function cmd::audit::help() {
cat <<EOF
Usage: wgctl audit [options]
Verify WireGuard and firewall state matches expected configuration.
Options:
--fix Attempt to fix detected issues
--peer <name> Audit specific peer only
--type <type> Audit peers of specific type only
Examples:
wgctl audit
wgctl audit --peer phone-nuno
wgctl audit --fix
EOF
}
function cmd::audit::run() {
local fix=false peer="" type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--fix) fix=true; shift ;;
--peer)
if [[ -z "${2:-}" ]]; then
log::error "Flag --peer requires a value"
cmd::audit::help
return 1
fi
peer="$2"
shift 2
;;
--type) type="$2"; shift 2 ;;
--help) cmd::audit::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
test::reset
log::section "WireGuard Audit"
# Precompute iptables counts via single Python call
declare -A peer_fw_counts
while IFS=":" read -r peer_name count; do
[[ -n "$peer_name" ]] && peer_fw_counts["$peer_name"]="$count"
done < <(json::audit_fw_counts "$(ctx::clients)")
test::section "Peer Rules"
while IFS= read -r peer_name; do
[[ -n "$peer" && "$peer_name" != "$peer" ]] && continue
[[ -n "$type" && "$peer_name" != "${type}-"* ]] && continue
cmd::audit::check_peer "$peer_name" "$fix" "${peer_fw_counts[$peer_name]:-0}"
done < <(peers::all)
test::section "WireGuard Server"
cmd::audit::check_wg "$fix" "$peer" "$type"
test::section "Rules"
cmd::audit::check_rules
test::summary
}
function cmd::audit::check_peer() {
local peer_name="$1"
local fix="$2"
local actual="${3:-0}"
local ip rule
ip=$(peers::get_ip "$peer_name")
rule=$(peers::get_meta "$peer_name" "rule")
# Check rule assigned
if [[ -z "$rule" ]]; then
test::warn "$peer_name — no rule assigned (effective: $(peers::default_rule "$peer_name"))"
return
fi
# Check rule exists
if ! rule::exists "$rule"; then
test::fail "$peer_name — assigned rule '$rule' does not exist"
return
fi
# Count expected iptables rules from rule file
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")
expected=$(( (block_ports + block_ips) * 2 + allow_ports + allow_ips ))
# actual is passed in as $3 — no iptables call here
if [[ "$actual" -eq "$expected" ]]; then
test::pass "$(printf "%-28s rule=%-15s fw: %s/%s" "$peer_name" "$rule" "$actual" "$expected")"
elif [[ "$actual" -gt "$expected" ]]; then
test::warn "$(printf "%-28s rule=%-15s fw: %s/%s (extra rules)" "$peer_name" "$rule" "$actual" "$expected")"
if $fix; then
fw::flush_peer "$ip"
rule::apply "$rule" "$ip" "$peer_name"
test::pass " Fixed: $peer_name"
fi
else
test::fail "$(printf "%-28s rule=%-15s fw: %s/%s (missing rules)" "$peer_name" "$rule" "$actual" "$expected")"
if $fix; then
rule::apply "$rule" "$ip" "$peer_name"
test::pass " Fixed: $peer_name"
fi
fi
}
function cmd::audit::check_wg() {
local fix="$1" filter_peer="$2" filter_type="$3"
local wg_peers
wg_peers=$(wg show wg0 peers 2>/dev/null)
while IFS= read -r peer_name; do
[[ -n "$filter_peer" && "$peer_name" != "$filter_peer" ]] && continue
[[ -n "$filter_type" && "$peer_name" != "${filter_type}-"* ]] && continue
local pub_key
pub_key=$(keys::public "$peer_name" 2>/dev/null)
[[ -z "$pub_key" ]] && test::fail "$peer_name — missing public key" && continue
if peers::is_blocked "$peer_name"; then
if echo "$wg_peers" | grep -q "$pub_key"; then
test::fail "$peer_name — blocked but still in wg0"
$fix && peers::remove_from_server "$peer_name" && peers::reload
else
test::pass "$peer_name — correctly blocked (not in wg0)"
fi
else
if echo "$wg_peers" | grep -q "$pub_key"; then
test::pass "$peer_name — present in wg0"
else
test::fail "$peer_name — missing from wg0"
if $fix; then
local ip
ip=$(peers::get_ip "$peer_name")
peers::add_to_server "$peer_name" "$pub_key" "$ip"
peers::reload
fi
fi
fi
done < <(peers::all)
}
function cmd::audit::check_rules() {
local rules_dir
rules_dir="$(ctx::rules)"
# Precompute peer counts
declare -A rule_counts
while IFS= read -r peer_name; do
local r
r=$(peers::get_meta "$peer_name" "rule")
[[ -z "$r" ]] && continue
rule_counts["$r"]=$(( ${rule_counts["$r"]:-0} + 1 ))
done < <(peers::all)
for rule_file in "${rules_dir}"/*.rule; do
[[ -f "$rule_file" ]] || continue
local name
name=$(json::get "$rule_file" "name")
local count=${rule_counts["$name"]:-0}
test::pass "Rule '${name}' — ${count} peers assigned"
done
}

View file

@ -7,6 +7,8 @@
function cmd::block::on_load() { function cmd::block::on_load() {
flag::register --name flag::register --name
flag::register --type flag::register --type
flag::register --force
flag::register --quiet
flag::register --ip flag::register --ip
flag::register --port flag::register --port
flag::register --proto flag::register --proto
@ -66,12 +68,15 @@ function cmd::block::run() {
local ips=() local ips=()
local subnets=() local subnets=()
local ports=() local ports=()
local quiet=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;; --ip) ips+=("$2"); shift 2 ;;
--force) force=true; shift ;;
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;; --subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;; --port) ports+=("$2"); shift 2 ;;
--help) cmd::block::help; return ;; --help) cmd::block::help; return ;;
@ -114,7 +119,7 @@ function cmd::block::run() {
local client_ip local client_ip
client_ip=$(cmd::block::get_client_ip "$name") || return 1 client_ip=$(cmd::block::get_client_ip "$name") || return 1
log::section "Blocking client: ${name} (${client_ip})" # $quiet || log::section "Blocking client: ${name} (${client_ip})"
# No specific target — block everything # No specific target — block everything
if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 ]]; then if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 ]]; then
@ -123,8 +128,8 @@ function cmd::block::run() {
public_key=$(keys::public "$name") || return 1 public_key=$(keys::public "$name") || return 1
endpoint=$(monitor::endpoint_for_key "$public_key") endpoint=$(monitor::endpoint_for_key "$public_key")
firewall::block_all "$client_ip" "$name" fw::block_all "$client_ip" "$name"
firewall::save_block "$name" "$client_ip" fw::save_block "$name" "$client_ip"
# Watch real endpoint IP if available # Watch real endpoint IP if available
if [[ -n "$endpoint" ]]; then if [[ -n "$endpoint" ]]; then
@ -136,22 +141,22 @@ function cmd::block::run() {
peers::remove_from_server "$name" peers::remove_from_server "$name"
peers::reload peers::reload
log::wg_success "Client blocked: ${name}" $quiet || log::wg_success "${name} has been blocked."
return 0 return 0
fi fi
# Block specific IPs # Block specific IPs
for ip in "${ips[@]}"; do for ip in "${ips[@]}"; do
ip::require_valid "$ip" ip::require_valid "$ip"
firewall::block_ip "$client_ip" "$ip" fw::block_ip "$client_ip" "$ip"
firewall::save_block "$name" "$client_ip" "$ip" fw::save_block "$name" "$client_ip" "$ip"
done done
# Block specific subnets # Block specific subnets
for subnet in "${subnets[@]}"; do for subnet in "${subnets[@]}"; do
ip::require_valid "$subnet" ip::require_valid "$subnet"
firewall::block_subnet "$client_ip" "$subnet" fw::block_subnet "$client_ip" "$subnet"
firewall::save_block "$name" "$client_ip" "$subnet" fw::save_block "$name" "$client_ip" "$subnet"
done done
# Block specific ports # Block specific ports
@ -160,9 +165,9 @@ function cmd::block::run() {
IFS=":" read -r target port proto <<< "$entry" IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}" proto="${proto:-tcp}"
ip::require_valid "$target" ip::require_valid "$target"
firewall::block_port "$client_ip" "$target" "$port" "$proto" fw::block_port "$client_ip" "$target" "$port" "$proto"
firewall::save_block "$name" "$client_ip" "$target" "$port" "$proto" fw::save_block "$name" "$client_ip" "$target" "$port" "$proto"
done done
log::wg_success "Block rules applied for: ${name}" log::debug "Block rules applied for: ${name}"
} }

154
commands/fw.command.sh Normal file
View file

@ -0,0 +1,154 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::fw::on_load() {
flag::register --peer
flag::register --type
flag::register --no-nflog
flag::register --no-accept
flag::register --no-drop
}
function cmd::fw::run() {
local subcmd="${1:-list}"
# If first arg is a flag, default to list
if [[ "$subcmd" == --* ]]; then
subcmd="list"
else
shift || true
fi
case "$subcmd" in
list) cmd::fw::list "$@" ;;
nat) cmd::fw::nat "$@" ;;
flush-nat) cmd::fw::flush_nat "$@" ;;
clean) cmd::fw::clean ;;
count) cmd::fw::count ;;
help) cmd::fw::help ;;
*) log::error "Unknown subcommand: $subcmd"; return 1 ;;
esac
}
function cmd::fw::list() {
local peer="" type=""
local show_nflog=true show_accept=true show_drop=true
while [[ $# -gt 0 ]]; do
case "$1" in
--peer) peer="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--no-nflog) show_nflog=false; shift ;;
--no-accept) show_accept=false; shift ;;
--no-drop) show_drop=false; shift ;;
*) shift ;;
esac
done
log::section "Firewall Rules (FORWARD)"
printf "\n"
if [[ -n "$peer" ]]; then
local ip
ip=$(peers::get_ip "$peer")
[[ -z "$ip" ]] && log::error "Peer not found: $peer" && return 1
iptables -L FORWARD -n -v | grep -F "$ip" \
| cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop"
elif [[ -n "$type" ]]; then
local subnet
subnet=$(config::subnet_for "$type")
[[ -z "$subnet" ]] && log::error "Unknown type: $type" && return 1
iptables -L FORWARD -n -v | grep -F "$subnet" \
| cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop"
else
iptables -L FORWARD -n -v \
| grep -v "^Chain\|^target\|^$\|ACCEPT.*0\.0\.0\.0.*0\.0\.0\.0" \
| cmd::fw::_print_filtered "$show_nflog" "$show_accept" "$show_drop"
fi
printf "\n"
}
function cmd::fw::nat() {
log::section "NAT Rules (PREROUTING)"
printf "\n"
iptables -t nat -L PREROUTING -n -v | while IFS= read -r rule; do
[[ -z "$rule" ]] && continue
ui::firewall_rule "$rule"
done
printf "\n"
}
function cmd::fw::flush_nat() {
local type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--type) type="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$type" ]]; then
log::error "Missing required flag: --type"
return 1
fi
local subnet
subnet=$(config::subnet_for "$type")
if [[ -z "$subnet" ]]; then
log::error "Unknown type: $type"
return 1
fi
local nat_linenums=()
while IFS= read -r linenum; do
[[ -n "$linenum" ]] && nat_linenums+=("$linenum")
done < <(iptables -t nat -L PREROUTING -n --line-numbers | grep -F "${subnet}" | awk '{print $1}')
local count=0
for (( i=${#nat_linenums[@]}-1; i>=0; i-- )); do
iptables -t nat -D PREROUTING "${nat_linenums[$i]}" 2>/dev/null || true
(( count++ )) || true
done
log::wg_success "Flushed ${count} NAT rules for type '${type}' (${subnet}.0/24)"
}
function cmd::fw::stats() {
log::section "Firewall Stats"
iptables -L FORWARD -n -v | grep -v "^Chain\|^target\|^$" | \
awk 'NR>1 {printf " %8s pkts %8s bytes %s\n", $1, $2, $0}'
}
function cmd::fw::count() {
log::section "Firewall Rule Counts"
local drop accept nflog total
drop=$(iptables -L FORWARD -n | grep -c "^DROP" || echo 0)
accept=$(iptables -L FORWARD -n | grep -c "^ACCEPT" || echo 0)
nflog=$(iptables -L FORWARD -n | grep -c "^NFLOG" || echo 0)
total=$(( drop + accept + nflog ))
printf "\n %-10s %s\n" "DROP:" "$drop"
printf " %-10s %s\n" "ACCEPT:" "$accept"
printf " %-10s %s\n" "NFLOG:" "$nflog"
printf " %-10s %s\n" "TOTAL:" "$total"
printf "\n"
}
function cmd::fw::clean() {
log::section "Duplicate Rule Report"
iptables-save | sort | uniq -d | grep "^-A FORWARD"
}
function cmd::fw::_print_filtered() {
local show_nflog="$1" show_accept="$2" show_drop="$3"
while IFS= read -r rule; do
[[ -z "$rule" ]] && continue
! $show_nflog && [[ "$rule" =~ NFLOG ]] && continue
! $show_accept && [[ "$rule" =~ ACCEPT ]] && continue
! $show_drop && [[ "$rule" =~ DROP ]] && continue
ui::firewall_rule "$rule"
done
}

761
commands/group.command.sh Normal file
View file

@ -0,0 +1,761 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::group::on_load() {
flag::register --name
flag::register --desc
flag::register --peer
flag::register --type
flag::register --rule
flag::register --new-name
flag::register --force
}
# ============================================
# Help
# ============================================
function cmd::group::help() {
cat <<EOF
Usage: wgctl group <subcommand> [options]
Manage peer groups.
Subcommands:
list, ls List all groups
show Show group details and members
add, new, create Create a new group
remove, rm, del Remove a group definition
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
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
watch Live monitor for all peers in group
Options:
--name <name> Group name
--desc <description> Group description
--peer <peer> Peer name
--type <type> Peer device type (for peer resolution)
--rule <rule> Rule name (for rule assign)
--new-name <name> New name (for rename)
--force Skip confirmation prompts
Examples:
wgctl group add --name family --desc "Family devices"
wgctl group peer add --name family --peer phone-nuno
wgctl group block --name family
wgctl group rule assign --name family --rule user
wgctl group audit --name family
EOF
}
# ============================================
# Run
# ============================================
function cmd::group::run() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
list|ls) cmd::group::list "$@" ;;
show) cmd::group::show "$@" ;;
add|new|create) cmd::group::add "$@" ;;
remove|rm|del|delete) cmd::group::remove "$@" ;;
rename) cmd::group::rename "$@" ;;
peer) cmd::group::peer "$@" ;;
rm-peers) cmd::group::rm_peers "$@" ;;
block) cmd::group::block "$@" ;;
unblock) cmd::group::unblock "$@" ;;
rule) cmd::group::rule "$@" ;;
audit) cmd::group::audit "$@" ;;
logs) cmd::group::logs "$@" ;;
watch) cmd::group::watch "$@" ;;
help) cmd::group::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::group::help
return 1
;;
esac
}
# ============================================
# List
# ============================================
function cmd::group::list() {
local groups_dir
groups_dir="$(ctx::groups)"
local groups=("${groups_dir}"/*.group)
if [[ ! -f "${groups[0]}" ]]; then
log::wg "No groups configured"
return 0
fi
log::section "Groups"
printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS"
printf " %s\n" "$(printf '─%.0s' {1..75})"
while IFS="|" read -r name desc total blocked; do
[[ -z "$name" ]] && continue
local status_color="" status_str="active"
if [[ "$total" -gt 0 ]]; then
if [[ "$blocked" -eq "$total" ]]; then
status_color="\033[1;31m"
status_str="blocked"
elif [[ "$blocked" -gt 0 ]]; then
status_color="\033[1;33m"
status_str="blocked (${blocked}/${total})"
else
status_color="\033[1;32m"
status_str="active"
fi
fi
local short_desc="${desc:0:33}"
[[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..."
printf " %-20s %-35s %-8s %b\n" \
"$name" "${short_desc:-}" "$total" \
"${status_color}${status_str}\033[0m"
done < <(json::group_list_data "$groups_dir" "$(ctx::blocks)")
printf "\n"
}
# function cmd::group::list() {
# local groups_dir
# groups_dir="$(ctx::groups)"
# local groups=("${groups_dir}"/*.group)
# if [[ ! -f "${groups[0]}" ]]; then
# log::wg "No groups configured"
# return 0
# fi
# log::section "Groups"
# printf "\n %-20s %-35s %-8s %s\n" "NAME" "DESCRIPTION" "PEERS" "STATUS"
# printf " %s\n" "$(printf '─%.0s' {1..75})"
# for group_file in "${groups_dir}"/*.group; do
# [[ -f "$group_file" ]] || continue
# local name desc
# name=$(json::get "$group_file" "name")
# desc=$(json::get "$group_file" "desc")
# # Count peers
# local peers_list=()
# mapfile -t peers_list < <(json::get "$group_file" "peers")
# # Filter empty entries
# local filtered=()
# for p in "${peers_list[@]:-}"; do
# [[ -n "$p" ]] && filtered+=("$p")
# done
# peers_list=("${filtered[@]:-}")
# local peer_count=${#peers_list[@]}
# [[ -z "${peers_list[0]}" ]] && peer_count=0
# # Check block status
# local blocked=0 total=0
# for peer_name in "${peers_list[@]}"; do
# [[ -z "$peer_name" ]] && continue
# (( total++ )) || true
# peers::is_blocked "$peer_name" && (( blocked++ )) || true
# done
# local status_color=""
# local status_str="active"
# if [[ "$total" -gt 0 ]]; then
# if [[ "$blocked" -eq "$total" ]]; then
# status_color="\033[1;31m"
# status_str="blocked"
# elif [[ "$blocked" -gt 0 ]]; then
# status_color="\033[1;33m"
# status_str="blocked (${blocked}/${total})"
# # status_color="\033[1;33m"
# # status_str="${blocked}/${total} blocked"
# else
# status_color="\033[1;32m"
# status_str="active"
# fi
# fi
# local short_desc="${desc:0:33}"
# [[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..."
# printf " %-20s %-35s %-8s %b\n" \
# "$name" \
# "${short_desc:-—}" \
# "$peer_count" \
# "${status_color}${status_str}\033[0m"
# done
# printf "\n"
# }
# ============================================
# Show
# ============================================
function cmd::group::show() {
local name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
local group_file
group_file="$(group::path "$name")"
log::section "Group: ${name}"
local desc
desc=$(json::get "$group_file" "desc")
printf "\n %-20s %s\n" "Description:" "${desc:-}"
# Load peers
local peers_list=()
mapfile -t peers_list < <(json::get "$group_file" "peers")
# Filter empty entries
local filtered=()
for p in "${peers_list[@]:-}"; do
[[ -n "$p" ]] && filtered+=("$p")
done
peers_list=("${filtered[@]:-}")
local peer_count=${#peers_list[@]}
[[ -z "${peers_list[0]}" ]] && peer_count=0
printf " %-20s %s\n" "Peers:" "$peer_count"
printf " %s\n" "$(printf '─%.0s' {1..50})"
if [[ "$peer_count" -gt 0 ]]; then
printf "\n %-28s %-15s %-12s %s\n" "NAME" "IP" "RULE" "STATUS"
printf " %s\n" "$(printf '─%.0s' {1..65})"
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
local ip rule status_str status_color
ip=$(peers::get_ip "$peer_name")
rule=$(peers::get_meta "$peer_name" "rule")
rule="${rule:-}"
if peers::is_blocked "$peer_name"; then
status_color="\033[1;31m"
status_str="blocked"
else
status_color="\033[1;32m"
status_str="active"
fi
printf " %-28s %-15s %-12s %b\n" \
"$peer_name" "${ip:-}" "$rule" \
"${status_color}${status_str}\033[0m"
done
else
printf " —\n"
fi
printf "\n"
}
# ============================================
# Add
# ============================================
function cmd::group::add() {
local name="" desc=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--desc) util::require_flag "--desc" "${2:-}" || return 1; desc="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
if group::exists "$name"; then
log::error "Group already exists: ${name}"
return 1
fi
local group_file
group_file="$(group::path "$name")"
python3 -c "
import json
group = {'name': '${name}', 'desc': '${desc}', 'peers': []}
with open('${group_file}', 'w') as f:
json.dump(group, f, indent=2)
" </dev/null
log::wg_success "Group created: ${name}"
}
# ============================================
# Remove
# ============================================
function cmd::group::remove() {
local name="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
if ! $force; then
read -r -p "Remove group '${name}'? This only removes the group definition, not the peers. [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
rm -f "$(group::path "$name")"
log::wg_success "Group removed: ${name}"
}
# ============================================
# Rename
# ============================================
function cmd::group::rename() {
local name="" new_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--new-name) util::require_flag "--new-name" "${2:-}" || return 1; new_name="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
[[ -z "$new_name" ]] && log::error "Missing required flag: --new-name" && return 1
group::require_exists "$name" || return 1
if group::exists "$new_name"; then
log::error "Group already exists: ${new_name}"
return 1
fi
local old_file new_file
old_file="$(group::path "$name")"
new_file="$(group::path "$new_name")"
# Update name field in file
json::set "$old_file" "name" "\"$new_name\""
mv "$old_file" "$new_file"
log::wg_success "Group renamed: ${name}${new_name}"
}
# ============================================
# Peer subcommand
# ============================================
function cmd::group::peer() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
add) cmd::group::peer_add "$@" ;;
remove|rm|del) cmd::group::peer_remove "$@" ;;
*)
log::error "Unknown peer subcommand: '${subcmd}'"
cmd::group::help
return 1
;;
esac
}
function cmd::group::peer_add() {
local name="" peer="" type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--peer) util::require_flag "--peer" "${2:-}" || return 1; peer="$2"; shift 2 ;;
--type) util::require_flag "--type" "${2:-}" || return 1; type="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
[[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1
group::require_exists "$name" || return 1
peer=$(peers::resolve_and_require "$peer" "$type") || return 1
# Check if already in group
if group::peers "$name" | grep -qF "$peer"; then
log::wg_warning "'${peer}' is already in group '${name}'"
return 0
fi
group::add_peer "$name" "$peer"
log::wg_success "Added '${peer}' to group '${name}'"
}
function cmd::group::peer_remove() {
local name="" peer="" type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--peer) util::require_flag "--peer" "${2:-}" || return 1; peer="$2"; shift 2 ;;
--type) util::require_flag "--type" "${2:-}" || return 1; type="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
[[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1
group::require_exists "$name" || return 1
peer=$(peers::resolve_and_require "$peer" "$type") || return 1
group::remove_peer "$name" "$peer"
log::wg_success "Removed '${peer}' from group '${name}'"
}
# ============================================
# Remove peers from WireGuard
# ============================================
function cmd::group::rm_peers() {
local name="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
local peers_list=()
mapfile -t peers_list < <(group::peers "$name")
local peer_count=${#peers_list[@]}
[[ -z "${peers_list[0]}" ]] && peer_count=0
if [[ "$peer_count" -eq 0 ]]; then
log::wg_warning "Group '${name}' has no peers"
return 0
fi
if ! $force; then
read -r -p "Remove all ${peer_count} peers in group '${name}' from WireGuard? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
local count=0
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
cmd::remove::run --name "$peer_name" --force
(( count++ )) || true
done
log::wg_success "Removed ${count} peers from WireGuard (group '${name}' definition kept)"
}
# ============================================
# Block / Unblock
# ============================================
function cmd::group::block() {
local name="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
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 "Blocking group: ${name}"
local count=0 blocked_names=()
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
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
# 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)."
}
function cmd::group::unblock() {
local name="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
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 count=0 unblocked_names=()
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
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
# printf "\n"
# for n in "${blocked_names[@]}"; do
# log::wg " Unblocked: ${n}"
# done
# fi
log::wg_unblock "All peers from ${name} have been unblocked (${count} peers)."
}
# ============================================
# Rule assign
# ============================================
function cmd::group::rule() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
assign) cmd::group::rule_assign "$@" ;;
*)
log::error "Unknown rule subcommand: '${subcmd}'"
cmd::group::help
return 1
;;
esac
}
function cmd::group::rule_assign() {
local name="" rule=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--rule) util::require_flag "--rule" "${2:-}" || return 1; rule="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
[[ -z "$rule" ]] && log::error "Missing required flag: --rule" && return 1
group::require_exists "$name" || return 1
rule::require_exists "$rule" || return 1
local peers_list=()
mapfile -t peers_list < <(group::peers "$name")
[[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
local count=0
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
cmd::rule::assign --name "$rule" --peer "$peer_name"
(( count++ )) || true
done
log::wg_success "Assigned rule '${rule}' to ${count} peers in group '${name}'"
}
# ============================================
# Audit
# ============================================
function cmd::group::audit() {
local name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
local peers_list=()
mapfile -t peers_list < <(group::peers "$name")
[[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
# Run audit filtered to group peers
load_command audit
# Pass first peer as filter — audit handles the rest per-peer
# Actually run full audit and filter output
local peer_args=()
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
peer_args+=("$peer_name")
done
test::reset
log::section "Audit: Group '${name}'"
# Precompute fw counts
declare -A peer_fw_counts
while IFS=":" read -r pname count; do
[[ -n "$pname" ]] && peer_fw_counts["$pname"]="$count"
done < <(json::audit_fw_counts "$(ctx::clients)")
test::section "Peer Rules"
for peer_name in "${peer_args[@]}"; do
cmd::audit::check_peer "$peer_name" false "${peer_fw_counts[$peer_name]:-0}"
done
test::summary
}
# ============================================
# Logs
# ============================================
function cmd::group::logs() {
local name="" limit=50
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--limit) util::require_flag "--limit" "${2:-}" || return 1; limit="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
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 "Logs: Group '${name}'"
for peer_name in "${peers_list[@]}"; do
[[ -z "$peer_name" ]] && continue
printf "\n \033[1;37m── %s ──\033[0m\n" "$peer_name"
load_command logs
cmd::logs::show --name "$peer_name" --limit "$limit"
done
}
# ============================================
# Watch
# ============================================
function cmd::group::watch() {
local name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1; name="$2"; shift 2 ;;
--help) cmd::group::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
[[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
group::require_exists "$name" || return 1
local peers_list=()
mapfile -t peers_list < <(group::peers "$name")
[[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0
# Build comma-separated peer list for watch filter
# Watch already supports --type filter but not multiple peers
# For now, use follow mode filtered to group peers one at a time
# or just run watch with no filter (shows all, user sees group context)
log::section "Live Monitor: Group '${name}'"
printf " Monitoring peers: %s\n\n" "${peers_list[*]}"
load_command logs
# Use follow mode — it shows all peers but user knows context
# Future: add --peers flag to follow for multi-peer filter
cmd::logs::follow "" "" "" false false
}

View file

@ -3,6 +3,8 @@
function cmd::inspect::on_load() { function cmd::inspect::on_load() {
flag::register --name flag::register --name
flag::register --type flag::register --type
flag::register --config
flag::register --qr
} }
function cmd::inspect::help() { function cmd::inspect::help() {
@ -15,18 +17,163 @@ Show detailed information for a single client.
Options: Options:
--name <name> Client name --name <name> Client name
--type <type> Device type (optional, combines with --name) --type <type> Device type (optional, combines with --name)
--config Show raw client config
--qr Show QR code
Examples: Examples:
wgctl inspect --name phone-nuno wgctl inspect --name phone-nuno
wgctl inspect --name nuno --type phone wgctl inspect --name nuno --type phone
wgctl inspect --name phone-nuno --config
wgctl inspect --name phone-nuno --qr
EOF EOF
} }
function cmd::inspect::run() { # ============================================
local name="" # Private helpers
local type="" # ============================================
function cmd::inspect::_section() {
local title="$1"
printf "\n \033[0;37m── %s ──────────────────────────────────\033[0m\n" "$title"
}
function cmd::inspect::_row() {
local label="$1" value="$2"
printf " %-20s %s\n" "${label}:" "$value"
}
function cmd::inspect::_peer_info() {
local name="$1"
local ip type rule status endpoint last_seen tunnel public_key
ip=$(peers::get_ip "$name")
type=$(peers::get_type "$name")
rule=$(peers::get_meta "$name" "rule")
public_key=$(keys::public "$name" 2>/dev/null)
# Status + endpoint + last seen — reuse list helpers
status=$(cmd::list::format_status "$name" "$public_key" "$ip")
last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip")
endpoint=$(monitor::get_cached_endpoint "$name")
# Tunnel mode from AllowedIPs in conf
local allowed_ips
allowed_ips=$(grep "^AllowedIPs" "$(ctx::clients)/${name}.conf" 2>/dev/null | cut -d'=' -f2- | xargs)
cmd::inspect::_section "Client"
cmd::inspect::_row "Name" "$name"
cmd::inspect::_row "IP" "$ip"
cmd::inspect::_row "Type" "$(cmd::list::display_type "$name" "$type")"
cmd::inspect::_row "Rule" "${rule:-}"
cmd::inspect::_row "Status" "$(echo -e "$status")"
cmd::inspect::_row "Endpoint" "${endpoint:-}"
cmd::inspect::_row "Last seen" "$last_seen"
cmd::inspect::_row "AllowedIPs" "$allowed_ips"
cmd::inspect::_row "Public key" "${public_key:-}"
}
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" "— (full access)"
fi
}
function cmd::inspect::_group_info() {
local name="$1"
ui::section "Groups"
local groups=()
mapfile -t groups < <(json::peer_groups "$(ctx::groups)" "$name")
if [[ ${#groups[@]} -eq 0 ]] || [[ -z "${groups[0]:-}" ]]; then
printf " —\n"
return 0
fi
for g in "${groups[@]}"; do
[[ -z "$g" ]] && continue
local count
count=$(json::count "$(group::path "$g")" "peers")
printf " %-20s %s peers\n" "$g" "$count"
done
}
function cmd::inspect::_firewall_info() {
local name="$1" show_nflog="${2:-false}"
local ip
ip=$(peers::get_ip "$name")
ui::section "Firewall"
local count=0
while IFS=":" read -r pname pcount; do
[[ "$pname" == "$name" ]] && count="$pcount" && break
done < <(json::audit_fw_counts "$(ctx::clients)")
ui::row "Active rules" "$count"
if [[ "$count" -gt 0 ]]; then
printf "\n"
iptables -L FORWARD -n </dev/null | grep -F "$ip" | while IFS= read -r rule; do
[[ -z "$rule" ]] && continue
echo "$rule" | grep -q "NFLOG" && continue # skip NFLOG
ui::firewall_rule "$rule"
done
fi
}
function cmd::inspect::_config() {
local name="$1"
cmd::inspect::_section "Config"
printf "\n"
cat "$(ctx::clients)/${name}.conf"
printf "\n"
}
# ============================================
# Run
# ============================================
function cmd::inspect::run() {
local name="" type="" show_config=false show_qr=false
# Support positional argument: wgctl inspect phone-nuno
if [[ $# -gt 0 && "$1" != "--"* ]]; then if [[ $# -gt 0 && "$1" != "--"* ]]; then
name="$1" name="$1"
shift shift
@ -36,6 +183,8 @@ function cmd::inspect::run() {
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--config) show_config=true; shift ;;
--qr) show_qr=true; shift ;;
--help) cmd::inspect::help; return ;; --help) cmd::inspect::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
@ -54,5 +203,24 @@ function cmd::inspect::run() {
name=$(peers::resolve_and_require "$name" "$type") || return 1 name=$(peers::resolve_and_require "$name" "$type") || return 1
load_command list load_command list
cmd::list::run --name "$name"
log::section "Inspect: ${name}"
cmd::inspect::_peer_info "$name"
cmd::inspect::_rule_info "$name"
cmd::inspect::_group_info "$name"
cmd::inspect::_firewall_info "$name"
if $show_config; then
cmd::inspect::_config "$name"
fi
if $show_qr; then
cmd::inspect::_section "QR Code"
printf "\n"
load_command qr
cmd::qr::run --name "$name"
fi
printf "\n"
} }

View file

@ -37,39 +37,61 @@ Options:
Examples: Examples:
wgctl list wgctl list
wgctl ls --type phone wgctl list --type phone
wgctl list --online wgctl list --online
wgctl list --blocked wgctl list --blocked
wgctl list --allowed
wgctl list --restricted
wgctl list --detailed wgctl list --detailed
wgctl list --name phone-nuno wgctl list --name phone-nuno
EOF EOF
} }
# ============================================ # ============================================
# Status Helpers # Precompute helpers
# ============================================ # ============================================
function cmd::list::last_handshake_ts() { function cmd::list::_precompute_wg() {
local public_key="$1" # Returns two associative arrays via nameref
wg show "$(config::interface)" latest-handshakes 2>/dev/null \ local -n _handshakes="$1"
| grep "^${public_key}" \ local -n _endpoints="$2"
| awk '{print $2}'
while IFS=$'\t' read -r pubkey ts; do
[[ -n "$pubkey" ]] && _handshakes["$pubkey"]="$ts"
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
while IFS=$'\t' read -r pubkey endpoint; do
[[ -n "$pubkey" ]] && _endpoints["$pubkey"]="$endpoint"
done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
} }
function cmd::list::last_dropped_ts() { function cmd::list::_precompute_block_status() {
local client_ip="$1" local -n _blocked="$1"
journalctl -k --grep "wgctl-dropped: " 2>/dev/null \ local -n _restricted="$2"
| grep "SRC=${client_ip}" \
| tail -1 \ # Blocked = not in wg server config
| awk '{print $1, $2, $3}' local wg_peers
wg_peers=$(wg show "$(config::interface)" peers 2>/dev/null)
while IFS= read -r name; do
# Check block file
[[ -f "$(ctx::block::path "${name}.block")" ]] && _restricted["$name"]=true || _restricted["$name"]=false
# Check if in server config
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::is_connected() { # ============================================
local public_key="$1" # Status / Display helpers
local ts # ============================================
ts=$(cmd::list::last_handshake_ts "$public_key")
function cmd::list::_is_connected() {
local ts="$1"
[[ -z "$ts" || "$ts" == "0" ]] && return 1 [[ -z "$ts" || "$ts" == "0" ]] && return 1
local now diff local now diff
now=$(date +%s) now=$(date +%s)
@ -77,138 +99,179 @@ function cmd::list::is_connected() {
(( diff < 180 )) (( diff < 180 ))
} }
function cmd::list::is_attempting() { function cmd::list::_is_attempting() {
local name="$1" local last_ts="$1"
local ts [[ -z "$last_ts" ]] && return 1
ts=$(monitor::last_attempt "$name")
[[ -z "$ts" ]] && return 1
local now attempt_ts diff local now attempt_ts diff
now=$(date +%s) now=$(date +%s)
attempt_ts=$(python3 -c " attempt_ts=$(json::iso_to_ts "$last_ts")
from datetime import datetime, timezone
dt = datetime.fromisoformat('${ts}')
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
" 2>/dev/null || echo 0)
diff=$(( now - attempt_ts )) diff=$(( now - attempt_ts ))
(( diff < 180 )) (( diff < 180 ))
} }
function cmd::list::is_blocked() { function cmd::list::_format_last_seen() {
local name="$1" local name="$1" pubkey="$2" is_blocked="$3"
peers::is_blocked "$name" local last_ts="$4" last_evt="$5" handshake_ts="$6"
}
function cmd::list::is_restricted() { if [[ "$is_blocked" == "true" ]]; then
local name="$1" if [[ -n "$last_ts" ]]; then
[[ -f "$(ctx::block::path "${name}.block")" ]]
}
function cmd::list::format_last_seen() {
local name="$1"
local public_key="$2"
local ip="$3"
if cmd::list::is_blocked "$name"; then
local ts
ts=$(monitor::last_attempt "$name")
if [[ -n "$ts" ]]; then
# Format ISO timestamp
local formatted local formatted
formatted=$(python3 -c " formatted=$(fmt::datetime "$last_ts")
from datetime import datetime, timezone
dt = datetime.fromisoformat('${ts}')
print(dt.strftime('%Y-%m-%d %H:%M'))
" 2>/dev/null || echo "$ts")
echo "${formatted} (dropped)" echo "${formatted} (dropped)"
else else
echo "—" echo "—"
fi fi
else else
local ts if [[ -z "$handshake_ts" || "$handshake_ts" == "0" ]]; then
ts=$(cmd::list::last_handshake_ts "$public_key")
if [[ -z "$ts" || "$ts" == "0" ]]; then
echo "—" echo "—"
else else
local formatted local formatted
formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts") formatted=$(fmt::datetime "$handshake_ts")
echo "${formatted} (handshake)" echo "${formatted} (handshake)"
fi fi
fi fi
} }
function cmd::list::format_status() { function cmd::list::_format_status() {
local name="$1" local name="$1" pubkey="$2"
local public_key="$2" local is_blocked="$3" is_restricted="$4"
local ip="$3" # new local handshake_ts="$5" last_ts="$6"
local connected=false local connected=false modifier="" color
local blocked=false
local restricted=false
cmd::list::is_blocked "$name" && blocked=true if [[ "$is_blocked" == "true" ]]; then
cmd::list::is_restricted "$name" && restricted=true cmd::list::_is_attempting "$last_ts" && connected=true
if $blocked; then
cmd::list::is_attempting "$name" && connected=true
modifier=" (blocked)" modifier=" (blocked)"
elif $restricted; then
cmd::list::is_connected "$public_key" && connected=true
modifier=" (restricted)"
else
cmd::list::is_connected "$public_key" && connected=true
modifier=""
fi
local conn_str
$connected && conn_str="online" || conn_str="offline"
local status="${conn_str}${modifier}"
local color
if $blocked; then
color="\033[1;31m" color="\033[1;31m"
elif $restricted; then elif [[ "$is_restricted" == "true" ]]; then
cmd::list::_is_connected "$handshake_ts" && connected=true
modifier=" (restricted)"
color="\033[1;33m" color="\033[1;33m"
elif $connected; then else
cmd::list::_is_connected "$handshake_ts" && connected=true
modifier=""
if $connected; then
color="\033[1;32m" color="\033[1;32m"
else else
color="\033[0;37m" color="\033[0;37m"
fi fi
fi
echo -e "${color}${status}\033[0m" local conn_str
$connected && conn_str="online" || conn_str="offline"
echo -e "${color}${conn_str}${modifier}\033[0m"
}
function cmd::list::_get_type() {
local ip="$1"
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "${subnet}."; then
type="$t"
break
fi
done
echo "$type"
}
function cmd::list::display_type() {
local name="$1" type="$2" subtype="$3"
if config::is_guest_type "$type" && [[ -n "$subtype" ]]; then
echo "guest/${subtype}"
elif config::is_guest_type "$type"; then
echo "guest"
else
echo "$type"
fi
}
function cmd::list::pad_status() {
local status="$1"
local width="${2:-25}"
local visible
visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g')
local pad=$(( width - ${#visible} ))
printf "%b%${pad}s" "$status" ""
} }
# ============================================ # ============================================
# Detail Card # Header / Footer
# ============================================
function cmd::list::_render_header() {
local has_groups="$1"
if $has_groups; then
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function cmd::list::_render_footer() {
local has_groups="$1"
if $has_groups; then
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
}
function cmd::list::_render_summary() {
local group_summary="${1:-}"
local -n _rule_counts="$2"
local total="${3:-}" # filtered total
# total=$(find "$(ctx::clients)" -name "*.conf" | wc -l | tr -d ' ')
# Count total from rule_counts (only filtered peers)
local total=0
for r in "${!_rule_counts[@]}"; do
(( total += _rule_counts[$r] )) || true
done
local summary=""
for r in "${!_rule_counts[@]}"; do
summary+="${_rule_counts[$r]} ${r}, "
done
summary="${summary%, }"
if [[ -n "$group_summary" ]]; then
printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary"
else
printf "\n Showing %s peers [%s]\n\n" "$total" "$summary"
fi
}
# ============================================
# Detail Card (show_client)
# ============================================ # ============================================
function cmd::list::show_client() { function cmd::list::show_client() {
local name="$1" local name="$1"
local dir local conf
dir="$(ctx::clients)" conf="$(ctx::clients)/${name}.conf"
local conf="${dir}/${name}.conf"
if [[ ! -f "$conf" ]]; then if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}" log::error "Client not found: ${name}"
return 1 return 1
fi fi
local ip local ip allowed_ips public_key
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local allowed_ips
allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}') allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}')
local public_key
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
# Get endpoint local type
type=$(cmd::list::_get_type "$ip")
local endpoint="—" local endpoint="—"
if cmd::list::is_blocked "$name"; then if peers::is_blocked "$name"; then
local ep local ep
ep=$(monitor::last_endpoint "$name") ep=$(monitor::last_endpoint "$name")
[[ -n "$ep" ]] && endpoint="$ep" [[ -n "$ep" ]] && endpoint="$ep"
@ -218,29 +281,28 @@ function cmd::list::show_client() {
[[ -n "$ep" ]] && endpoint="$ep" [[ -n "$ep" ]] && endpoint="$ep"
fi fi
# Determine type # Get handshake and last attempt for status/last_seen
local type="unknown" local handshake_ts
for t in $(config::device_types); do handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null \
local subnet | grep "^${public_key}" | awk '{print $2}')
subnet=$(config::subnet_for "$t") handshake_ts="${handshake_ts:-0}"
if string::starts_with "$ip" "$subnet"; then
type="$t"
break
fi
done
local status local last_ts
status=$(cmd::list::format_status "$name" "$public_key" "$ip") last_ts=$(monitor::last_attempt "$name")
local last_seen local is_blocked="false"
last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip") peers::is_blocked "$name" && is_blocked="true"
local status last_seen
status=$(cmd::list::_format_status "$name" "$public_key" \
"$is_blocked" "false" "$handshake_ts" "$last_ts")
last_seen=$(cmd::list::_format_last_seen "$name" "$public_key" \
"$is_blocked" "$last_ts" "" "$handshake_ts")
# Block rules
local block_file local block_file
block_file="$(ctx::block::path "${name}.block")" block_file="$(ctx::block::path "${name}.block")"
local blocks="" local blocks=""
if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
while IFS=" " read -r client_ip target port proto; do while IFS=" " read -r client_ip target port proto; do
if [[ -z "$target" ]]; then if [[ -z "$target" ]]; then
blocks+=" all traffic blocked\n" blocks+=" all traffic blocked\n"
@ -252,47 +314,43 @@ if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
done < "$block_file" done < "$block_file"
fi fi
local sep ui::section "Client: ${name}"
sep="$(printf '─%.0s' {1..50})" ui::row "IP" "$ip"
ui::row "Type" "$type"
echo "" ui::row "Status" "$(echo -e "$status")"
echo " ${sep}" ui::row "Endpoint" "$endpoint"
printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name" ui::row "Last seen" "$last_seen"
echo " ${sep}" ui::row "AllowedIPs" "$allowed_ips"
printf " %-20s %s\n" "IP:" "$ip" ui::row "Public key" "$public_key"
printf " %-20s %s\n" "Type:" "$type"
printf " %-20s %b\n" "Status:" "$status"
printf " %-20s %s\n" "Endpoint:" "$endpoint"
printf " %-20s %s\n" "Last seen:" "$last_seen"
printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips"
printf " %-20s %s\n" "Public key:" "$public_key"
if [[ -z "$blocks" ]]; then if [[ -z "$blocks" ]]; then
printf " %-20s %s\n" "Blocks:" "none" ui::row "Blocks" "none"
elif [[ "$blocks" == *"all traffic blocked"* ]]; then elif [[ "$blocks" == *"all traffic blocked"* ]]; then
printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:" ui::row "Blocks" "$(echo -e "\033[1;31mAll\033[0m")"
else else
printf " %-20s\n" "Blocks:" printf " %-20s\n" "Blocks:"
echo -e "$blocks" echo -e "$blocks"
fi fi
printf "\n"
echo " ${sep}"
echo ""
} }
# Keep old format_status/format_last_seen for backward compat
function cmd::list::format_status() { cmd::list::_format_status "$@"; }
function cmd::list::format_last_seen() { cmd::list::_format_last_seen "$@"; }
function cmd::list::is_blocked() { peers::is_blocked "$1"; }
function cmd::list::is_restricted() { [[ -f "$(ctx::block::path "${1}.block")" ]]; }
function cmd::list::is_connected() { cmd::list::_is_connected "$@"; }
function cmd::list::is_attempting() { cmd::list::_is_attempting "$@"; }
# ============================================ # ============================================
# Run # Run
# ============================================ # ============================================
function cmd::list::run() { function cmd::list::run() {
local filter_type="" local filter_type=""
local online_only=false local online_only=false offline_only=false
local offline_only=false local restricted_only=false blocked_only=false allowed_only=false
local restricted_only=false local detailed=false single_name=""
local blocked_only=false
local allowed_only=false
local detailed=false
local single_name=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@ -313,7 +371,7 @@ function cmd::list::run() {
esac esac
done done
# Single client detail card # Single detail card
if [[ -n "$single_name" ]]; then if [[ -n "$single_name" ]]; then
cmd::list::show_client "$single_name" cmd::list::show_client "$single_name"
return return
@ -322,106 +380,163 @@ function cmd::list::run() {
local dir local dir
dir="$(ctx::clients)" dir="$(ctx::clients)"
local confs=("${dir}"/*.conf) local confs=("${dir}"/*.conf)
if [[ ! -f "${confs[0]}" ]]; then if [[ ! -f "${confs[0]}" ]]; then
log::wg_list "No clients configured" log::wg_list "No clients configured"
return 0 return 0
fi fi
# Detailed mode — cards only, no table # ── Precompute everything ──────────────────
# Peer data (ip, rule, subtype, last_ts, last_evt) — single Python call
declare -A p_ips p_rules p_subtypes p_last_ts p_last_evt
while IFS="|" read -r name ip rule subtype last_ts last_evt; do
[[ -z "$name" ]] && continue
p_ips["$name"]="$ip"
p_rules["$name"]="${rule:-}"
p_subtypes["$name"]="$subtype"
p_last_ts["$name"]="$last_ts"
p_last_evt["$name"]="$last_evt"
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
# WireGuard handshakes + endpoints — two wg show calls
declare -A wg_handshakes wg_endpoints
cmd::list::_precompute_wg wg_handshakes wg_endpoints
# Block/restricted status
declare -A p_blocked p_restricted
cmd::list::_precompute_block_status p_blocked p_restricted
# Public keys — read from key files
declare -A p_pubkeys
for kf in "${dir}"/*_public.key; do
[[ -f "$kf" ]] || continue
local kname
kname=$(basename "$kf" _public.key)
p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
done
# Group map
local has_groups=false
local groups_dir
groups_dir="$(ctx::groups)"
local group_files=("${groups_dir}"/*.group)
[[ -f "${group_files[0]}" ]] && has_groups=true
declare -A peer_group_map
if $has_groups; then
while IFS=":" read -r peer_name group_name; do
[[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
done < <(json::peer_group_map "$groups_dir")
fi
# ── Detailed mode ──────────────────────────
if $detailed; then if $detailed; then
log::section "WireGuard Clients" log::section "WireGuard Clients"
for conf in "${dir}"/*.conf; do cmd::list::_iter_confs "$filter_type" cmd::list::show_client
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
# Apply type filter
if [[ -n "$filter_type" ]]; then
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet"; then
type="$t"
break
fi
done
[[ "$type" != "$filter_type" ]] && continue
fi
cmd::list::show_client "$client_name"
done
return return
fi fi
# Normal table view # ── Table view ─────────────────────────────
log::section "WireGuard Clients"
printf "\n %-28s %-15s %-10s %-22s %s\n" \ log::section "WireGuard Clients"
"NAME" "IP" "TYPE" "STATUS" "LAST SEEN" cmd::list::_render_header $has_groups
printf " %s\n" "$(printf '─%.0s' {1..90})"
declare -A rule_counts
declare -A group_counts
cmd::list::_iter_confs "$filter_type" cmd::list::_render_row
cmd::list::_render_footer $has_groups
# Build summaries
declare -A displayed_rules displayed_groups
local group_summary=""
if $has_groups; then
for g in "${!group_counts[@]}"; do
group_summary+="${group_counts[$g]} in ${g}, "
done
group_summary="${group_summary%, }"
fi
cmd::list::_render_summary "$group_summary" rule_counts
}
function cmd::list::_iter_confs() {
# Usage: cmd::list::_iter_confs <filter_type> <callback>
local filter_type="$1"
local callback="$2"
local dir
dir="$(ctx::clients)"
for conf in "${dir}"/*.conf; do for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue [[ -f "$conf" ]] || continue
local client_name local client_name
client_name=$(basename "$conf" .conf) client_name=$(basename "$conf" .conf)
local ip="${p_ips[$client_name]:-}"
local ip if [[ -z "$ip" ]]; then
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
# Determine type
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet"; then
type="$t"
break
fi fi
local type
type=$(cmd::list::_get_type "$ip")
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
"$callback" "$client_name" "$ip" "$type"
done done
}
# Apply type filter
if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then function cmd::list::_render_row() {
continue local client_name="$1" ip="$2" type="$3"
fi
local pubkey="${p_pubkeys[$client_name]:-}"
local public_key local handshake_ts="${wg_handshakes[$pubkey]:-0}"
public_key=$(keys::public "$client_name" 2>/dev/null || echo "") local is_blocked="${p_blocked[$client_name]:-false}"
local is_restricted="${p_restricted[$client_name]:-false}"
# Apply filters local last_ts="${p_last_ts[$client_name]:-}"
if $online_only && ! cmd::list::is_connected "$public_key"; then
continue # Apply status filters
fi if $online_only; then cmd::list::_is_connected "$handshake_ts" || return 0; fi
if $offline_only; then cmd::list::_is_connected "$handshake_ts" && return 0; fi
if $offline_only && cmd::list::is_connected "$public_key"; then if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
continue if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
fi if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
[[ "$is_restricted" == "true" ]]; }; then return 0; fi
if $restricted_only && ! cmd::list::is_restricted "$client_name"; then
continue # Format display values
fi local status last_seen display_type rule group_display
status=$(cmd::list::_format_status "$client_name" "$pubkey" \
if $blocked_only && ! cmd::list::is_blocked "$client_name"; then "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
continue last_seen=$(cmd::list::_format_last_seen "$client_name" "$pubkey" \
fi "$is_blocked" "$last_ts" "" "$handshake_ts")
display_type=$(cmd::list::display_type "$client_name" "$type" \
if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then "${p_subtypes[$client_name]:-}")
continue rule="${p_rules[$client_name]:-}"
fi
# Update rule counts for summary (outer scope array)
local status rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
status=$(cmd::list::format_status "$client_name" "$public_key" "$ip")
# Pad status
local last_seen local padded_status
last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip") padded_status=$(cmd::list::pad_status "$status" 25)
printf " %-28s %-15s %-10s %-32b %s\n" \ # Render row
"$client_name" "$ip" "$type" "$status" "$last_seen" if $has_groups; then
done group_display="${peer_group_map[$client_name]:-}"
printf "\n" if [[ -n "${peer_group_map[$client_name]:-}" ]]; then
group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true
fi
local rule_col_width=12 group_col_width=12
[[ "$rule" == "—" ]] && rule_col_width=14
[[ "$group_display" == "—" ]] && group_col_width=14
printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$group_display" "$padded_status" "$last_seen"
else
local rule_col_width=12
[[ "$rule" == "—" ]] && rule_col_width=14
printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$padded_status" "$last_seen"
fi
} }

View file

@ -0,0 +1,560 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::list::on_load() {
flag::register --type
flag::register --online
flag::register --offline
flag::register --restricted
flag::register --blocked
flag::register --allowed
flag::register --detailed
flag::register --name
}
# ============================================
# Help
# ============================================
function cmd::list::help() {
cat <<EOF
Usage: wgctl list [options]
List all WireGuard clients.
Options:
--type <type> Filter by device type
--online Show only connected clients
--offline Show only disconnected clients
--allowed Show only fully allowed clients
--restricted Show only restricted clients
--blocked Show only blocked clients
--detailed Show full detail cards for all clients
--name <name> Show detail card for a single client
Examples:
wgctl list
wgctl ls --type phone
wgctl list --online
wgctl list --blocked
wgctl list --allowed
wgctl list --restricted
wgctl list --detailed
wgctl list --name phone-nuno
EOF
}
# ============================================
# Status Helpers
# ============================================
function cmd::list::last_handshake_ts() {
local public_key="$1"
wg show "$(config::interface)" latest-handshakes 2>/dev/null \
| grep "^${public_key}" \
| awk '{print $2}'
}
function cmd::list::last_dropped_ts() {
local client_ip="$1"
journalctl -k --grep "wgctl-dropped: " 2>/dev/null \
| grep "SRC=${client_ip}" \
| tail -1 \
| awk '{print $1, $2, $3}'
}
function cmd::list::is_connected() {
local public_key="$1"
local ts
ts=$(cmd::list::last_handshake_ts "$public_key")
[[ -z "$ts" || "$ts" == "0" ]] && return 1
local now diff
now=$(date +%s)
diff=$(( now - ts ))
(( diff < 180 ))
}
function cmd::list::is_attempting() {
local name="$1"
local ts
ts=$(monitor::last_attempt "$name")
[[ -z "$ts" ]] && return 1
local now attempt_ts diff
now=$(date +%s)
attempt_ts=$(python3 -c "
from datetime import datetime, timezone
dt = datetime.fromisoformat('${ts}')
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
" 2>/dev/null || echo 0)
diff=$(( now - attempt_ts ))
(( diff < 180 ))
}
function cmd::list::is_blocked() {
local name="$1"
peers::is_blocked "$name"
}
function cmd::list::is_restricted() {
local name="$1"
[[ -f "$(ctx::block::path "${name}.block")" ]]
}
function cmd::list::format_last_seen() {
local name="$1"
local public_key="$2"
local ip="$3"
if cmd::list::is_blocked "$name"; then
local ts
ts=$(monitor::last_attempt "$name")
if [[ -n "$ts" ]]; then
# Format ISO timestamp
local formatted
formatted=$(python3 -c "
from datetime import datetime, timezone
dt = datetime.fromisoformat('${ts}')
print(dt.strftime('%Y-%m-%d %H:%M'))
" 2>/dev/null || echo "$ts")
echo "${formatted} (dropped)"
else
echo "—"
fi
else
local ts
ts=$(cmd::list::last_handshake_ts "$public_key")
if [[ -z "$ts" || "$ts" == "0" ]]; then
echo "—"
else
local formatted
formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts")
echo "${formatted} (handshake)"
fi
fi
}
function cmd::list::format_status() {
local name="$1"
local public_key="$2"
local ip="$3" # new
local connected=false
local blocked=false
local restricted=false
cmd::list::is_blocked "$name" && blocked=true
cmd::list::is_restricted "$name" && restricted=true
if $blocked; then
cmd::list::is_attempting "$name" && connected=true
modifier=" (blocked)"
elif $restricted; then
cmd::list::is_connected "$public_key" && connected=true
modifier=" (restricted)"
else
cmd::list::is_connected "$public_key" && connected=true
modifier=""
fi
local conn_str
$connected && conn_str="online" || conn_str="offline"
local status="${conn_str}${modifier}"
local color
if $blocked; then
color="\033[1;31m"
elif $restricted; then
color="\033[1;33m"
elif $connected; then
color="\033[1;32m"
else
color="\033[0;37m"
fi
echo -e "${color}${status}\033[0m"
}
# ============================================
# Display Type
# ============================================
function cmd::list::display_type() {
local name="$1"
local type="$2"
# log::debug "$(config::is_guest_type "$type")"
if config::is_guest_type "$type"; then
local subtype
subtype=$(peers::get_meta "$name" "subtype")
# log::debug "$subtype"
if [[ -n "$subtype" ]]; then
echo "guest/${subtype}"
else
echo "guest"
fi
else
echo "$type"
fi
}
# ============================================
# Detail Card
# ============================================
function cmd::list::show_client() {
local name="$1"
local dir
dir="$(ctx::clients)"
local conf="${dir}/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local allowed_ips
allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}')
local public_key
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
# Get endpoint
local endpoint="—"
if cmd::list::is_blocked "$name"; then
local ep
ep=$(monitor::last_endpoint "$name")
[[ -n "$ep" ]] && endpoint="$ep"
else
local ep
ep=$(monitor::endpoint_for_key "$public_key")
[[ -n "$ep" ]] && endpoint="$ep"
fi
# Determine type
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet."; then
type="$t"
break
fi
done
local status
status=$(cmd::list::format_status "$name" "$public_key" "$ip")
local last_seen
last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip")
# Block rules
local block_file
block_file="$(ctx::block::path "${name}.block")"
local blocks=""
if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
while IFS=" " read -r client_ip target port proto; do
if [[ -z "$target" ]]; then
blocks+=" all traffic blocked\n"
else
local rule=" ${target}"
[[ -n "$port" ]] && rule+=":${port}/${proto}"
blocks+="${rule}\n"
fi
done < "$block_file"
fi
local sep
sep="$(printf '─%.0s' {1..50})"
echo ""
echo " ${sep}"
printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name"
echo " ${sep}"
printf " %-20s %s\n" "IP:" "$ip"
printf " %-20s %s\n" "Type:" "$type"
printf " %-20s %b\n" "Status:" "$status"
printf " %-20s %s\n" "Endpoint:" "$endpoint"
printf " %-20s %s\n" "Last seen:" "$last_seen"
printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips"
printf " %-20s %s\n" "Public key:" "$public_key"
if [[ -z "$blocks" ]]; then
printf " %-20s %s\n" "Blocks:" "none"
elif [[ "$blocks" == *"all traffic blocked"* ]]; then
printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:"
else
printf " %-20s\n" "Blocks:"
echo -e "$blocks"
fi
echo " ${sep}"
echo ""
}
# ============================================
# Run
# ============================================
function cmd::list::run() {
local filter_type=""
local online_only=false
local offline_only=false
local restricted_only=false
local blocked_only=false
local allowed_only=false
local detailed=false
local single_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--type) filter_type="$2"; shift 2 ;;
--online) online_only=true; shift ;;
--offline) offline_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
--blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;;
--detailed) detailed=true; shift ;;
--name) single_name="$2"; shift 2 ;;
--help) cmd::list::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::list::help
return 1
;;
esac
done
# Single client detail card
if [[ -n "$single_name" ]]; then
cmd::list::show_client "$single_name"
return
fi
local dir
dir="$(ctx::clients)"
local confs=("${dir}"/*.conf)
if [[ ! -f "${confs[0]}" ]]; then
log::wg_list "No clients configured"
return 0
fi
# START - GROUP SECTION
# Check if any groups exist
local has_groups=false
local groups_dir
groups_dir="$(ctx::groups)"
local group_files=("${groups_dir}"/*.group)
[[ -f "${group_files[0]}" ]] && has_groups=true
# Precompute peer->group map
declare -A peer_group_map
if $has_groups; then
while IFS=":" read -r peer_name group_name; do
[[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
done < <(json::peer_group_map "$groups_dir")
fi
# END - GROUP SECTION
# Detailed mode — cards only, no table
if $detailed; then
log::section "WireGuard Clients"
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
# Apply type filter
if [[ -n "$filter_type" ]]; then
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet."; then
type="$t"
break
fi
done
[[ "$type" != "$filter_type" ]] && continue
fi
cmd::list::show_client "$client_name"
done
return
fi
# Normal table view
log::section "WireGuard Clients"
if $has_groups; then
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
# Determine type
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet."; then
type="$t"
break
fi
done
# Apply type filter
if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then
continue
fi
local public_key
public_key=$(keys::public "$client_name" 2>/dev/null || echo "")
# Apply filters
if $online_only && ! cmd::list::is_connected "$public_key"; then
continue
fi
if $offline_only && cmd::list::is_connected "$public_key"; then
continue
fi
if $restricted_only && ! cmd::list::is_restricted "$client_name"; then
continue
fi
if $blocked_only && ! cmd::list::is_blocked "$client_name"; then
continue
fi
if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then
continue
fi
local status
status=$(cmd::list::format_status "$client_name" "$public_key" "$ip")
local last_seen
last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip")
local display_type
display_type=$(cmd::list::display_type "$client_name" "$type")
# log::debug "display_type called with name=$client_name type=$type"
local rule
rule=$(peers::effective_rule "$client_name")
rule="${rule:-}"
local padded_status
padded_status=$(cmd::list::pad_status "$status" 25)
local group_display="—"
if $has_groups; then
group_display="${peer_group_map[$client_name]:-}"
fi
local rule_col_width=12
[[ "$rule" == "—" ]] && rule_col_width=14
local group_col_width=12
[[ "$group_display" == "—" ]] && group_col_width=14
if $has_groups; then
printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$group_display" "$padded_status" "$last_seen"
else
printf " %-28s %-15s %-13s %-12s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$padded_status" "$last_seen"
fi
done
if $has_groups; then
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
local group_summary=""
if $has_groups; then
declare -A group_counts
for peer in "${!peer_group_map[@]}"; do
local g="${peer_group_map[$peer]}"
group_counts["$g"]=$(( ${group_counts["$g"]:-0} + 1 )) || true
done
for g in "${!group_counts[@]}"; do
group_summary+="${group_counts[$g]} in ${g}, "
done
group_summary="${group_summary%, }"
fi
cmd::list::_render_summary "$group_summary"
printf "\n"
}
function cmd::list::_render_summary() {
local group_summary="${1:-}"
# Summary line
local total online_count
total=$(peers::all | wc -l)
# Count by rule
declare -A rule_summary
while IFS= read -r peer_name; do
local r
r=$(peers::effective_rule "$peer_name")
rule_summary["$r"]=$(( ${rule_summary["$r"]:-0} + 1 ))
done < <(peers::all)
local summary=""
for r in "${!rule_summary[@]}"; do
summary+="${rule_summary[$r]} ${r}, "
done
summary="${summary%, }" # remove trailing comma
if [[ -n "$group_summary" ]]; then
printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary"
else
printf "\n Showing %s peers [%s]\n\n" "$total" "$summary"
fi
}
# Strip ANSI codes to measure visible length, then pad manually
function cmd::list::pad_status() {
local status="$1"
local width="${2:-20}"
local visible
visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g')
local pad=$(( width - ${#visible} ))
printf "%b%${pad}s" "$status" ""
}

252
commands/logs.command.sh Normal file
View file

@ -0,0 +1,252 @@
#!/usr/bin/env bash
FW_EVENTS_LOG="$(ctx::fw_events_log)"
WG_EVENTS_LOG="$(ctx::events_log)"
function cmd::logs::on_load() {
flag::register --name
flag::register --type
flag::register --since
flag::register --limit
flag::register --fw
flag::register --wg
flag::register --follow
}
function cmd::logs::help() {
cat <<EOF
Usage: wgctl logs [options]
Show WireGuard and firewall activity logs.
Options:
--name <name> Filter by client name
--type <type> Filter by device type
--since <time> Time filter (e.g. 1h, 24h, 7d)
--limit <n> Max results per source (default 50)
--fw Show only firewall drops
--wg Show only WireGuard events
Examples:
wgctl logs
wgctl logs --name guest-test
wgctl logs --type guest
wgctl logs --since 1h
wgctl logs --fw --limit 100
EOF
}
function cmd::logs::run() {
local subcmd="${1:-show}"
# Check if first arg is a flag
if [[ "$subcmd" == --* ]]; then
subcmd="show"
else
shift || true
fi
case "$subcmd" in
show) cmd::logs::show "$@" ;;
remove|rm|del) cmd::logs::remove "$@" ;;
help) cmd::logs::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::logs::help
return 1
;;
esac
}
function cmd::logs::show() {
local name="" type="" since="" limit=50
local fw_only=false wg_only=false follow=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--fw) fw_only=true; shift ;;
--wg) wg_only=true; shift ;;
--follow|-f) follow=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -n "$name" && -n "$type" ]]; then
name=$(peers::resolve_and_require "$name" "$type") || return 1
fi
local filter_ip=""
if [[ -n "$name" ]]; then
filter_ip=$(peers::get_ip "$name")
[[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1
fi
if $follow; then
cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only"
return
fi
log::section "WireGuard Activity Log"
printf "\n"
$wg_only || cmd::logs::show_fw_events "$filter_ip" "$name" "$type" "$limit"
$fw_only || cmd::logs::show_wg_events "$filter_ip" "$name" "$type" "$limit"
}
function cmd::logs::follow() {
local filter_ip="$1" filter_name="$2" filter_type="$3"
local fw_only="$4" wg_only="$5"
local clients_dir
clients_dir="$(ctx::clients)"
local wg_log="$WG_EVENTS_LOG"
local fw_log="$FW_EVENTS_LOG"
$fw_only && wg_log=""
$wg_only && fw_log=""
log::section "WireGuard Live Log (Ctrl+C to stop)"
printf "\n %-20s %-8s %-20s %-25s %s\n" \
"TIME" "SOURCE" "CLIENT" "DESTINATION/ENDPOINT" "EVENT"
printf " %s\n" "$(printf '─%.0s' {1..90})"
while IFS="|" read -r source ts client dst_or_endpoint event; do
if [[ "$source" == "fw" ]]; then
local colored_event
case "$event" in
tcp) colored_event="\033[1;33mtcp\033[0m" ;;
udp) colored_event="\033[0;36mudp\033[0m" ;;
icmp) colored_event="\033[0;37micmp\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "firewall" "$client" "$dst_or_endpoint" "$colored_event"
else
local colored_event
case "$event" in
attempt) colored_event="\033[1;31mattempt\033[0m" ;;
handshake) colored_event="\033[1;32mhandshake\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-8s %-20s %-25s %b\n" \
"$ts" "wireguard" "$client" "$dst_or_endpoint" "$colored_event"
fi
done < <(json::follow_logs "$fw_log" "$wg_log" "$filter_ip" "$filter_type" "$clients_dir")
}
function cmd::logs::remove() {
local name="" type="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::logs::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
local client_ip
client_ip=$(peers::get_ip "$name")
local before_wg before_fw after_wg after_fw
before_wg=$(wc -l < "$WG_EVENTS_LOG" 2>/dev/null || echo 0)
before_fw=$(wc -l < "$FW_EVENTS_LOG" 2>/dev/null || echo 0)
json::remove_events "$WG_EVENTS_LOG" "$name"
json::remove_events "$FW_EVENTS_LOG" "$client_ip"
after_wg=$(wc -l < "$WG_EVENTS_LOG" 2>/dev/null || echo 0)
after_fw=$(wc -l < "$FW_EVENTS_LOG" 2>/dev/null || echo 0)
local removed=$(( (before_wg - after_wg) + (before_fw - after_fw) ))
if [[ "$removed" -eq 0 ]]; then
log::wg_warning "No log entries found for: ${name}"
return 0
fi
if ! $force; then
read -r -p "Remove all log entries for '${name}'? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*) log::info "Aborted"; return 0 ;;
esac
fi
log::wg_success "Removed ${removed} log entries for: ${name}"
json::remove_events "$WG_EVENTS_LOG" "$name"
json::remove_events "$FW_EVENTS_LOG" "$client_ip"
log::wg_success "Removed log entries for: ${name}"
}
function cmd::logs::show_wg_events() {
local filter_ip="$1" filter_name="$2" filter_type="$3" limit="$4"
[[ ! -f "$WG_EVENTS_LOG" ]] && return 0
printf " WireGuard Events:\n"
printf " %-20s %-20s %-18s %s\n" "TIME" "CLIENT" "ENDPOINT" "EVENT"
printf " %s\n" "$(printf '─%.0s' {1..75})"
local found=false
while IFS="|" read -r ts client endpoint event; do
[[ -z "$ts" ]] && continue
local colored_event
case "$event" in
attempt) colored_event="\033[1;31mattempt\033[0m" ;;
handshake) colored_event="\033[1;32mhandshake\033[0m" ;;
*) colored_event="$event" ;;
esac
printf " %-20s %-20s %-18s %b\n" "$ts" "$client" "$endpoint" "$colored_event"
found=true
done < <(json::wg_events "$WG_EVENTS_LOG" "$filter_name" "$filter_type" "$limit")
$found || printf " —\n"
printf "\n"
}
function cmd::logs::show_fw_events() {
local filter_ip="$1" filter_name="$2" filter_type="$3" limit="$4"
[[ ! -f "$FW_EVENTS_LOG" ]] && return 0
printf " Firewall Drops:\n"
printf " %-20s %-18s %-25s %s\n" "TIME" "CLIENT" "DESTINATION" "PROTOCOL"
printf " %s\n" "$(printf '─%.0s' {1..75})"
local found=false
while IFS="|" read -r ts client dst proto; do
[[ -z "$ts" ]] && continue
local colored_proto
case "$proto" in
tcp) colored_proto="\033[1;33mtcp\033[0m" ;;
udp) colored_proto="\033[1;36mudp\033[0m" ;;
icmp) colored_proto="\033[0;37micmp\033[0m" ;;
*) colored_proto="$proto" ;;
esac
printf " %-20s %-18s %-25s %b\n" "$ts" "$client" "$dst" "$colored_proto"
found=true
done < <(json::fw_events "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" "$(ctx::clients)" "$limit")
$found || printf " —\n"
printf "\n"
}

View file

@ -76,13 +76,25 @@ function cmd::remove::run() {
log::section "Removing client: ${name}" log::section "Removing client: ${name}"
# Extract IP before removing anything
local client_ip local client_ip
client_ip=$(grep "^Address" "$(ctx::clients)/${name}.conf" 2>/dev/null | awk '{print $3}' | cut -d'/' -f1) client_ip=$(peers::get_ip "$name")
local was_blocked=false local was_blocked=false
peers::is_blocked "$name" && was_blocked=true peers::is_blocked "$name" && was_blocked=true
# Unapply rule if assigned
local assigned_rule
assigned_rule=$(peers::get_meta "$name" "rule")
if [[ -z "$assigned_rule" ]]; then
assigned_rule=$(peers::default_rule "$name")
fi
# Flush all iptables rules for this peer IP
if [[ -n "$client_ip" ]]; then
fw::flush_peer "$client_ip"
fi
# Remove peer from server config # Remove peer from server config
peers::remove_from_server "$name" || return 1 peers::remove_from_server "$name" || return 1
@ -94,28 +106,12 @@ function cmd::remove::run() {
# Remove block rules only if client was fully blocked # Remove block rules only if client was fully blocked
if [[ -n "$client_ip" ]] && $was_blocked; then if [[ -n "$client_ip" ]] && $was_blocked; then
firewall::unblock_all "$client_ip" fw::unblock_all "$client_ip"
fi fi
firewall::remove_block_file "$name" 2>/dev/null || true fw::remove_block_file "$name" 2>/dev/null || true
# If this was a guest type, check if any guests remain peers::remove_meta "$name" 2>/dev/null || true
# If no guests left, remove guest firewall rules
local type
type=$(echo "$name" | cut -d'-' -f1)
if [[ "$type" == "guest" ]]; then
local remaining_guests=0
local guest_confs=("$(ctx::clients)"/guest-*.conf)
if [[ -f "${guest_confs[0]}" ]]; then
remaining_guests=${#guest_confs[@]}
fi
if [[ "$remaining_guests" -eq 0 ]]; then
firewall::remove_guest_rules
log::wg "No guests remaining — removed guest firewall rules"
fi
fi
# Reload WireGuard # Reload WireGuard
peers::reload || return 1 peers::reload || return 1

View file

@ -115,6 +115,11 @@ function cmd::rename::run() {
log::fs_write "Renamed block file" log::fs_write "Renamed block file"
fi fi
local old_meta new_meta
old_meta=$(peers::meta_path "$name")
new_meta=$(peers::meta_path "$new_name")
[[ -f "$old_meta" ]] && mv "$old_meta" "$new_meta"
# Reload WireGuard # Reload WireGuard
peers::reload peers::reload

522
commands/rule.command.sh Normal file
View file

@ -0,0 +1,522 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::rule::on_load() {
flag::register --name
flag::register --desc
flag::register --block-ip
flag::register --allow-ip
flag::register --block-port
flag::register --allow-port
flag::register --remove-block-ip
flag::register --remove-allow-ip
flag::register --remove-block-port
flag::register --remove-allow-port
flag::register --peer
flag::register --peers
flag::register --dns-redirect
}
# ============================================
# Help
# ============================================
function cmd::rule::help() {
cat <<EOF
Usage: wgctl rule <subcommand> [options]
Manage firewall rules for peers.
Subcommands:
list, ls List all rules
show Show rule details and assigned peers
add, new, create Create a new rule
update, edit Update a rule and re-apply to all 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
Options for add/update:
--name <name> Rule name (e.g. guest, user, dev-01)
--desc <description> Human readable description
--allow-ip <ip/cidr> Allow IP or subnet (repeatable)
--allow-port <ip:port:proto> Allow specific port (repeatable)
--block-ip <ip/cidr> Block IP or subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable)
--dns-redirect Force DNS through Pi-hole
--remove-allow-ip <ip> Remove allow IP entry (update only)
--remove-allow-port <entry> Remove allow port entry (update only)
--remove-block-ip <ip> Remove block IP entry (update only)
--remove-block-port <entry> Remove block port entry (update only)
Options for assign/unassign:
--name <rule> Rule name
--peer <peer> Peer name (e.g. phone-nuno)
--type <type> Peer device type (optional)
Examples:
wgctl rule list
wgctl rule show --name guest
wgctl rule add --name dev-01 --desc "Dev VM only" --allow-ip 10.0.0.50 --block-ip 10.0.0.0/24
wgctl rule update --name user --block-port 10.0.0.100:8006:tcp
wgctl rule update --name user --remove-block-ip 10.0.0.99
wgctl rule assign --name dev-01 --peer laptop-nuno
wgctl rule unassign --peer laptop-nuno --type laptop
wgctl rule migrate
EOF
}
# ============================================
# Run
# ============================================
function cmd::rule::run() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
list|ls) cmd::rule::list "$@" ;;
show) cmd::rule::show "$@" ;;
add|new|create) cmd::rule::add "$@" ;;
update|edit) cmd::rule::update "$@" ;;
remove|rm|del|delete) cmd::rule::remove "$@" ;;
assign) cmd::rule::assign "$@" ;;
unassign) cmd::rule::unassign "$@" ;;
migrate) cmd::rule::migrate "$@" ;;
help) cmd::rule::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::rule::help
return 1
;;
esac
}
# ============================================
# List
# ============================================
function cmd::rule::list() {
local rules_dir
rules_dir="$(ctx::rules)"
local rules=("${rules_dir}"/*.rule)
if [[ ! -f "${rules[0]}" ]]; then
log::wg "No rules configured"
return 0
fi
log::section "Firewall Rules"
printf "\n %-20s %-45s %-8s %-8s %s\n" \
"NAME" "DESCRIPTION" "ALLOWS" "BLOCKS" "PEERS"
printf " %s\n" "$(printf '─%.0s' {1..95})"
while IFS="|" read -r name desc n_allows n_blocks peer_count; do
[[ -z "$name" ]] && continue
local short_desc="${desc:0:43}"
[[ ${#desc} -gt 43 ]] && short_desc="${short_desc}..."
printf " %-20s %-45s %-8s %-8s %s\n" \
"$name" "${short_desc:-}" "$n_allows" "$n_blocks" "${peer_count} peers"
done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)")
printf "\n"
}
# ============================================
# Show
# ============================================
function cmd::rule::show() {
local name="" show_peers=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--peers) show_peers=true; shift ;;
--help) cmd::rule::help; return ;;
*) log::error "Unknown flag: $1"; return 1 ;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
rule::require_exists "$name" || return 1
local rule_file
rule_file="$(ctx::rule::path "${name}.rule")"
log::section "Rule: ${name}"
local desc dns_redirect
desc=$(json::get "$rule_file" "desc")
dns_redirect=$(json::get "$rule_file" "dns_redirect")
printf "\n %-20s %s\n" "Description:" "${desc:-}"
printf " %-20s %s\n" "DNS Redirect:" "${dns_redirect:-false}"
# Allow ports
local allow_ports
allow_ports=$(json::get "$rule_file" "allow_ports")
if [[ -n "$allow_ports" ]]; then
printf "\n Allow Ports:\n"
while IFS= read -r entry; do
printf " + %s\n" "$entry"
done <<< "$allow_ports"
fi
# Allow IPs
local allow_ips
allow_ips=$(json::get "$rule_file" "allow_ips")
if [[ -n "$allow_ips" ]]; then
printf "\n Allow IPs:\n"
while IFS= read -r ip; do
printf " + %s\n" "$ip"
done <<< "$allow_ips"
fi
# Block IPs
local block_ips
block_ips=$(json::get "$rule_file" "block_ips")
if [[ -n "$block_ips" ]]; then
printf "\n Block IPs:\n"
while IFS= read -r ip; do
printf " - %s\n" "$ip"
done <<< "$block_ips"
fi
# Block ports
local block_ports
block_ports=$(json::get "$rule_file" "block_ports")
if [[ -n "$block_ports" ]]; then
printf "\n Block Ports:\n"
while IFS= read -r entry; do
printf " - %s\n" "$entry"
done <<< "$block_ports"
fi
# Precompute peers before any other operations
local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name")
local peer_count=${#peer_list[@]}
# Peer count — always shown
printf "\n %-20s %s\n" "Assigned Peers:" "$peer_count"
printf " %s\n" "$(printf '─%.0s' {1..40})"
# Peer details — only with --peers flag
if $show_peers && [[ $peer_count -gt 0 ]]; then
for peer_name in "${peer_list[@]}"; do
local ip
ip=$(peers::get_ip "$peer_name")
printf " %-28s %s\n" "$peer_name" "$ip"
done
fi
printf "\n"
}
# ============================================
# Add
# ============================================
function cmd::rule::add() {
local name="" desc=""
local allow_ips=() block_ips=() block_ports=()
local dns_redirect=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--desc) desc="$2"; shift 2 ;;
--allow-ip) allow_ips+=("$2"); shift 2 ;;
--block-ip) block_ips+=("$2"); shift 2 ;;
--block-port) block_ports+=("$2"); shift 2 ;;
--dns-redirect) dns_redirect=true; shift ;;
--help) cmd::rule::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
if rule::exists "$name"; then
log::error "Rule already exists: ${name}"
return 1
fi
local rule_file
rule_file="$(ctx::rule::path "${name}.rule")"
# Build JSON using json_helper
python3 -c "
import json
rule = {
'name': '${name}',
'desc': '${desc}',
'dns_redirect': $(${dns_redirect} && echo 'true' || echo 'false'),
'allow_ips': [$(printf '"%s",' "${allow_ips[@]}" | sed 's/,$//')] ,
'block_ips': [$(printf '"%s",' "${block_ips[@]}" | sed 's/,$//')],
'block_ports': [$(printf '"%s",' "${block_ports[@]}" | sed 's/,$//')]
}
with open('${rule_file}', 'w') as f:
json.dump(rule, f, indent=2)
"
log::wg_success "Rule created: ${name}"
}
# ============================================
# Update
# ============================================
function cmd::rule::update() {
local name="" desc=""
local allow_ips=() block_ips=() block_ports=()
local allow_ports=()
local rm_allow_ips=() rm_block_ips=() rm_block_ports=() rm_allow_ports=()
local dns_redirect=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--desc) desc="$2"; shift 2 ;;
--allow-ip) allow_ips+=("$2"); shift 2 ;;
--block-ip) block_ips+=("$2"); shift 2 ;;
--block-port) block_ports+=("$2"); shift 2 ;;
--allow-port) allow_ports+=("$2"); shift 2 ;;
--remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;;
--remove-block-ip) rm_block_ips+=("$2"); shift 2 ;;
--remove-block-port) rm_block_ports+=("$2"); shift 2 ;;
--remove-allow-port) rm_allow_ports+=("$2"); shift 2 ;;
--dns-redirect) dns_redirect=true; shift ;;
--help) cmd::rule::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
rule::require_exists "$name" || return 1
local rule_file
rule_file="$(ctx::rule::path "${name}.rule")"
# Update desc and dns_redirect
[[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\""
[[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true"
# Add entries
for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done
for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done
for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done
for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done
# Remove entries
for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done
for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done
for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done
for p in "${rm_allow_ports[@]}"; do json::remove "$rule_file" "allow_ports" "$p"; done
log::wg_success "Rule updated: ${name}"
# Re-apply to all assigned peers
rule::reapply_all "$name"
}
# ============================================
# Remove
# ============================================
function cmd::rule::remove() {
local name="" force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::rule::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
rule::require_exists "$name" || return 1
# Check for assigned peers
local peer_count
peer_count=$(peers::with_rule "$name" | grep -c . || echo 0)
if [[ "$peer_count" -gt 0 ]]; then
log::error "Rule '${name}' is assigned to ${peer_count} peer(s) — unassign first or use --force"
if ! $force; then
return 1
fi
# Force: unassign from all peers
while IFS= read -r peer; do
local ip
ip=$(peers::get_ip "$peer")
rule::unapply "$name" "$ip"
done < <(peers::with_rule "$name")
fi
rm -f "$(ctx::rule::path "${name}.rule")"
log::wg_success "Rule removed: ${name}"
}
# ============================================
# Assign
# ============================================
function cmd::rule::assign() {
local name="" peer="" type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--peer) peer="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--help) cmd::rule::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -z "$name" || -z "$peer" ]]; then
log::error "Missing required flags: --name and --peer"
return 1
fi
rule::require_exists "$name" || return 1
# Support --type for peer resolution
peer=$(peers::resolve_and_require "$peer" "$type") || return 1
# Unapply existing rule first if any
local existing_rule
existing_rule=$(peers::get_meta "$peer" "rule")
if [[ -n "$existing_rule" ]]; then
local ip
ip=$(peers::get_ip "$peer")
rule::unapply "$existing_rule" "$ip"
log::wg "Removed existing rule '${existing_rule}' from: ${peer}"
fi
local ip
ip=$(peers::get_ip "$peer")
rule::apply "$name" "$ip"
log::wg_success "Assigned rule '${name}' to: ${peer}"
}
# ============================================
# Unassign
# ============================================
function cmd::rule::unassign() {
local peer="" type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--peer) peer="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--help) cmd::rule::help; return ;;
*)
log::error "Unknown flag: $1"
return 1
;;
esac
done
if [[ -z "$peer" ]]; then
log::error "Missing required flag: --peer"
return 1
fi
peer=$(peers::resolve_and_require "$peer" "$type") || return 1
local existing_rule
existing_rule=$(peers::get_meta "$peer" "rule")
if [[ -z "$existing_rule" ]]; then
log::wg_warning "Peer '${peer}' has no assigned rule"
return 0
fi
local ip
ip=$(peers::get_ip "$peer")
rule::unapply "$existing_rule" "$ip"
log::wg_success "Unassigned rule from: ${peer}"
}
# ============================================
# Migrate Rules
# ============================================
function cmd::rule::migrate() {
log::section "Migrating peers to default rules"
# Write migration plan to temp file to avoid fd conflicts
local tmp
tmp=$(mktemp)
while IFS= read -r peer_name; do
local existing
existing=$(peers::get_meta "$peer_name" "rule")
[[ -n "$existing" ]] && continue
local default_rule
default_rule=$(peers::default_rule "$peer_name")
rule::exists "$default_rule" || continue
local ip
ip=$(peers::get_ip "$peer_name")
echo "${peer_name} ${default_rule} ${ip}" >> "$tmp"
done < <(peers::all)
local count=0
local lines
mapfile -t lines < "$tmp"
echo "DEBUG: lines count=${#lines[@]}"
for line in "${lines[@]}"; do
echo "DEBUG: processing line=$line"
IFS=" " read -r peer_name default_rule ip <<< "$line"
rule::apply "$default_rule" "$ip" "$peer_name" </dev/null
echo "DEBUG: after apply, count=$count"
(( count++ )) || true
echo "DEBUG: incremented count=$count"
done
echo "DEBUG: loop done, count=$count"
echo "DEBUG: final count=$count"
echo "DEBUG: tmp contents:"
cat "$tmp"
rm -f "$tmp"
log::wg_success "Migrated ${count} peers"
}

View file

@ -70,15 +70,20 @@ function cmd::service::stop() {
function cmd::service::restart() { function cmd::service::restart() {
log::wg_start "Restarting WireGuard..." log::wg_start "Restarting WireGuard..."
# Flush firewall rules before restart so restore starts clean
iptables -F FORWARD
iptables -t nat -F PREROUTING
systemctl restart "wg-quick@$(config::interface)" systemctl restart "wg-quick@$(config::interface)"
firewall::restore_blocks fw::restore_blocks
log::wg_success "WireGuard restarted" log::wg_success "WireGuard restarted"
} }
function cmd::service::reload() { function cmd::service::reload() {
log::wg_start "Reloading WireGuard config..." log::wg_start "Reloading WireGuard config..."
peers::reload peers::reload
firewall::restore_blocks fw::restore_blocks
} }
function cmd::service::status() { function cmd::service::status() {

164
commands/shell.command.sh Normal file
View file

@ -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 preset 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 <<EOF
Usage: wgctl shell
Start an interactive wgctl shell. Supports all wgctl commands
directly (no 'wgctl' prefix needed), plus any bash command.
Builtins handled: cd, export, unset, source
Everything else: passed to bash
Examples:
wgctl shell
wgctl> 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
}

View file

@ -7,6 +7,8 @@
function cmd::unblock::on_load() { function cmd::unblock::on_load() {
flag::register --name flag::register --name
flag::register --type flag::register --type
flag::register --force
flag::register --quiet
flag::register --ip flag::register --ip
flag::register --port flag::register --port
flag::register --proto flag::register --proto
@ -66,12 +68,15 @@ function cmd::unblock::run() {
local subnets=() local subnets=()
local ports=() local ports=()
local all=false local all=false
local quiet=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;; --type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;; --ip) ips+=("$2"); shift 2 ;;
--force) force=true; shift ;;
--quiet) quiet=true; shift ;;
--subnet) subnets+=("$2"); shift 2 ;; --subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;; --port) ports+=("$2"); shift 2 ;;
--all) all=true; shift ;; --all) all=true; shift ;;
@ -106,11 +111,11 @@ function cmd::unblock::run() {
local client_ip local client_ip
client_ip=$(cmd::unblock::get_client_ip "$name") || return 1 client_ip=$(cmd::unblock::get_client_ip "$name") || return 1
log::section "Unblocking client: ${name} (${client_ip})" # $quiet || log::section "Unblocking client: ${name} (${client_ip})"
if $all; then if $all; then
firewall::unblock_all "$client_ip" fw::unblock_all "$client_ip"
firewall::remove_block_file "$name" fw::remove_block_file "$name"
monitor::unwatch_client "$name" monitor::unwatch_client "$name"
# Re-add peer to server if missing # Re-add peer to server if missing
@ -121,18 +126,18 @@ function cmd::unblock::run() {
peers::reload peers::reload
fi fi
log::wg_success "All block rules removed for: ${name}" $quiet || log::wg_success "${name} has been unblocked."
return 0 return 0
fi fi
# Unblock specific IPs # Unblock specific IPs
for ip in "${ips[@]}"; do for ip in "${ips[@]}"; do
firewall::unblock_ip "$client_ip" "$ip" fw::unblock_ip "$client_ip" "$ip"
done done
# Unblock specific subnets # Unblock specific subnets
for subnet in "${subnets[@]}"; do for subnet in "${subnets[@]}"; do
firewall::unblock_subnet "$client_ip" "$subnet" fw::unblock_subnet "$client_ip" "$subnet"
done done
# Unblock specific ports # Unblock specific ports
@ -140,8 +145,8 @@ function cmd::unblock::run() {
local target port proto local target port proto
IFS=":" read -r target port proto <<< "$entry" IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}" proto="${proto:-tcp}"
firewall::unblock_port "$client_ip" "$target" "$port" "$proto" fw::unblock_port "$client_ip" "$target" "$port" "$proto"
done done
log::wg_success "Unblock rules applied for: ${name}" $quiet || log::wg_success "Unblock rules applied for: ${name}"
} }

View file

@ -132,7 +132,7 @@ function cmd::watch::poll_handshakes() {
echo "$ts" > "$prev_ts_file" echo "$ts" > "$prev_ts_file"
local formatted_ts local formatted_ts
formatted_ts=$(date -d "@${ts}" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$ts") formatted_ts=$(fmt::datetime "$ts")
local endpoint local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key") endpoint=$(monitor::endpoint_for_key "$public_key")
@ -209,12 +209,7 @@ except:
fi fi
local formatted_ts local formatted_ts
formatted_ts=$(python3 -c " formatted_ts=$(fmt::datetime_iso "$ts")
from datetime import datetime
dt = datetime.fromisoformat('${ts}')
dt = dt.astimezone()
print(dt.strftime('%Y-%m-%d %H:%M:%S'))
" 2>/dev/null || echo "$ts")
# Before printing the event # Before printing the event
local now local now

View file

@ -11,3 +11,7 @@ source "${WGCTL_DIR}/core/utils.sh"
source "${WGCTL_DIR}/core/module.sh" source "${WGCTL_DIR}/core/module.sh"
source "${WGCTL_DIR}/core/command.sh" source "${WGCTL_DIR}/core/command.sh"
source "${WGCTL_DIR}/core/flag.sh" source "${WGCTL_DIR}/core/flag.sh"
source "${WGCTL_DIR}/core/json.sh"
source "${WGCTL_DIR}/core/ui.sh"
source "${WGCTL_DIR}/core/fmt.sh"
source "${WGCTL_DIR}/core/test/test.sh"

View file

@ -5,13 +5,25 @@
# ============================================ # ============================================
_CTX_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" _CTX_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
_CTX_WG="/etc/wireguard"
_CTX_CORE="${_CTX_ROOT}/core" _CTX_CORE="${_CTX_ROOT}/core"
_CTX_MODULES="${_CTX_ROOT}/modules" _CTX_MODULES="${_CTX_ROOT}/modules"
_CTX_COMMANDS="${_CTX_ROOT}/commands" _CTX_COMMANDS="${_CTX_ROOT}/commands"
_CTX_PRESETS="${_CTX_ROOT}/presets" _CTX_PRESETS="${_CTX_ROOT}/presets"
_CTX_BLOCKS="${_CTX_ROOT}/blocks" _CTX_CLIENTS="${_CTX_WG}/clients"
_CTX_CLIENTS="/etc/wireguard/clients" _CTX_DATA="${_CTX_WG}/.wgctl"
_CTX_WG="/etc/wireguard"
# ============================================
# Artifacts
# ============================================
_CTX_RULES="${_CTX_DATA}/rules"
_CTX_GROUPS="${_CTX_DATA}/groups"
_CTX_BLOCKS="${_CTX_DATA}/blocks"
_CTX_META="${_CTX_DATA}/meta"
_CTX_DAEMON="${_CTX_DATA}/daemon"
# ============================================
function ctx::root() { echo "$_CTX_ROOT"; } function ctx::root() { echo "$_CTX_ROOT"; }
function ctx::core() { echo "$_CTX_CORE"; } function ctx::core() { echo "$_CTX_CORE"; }
@ -19,8 +31,19 @@ function ctx::modules() { echo "$_CTX_MODULES"; }
function ctx::commands() { echo "$_CTX_COMMANDS"; } function ctx::commands() { echo "$_CTX_COMMANDS"; }
function ctx::presets() { echo "$_CTX_PRESETS"; } function ctx::presets() { echo "$_CTX_PRESETS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; } function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::clients() { echo "$_CTX_CLIENTS"; } function ctx::clients() { echo "$_CTX_CLIENTS"; }
function ctx::wg() { echo "$_CTX_WG"; } function ctx::wg() { echo "$_CTX_WG"; }
function ctx::data() { echo "$_CTX_DATA"; }
function ctx::rules() { echo "$_CTX_RULES"; }
function ctx::groups() { echo "$_CTX_GROUPS"; }
function ctx::blocks() { echo "$_CTX_BLOCKS"; }
function ctx::meta() { echo "$_CTX_META"; }
function ctx::daemon() { echo "$_CTX_DAEMON"; }
function ctx::events_log() { echo "$(ctx::daemon)/events.log"; }
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
# ============================================ # ============================================
# Path Helpers # Path Helpers
@ -36,7 +59,22 @@ function ctx::preset::path() {
echo "$_CTX_PRESETS/$*" echo "$_CTX_PRESETS/$*"
} }
function ctx::meta::path() {
local IFS="/"
echo "$_CTX_META/$*"
}
function ctx::block::path() { function ctx::block::path() {
local IFS="/" local IFS="/"
echo "$_CTX_BLOCKS/$*" echo "$_CTX_BLOCKS/$*"
} }
function ctx::group::path() {
local IFS="/"
echo "$_CTX_GROUPS/$*"
}
function ctx::rule::path() {
local IFS="/"
echo "$_CTX_RULES/$*"
}

55
core/fmt.sh Normal file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# ============================================
# Date Formats
# ============================================
FMT_DATE_ISO="%Y-%m-%d" # 2026-05-10
FMT_DATE_EU="%d/%m/%Y" # 10/05/2026
FMT_DATE_EU_DASH="%d-%m-%Y" # 10-05-2026
FMT_DATETIME_ISO="%Y-%m-%d %H:%M" # 2026-05-10 22:39
FMT_DATETIME_EU="%d/%m/%Y %H:%M" # 10/05/2026 22:39
# Default — can be overridden in wgctl.conf
FMT_DATE="${FMT_DATE_ISO}"
FMT_DATETIME="${FMT_DATETIME_ISO}"
# Load from config or use default
_FMT_DATE_FORMAT="${DATE_FORMAT:-iso}"
FMT_HELPER="${_CTX_ROOT}/core/fmt_helper.py"
function fmt::date() {
local ts="$1"
date -d "@${ts}" "+${FMT_DATE}" 2>/dev/null || echo "$ts"
}
function fmt::datetime() {
local ts="$1"
date -d "@${ts}" "+${FMT_DATETIME}" 2>/dev/null || echo "$ts"
}
function fmt::datetime_iso() {
local iso="$1"
python3 "$FMT_HELPER" fmt_datetime "$iso" "$FMT_DATETIME" </dev/null
}
function fmt::set_date_format() {
local format="$1"
case "$format" in
iso)
FMT_DATE="%Y-%m-%d"
FMT_DATETIME="%Y-%m-%d %H:%M"
;;
eu)
FMT_DATE="%d/%m/%Y"
FMT_DATETIME="%d/%m/%Y %H:%M"
;;
eu-dash)
FMT_DATE="%d-%m-%Y"
FMT_DATETIME="%d-%m-%Y %H:%M"
;;
*) log::error "Unknown date format: $format" ;;
esac
}

30
core/fmt_helper.py Normal file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""
wgctl format helper date/time formatting utilities
"""
import sys
def fmt_datetime(iso_str, fmt):
try:
from datetime import datetime, timezone
dt = datetime.fromisoformat(iso_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(dt.strftime(fmt))
except:
print(iso_str)
commands = {
'fmt_datetime': lambda args: fmt_datetime(args[0], args[1]),
}
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: fmt_helper.py <command> [args...]", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd not in commands:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
commands[cmd](args)

29
core/json.sh Normal file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
JSON_HELPER="${_CTX_ROOT}/core/json_helper.py"
function json::get() { python3 "$JSON_HELPER" get "$@" </dev/null; }
function json::set() { python3 "$JSON_HELPER" set "$@" </dev/null; }
function json::delete() { python3 "$JSON_HELPER" delete "$@" </dev/null; }
function json::append() { python3 "$JSON_HELPER" append "$@" </dev/null; }
function json::remove() { python3 "$JSON_HELPER" remove "$@" </dev/null; }
function json::cat() { python3 "$JSON_HELPER" cat "$@" </dev/null; }
function json::has_key() { python3 "$JSON_HELPER" has_key "$@" </dev/null; }
function json::filter_values() { python3 "$JSON_HELPER" filter_values "$@" </dev/null; }
function json::last_event() { python3 "$JSON_HELPER" last_event "$@" </dev/null; }
function json::events_for() { python3 "$JSON_HELPER" events_for "$@" </dev/null; }
function json::fw_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" fw_events "$@" </dev/null; }
function json::wg_events() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" wg_events "$@" </dev/null; }
function json::format_fw_event() { echo "$1" | python3 "$JSON_HELPER" format_fw_event "$2"; }
function json::format_wg_event() { echo "$1" | python3 "$JSON_HELPER" format_wg_event; }
function json::remove_events() { python3 "$JSON_HELPER" remove_events "$@" </dev/null; }
function json::follow_logs() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" follow_logs "$@"; }
function json::count() { python3 "$JSON_HELPER" count "$@" </dev/null; }
function json::audit_fw_counts() { python3 "$JSON_HELPER" audit_fw_counts "$@" </dev/null; }
function json::peer_group_map() { python3 "$JSON_HELPER" peer_group_map "$@" </dev/null; }
function json::peer_groups() { python3 "$JSON_HELPER" peer_groups "$@" </dev/null; }
function json::peer_data() { WGCTL_DATETIME_FMT="$FMT_DATETIME" python3 "$JSON_HELPER" peer_data "$@" </dev/null; }
function json::iso_to_ts() { python3 "$JSON_HELPER" iso_to_ts "$@" </dev/null; }
function json::rule_list_data() { python3 "$JSON_HELPER" rule_list_data "$@" </dev/null; }
function json::group_list_data() { python3 "$JSON_HELPER" group_list_data "$@" </dev/null; }
function json::fmt_datetime() { python3 "$JSON_HELPER" fmt_datetime "$@" </dev/null; }

668
core/json_helper.py Normal file
View file

@ -0,0 +1,668 @@
#!/usr/bin/env python3
"""
wgctl JSON helper called by shell functions to read/write JSON files.
Usage: json_helper.py <command> <file> [key] [value]
"""
import sys
import json
import os
DATETIME_FMT = os.environ.get('WGCTL_DATETIME_FMT', '%Y-%m-%d %H:%M')
def get(file, key):
try:
with open(file) as f:
data = json.load(f)
val = data.get(key, [])
if isinstance(val, bool):
print(str(val).lower()) # true/false not True/False
elif isinstance(val, list):
if val:
print('\n'.join(str(v) for v in val))
else:
if val:
print(val)
except:
sys.exit(0)
def set_key(file, key, value):
try:
data = {}
if os.path.exists(file):
with open(file) as f:
data = json.load(f)
# Try to parse as JSON value first (for arrays/bools)
try:
data[key] = json.loads(value)
except:
data[key] = value
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def delete_key(file, key):
try:
with open(file) as f:
data = json.load(f)
data.pop(key, None)
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def append(file, key, value):
try:
data = {}
if os.path.exists(file):
with open(file) as f:
data = json.load(f)
if key not in data:
data[key] = []
if value not in data[key]:
data[key].append(value)
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def remove_value(file, key, value):
try:
with open(file) as f:
data = json.load(f)
if key in data and value in data[key]:
data[key].remove(value)
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cat(file):
try:
with open(file) as f:
data = json.load(f)
print(json.dumps(data, indent=2))
except Exception as e:
sys.exit(1)
def has_key(file, key):
try:
with open(file) as f:
data = json.load(f)
sys.exit(0 if key in data else 1)
except:
sys.exit(1)
def filter_values(file, key, value):
"""Remove all entries where value matches"""
try:
with open(file) as f:
data = json.load(f)
data = {k: v for k, v in data.items() if v != value}
with open(file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def last_event(file, key, field, client):
"""Get last event field for a client"""
try:
last = None
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get(key) == client:
last = e
except:
pass
if last:
print(last.get(field, ''))
except:
pass
def events_for(file, ip, limit):
"""Format events for a given IP"""
try:
from datetime import datetime
events = []
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('ip') == ip:
events.append(e)
except:
pass
for e in events[-int(limit):]:
ts = e.get('timestamp', '')
try:
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
endpoint = e.get('endpoint', '')
client = e.get('client', '')
event = e.get('event', '')
print(f' {ts} {client:<20} {endpoint:<20} {event}')
except:
pass
def fw_events(file, filter_ip, filter_type, clients_dir, limit):
"""Format firewall drop events"""
import glob
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# Build ip->name map
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name
except:
pass
events = []
last_seen = {} # (src, dst, port, proto) -> last timestamp
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
src = e.get('src_ip', '')
if not src:
continue
if filter_ip and src != filter_ip:
continue
# Dedup key
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto = e.get('ip.protocol', 0)
key = (src, dst, port, proto)
ts_str = e.get('timestamp', '')
try:
from datetime import datetime
ts = datetime.fromisoformat(ts_str).timestamp()
except:
ts = 0
# Protocol-aware dedup window
dedup_windows = {1: 5, 6: 30, 17: 10} # icmp=5s, tcp=30s, udp=10s
window = dedup_windows.get(proto, 10)
# Skip if same event within protocol window
if key in last_seen and (ts - last_seen[key]) < window:
continue
last_seen[key] = ts
events.append(e)
except:
pass
except:
pass
for e in events[-int(limit):]:
ts = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
src = e.get('src_ip', '')
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_num = e.get('ip.protocol', 0)
proto = proto_map.get(proto_num, str(proto_num))
dst_str = f"{dst}:{port}" if port else dst
client = ip_to_name.get(src, src)
if filter_type and not client.startswith(filter_type + '-'):
continue
print(f"{ts}|{client}|{dst_str}|{proto}")
def wg_events(file, filter_client, filter_type, limit):
"""Format WireGuard events from events.log"""
events = []
try:
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
client = e.get('client', '')
if not client:
continue
if filter_client and client != filter_client:
continue
if filter_type and not client.startswith(filter_type + '-'):
continue
events.append(e)
except:
pass
except:
pass
for e in events[-int(limit):]:
ts = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
client = e.get('client', '')
endpoint = e.get('endpoint', '')
event = e.get('event', '')
print(f"{ts}|{client}|{endpoint}|{event}")
def format_fw_event(line, clients_dir):
"""Format a single fw_event line"""
import glob
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# Build ip->name map
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for l in f:
if l.startswith('Address'):
ip = l.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name
except:
pass
try:
e = json.loads(line.strip())
src = e.get('src_ip', '')
if not src:
return None
ts = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_num = e.get('ip.protocol', 0)
proto = proto_map.get(proto_num, str(proto_num))
dst_str = f"{dst}:{port}" if port else dst
client = ip_to_name.get(src, src)
return f"{ts}|{client}|{dst_str}|{proto}"
except:
return None
def format_wg_event(line):
"""Format a single wg_event line"""
try:
e = json.loads(line.strip())
client = e.get('client', '')
if not client:
return None
ts = e.get('timestamp', '')
try:
from datetime import datetime
dt = datetime.fromisoformat(ts)
ts = dt.strftime(DATETIME_FMT)
except:
pass
endpoint = e.get('endpoint', '')
event = e.get('event', '')
return f"{ts}|{client}|{endpoint}|{event}|wg"
except:
return None
def remove_events(file, identifier):
"""Remove all events for a client/ip from a JSONL file"""
try:
lines = []
with open(file) as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('client') == identifier or e.get('src_ip') == identifier:
continue
lines.append(line)
except:
lines.append(line)
with open(file, 'w') as f:
f.writelines(lines)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir):
"""Follow both log files and output formatted events"""
import glob, time, select
proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
# Build ip->name map
ip_to_name = {}
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for l in f:
if l.startswith('Address'):
ip = l.split('=')[1].strip().split('/')[0]
ip_to_name[ip] = name
except:
pass
# Open files and seek to end
files = {}
for label, path in [('fw', fw_file), ('wg', wg_file)]:
if path and os.path.exists(path):
f = open(path)
f.seek(0, 2) # seek to end
files[label] = f
dedup = {}
try:
while True:
for label, f in files.items():
line = f.readline()
if not line:
continue
try:
e = json.loads(line.strip())
except:
continue
if label == 'fw':
src = e.get('src_ip', '')
if not src:
continue
if filter_ip and src != filter_ip:
continue
dst = e.get('dest_ip', '')
port = e.get('dest_port', '')
proto_num = e.get('ip.protocol', 0)
proto = proto_map.get(proto_num, str(proto_num))
# Dedup
key = (src, dst, port, proto_num)
windows = {1: 5, 6: 30, 17: 10}
window = windows.get(proto_num, 10)
now = time.time()
if key in dedup and (now - dedup[key]) < window:
continue
dedup[key] = now
client = ip_to_name.get(src, src)
if filter_type and not client.startswith(filter_type + '-'):
continue
dst_str = f"{dst}:{port}" if port else dst
ts = e.get('timestamp', '')[:16].replace('T', ' ')
print(f"fw|{ts}|{client}|{dst_str}|{proto}", flush=True)
elif label == 'wg':
client = e.get('client', '')
if not client:
continue
if filter_ip:
ip = ip_to_name.get(filter_ip, '')
if client != ip and client != filter_ip:
continue
if filter_type and not client.startswith(filter_type + '-'):
continue
ts = e.get('timestamp', '')[:16].replace('T', ' ')
endpoint = e.get('endpoint', '')
event = e.get('event', '')
print(f"wg|{ts}|{client}|{endpoint}|{event}", flush=True)
time.sleep(0.1)
except KeyboardInterrupt:
pass
def count(file, key):
try:
with open(file) as f:
data = json.load(f)
val = data.get(key, [])
print(len(val) if isinstance(val, list) else 0)
except:
print(0)
def audit_fw_counts(clients_dir):
"""Return peer_name:fw_count pairs from iptables and client configs"""
import glob, subprocess
# Get iptables output once
try:
result = subprocess.run(
['iptables', '-L', 'FORWARD', '-n'],
capture_output=True, text=True
)
fw_output = result.stdout
except:
fw_output = ""
# Build ip->name and count rules
for conf in glob.glob(f"{clients_dir}/*.conf"):
name = os.path.basename(conf).replace('.conf', '')
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
count = fw_output.count(ip)
print(f"{name}:{count}")
break
except:
pass
def peer_group_map(groups_dir):
"""Return peer:group pairs for all groups"""
import glob
try:
for group_file in glob.glob(f"{groups_dir}/*.group"):
try:
with open(group_file) as f:
g = json.load(f)
name = g.get('name', '')
for peer in g.get('peers', []):
if peer:
print(f"{peer}:{name}")
except:
pass
except:
pass
def peer_groups(groups_dir, peer_name):
"""Find all groups containing a peer"""
import glob
try:
for group_file in glob.glob(f"{groups_dir}/*.group"):
try:
with open(group_file) as f:
g = json.load(f)
if peer_name in g.get('peers', []):
print(g.get('name', ''))
except:
pass
except:
pass
def peer_data(clients_dir, meta_dir, events_log):
import glob
meta = {}
for f in glob.glob(f"{meta_dir}/*.meta"):
name = os.path.basename(f).replace('.meta', '')
try:
with open(f) as mf:
meta[name] = json.load(mf)
except:
meta[name] = {}
last_events = {}
try:
with open(events_log) as f:
for line in f:
try:
e = json.loads(line.strip())
client = e.get('client', '')
if client:
last_events[client] = e
except:
pass
except:
pass
for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
name = os.path.basename(conf).replace('.conf', '')
ip = ''
try:
with open(conf) as f:
for line in f:
if line.startswith('Address'):
ip = line.split('=')[1].strip().split('/')[0]
break
except:
pass
m = meta.get(name, {})
rule = m.get('rule', '')
subtype = m.get('subtype', '')
last_event = last_events.get(name, {})
last_ts = last_event.get('timestamp', '') # raw ISO, no formatting
last_evt = last_event.get('event', '') # fixed: was last_event
print(f"{name}|{ip}|{rule}|{subtype}|{last_ts}|{last_evt}")
def iso_to_ts(iso_str):
"""Convert ISO timestamp to unix timestamp"""
try:
from datetime import datetime, timezone
dt = datetime.fromisoformat(iso_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
except:
print(0)
def rule_list_data(rules_dir, meta_dir):
"""Return all rule data for list display in one call"""
import glob
# Count peers per rule from meta files
rule_peer_counts = {}
for f in glob.glob(f"{meta_dir}/*.meta"):
try:
with open(f) as mf:
meta = json.load(mf)
rule = meta.get('rule', '')
if rule:
rule_peer_counts[rule] = rule_peer_counts.get(rule, 0) + 1
except:
pass
# Read each rule file
for rule_file in sorted(glob.glob(f"{rules_dir}/*.rule")):
try:
with open(rule_file) as f:
r = json.load(f)
name = r.get('name', '')
desc = r.get('desc', '')
n_allows = len(r.get('allow_ips', [])) + len(r.get('allow_ports', []))
n_blocks = len(r.get('block_ips', [])) + len(r.get('block_ports', []))
peer_count = rule_peer_counts.get(name, 0)
print(f"{name}|{desc}|{n_allows}|{n_blocks}|{peer_count}")
except:
pass
def group_list_data(groups_dir, blocks_dir):
"""Return group summary data in one call"""
import glob
# Get all block files
blocked_peers = set()
for f in glob.glob(f"{blocks_dir}/*.block"):
name = os.path.basename(f).replace('.block', '')
blocked_peers.add(name)
for group_file in sorted(glob.glob(f"{groups_dir}/*.group")):
try:
with open(group_file) as f:
g = json.load(f)
name = g.get('name', '')
desc = g.get('desc', '')
peers = [p for p in g.get('peers', []) if p]
total = len(peers)
blocked = sum(1 for p in peers if p in blocked_peers)
print(f"{name}|{desc}|{total}|{blocked}")
except:
pass
def fmt_datetime(iso_str, fmt):
"""Format ISO timestamp with given strftime format"""
try:
from datetime import datetime
dt = datetime.fromisoformat(iso_str)
print(dt.strftime(fmt))
except:
print(iso_str)
commands = {
'get': lambda args: get(args[0], args[1]),
'set': lambda args: set_key(args[0], args[1], args[2]),
'delete': lambda args: delete_key(args[0], args[1]),
'append': lambda args: append(args[0], args[1], args[2]),
'remove': lambda args: remove_value(args[0], args[1], args[2]),
'cat': lambda args: cat(args[0]),
'has_key': lambda args: has_key(args[0], args[1]),
'filter_values': lambda args: filter_values(args[0], args[1], args[2]),
'last_event': lambda args: last_event(args[0], args[1], args[2], args[3]),
'events_for': lambda args: events_for(args[0], args[1], args[2]),
'fw_events': lambda args: fw_events(args[0], args[1], args[2], args[3], args[4]),
'wg_events': lambda args: wg_events(args[0], args[1], args[2], args[3]),
'format_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]),
'format_wg_event': lambda args: format_wg_event(sys.stdin.read()),
'remove_events': lambda args: remove_events(args[0], args[1]),
'follow_logs': lambda args: follow_logs(args[0], args[1], args[2], args[3], args[4]),
'count': lambda args: count(args[0], args[1]),
'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
'peer_group_map': lambda args: peer_group_map(args[0]),
'peer_groups': lambda args: peer_groups(args[0], args[1]),
'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
'iso_to_ts': lambda args: iso_to_ts(args[0]),
'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
'group_list_data': lambda args: group_list_data(args[0], args[1]),
'fmt_datetime': lambda args: fmt_datetime(args[0], args[1]),
}
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: json_helper.py <command> <file> [key] [value]", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd not in commands:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
commands[cmd](args)

View file

@ -15,11 +15,11 @@ readonly _MODULE_AUTO_LOAD_HOOK="on_load"
function module::loaded() { [[ -n "${_LOADED_MODULES["$1"]:-}" ]]; } function module::loaded() { [[ -n "${_LOADED_MODULES["$1"]:-}" ]]; }
# Convert path-style name to namespace # Convert path-style name to namespace
# e.g. firewall/iptables -> firewall::iptables # e.g. firewall/iptables -> fw::iptables
function module::to_namespace() { echo "${1//\//:}"; } function module::to_namespace() { echo "${1//\//:}"; }
# Build fully qualified function name # Build fully qualified function name
# e.g. module::fn "firewall/iptables" "on_load" -> firewall::iptables::on_load # e.g. module::fn "firewall/iptables" "on_load" -> fw::iptables::on_load
function module::fn() { function module::fn() {
local namespace local namespace
namespace=$(module::to_namespace "$1") namespace=$(module::to_namespace "$1")

46
core/test/test.sh Normal file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# ============================================
# Test Framework
# ============================================
TEST_PASS=0
TEST_FAIL=0
TEST_WARN=0
function test::pass() {
local desc="$1"
printf " \033[1;32m✅\033[0m %s\n" "$desc"
(( TEST_PASS++ )) || true
}
function test::fail() {
local desc="$1"
printf " \033[1;31m❌\033[0m %s\n" "$desc"
(( TEST_FAIL++ )) || true
}
function test::warn() {
local desc="$1"
printf " \033[1;33m⚠ \033[0m %s\n" "$desc"
(( TEST_WARN++ )) || true
}
function test::section() {
printf "\n \033[1;37m%s\033[0m\n" "$1"
printf " %s\n" "$(printf '─%.0s' {1..50})"
}
function test::reset() {
TEST_PASS=0
TEST_FAIL=0
TEST_WARN=0
}
function test::summary() {
printf "\n"
printf " \033[1;32m✅ %s passed\033[0m " "$TEST_PASS"
printf "\033[1;31m❌ %s failed\033[0m " "$TEST_FAIL"
printf "\033[1;33m⚠ %s warnings\033[0m\n\n" "$TEST_WARN"
[[ "$TEST_FAIL" -eq 0 ]]
}

55
core/ui.sh Normal file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
UI_ROW_WIDTH=${UI_ROW_WIDTH:-20}
UI_SECTION_WIDTH=${UI_SECTION_WIDTH:-44}
function ui::row() {
local label="$1" value="$2" width="${3:-$UI_ROW_WIDTH}"
printf " %-${width}s %s\n" "${label}:" "$value"
}
function ui::section() {
local title="$1" width="${2:-$UI_SECTION_WIDTH}"
local dashes
dashes=$(printf '─%.0s' $(seq 1 $(( width - ${#title} - 4 ))))
printf "\n \033[0;37m── %s %s\033[0m\n" "$title" "$dashes"
}
function ui::list_item() {
local prefix="$1" value="$2"
printf " %s %s\n" "$prefix" "$value"
}
function ui::print_list() {
local prefix="$1" input="$2"
[[ -z "$input" ]] && return 0 # early return for empty input
while IFS= read -r e; do
[[ -n "$e" ]] && ui::list_item "$prefix" "$e"
done <<< "$input"
}
function ui::divider() {
local width="${1:-48}"
printf " %s\n" "$(printf '─%.0s' $(seq 1 $width))"
}
function ui::pad() {
local text="$1" width="${2:-20}"
local visible
visible=$(echo -e "$text" | sed 's/\x1b\[[0-9;]*m//g')
local pad=$(( width - ${#visible} ))
printf "%b%${pad}s" "$text" ""
}
function ui::firewall_rule() {
local rule="$1"
if [[ "$rule" =~ ACCEPT|DNAT ]]; then
printf "\033[0;32m%s\033[0m\n" "$rule"
elif [[ "$rule" =~ DROP ]]; then
printf "\033[0;31m%s\033[0m\n" "$rule"
elif [[ "$rule" =~ NFLOG|LOG ]]; then
printf "\033[0;37m%s\033[0m\n" "$rule"
else
printf "%s\n" "$rule"
fi
}

View file

@ -17,6 +17,22 @@ function core::call_if_exists() {
return 0 return 0
} }
WGCTL_QUIET=false
function core::set_quiet() { WGCTL_QUIET=true; }
function core::is_quiet() { [[ "$WGCTL_QUIET" == "true" ]]; }
# ============================================
# Utility
# ============================================
function util::require_flag() {
local flag="$1" value="$2"
if [[ -z "$value" ]]; then
log::error "Flag ${flag} requires a value"
return 1
fi
}
# ============================================ # ============================================
# String # String
# ============================================ # ============================================

View file

@ -1,3 +1,9 @@
{ {
"phone-nuno": "148.69.48.74" "phone-fred": "94.63.0.129",
"phone-helena": "148.69.38.203",
"phone-nuno": "148.69.48.87",
"tablet-nuno": "148.69.202.5",
"guest-zephyr": "86.120.152.105",
"guest-zephyr-test": "94.63.0.129",
"desktop-roboclean": "46.189.215.231"
} }

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
{}

View file

@ -1,188 +0,0 @@
#!/usr/bin/env python3
import json
import logging
import os
import signal
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from scapy.all import IP, UDP, sniff
# ============================================
# Config
# ============================================
WATCHLIST_FILE = Path("/etc/wireguard/wgctl/daemon/watchlist.json")
EVENTS_LOG = Path("/etc/wireguard/wgctl/daemon/events.log")
WG_INTERFACE = os.environ.get("WG_INTERFACE", "eth0")
WG_PORT = int(os.environ.get("WG_PORT", "51820"))
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
# ============================================
# Logging
# ============================================
logging.basicConfig(
level=getattr(logging, LOG_LEVEL),
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
log = logging.getLogger("wgctl-monitor")
# ============================================
# Watchlist
# ============================================
_watchlist: dict[str, str] = {}
_watchlist_mtime: float = 0.0
def load_watchlist() -> dict[str, str]:
global _watchlist, _watchlist_mtime
try:
mtime = WATCHLIST_FILE.stat().st_mtime
if mtime == _watchlist_mtime:
return _watchlist
with WATCHLIST_FILE.open() as f:
_watchlist = json.load(f)
_watchlist_mtime = mtime
log.debug(f"Watchlist reloaded: {len(_watchlist)} entries")
except Exception as e:
log.error(f"Failed to load watchlist: {e}")
return _watchlist
def is_watched(ip: str) -> str | None:
watchlist = load_watchlist()
return watchlist.get(ip)
# ============================================
# Endpoint Resolution
# ============================================
def get_endpoint(public_key: str) -> str | None:
try:
import subprocess
result = subprocess.run(
["wg", "show", WG_INTERFACE, "endpoints"],
capture_output=True, text=True
)
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0] == public_key:
# Return just the IP without port
return parts[1].rsplit(":", 1)[0]
except Exception as e:
log.debug(f"Failed to get endpoint: {e}")
return None
def get_client_public_key(client_name: str) -> str | None:
key_file = Path(f"/etc/wireguard/clients/{client_name}_public.key")
try:
return key_file.read_text().strip()
except Exception:
return None
# ============================================
# Event Logging
# ============================================
def log_event(ip: str, client: str, event: str, endpoint: str | None = None):
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"ip": ip,
"client": client,
"event": event,
}
# Update endpoint cache when we see a packet
cache_file = os.path.join(os.path.dirname(WATCHLIST_FILE), 'endpoint_cache.json')
try:
with open(cache_file) as f:
cache = json.load(f)
except:
cache = {}
cache[client] = ip
with open(cache_file, 'w') as f:
json.dump(cache, f, indent=2)
if endpoint:
entry["endpoint"] = endpoint
try:
with EVENTS_LOG.open("a") as f:
f.write(json.dumps(entry) + "\n")
log.debug(f"Event logged: {entry}")
except Exception as e:
log.error(f"Failed to write event: {e}")
# ============================================
# Packet Handler
# ============================================
def handle_packet(pkt):
if not (IP in pkt and UDP in pkt):
return
# Only care about packets targeting WireGuard port
if pkt[UDP].dport != WG_PORT:
return
src_ip = pkt[IP].src
client = is_watched(src_ip)
if not client:
return
# Resolve real endpoint IP
public_key = get_client_public_key(client)
endpoint = None
if public_key:
endpoint = get_endpoint(public_key)
# If no endpoint from wg show, use packet source IP
if not endpoint:
endpoint = src_ip
log_event(src_ip, client, "attempt", endpoint)
log.info(f"Blocked attempt: {client} ({src_ip}) from endpoint {endpoint}")
# ============================================
# Signal Handling
# ============================================
def handle_signal(signum, frame):
log.info("Shutting down wgctl-monitor")
sys.exit(0)
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
# ============================================
# Main
# ============================================
def main():
log.info(f"wgctl-monitor starting on interface {WG_INTERFACE} port {WG_PORT}")
if not WATCHLIST_FILE.exists():
log.error(f"Watchlist not found: {WATCHLIST_FILE}")
sys.exit(1)
load_watchlist()
log.info("Watchlist loaded, starting packet capture...")
sniff(
iface=WG_INTERFACE,
filter=f"udp port {WG_PORT}",
prn=handle_packet,
store=0
)
if __name__ == "__main__":
main()

View file

@ -4,7 +4,7 @@ After=network.target wg-quick@wg0.service
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/bin/python3 /etc/wireguard/wgctl/daemon/wgctl-monitor.py ExecStart=/usr/bin/python3 /etc/wireguard/.wgctl/daemon/wgctl-monitor.py
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=WG_INTERFACE=eth0 Environment=WG_INTERFACE=eth0

68
install/install.sh Normal file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -e
WGCTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DATA_DIR="/etc/wireguard/.wgctl"
# ============================================
# Helpers
# ============================================
function install::dependencies() {
apt install -y wireguard ulogd2 ulogd2-json python3 qrencode
}
function install::make_structure() {
mkdir -p "${DATA_DIR}"/{rules,groups,blocks,meta,daemon}
}
function install::daemon_logs() {
touch "${DATA_DIR}/daemon/fw_events.log"
chown ulog:ulog "${DATA_DIR}/daemon/fw_events.log"
echo '{}' > "${DATA_DIR}/daemon/watchlist.json"
echo '{}' > "${DATA_DIR}/daemon/endpoint_cache.json"
}
function install::symlink() {
ln -sf /etc/wireguard/wgctl/wgctl /usr/local/bin/wgctl
}
function install::wgctl_service() {
cp "${WGCTL_DIR}/install/wgctl-monitor.service" /etc/systemd/system/
systemctl daemon-reload
systemctl enable wgctl-monitor
systemctl start wgctl-monitor
}
function install::ulogd() {
cp "${WGCTL_DIR}/install/ulogd.conf" /etc/ulogd.conf
systemctl restart ulogd2
}
function install::create_config() {
if [[ ! -f "${DATA_DIR}/wgctl.conf" ]]; then
cp "${WGCTL_DIR}/install/wgctl.conf.example" "${DATA_DIR}/wgctl.conf"
echo " Created ${DATA_DIR}/wgctl.conf — edit with your settings before using wgctl"
fi
}
# ============================================
# Main
# ============================================
function install::main() {
echo "Installing wgctl..."
install::dependencies
install::make_structure
install::daemon_logs
install::symlink
install::wgctl_service
install::ulogd
install::create_config
echo "wgctl installed successfully!"
echo " Edit ${DATA_DIR}/wgctl.conf before first use."
}
install::main

17
install/ulogd.conf Normal file
View file

@ -0,0 +1,17 @@
[global]
logfile="syslog"
loglevel=5
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_inppkt_NFLOG.so"
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_raw2packet_BASE.so"
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_filter_IP2STR.so"
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_output_JSON.so"
stack=log1:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
[log1]
group=1
[json1]
sync=1
file="/etc/wireguard/.wgctl/daemon/fw_events.log"

View file

@ -0,0 +1,15 @@
[Unit]
Description=wgctl WireGuard Monitor Daemon
After=network.target wg-quick@wg0.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /etc/wireguard/.wgctl/daemon/wgctl-monitor.py
Restart=always
RestartSec=5
Environment=WG_INTERFACE=eth0
Environment=WG_PORT=51820
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,9 @@
# wgctl configuration
# Copy to /etc/wireguard/.wgctl/wgctl.conf and edit
WG_INTERFACE=wg0
WG_ENDPOINT=wg.yourdomain.com:51820
WG_DNS=10.0.0.103
WG_LISTEN_PORT=51820
WG_SUBNET=10.1.0.0/16
WG_LAN=10.0.0.0/24

View file

@ -5,22 +5,65 @@
# ============================================ # ============================================
function config::on_load() { function config::on_load() {
config::_init_defaults
config::load
config::validate config::validate
fmt::set_date_format "${_FMT_DATE_FORMAT:-iso}"
} }
# ============================================ # ============================================
# Server # Defaults
# ============================================ # ============================================
WG_INTERFACE="wg0" function config::_init_defaults() {
WG_CONFIG="$(ctx::wg)/${WG_INTERFACE}.conf" _WG_INTERFACE="${WG_INTERFACE:-wg0}"
WG_SERVER_PUBLIC_KEY_FILE="$(ctx::wg)/server_public.key" _WG_DNS="${WG_DNS:-10.0.0.103}"
WG_SERVER_PRIVATE_KEY_FILE="$(ctx::wg)/server_private.key" _WG_LAN="${WG_LAN:-10.0.0.0/24}"
WG_ENDPOINT="wg.krilio.net:51820" _WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}"
WG_DNS="10.0.0.103" _WG_PORT="${WG_PORT:-51820}"
WG_LISTEN_PORT="51820" _WG_ENDPOINT="${WG_ENDPOINT:-}"
WG_SUBNET="10.1.0.0/16"
WG_LAN="10.0.0.0/24" # Derived
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
_WG_SERVER_PUBLIC_KEY_FILE="$(ctx::wg)/server_public.key"
_WG_SERVER_PRIVATE_KEY_FILE="$(ctx::wg)/server_private.key"
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
_WG_TUNNEL_FULL="0.0.0.0/0, ::/0"
}
# ============================================
# Load overrides from .wgctl/wgctl.conf
# ============================================
function config::load() {
local conf_file
conf_file="$(ctx::data)/wgctl.conf"
[[ ! -f "$conf_file" ]] && return 0
while IFS='=' read -r key value || [[ -n "$key" ]]; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
key="${key// /}"
value="${value// /}"
case "$key" in
WG_INTERFACE) _WG_INTERFACE="$value" ;;
WG_ENDPOINT) _WG_ENDPOINT="$value" ;;
WG_DNS) _WG_DNS="$value" ;;
WG_PORT) _WG_PORT="$value" ;;
WG_SUBNET) _WG_SUBNET="$value" ;;
WG_LAN) _WG_LAN="$value" ;;
# Add debug temporarily to config::load:
DATE_FORMAT)
log::debug "config: setting date format to $value"
_FMT_DATE_FORMAT="$value"
fmt::set_date_format "$value"
;;
esac
done < "$conf_file"
# Recompute derived values after overrides
_WG_CONFIG="$(ctx::wg)/${_WG_INTERFACE}.conf"
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
}
# ============================================ # ============================================
# Device Type → Subnet Mapping # Device Type → Subnet Mapping
@ -32,42 +75,44 @@ declare -gA DEVICE_SUBNETS=(
[phone]="10.1.3" [phone]="10.1.3"
[tablet]="10.1.4" [tablet]="10.1.4"
[guest]="10.1.100" [guest]="10.1.100"
[guest-desktop]="10.1.101"
[guest-laptop]="10.1.102"
[guest-phone]="10.1.103"
[guest-tablet]="10.1.104"
) )
# ============================================ # ============================================
# Tunnel Modes # Tunnel Modes
# ============================================ # ============================================
# split — route only VPN subnet + LAN through WireGuard
# full — route all traffic through WireGuard
WG_TUNNEL_SPLIT="${WG_SUBNET}, ${WG_LAN}"
WG_TUNNEL_FULL="0.0.0.0/0, ::/0"
# Default tunnel mode per device type
declare -gA DEVICE_TUNNEL_MODE=( declare -gA DEVICE_TUNNEL_MODE=(
[desktop]="split" [desktop]="split"
[laptop]="split" [laptop]="split"
[phone]="split" [phone]="split"
[tablet]="split" [tablet]="split"
[guest]="split" [guest]="split"
[guest-desktop]="split"
[guest-laptop]="split"
[guest-phone]="split"
[guest-tablet]="split"
) )
# ============================================ # ============================================
# Accessors # Accessors
# ============================================ # ============================================
function config::interface() { echo "$WG_INTERFACE"; } function config::interface() { echo "$_WG_INTERFACE"; }
function config::config_file() { echo "$WG_CONFIG"; } function config::config_file() { echo "$_WG_CONFIG"; }
function config::endpoint() { echo "$WG_ENDPOINT"; } function config::endpoint() { echo "$_WG_ENDPOINT"; }
function config::dns() { echo "$WG_DNS"; } function config::dns() { echo "$_WG_DNS"; }
function config::listen_port() { echo "$WG_LISTEN_PORT"; } function config::port() { echo "$_WG_PORT"; }
function config::subnet() { echo "$WG_SUBNET"; } function config::subnet() { echo "$_WG_SUBNET"; }
function config::lan() { echo "$WG_LAN"; } function config::lan() { echo "$_WG_LAN"; }
function config::tunnel_split() { echo "$WG_TUNNEL_SPLIT"; } function config::tunnel_split() { echo "$_WG_TUNNEL_SPLIT"; }
function config::tunnel_full() { echo "$WG_TUNNEL_FULL"; } function config::tunnel_full() { echo "$_WG_TUNNEL_FULL"; }
function config::server_public_key() { function config::server_public_key() {
cat "$WG_SERVER_PUBLIC_KEY_FILE" cat "$_WG_SERVER_PUBLIC_KEY_FILE"
} }
function config::device_types() { function config::device_types() {
@ -83,6 +128,11 @@ function config::is_valid_type() {
[[ -n "$subnet" ]] [[ -n "$subnet" ]]
} }
function config::is_guest_type() {
local type="$1"
[[ "$type" == "guest" || "$type" == guest-* ]]
}
function config::subnet_for() { function config::subnet_for() {
local type="$1" local type="$1"
local result local result
@ -101,14 +151,13 @@ function config::allowed_ips_for() {
local type="$1" local type="$1"
local tunnel="${2:-}" local tunnel="${2:-}"
# If tunnel mode not specified, use device default
if [[ -z "$tunnel" ]]; then if [[ -z "$tunnel" ]]; then
tunnel=$(config::default_tunnel_for "$type") tunnel=$(config::default_tunnel_for "$type")
fi fi
case "$tunnel" in case "$tunnel" in
full) echo "$WG_TUNNEL_FULL" ;; full) echo "$_WG_TUNNEL_FULL" ;;
split) echo "$WG_TUNNEL_SPLIT" ;; split) echo "$_WG_TUNNEL_SPLIT" ;;
*) *)
log::error "Unknown tunnel mode: ${tunnel} (use 'split' or 'full')" log::error "Unknown tunnel mode: ${tunnel} (use 'split' or 'full')"
return 1 return 1
@ -121,18 +170,18 @@ function config::allowed_ips_for() {
# ============================================ # ============================================
function config::validate() { function config::validate() {
if [[ ! -f "$WG_SERVER_PUBLIC_KEY_FILE" ]]; then if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
log::error "Server public key not found: ${WG_SERVER_PUBLIC_KEY_FILE}" log::error "Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}"
exit 1 exit 1
fi fi
if [[ ! -f "$WG_SERVER_PRIVATE_KEY_FILE" ]]; then if [[ ! -f "$_WG_SERVER_PRIVATE_KEY_FILE" ]]; then
log::error "Server private key not found: ${WG_SERVER_PRIVATE_KEY_FILE}" log::error "Server private key not found: ${_WG_SERVER_PRIVATE_KEY_FILE}"
exit 1 exit 1
fi fi
if [[ ! -f "$WG_CONFIG" ]]; then if [[ ! -f "$_WG_CONFIG" ]]; then
log::error "WireGuard config not found: ${WG_CONFIG}" log::error "WireGuard config not found: ${_WG_CONFIG}"
exit 1 exit 1
fi fi
} }

View file

@ -4,7 +4,7 @@
# Lifecycle # Lifecycle
# ============================================ # ============================================
function firewall::on_load() { function fw::on_load() {
system::require_command iptables system::require_command iptables
} }
@ -12,158 +12,108 @@ function firewall::on_load() {
# Rule Management # Rule Management
# ============================================ # ============================================
function firewall::block_ip() { function fw::block_ip() {
local client_ip="$1" local client_ip="$1" target_ip="$2"
local target_ip="$2" iptables -I FORWARD 1 -s "$client_ip" -d "$target_ip" -j DROP
iptables -I FORWARD 1 -s "$client_ip" -d "$target_ip" -j NFLOG --nflog-group 1 --nflog-prefix "wgctl-drop:"
iptables -A FORWARD -s "$client_ip" -d "$target_ip" -j LOG --log-prefix "wgctl-dropped: " --log-level 4
iptables -A FORWARD -s "$client_ip" -d "$target_ip" -j DROP
log::wg_block "Blocked ${client_ip}${target_ip}"
} }
function firewall::unblock_ip() { function fw::unblock_ip() {
local client_ip="$1" local client_ip="$1" target_ip="$2"
local target_ip="$2"
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -j LOG --log-prefix "wgctl-dropped: " --log-level 4 2>/dev/null || true iptables -D FORWARD -s "$client_ip" -d "$target_ip" -j LOG --log-prefix "wgctl-dropped: " --log-level 4 2>/dev/null || true
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -j DROP 2>/dev/null || true iptables -D FORWARD -s "$client_ip" -d "$target_ip" -j DROP 2>/dev/null || true
log::wg_unblock "Unblocked ${client_ip}${target_ip}"
} }
function firewall::block_port() { function fw::block_port() {
local client_ip="$1" local client_ip="$1" target_ip="$2" port="$3" proto="${4:-tcp}"
local target_ip="$2" iptables -I FORWARD 1 -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j DROP
local port="$3" iptables -I FORWARD 1 -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j NFLOG --nflog-group 1 --nflog-prefix "wgctl-drop:"
local proto="${4:-tcp}"
iptables -A FORWARD -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j DROP
log::wg_block "Blocked ${client_ip}${target_ip}:${port}/${proto}"
} }
function firewall::unblock_port() { function fw::unblock_port() {
local client_ip="$1" local client_ip="$1" target_ip="$2" port="$3" proto="${4:-tcp}"
local target_ip="$2"
local port="$3"
local proto="${4:-tcp}"
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j DROP 2>/dev/null || true iptables -D FORWARD -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j DROP 2>/dev/null || true
log::wg_unblock "Unblocked ${client_ip}${target_ip}:${port}/${proto}"
} }
function firewall::block_all() { function fw::block_all() {
local client_ip="$1" local client_ip="$1" client_name="$2"
local client_name="$2"
iptables -A FORWARD -s "$client_ip" -j LOG --log-prefix "wgctl-dropped: " --log-level 4
iptables -A FORWARD -s "$client_ip" -j DROP iptables -A FORWARD -s "$client_ip" -j DROP
log::wg_block "Blocked all traffic from: ${client_ip}" log::debug "Blocked all traffic from: ${client_ip}"
} }
function firewall::unblock_all() { function fw::unblock_all() {
local client_ip="$1" local client_ip="$1"
iptables -D FORWARD -s "$client_ip" -j LOG --log-prefix "wgctl-dropped: " --log-level 4 2>/dev/null || true
iptables -D FORWARD -s "$client_ip" -j DROP 2>/dev/null || true iptables -D FORWARD -s "$client_ip" -j DROP 2>/dev/null || true
monitor::unwatch "$client_ip" monitor::unwatch "$client_ip"
log::wg_unblock "Unblocked all traffic from: ${client_ip}" log::debug "Unblocked all traffic from: ${client_ip}"
} }
function firewall::block_subnet() { function fw::block_subnet() {
local client_ip="$1" local client_ip="$1" target_subnet="$2"
local target_subnet="$2"
iptables -A FORWARD -s "$client_ip" -d "$target_subnet" -j DROP iptables -A FORWARD -s "$client_ip" -d "$target_subnet" -j DROP
log::wg_block "Blocked ${client_ip} → subnet ${target_subnet}" log::wg_block "Blocked ${client_ip} → subnet ${target_subnet}"
} }
function firewall::unblock_subnet() { function fw::unblock_subnet() {
local client_ip="$1" local client_ip="$1" target_subnet="$2"
local target_subnet="$2"
iptables -D FORWARD -s "$client_ip" -d "$target_subnet" -j DROP 2>/dev/null || true iptables -D FORWARD -s "$client_ip" -d "$target_subnet" -j DROP 2>/dev/null || true
log::wg_unblock "Unblocked ${client_ip} → subnet ${target_subnet}" log::wg_unblock "Unblocked ${client_ip} → subnet ${target_subnet}"
} }
function fw::allow_ip() {
local client_ip="$1" target_ip="$2"
iptables -I FORWARD 1 -s "$client_ip" -d "$target_ip" -j ACCEPT
}
function fw::unallow_ip() {
local client_ip="$1" target_ip="$2"
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -j ACCEPT 2>/dev/null || true
}
function fw::allow_port() {
local client_ip="$1" target_ip="$2" port="$3" proto="${4:-tcp}"
iptables -I FORWARD 1 -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j ACCEPT
}
function fw::unallow_port() {
local client_ip="$1" target_ip="$2" port="$3" proto="${4:-tcp}"
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j ACCEPT 2>/dev/null || true
}
function fw::flush_peer() {
local client_ip="$1"
log::debug "flush_peer: starting for $client_ip"
# Collect line numbers into array
local linenums=()
while IFS= read -r linenum; do
[[ -n "$linenum" ]] && linenums+=("$linenum")
done < <(iptables -L FORWARD -n --line-numbers | grep -F "$client_ip" | awk '{print $1}')
# Delete in reverse order (highest number first)
local count=0
local i
for (( i=${#linenums[@]}-1; i>=0; i-- )); do
iptables -D FORWARD "${linenums[$i]}" 2>/dev/null || true
(( count++ )) || true
done
log::debug "flush_peer: removed $count FORWARD rules for: $client_ip"
}
# ============================================ # ============================================
# Guest Subnet Rules # Guest Subnet Rules
# ============================================ # ============================================
# Sensitive services blocked for all guest peers function fw::apply_dns_redirect() {
declare -ga GUEST_BLOCKED_SERVICES=( if iptables -t nat -C PREROUTING -s "$(config::subnet_for guest).0/24" -p udp --dport 53 -j DNAT --to-destination "$(config::dns):53" 2>/dev/null; then
"10.0.0.100:8006:tcp" # Proxmox UI
"10.0.0.100:22:tcp" # Proxmox SSH
"10.0.0.105:8007:tcp" # PBS UI
"10.0.0.102:22:tcp" # WireGuard LXC SSH
"10.0.0.200:80:tcp" # TrueNAS UI HTTP
"10.0.0.200:443:tcp" # TrueNAS UI HTTPS
"10.0.0.103:80:tcp" # Pi-hole WebUI
"10.0.0.101:80:tcp" # NPM WebUI HTTP
"10.0.0.101:443:tcp" # NPM WebUI HTTPS
"10.0.0.210:9000:tcp" # Portainer direct port
)
function firewall::guest_rules_applied() {
local guest_subnet
guest_subnet="$(config::subnet_for "guest").0/24"
# Check if at least the first rule exists
local first_entry="${GUEST_BLOCKED_SERVICES[0]}"
local target port proto
IFS=":" read -r target port proto <<< "$first_entry"
proto="${proto:-tcp}"
iptables -C FORWARD -s "$guest_subnet" -d "$target" -p "$proto" --dport "$port" -j DROP 2>/dev/null
}
function firewall::apply_guest_rules() {
local guest_subnet
guest_subnet="$(config::subnet_for "guest").0/24"
# Skip if already applied
if firewall::guest_rules_applied; then
log::wg "Guest firewall rules already applied"
return 0
fi
for entry in "${GUEST_BLOCKED_SERVICES[@]}"; do
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
iptables -I FORWARD 1 -s "$guest_subnet" -d "$target" -p "$proto" --dport "$port" -j DROP
log::wg_block "Guest rule: blocked ${guest_subnet}${target}:${port}/${proto}"
done
# Persist guest rules marker
local marker
marker="$(ctx::blocks)/_guest_rules.active"
touch "$marker"
log::wg_block "Applied guest firewall rules"
firewall::apply_guest_dns_redirect
}
function firewall::remove_guest_rules() {
local guest_subnet
guest_subnet="$(config::subnet_for "guest").0/24"
for entry in "${GUEST_BLOCKED_SERVICES[@]}"; do
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
iptables -D FORWARD -s "$guest_subnet" -d "$target" -p "$proto" --dport "$port" -j DROP 2>/dev/null || true
done
# Remove persistence marker
local marker
marker="$(ctx::blocks)/_guest_rules.active"
rm -f "$marker"
log::wg_unblock "Removed guest firewall rules"
firewall::remove_guest_dns_redirect
}
function firewall::apply_guest_dns_redirect() {
if iptables -t nat -C PREROUTING -i wg0 -s "$(config::subnet_for guest).0/24" -p udp --dport 53 -j DNAT --to-destination "$(config::dns):53" 2>/dev/null; then
log::wg "Guest DNS redirect already applied" log::wg "Guest DNS redirect already applied"
return 0 return 0
fi fi
@ -173,42 +123,42 @@ function firewall::apply_guest_dns_redirect() {
dns="$(config::dns)" dns="$(config::dns)"
# Log DNS bypass attempts (queries not directed at Pi-hole) # Log DNS bypass attempts (queries not directed at Pi-hole)
iptables -t nat -A PREROUTING -i wg0 -s "$guest_subnet" -p udp --dport 53 \ iptables -t nat -A PREROUTING -s "$guest_subnet" -p udp --dport 53 \
! -d "$dns" \ ! -d "$dns" \
-j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4 -j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4
iptables -t nat -A PREROUTING -i wg0 -s "$guest_subnet" -p tcp --dport 53 \ iptables -t nat -A PREROUTING -s "$guest_subnet" -p tcp --dport 53 \
! -d "$dns" \ ! -d "$dns" \
-j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4 -j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4
# Redirect all DNS to Pi-hole # Redirect all DNS to Pi-hole
iptables -t nat -A PREROUTING -i wg0 -s "$guest_subnet" -p udp --dport 53 -j DNAT --to-destination "${dns}:53" iptables -t nat -A PREROUTING -s "$guest_subnet" -p udp --dport 53 -j DNAT --to-destination "${dns}:53"
iptables -t nat -A PREROUTING -i wg0 -s "$guest_subnet" -p tcp --dport 53 -j DNAT --to-destination "${dns}:53" iptables -t nat -A PREROUTING -s "$guest_subnet" -p tcp --dport 53 -j DNAT --to-destination "${dns}:53"
log::wg_block "Guest DNS redirected to Pi-hole (${dns}), bypass attempts will be logged" log::wg_block "Guest DNS redirected to Pi-hole (${dns}), bypass attempts will be logged"
} }
function firewall::remove_guest_dns_redirect() { function fw::remove_dns_redirect() {
local guest_subnet dns local guest_subnet dns
guest_subnet="$(config::subnet_for "guest").0/24" guest_subnet="$(config::subnet_for "guest").0/24"
dns="$(config::dns)" dns="$(config::dns)"
iptables -t nat -D PREROUTING -i wg0 -s "$guest_subnet" -p udp --dport 53 \ iptables -t nat -D PREROUTING -s "$guest_subnet" -p udp --dport 53 \
! -d "$dns" \ ! -d "$dns" \
-j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4 2>/dev/null || true -j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4 2>/dev/null || true
iptables -t nat -D PREROUTING -i wg0 -s "$guest_subnet" -p tcp --dport 53 \ iptables -t nat -D PREROUTING -s "$guest_subnet" -p tcp --dport 53 \
! -d "$dns" \ ! -d "$dns" \
-j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4 2>/dev/null || true -j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4 2>/dev/null || true
iptables -t nat -D PREROUTING -i wg0 -s "$guest_subnet" -p udp --dport 53 -j DNAT --to-destination "${dns}:53" 2>/dev/null || true iptables -t nat -D PREROUTING -s "$guest_subnet" -p udp --dport 53 -j DNAT --to-destination "${dns}:53" 2>/dev/null || true
iptables -t nat -D PREROUTING -i wg0 -s "$guest_subnet" -p tcp --dport 53 -j DNAT --to-destination "${dns}:53" 2>/dev/null || true iptables -t nat -D PREROUTING -s "$guest_subnet" -p tcp --dport 53 -j DNAT --to-destination "${dns}:53" 2>/dev/null || true
log::wg_unblock "Removed guest DNS redirect" log::debug "Removed guest DNS redirect"
} }
# ============================================ # ============================================
# Persistence — block files # Persistence — block files
# ============================================ # ============================================
function firewall::save_block() { function fw::save_block() {
local name="$1" local name="$1"
local client_ip="$2" local client_ip="$2"
local target="${3:-}" local target="${3:-}"
@ -219,47 +169,40 @@ function firewall::save_block() {
block_file="$(ctx::block::path "${name}.block")" block_file="$(ctx::block::path "${name}.block")"
echo "${client_ip} ${target} ${port} ${proto}" >> "$block_file" echo "${client_ip} ${target} ${port} ${proto}" >> "$block_file"
log::wg_block "Persisted block rule for: ${name}" log::debug "Persisted block rule for: ${name}"
} }
function firewall::remove_block_file() { function fw::remove_block_file() {
local name="$1" local name="$1"
local block_file local block_file
block_file="$(ctx::block::path "${name}.block")" block_file="$(ctx::block::path "${name}.block")"
rm -f "$block_file" rm -f "$block_file"
log::wg_unblock "Removed block file for: ${name}" log::debug "Removed block file for: ${name}"
} }
function firewall::restore_blocks() { function fw::restore_blocks() {
local blocks_dir local blocks_dir
blocks_dir="$(ctx::blocks)" blocks_dir="$(ctx::blocks)"
# Restore guest rules if marker exists # Restore rules from meta files (new system)
local marker="${blocks_dir}/_guest_rules.active" rule::restore_all
if [[ -f "$marker" ]]; then
firewall::apply_guest_rules
log::wg "Restored guest firewall rules"
fi
# Restore per-client block rules # Restore per-client full-blocks (wgctl block/unblock system)
for block_file in "${blocks_dir}"/*.block; do for block_file in "${blocks_dir}"/*.block; do
[[ -f "$block_file" ]] || continue [[ -f "$block_file" ]] || continue
local name local name
name=$(basename "$block_file" .block) name=$(basename "$block_file" .block)
while IFS=" " read -r client_ip target port proto; do while IFS=" " read -r client_ip target port proto; do
if [[ -z "$target" ]]; then if [[ -z "$target" ]]; then
firewall::block_all "$client_ip" "$name" fw::block_all "$client_ip" "$name"
elif [[ -n "$port" ]]; then elif [[ -n "$port" ]]; then
firewall::block_port "$client_ip" "$target" "$port" "${proto:-tcp}" fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}"
else else
firewall::block_ip "$client_ip" "$target" fw::block_ip "$client_ip" "$target"
fi fi
done < "$block_file" done < "$block_file"
log::debug "Restored block rules for: ${name}"
log::wg "Restored block rules for: ${name}"
done done
} }
@ -267,7 +210,7 @@ function firewall::restore_blocks() {
# Preset Application # Preset Application
# ============================================ # ============================================
function firewall::apply_preset() { function fw::apply_preset() {
local name="$1" local name="$1"
local client_ip="$2" local client_ip="$2"
local preset_file local preset_file
@ -282,15 +225,15 @@ function firewall::apply_preset() {
if [[ -n "${BLOCK_IPS:-}" ]]; then if [[ -n "${BLOCK_IPS:-}" ]]; then
for ip in $BLOCK_IPS; do for ip in $BLOCK_IPS; do
firewall::block_ip "$client_ip" "$ip" fw::block_ip "$client_ip" "$ip"
firewall::save_block "$client_ip" "$client_ip" "$ip" fw::save_block "$client_ip" "$client_ip" "$ip"
done done
fi fi
if [[ -n "${BLOCK_SUBNETS:-}" ]]; then if [[ -n "${BLOCK_SUBNETS:-}" ]]; then
for subnet in $BLOCK_SUBNETS; do for subnet in $BLOCK_SUBNETS; do
firewall::block_subnet "$client_ip" "$subnet" fw::block_subnet "$client_ip" "$subnet"
firewall::save_block "$client_ip" "$client_ip" "$subnet" fw::save_block "$client_ip" "$client_ip" "$subnet"
done done
fi fi
@ -299,10 +242,10 @@ function firewall::apply_preset() {
local target port proto local target port proto
IFS=":" read -r target port proto <<< "$entry" IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}" proto="${proto:-tcp}"
firewall::block_port "$client_ip" "$target" "$port" "$proto" fw::block_port "$client_ip" "$target" "$port" "$proto"
firewall::save_block "$name" "$client_ip" "$target" "$port" "$proto" fw::save_block "$name" "$client_ip" "$target" "$port" "$proto"
done done
fi fi
log::wg_preset "Applied preset '${name}' to: ${client_ip}" log::debug "Applied preset '${name}' to: ${client_ip}"
} }

53
modules/group.module.sh Normal file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
function group::exists() {
local name="$1"
[[ -f "$(ctx::group::path "${name}.group")" ]]
}
function group::require_exists() {
local name="$1"
if ! group::exists "$name"; then
log::error "Group not found: ${name}"
return 1
fi
}
function group::path() {
local name="$1"
ctx::group::path "${name}.group"
}
function group::peers() {
local name="$1"
json::get "$(group::path "$name")" "peers"
}
function group::add_peer() {
local name="$1" peer="$2"
json::append "$(group::path "$name")" "peers" "$peer"
}
function group::remove_peer() {
local name="$1" peer="$2"
json::remove "$(group::path "$name")" "peers" "$peer"
}
function group::all() {
local dir
dir="$(ctx::groups)"
for f in "${dir}"/*.group; do
[[ -f "$f" ]] || continue
basename "$f" .group
done
}
function group::peer_groups() {
local peer_name="$1"
# Returns all groups that contain this peer
while IFS= read -r group_name; do
if group::peers "$group_name" | grep -qF "$peer_name"; then
echo "$group_name"
fi
done < <(group::all)
}

View file

@ -14,7 +14,7 @@ function internal::get_log_priority() {
case "$1" in case "$1" in
DEBUG) echo 0 ;; DEBUG) echo 0 ;;
INFO) echo 1 ;; INFO) echo 1 ;;
SUCCESS) echo 1 ;; # FIX: SUCCESS is informational, not above ERROR SUCCESS) echo 1 ;;
WARN) echo 2 ;; WARN) echo 2 ;;
ERROR) echo 3 ;; ERROR) echo 3 ;;
*) echo 1 ;; *) echo 1 ;;
@ -25,9 +25,15 @@ function internal::log() {
local level="$1" local level="$1"
shift shift
# Quiet mode — suppress INFO and SUCCESS
if core::is_quiet; then
case "$level" in
INFO|SUCCESS) return 0 ;;
esac
fi
local current_priority local current_priority
local message_priority local message_priority
current_priority=$(internal::get_log_priority "$LOG_LEVEL") current_priority=$(internal::get_log_priority "$LOG_LEVEL")
message_priority=$(internal::get_log_priority "$level") message_priority=$(internal::get_log_priority "$level")
@ -37,11 +43,11 @@ function internal::log() {
local color local color
case "$level" in case "$level" in
DEBUG) color="\033[0;36m" ;; # FIX: actual cyan (was white \033[0;37m) DEBUG) color="\033[0;36m" ;;
INFO) color="\033[1;34m" ;; # blue INFO) color="\033[1;34m" ;;
WARN) color="\033[1;33m" ;; # yellow WARN) color="\033[1;33m" ;;
ERROR) color="\033[1;31m" ;; # red ERROR) color="\033[1;31m" ;;
SUCCESS) color="\033[1;32m" ;; # green SUCCESS) color="\033[1;32m" ;;
esac esac
echo -e "${color}=> ${level}:\033[0m $*" echo -e "${color}=> ${level}:\033[0m $*"
@ -103,14 +109,13 @@ function internal::icon() {
db:warning) echo "⚠️ 🗄️ " ;; db:warning) echo "⚠️ 🗄️ " ;;
db:error) echo "❌ 🗄️ " ;; db:error) echo "❌ 🗄️ " ;;
# ADDED: WireGuard context
wg:log) echo "🔒 " ;; wg:log) echo "🔒 " ;;
wg:start) echo "🟢 🔒 " ;; wg:start) echo "🟢 🔒 " ;;
wg:stop) echo "🔴 🔒 " ;; wg:stop) echo "🔴 🔒 " ;;
wg:add) echo " 🔒 " ;; wg:add) echo " 🔒 " ;;
wg:remove) echo " 🔒 " ;; wg:remove) echo " 🔒 " ;;
wg:block) echo "🚫 🔒 " ;; wg:block) echo "🚫 🔒 " ;;
wg:unblock) echo " 🔒 " ;; wg:unblock) echo "🔓 🔒 " ;;
wg:key) echo "🔑 🔒 " ;; wg:key) echo "🔑 🔒 " ;;
wg:success) echo "✅ 🔒 " ;; wg:success) echo "✅ 🔒 " ;;
wg:warning) echo "⚠️ 🔒 " ;; wg:warning) echo "⚠️ 🔒 " ;;
@ -123,7 +128,7 @@ function internal::icon() {
log:warn) echo "⚠️ " ;; log:warn) echo "⚠️ " ;;
log:error) echo "❌ " ;; log:error) echo "❌ " ;;
log:success) echo "✅ " ;; log:success) echo "✅ " ;;
log:debug) echo "🔍 " ;; # FIX: was missing, caused fallthrough log:debug) echo "🔍 " ;;
*) echo "🔹" ;; *) echo "🔹" ;;
esac esac
@ -138,8 +143,8 @@ function internal::get_context_icon() {
env) echo "⚙️" ;; env) echo "⚙️" ;;
fs) echo "📁" ;; fs) echo "📁" ;;
db) echo "🗄️" ;; db) echo "🗄️" ;;
wg) echo "🔒" ;; # ADDED: missing from original wg) echo "🔒" ;;
log) echo "🔹" ;; # ADDED: missing from original log) echo "🔹" ;;
*) echo "🔹" ;; *) echo "🔹" ;;
esac esac
} }
@ -159,36 +164,31 @@ function internal::log::debug() { internal::log DEBUG "$*"; }
# ============================================ # ============================================
function log::context() { function log::context() {
local context="$1" local context="$1" action="$2"
local action="$2"
shift 2 shift 2
internal::log::info "$(internal::icon "$context" "$action") $*" internal::log::info "$(internal::icon "$context" "$action") $*"
} }
function log::warn_context() { function log::warn_context() {
local context="$1" local context="$1" action="$2"
local action="$2"
shift 2 shift 2
internal::log::warn "$(internal::icon "$context" "$action") $*" internal::log::warn "$(internal::icon "$context" "$action") $*"
} }
function log::error_context() { function log::error_context() {
local context="$1" local context="$1" action="$2"
local action="$2"
shift 2 shift 2
internal::log::error "$(internal::icon "$context" "$action") $*" internal::log::error "$(internal::icon "$context" "$action") $*"
} }
function log::success_context() { function log::success_context() {
local context="$1" local context="$1" action="$2"
local action="$2"
shift 2 shift 2
internal::log::success "$(internal::icon "$context" "$action") $*" internal::log::success "$(internal::icon "$context" "$action") $*"
} }
function log::debug_context() { function log::debug_context() {
local context="$1" local context="$1" action="$2"
local action="$2"
shift 2 shift 2
internal::log::debug "$(internal::icon "$context" "$action") $*" internal::log::debug "$(internal::icon "$context" "$action") $*"
} }
@ -201,10 +201,10 @@ function log::info() { log::context log info "$@"; }
function log::warn() { log::warn_context log warn "$@"; } function log::warn() { log::warn_context log warn "$@"; }
function log::error() { log::error_context log error "$@"; } function log::error() { log::error_context log error "$@"; }
function log::success() { log::success_context log success "$@"; } function log::success() { log::success_context log success "$@"; }
function log::debug() { log::debug_context log debug "$@"; } # FIX: was calling warn_context function log::debug() { log::debug_context log debug "$@"; }
# ADDED: section separator for visual grouping in output
function log::section() { function log::section() {
core::is_quiet && return 0
local label="$1" local label="$1"
local width=48 local width=48
local line local line
@ -214,16 +214,24 @@ function log::section() {
echo -e "\033[1;34m${line}\033[0m" echo -e "\033[1;34m${line}\033[0m"
} }
# ============================================
# Docker
# ============================================
function log::docker() { log::context docker log "$@"; } function log::docker() { log::context docker log "$@"; }
function log::docker_start() { log::context docker start "$@"; } function log::docker_start() { log::context docker start "$@"; }
function log::docker_stop() { log::context docker stop "$@"; } function log::docker_stop() { log::context docker stop "$@"; }
function log::docker_success() { log::success_context docker success "$@"; } # FIX: was using context not success_context function log::docker_success() { log::success_context docker success "$@"; }
function log::docker_logs() { log::context docker logs "$@"; } function log::docker_logs() { log::context docker logs "$@"; }
function log::docker_list() { log::context docker list "$@"; } function log::docker_list() { log::context docker list "$@"; }
function log::docker_build() { log::context docker build "$@"; } function log::docker_build() { log::context docker build "$@"; }
function log::docker_warning() { log::warn_context docker warning "$@"; } function log::docker_warning() { log::warn_context docker warning "$@"; }
function log::docker_error() { log::error_context docker error "$@"; } function log::docker_error() { log::error_context docker error "$@"; }
# ============================================
# Build
# ============================================
function log::build() { log::context build log "$@"; } function log::build() { log::context build log "$@"; }
function log::build_start() { log::context build start "$@"; } function log::build_start() { log::context build start "$@"; }
function log::build_stop() { log::context build stop "$@"; } function log::build_stop() { log::context build stop "$@"; }
@ -231,6 +239,10 @@ function log::build_success() { log::success_context build success "$@"; }
function log::build_warning() { log::warn_context build warning "$@"; } function log::build_warning() { log::warn_context build warning "$@"; }
function log::build_error() { log::error_context build error "$@"; } function log::build_error() { log::error_context build error "$@"; }
# ============================================
# Network
# ============================================
function log::network() { log::context network log "$@"; } function log::network() { log::context network log "$@"; }
function log::network_setup() { log::context network setup "$@"; } function log::network_setup() { log::context network setup "$@"; }
function log::network_stop() { log::context network stop "$@"; } function log::network_stop() { log::context network stop "$@"; }
@ -238,6 +250,10 @@ function log::network_success() { log::success_context network success "$@"; }
function log::network_warning() { log::warn_context network warning "$@"; } function log::network_warning() { log::warn_context network warning "$@"; }
function log::network_error() { log::error_context network error "$@"; } function log::network_error() { log::error_context network error "$@"; }
# ============================================
# Auth
# ============================================
function log::auth() { log::context auth log "$@"; } function log::auth() { log::context auth log "$@"; }
function log::auth_setup() { log::context auth setup "$@"; } function log::auth_setup() { log::context auth setup "$@"; }
function log::auth_login() { log::context auth login "$@"; } function log::auth_login() { log::context auth login "$@"; }
@ -245,12 +261,20 @@ function log::auth_success() { log::success_context auth success "$@"; }
function log::auth_warning() { log::warn_context auth warning "$@"; } function log::auth_warning() { log::warn_context auth warning "$@"; }
function log::auth_error() { log::error_context auth error "$@"; } function log::auth_error() { log::error_context auth error "$@"; }
# ============================================
# Env
# ============================================
function log::env() { log::context env log "$@"; } function log::env() { log::context env log "$@"; }
function log::env_load() { log::context env load "$@"; } function log::env_load() { log::context env load "$@"; }
function log::env_success() { log::success_context env success "$@"; } function log::env_success() { log::success_context env success "$@"; }
function log::env_warning() { log::warn_context env warning "$@"; } function log::env_warning() { log::warn_context env warning "$@"; }
function log::env_error() { log::error_context env error "$@"; } function log::env_error() { log::error_context env error "$@"; }
# ============================================
# Filesystem
# ============================================
function log::fs() { log::context fs log "$@"; } function log::fs() { log::context fs log "$@"; }
function log::fs_read() { log::context fs read "$@"; } function log::fs_read() { log::context fs read "$@"; }
function log::fs_write() { log::context fs write "$@"; } function log::fs_write() { log::context fs write "$@"; }
@ -258,6 +282,10 @@ function log::fs_success() { log::success_context fs success "$@"; }
function log::fs_warning() { log::warn_context fs warning "$@"; } function log::fs_warning() { log::warn_context fs warning "$@"; }
function log::fs_error() { log::error_context fs error "$@"; } function log::fs_error() { log::error_context fs error "$@"; }
# ============================================
# Database
# ============================================
function log::db() { log::context db log "$@"; } function log::db() { log::context db log "$@"; }
function log::db_start() { log::context db start "$@"; } function log::db_start() { log::context db start "$@"; }
function log::db_migrate() { log::context db migrate "$@"; } function log::db_migrate() { log::context db migrate "$@"; }
@ -265,14 +293,15 @@ function log::db_success() { log::success_context db success "$@"; }
function log::db_warning() { log::warn_context db warning "$@"; } function log::db_warning() { log::warn_context db warning "$@"; }
function log::db_error() { log::error_context db error "$@"; } function log::db_error() { log::error_context db error "$@"; }
# ADDED: WireGuard context helpers # ============================================
# WireGuard
# ============================================
function log::wg() { log::context wg log "$@"; } function log::wg() { log::context wg log "$@"; }
function log::wg_start() { log::context wg start "$@"; } function log::wg_start() { log::context wg start "$@"; }
function log::wg_stop() { log::context wg stop "$@"; } function log::wg_stop() { log::context wg stop "$@"; }
function log::wg_add() { log::context wg add "$@"; } function log::wg_add() { log::context wg add "$@"; }
function log::wg_remove() { log::context wg remove "$@"; } function log::wg_remove() { log::context wg remove "$@"; }
function log::wg_block() { log::context wg block "$@"; }
function log::wg_unblock() { log::context wg unblock "$@"; }
function log::wg_key() { log::context wg key "$@"; } function log::wg_key() { log::context wg key "$@"; }
function log::wg_list() { log::context wg list "$@"; } function log::wg_list() { log::context wg list "$@"; }
function log::wg_qr() { log::context wg qr "$@"; } function log::wg_qr() { log::context wg qr "$@"; }
@ -281,6 +310,18 @@ function log::wg_success() { log::success_context wg success "$@"; }
function log::wg_warning() { log::warn_context wg warning "$@"; } function log::wg_warning() { log::warn_context wg warning "$@"; }
function log::wg_error() { log::error_context wg error "$@"; } function log::wg_error() { log::error_context wg error "$@"; }
function log::wg_block() {
log::context wg block "$@"
}
function log::wg_unblock() {
log::context wg unblock "$@"
}
# ============================================
# Run Step
# ============================================
function log::run_step() { function log::run_step() {
local context="$1" local context="$1"
local mode="strict" local mode="strict"
@ -309,9 +350,7 @@ function log::run_step() {
local status=$? local status=$?
if [[ $status -eq 0 ]]; then if [[ $status -eq 0 ]]; then
if [[ "$mode" == "info" ]]; then [[ "$mode" == "info" ]] && return 0
return 0
fi
internal::log::success "$icon $description" internal::log::success "$icon $description"
return 0 return 0
fi fi

326
modules/log.module.sh.bak Normal file
View file

@ -0,0 +1,326 @@
#!/usr/bin/env bash
# ============================================
# Core
# ============================================
LOG_LEVEL=${LOG_LEVEL:-INFO}
# ============================================
# Internal
# ============================================
function internal::get_log_priority() {
case "$1" in
DEBUG) echo 0 ;;
INFO) echo 1 ;;
SUCCESS) echo 1 ;; # FIX: SUCCESS is informational, not above ERROR
WARN) echo 2 ;;
ERROR) echo 3 ;;
*) echo 1 ;;
esac
}
function internal::log() {
local level="$1"
shift
local current_priority
local message_priority
current_priority=$(internal::get_log_priority "$LOG_LEVEL")
message_priority=$(internal::get_log_priority "$level")
if (( message_priority < current_priority )); then
return 0
fi
local color
case "$level" in
DEBUG) color="\033[0;36m" ;; # FIX: actual cyan (was white \033[0;37m)
INFO) color="\033[1;34m" ;; # blue
WARN) color="\033[1;33m" ;; # yellow
ERROR) color="\033[1;31m" ;; # red
SUCCESS) color="\033[1;32m" ;; # green
esac
echo -e "${color}=> ${level}:\033[0m $*"
}
function internal::icon() {
local context="$1"
local action="$2"
case "$context:$action" in
docker:log) echo "🐳 " ;;
docker:start) echo "🟢 🐳 " ;;
docker:stop) echo "🔴 🐳 " ;;
docker:success) echo "✅ 🐳 " ;;
docker:warning) echo "⚠️ 🐳 " ;;
docker:error) echo "❌ 🐳 " ;;
docker:logs) echo "📜 🐳 " ;;
docker:list) echo "🔍 🐳 " ;;
docker:build) echo "📦 🐳 " ;;
build:log) echo "🏗️ " ;;
build:start) echo "🟢 🏗️ " ;;
build:stop) echo "🔴 🏗️ " ;;
build:success) echo "✅ 🏗️ " ;;
build:warning) echo "⚠️ 🏗️ " ;;
build:error) echo "❌ 🏗️ " ;;
network:log) echo "🌐 " ;;
network:setup) echo "⚙️ 🌐 " ;;
network:stop) echo "🔴 🌐 " ;;
network:success) echo "✅ 🌐 " ;;
network:warning) echo "⚠️ 🌐 " ;;
network:error) echo "❌ 🌐 " ;;
auth:log) echo "🔑 " ;;
auth:setup) echo "⚙️ 🔑 " ;;
auth:login) echo "🔐 🔑 " ;;
auth:success) echo "✅ 🔑 " ;;
auth:warning) echo "⚠️ 🔑 " ;;
auth:error) echo "❌ 🔑 " ;;
env:log) echo "⚙️ " ;;
env:load) echo "📥 ⚙️ " ;;
env:success) echo "✅ ⚙️ " ;;
env:warning) echo "⚠️ ⚙️ " ;;
env:error) echo "❌ ⚙️ " ;;
fs:log) echo "📁 " ;;
fs:read) echo "📥 📁 " ;;
fs:write) echo "📤 📁 " ;;
fs:success) echo "✅ 📁 " ;;
fs:warning) echo "⚠️ 📁 " ;;
fs:error) echo "❌ 📁 " ;;
db:log) echo "🗄️ " ;;
db:start) echo "🟢 🗄️ " ;;
db:migrate) echo "📜 🗄️ " ;;
db:success) echo "✅ 🗄️ " ;;
db:warning) echo "⚠️ 🗄️ " ;;
db:error) echo "❌ 🗄️ " ;;
# ADDED: WireGuard context
wg:log) echo "🔒 " ;;
wg:start) echo "🟢 🔒 " ;;
wg:stop) echo "🔴 🔒 " ;;
wg:add) echo " 🔒 " ;;
wg:remove) echo " 🔒 " ;;
wg:block) echo "🚫 🔒 " ;;
wg:unblock) echo "✅ 🔒 " ;;
wg:key) echo "🔑 🔒 " ;;
wg:success) echo "✅ 🔒 " ;;
wg:warning) echo "⚠️ 🔒 " ;;
wg:error) echo "❌ 🔒 " ;;
wg:list) echo "🔍 🔒 " ;;
wg:qr) echo "📱 🔒 " ;;
wg:preset) echo "📋 🔒 " ;;
log:info) echo "🔹 " ;;
log:warn) echo "⚠️ " ;;
log:error) echo "❌ " ;;
log:success) echo "✅ " ;;
log:debug) echo "🔍 " ;; # FIX: was missing, caused fallthrough
*) echo "🔹" ;;
esac
}
function internal::get_context_icon() {
case "$1" in
docker) echo "🐳" ;;
build) echo "🏗️" ;;
network) echo "🌐" ;;
auth) echo "🔑" ;;
env) echo "⚙️" ;;
fs) echo "📁" ;;
db) echo "🗄️" ;;
wg) echo "🔒" ;; # ADDED: missing from original
log) echo "🔹" ;; # ADDED: missing from original
*) echo "🔹" ;;
esac
}
# ============================================
# Loggers
# ============================================
function internal::log::info() { internal::log INFO "$*"; }
function internal::log::warn() { internal::log WARN "$*"; }
function internal::log::error() { internal::log ERROR "$*"; }
function internal::log::success() { internal::log SUCCESS "$*"; }
function internal::log::debug() { internal::log DEBUG "$*"; }
# ============================================
# Context Loggers
# ============================================
function log::context() {
local context="$1"
local action="$2"
shift 2
internal::log::info "$(internal::icon "$context" "$action") $*"
}
function log::warn_context() {
local context="$1"
local action="$2"
shift 2
internal::log::warn "$(internal::icon "$context" "$action") $*"
}
function log::error_context() {
local context="$1"
local action="$2"
shift 2
internal::log::error "$(internal::icon "$context" "$action") $*"
}
function log::success_context() {
local context="$1"
local action="$2"
shift 2
internal::log::success "$(internal::icon "$context" "$action") $*"
}
function log::debug_context() {
local context="$1"
local action="$2"
shift 2
internal::log::debug "$(internal::icon "$context" "$action") $*"
}
# ============================================
# Logger Helpers
# ============================================
function log::info() { log::context log info "$@"; }
function log::warn() { log::warn_context log warn "$@"; }
function log::error() { log::error_context log error "$@"; }
function log::success() { log::success_context log success "$@"; }
function log::debug() { log::debug_context log debug "$@"; } # FIX: was calling warn_context
# ADDED: section separator for visual grouping in output
function log::section() {
local label="$1"
local width=48
local line
line=$(printf '─%.0s' $(seq 1 $width))
echo -e "\n\033[1;34m${line}\033[0m"
echo -e "\033[1;34m $label\033[0m"
echo -e "\033[1;34m${line}\033[0m"
}
function log::docker() { log::context docker log "$@"; }
function log::docker_start() { log::context docker start "$@"; }
function log::docker_stop() { log::context docker stop "$@"; }
function log::docker_success() { log::success_context docker success "$@"; } # FIX: was using context not success_context
function log::docker_logs() { log::context docker logs "$@"; }
function log::docker_list() { log::context docker list "$@"; }
function log::docker_build() { log::context docker build "$@"; }
function log::docker_warning() { log::warn_context docker warning "$@"; }
function log::docker_error() { log::error_context docker error "$@"; }
function log::build() { log::context build log "$@"; }
function log::build_start() { log::context build start "$@"; }
function log::build_stop() { log::context build stop "$@"; }
function log::build_success() { log::success_context build success "$@"; }
function log::build_warning() { log::warn_context build warning "$@"; }
function log::build_error() { log::error_context build error "$@"; }
function log::network() { log::context network log "$@"; }
function log::network_setup() { log::context network setup "$@"; }
function log::network_stop() { log::context network stop "$@"; }
function log::network_success() { log::success_context network success "$@"; }
function log::network_warning() { log::warn_context network warning "$@"; }
function log::network_error() { log::error_context network error "$@"; }
function log::auth() { log::context auth log "$@"; }
function log::auth_setup() { log::context auth setup "$@"; }
function log::auth_login() { log::context auth login "$@"; }
function log::auth_success() { log::success_context auth success "$@"; }
function log::auth_warning() { log::warn_context auth warning "$@"; }
function log::auth_error() { log::error_context auth error "$@"; }
function log::env() { log::context env log "$@"; }
function log::env_load() { log::context env load "$@"; }
function log::env_success() { log::success_context env success "$@"; }
function log::env_warning() { log::warn_context env warning "$@"; }
function log::env_error() { log::error_context env error "$@"; }
function log::fs() { log::context fs log "$@"; }
function log::fs_read() { log::context fs read "$@"; }
function log::fs_write() { log::context fs write "$@"; }
function log::fs_success() { log::success_context fs success "$@"; }
function log::fs_warning() { log::warn_context fs warning "$@"; }
function log::fs_error() { log::error_context fs error "$@"; }
function log::db() { log::context db log "$@"; }
function log::db_start() { log::context db start "$@"; }
function log::db_migrate() { log::context db migrate "$@"; }
function log::db_success() { log::success_context db success "$@"; }
function log::db_warning() { log::warn_context db warning "$@"; }
function log::db_error() { log::error_context db error "$@"; }
# ADDED: WireGuard context helpers
function log::wg() { log::context wg log "$@"; }
function log::wg_start() { log::context wg start "$@"; }
function log::wg_stop() { log::context wg stop "$@"; }
function log::wg_add() { log::context wg add "$@"; }
function log::wg_remove() { log::context wg remove "$@"; }
function log::wg_block() { log::context wg block "$@"; }
function log::wg_unblock() { log::context wg unblock "$@"; }
function log::wg_key() { log::context wg key "$@"; }
function log::wg_list() { log::context wg list "$@"; }
function log::wg_qr() { log::context wg qr "$@"; }
function log::wg_preset() { log::context wg preset "$@"; }
function log::wg_success() { log::success_context wg success "$@"; }
function log::wg_warning() { log::warn_context wg warning "$@"; }
function log::wg_error() { log::error_context wg error "$@"; }
function log::run_step() {
local context="$1"
local mode="strict"
local description
shift
if [[ "$1" == "soft" || "$1" == "strict" || "$1" == "info" ]]; then
mode="$1"
shift
fi
description="$1"
shift
local icon
icon=$(internal::get_context_icon "$context")
if [[ "$mode" == "info" ]]; then
internal::log::info "$icon $description"
else
internal::log::info "🔄 $icon $description"
fi
"$@"
local status=$?
if [[ $status -eq 0 ]]; then
if [[ "$mode" == "info" ]]; then
return 0
fi
internal::log::success "$icon $description"
return 0
fi
if [[ "$mode" == "soft" || "$mode" == "info" ]]; then
internal::log::warn "⚠️ $icon $description → skipped"
return 0
fi
internal::log::error "$icon $description → failed"
return $status
}

View file

@ -4,9 +4,11 @@
# Config # Config
# ============================================ # ============================================
MONITOR_DIR="$(ctx::root)/daemon" MONITOR_DIR="$(ctx::daemon)"
WATCHLIST_FILE="${MONITOR_DIR}/watchlist.json" WATCHLIST_FILE="${MONITOR_DIR}/watchlist.json"
EVENTS_LOG="${MONITOR_DIR}/events.log" EVENTS_LOG="${MONITOR_DIR}/events.log"
ENDPOINT_CACHE="${MONITOR_DIR}/endpoint_cache.json"
FW_EVENTS_LOG="${MONITOR_DIR}/fw_events.log"
MONITOR_SERVICE="wgctl-monitor" MONITOR_SERVICE="wgctl-monitor"
# ============================================ # ============================================
@ -27,55 +29,25 @@ function monitor::on_load() {
# ============================================ # ============================================
function monitor::watch() { function monitor::watch() {
local ip="$1" local ip="$1" client="$2"
local client="$2" json::set "$WATCHLIST_FILE" "$ip" "$client"
log::debug "Watching: ${client} (${ip})"
python3 -c "
import json
with open('${WATCHLIST_FILE}') as f:
wl = json.load(f)
wl['${ip}'] = '${client}'
with open('${WATCHLIST_FILE}', 'w') as f:
json.dump(wl, f, indent=2)
"
log::wg "Watching: ${client} (${ip})"
} }
function monitor::unwatch() { function monitor::unwatch() {
local ip="$1" local ip="$1"
json::delete "$WATCHLIST_FILE" "$ip"
python3 -c "
import json
with open('${WATCHLIST_FILE}') as f:
wl = json.load(f)
wl.pop('${ip}', None)
with open('${WATCHLIST_FILE}', 'w') as f:
json.dump(wl, f, indent=2)
"
log::wg "Unwatched: ${ip}"
} }
function monitor::is_watched() { function monitor::is_watched() {
local ip="$1" local ip="$1"
python3 -c " json::has_key "$WATCHLIST_FILE" "$ip"
import json
with open('${WATCHLIST_FILE}') as f:
wl = json.load(f)
exit(0 if '${ip}' in wl else 1)
"
} }
function monitor::unwatch_client() { function monitor::unwatch_client() {
local name="$1" local name="$1"
python3 -c " json::filter_values "$WATCHLIST_FILE" "value" "$name"
import json log::debug "Unwatched client: ${name}"
with open('${WATCHLIST_FILE}') as f:
wl = json.load(f)
wl = {k: v for k, v in wl.items() if v != '${name}'}
with open('${WATCHLIST_FILE}', 'w') as f:
json.dump(wl, f, indent=2)
"
log::wg "Unwatched client: ${name}"
} }
# ============================================ # ============================================
@ -84,82 +56,18 @@ with open('${WATCHLIST_FILE}', 'w') as f:
function monitor::last_attempt() { function monitor::last_attempt() {
local client="$1" local client="$1"
python3 -c " json::last_event "$EVENTS_LOG" "client" "timestamp" "$client"
import json
events = []
try:
with open('${EVENTS_LOG}') as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('client') == '${client}':
events.append(e)
except:
pass
except:
pass
if events:
print(events[-1].get('timestamp', ''))
"
} }
function monitor::last_endpoint() { function monitor::last_endpoint() {
local client="$1" local client="$1"
python3 -c " json::last_event "$EVENTS_LOG" "client" "endpoint" "$client"
import json
events = []
try:
with open('${EVENTS_LOG}') as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('client') == '${client}' and e.get('endpoint'):
events.append(e)
except:
pass
except:
pass
if events:
print(events[-1].get('endpoint', ''))
"
} }
function monitor::events_for() { function monitor::events_for() {
local ip="$1" local ip="$1"
local limit="${2:-50}" local limit="${2:-50}"
python3 -c " json::events_for "$EVENTS_LOG" "$ip" "$limit"
import json
from datetime import datetime, timezone
events = []
try:
with open('${EVENTS_LOG}') as f:
for line in f:
try:
e = json.loads(line.strip())
if e.get('ip') == '${ip}':
events.append(e)
except:
pass
except:
pass
for e in events[-${limit}:]:
ts = e.get('timestamp', '')
try:
dt = datetime.fromisoformat(ts)
ts = dt.strftime('%Y-%m-%d %H:%M:%S')
except:
pass
endpoint = e.get('endpoint', '—')
client = e.get('client', '—')
event = e.get('event', '—')
print(f' {ts} {client:<20} {endpoint:<20} {event}')
"
} }
# ============================================ # ============================================
@ -169,35 +77,13 @@ for e in events[-${limit}:]:
ENDPOINT_CACHE="${WGCTL_DIR}/daemon/endpoint_cache.json" ENDPOINT_CACHE="${WGCTL_DIR}/daemon/endpoint_cache.json"
function monitor::cache_endpoint() { function monitor::cache_endpoint() {
local client="$1" local client="$1" ip="$2"
local endpoint="$2" json::set "$ENDPOINT_CACHE" "$client" "$ip"
[[ -z "$endpoint" || "$endpoint" == "(none)" ]] && return 0
python3 -c "
import json
cache = {}
try:
with open('${ENDPOINT_CACHE}') as f:
cache = json.load(f)
except:
pass
cache['${client}'] = '${endpoint}'
with open('${ENDPOINT_CACHE}', 'w') as f:
json.dump(cache, f, indent=2)
"
} }
function monitor::get_cached_endpoint() { function monitor::get_cached_endpoint() {
local client="$1" local client="$1"
python3 -c " json::get "$ENDPOINT_CACHE" "$client"
import json
try:
with open('${ENDPOINT_CACHE}') as f:
cache = json.load(f)
print(cache.get('${client}', ''))
except:
print('')
"
} }
function monitor::update_endpoint_cache() { function monitor::update_endpoint_cache() {
@ -229,17 +115,17 @@ function monitor::endpoint_for_key() {
function monitor::start() { function monitor::start() {
systemctl start "$MONITOR_SERVICE" systemctl start "$MONITOR_SERVICE"
log::wg_start "Monitor daemon started" log::debug "Monitor daemon started"
} }
function monitor::stop() { function monitor::stop() {
systemctl stop "$MONITOR_SERVICE" systemctl stop "$MONITOR_SERVICE"
log::wg_stop "Monitor daemon stopped" log::debug "Monitor daemon stopped"
} }
function monitor::restart() { function monitor::restart() {
systemctl restart "$MONITOR_SERVICE" systemctl restart "$MONITOR_SERVICE"
log::wg_start "Monitor daemon restarted" log::debug "Monitor daemon restarted"
} }
function monitor::is_running() { function monitor::is_running() {

View file

@ -38,7 +38,7 @@ PersistentKeepalive = 25
EOF EOF
chmod 600 "$conf" chmod 600 "$conf"
log::wg_add "Created client config: ${name} (${ip})" log::debug "Created client config: ${name} (${ip})"
} }
function peers::remove_client_config() { function peers::remove_client_config() {
@ -52,7 +52,7 @@ function peers::remove_client_config() {
fi fi
rm -f "$conf" rm -f "$conf"
log::wg_remove "Removed client config: ${name}" log::debug "Removed client config: ${name}"
} }
# ============================================ # ============================================
@ -93,7 +93,7 @@ PublicKey = ${public_key}
AllowedIPs = ${ip}/32 AllowedIPs = ${ip}/32
EOF EOF
log::wg_add "Added peer to server config: ${name}" log::debug "Added peer to server config: ${name}"
} }
function peers::remove_block() { function peers::remove_block() {
@ -114,7 +114,7 @@ function peers::remove_from_server() {
local name="$1" local name="$1"
peers::remove_block "$name" peers::remove_block "$name"
peers::cleanup_config peers::cleanup_config
log::wg_remove "Removed peer from server config: ${name}" log::debug "Removed peer from server config: ${name}"
} }
# ============================================ # ============================================
@ -187,6 +187,79 @@ function peers::is_blocked() {
! peers::exists_in_server "$name" ! peers::exists_in_server "$name"
} }
# ============================================
# Default Rule
# ============================================
function peers::get_type() {
local name="$1"
local ip
ip=$(peers::get_ip "$name")
[[ -z "$ip" ]] && echo "unknown" && return
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "${subnet}."; then
type="$t"
break
fi
done
echo "$type"
}
function peers::default_rule() {
local name="$1"
local type
type=$(peers::get_type "$name")
config::is_guest_type "$type" && echo "guest" || echo "user"
}
function peers::effective_rule() {
local name="$1"
local rule
rule=$(peers::get_meta "$name" "rule")
echo "${rule:---}"
}
# ============================================
# Query
# ============================================
function peers::all() {
local dir
dir="$(ctx::clients)"
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
basename "$conf" .conf
done
}
function peers::with_rule() {
local rule="$1"
while IFS= read -r name; do
local effective
effective=$(peers::effective_rule "$name")
[[ "$effective" == "$rule" ]] && echo "$name"
done < <(peers::all)
}
function peers::get_ip() {
local name="$1"
grep "^Address" "$(ctx::clients)/${name}.conf" 2>/dev/null \
| awk '{print $3}' | cut -d'/' -f1
}
function peers::find_by_ip() {
local target_ip="$1"
while IFS= read -r name; do
local ip
ip=$(peers::get_ip "$name")
[[ "$ip" == "$target_ip" ]] && echo "$name" && return 0
done < <(peers::all)
}
# ============================================ # ============================================
# Name + Type Parsing # Name + Type Parsing
# ============================================ # ============================================
@ -224,11 +297,35 @@ function peers::resolve_and_require() {
echo "$resolved" echo "$resolved"
} }
# ============================================
# Helpers - Meta File
# ============================================
function peers::meta_path() {
local name="$1"
echo "$(ctx::meta)/${name}.meta"
}
function peers::get_meta() {
local name="$1" key="$2"
json::get "$(peers::meta_path "$name")" "$key"
}
function peers::set_meta() {
local name="$1" key="$2" value="$3"
json::set "$(peers::meta_path "$name")" "$key" "$value"
}
function peers::remove_meta() {
local name="$1"
rm -f "$(peers::meta_path "$name")"
}
# ============================================ # ============================================
# Live Reload # Live Reload
# ============================================ # ============================================
function peers::reload() { function peers::reload() {
wg syncconf "$(config::interface)" <(wg-quick strip "$(config::interface)") wg syncconf "$(config::interface)" <(wg-quick strip "$(config::interface)")
log::wg_success "WireGuard config reloaded" log::debug "WireGuard config reloaded"
} }

292
modules/rule.module.sh Normal file
View file

@ -0,0 +1,292 @@
#!/usr/bin/env bash
# ============================================
# Rule File Parsing
# ============================================
function rule::exists() {
local name="$1"
[[ -f "$(ctx::rule::path "${name}.rule")" ]]
}
function rule::require_exists() {
local name="$1"
if ! rule::exists "$name"; then
log::error "Rule not found: ${name}"
return 1
fi
}
function rule::get() {
local name="$1" key="$2"
json::get "$(ctx::rule::path "${name}.rule")" "$key"
}
function rule::get_all() {
local name="$1"
local rule_file
rule_file="$(ctx::rule::path "${name}.rule")"
cat "$rule_file"
}
function rule::is_applied() {
local rule_name="$1"
local client_ip="$2"
# Try first block_ports entry
local first_port
first_port=$(rule::get "$rule_name" "block_ports" | head -1)
if [[ -n "$first_port" ]]; then
local target port proto
IFS=":" read -r target port proto <<< "$first_port"
proto="${proto:-tcp}"
iptables -C FORWARD -s "$client_ip" -d "$target" -p "$proto" --dport "$port" -j DROP 2>/dev/null
return $?
fi
# Fall back to first block_ips entry
local first_ip
first_ip=$(rule::get "$rule_name" "block_ips" | head -1)
if [[ -n "$first_ip" ]]; then
iptables -C FORWARD -s "$client_ip" -d "$first_ip" -j DROP 2>/dev/null
return $?
fi
# No rules to check (admin rule) — check allow_ports
local first_allow
first_allow=$(rule::get "$rule_name" "allow_ports" | head -1)
if [[ -n "$first_allow" ]]; then
local target port proto
IFS=":" read -r target port proto <<< "$first_allow"
proto="${proto:-tcp}"
iptables -C FORWARD -s "$client_ip" -d "$target" -p "$proto" --dport "$port" -j ACCEPT 2>/dev/null
return $?
fi
# Empty rule (admin) — never "applied" in iptables sense
return 1
}
# ============================================
# Rule Application
# ============================================
function rule::apply() {
local rule_name="$1"
local client_ip="$2"
local peer_name="${3:-}" # optional, avoids find_by_ip call
rule::require_exists "$rule_name" || return 1
# Use provided peer_name or look it up
if [[ -z "$peer_name" ]]; then
peer_name=$(peers::find_by_ip "$client_ip")
fi
# Check if already applied
if rule::is_applied "$rule_name" "$client_ip"; then
log::wg "Rule '${rule_name}' already applied to: ${client_ip}"
# Still update meta even if rules exist
if [[ -n "$peer_name" ]]; then
peers::set_meta "$peer_name" "rule" "$rule_name"
fi
return 0
fi
# Check if already applied
local peer_name
peer_name=$(peers::find_by_ip "$client_ip")
log::debug "rule::apply: find_by_ip($client_ip) = '$peer_name'"
if [[ -n "$peer_name" ]]; then
# Check if already applied via iptables
if rule::is_applied "$rule_name" "$client_ip"; then
log::wg "Rule '${rule_name}' already applied to: ${client_ip}"
return 0
fi
fi
# Process block_ips
while IFS= read -r ip; do
[[ -z "$ip" ]] && continue
fw::block_ip "$client_ip" "$ip"
done < <(rule::get "$rule_name" "block_ips")
# Process block_ports
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
fw::block_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "block_ports")
# Process allow_ips (inserted before blocks)
while IFS= read -r ip; do
[[ -z "$ip" ]] && continue
fw::allow_ip "$client_ip" "$ip"
done < <(rule::get "$rule_name" "allow_ips")
# allow_ports (inserted last = highest priority)
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
fw::allow_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "allow_ports")
# Persist rule assignment in meta
log::debug "rule::apply: peer_name=$peer_name ip=$client_ip"
if [[ -n "$peer_name" ]]; then
peers::set_meta "$peer_name" "rule" "$rule_name"
log::debug "rule::apply: set meta rule=$rule_name for $peer_name"
fi
local dns_redirect
dns_redirect=$(rule::get "$rule_name" "dns_redirect")
if [[ "$dns_redirect" == "true" ]]; then
local peer_subnet
peer_subnet=$(peers::get_ip "$peer_name" | cut -d'.' -f1-3)
# Only apply if not already in PREROUTING
if ! iptables -t nat -C PREROUTING -i wg0 -s "${peer_subnet}.0/24" -p udp --dport 53 \
-j DNAT --to-destination "$(config::dns):53" 2>/dev/null; then
rule::apply_dns_redirect "${peer_subnet}.0/24"
log::debug "dns_redirect: applied for ${peer_subnet}.0/24"
else
log::debug "dns_redirect: already applied for ${peer_subnet}.0/24"
fi
fi
log::debug "Applied rule '${rule_name}' to: ${client_ip}"
}
function rule::unapply() {
local rule_name="$1"
local client_ip="$2"
rule::require_exists "$rule_name" || return 1
# Remove allow_ports first (reverse order of apply)
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
fw::unallow_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "allow_ports")
# Remove allow_ips
while IFS= read -r ip; do
[[ -z "$ip" ]] && continue
fw::unallow_ip "$client_ip" "$ip"
done < <(rule::get "$rule_name" "allow_ips")
# Remove block_ports
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
fw::unblock_port "$client_ip" "$target" "$port" "$proto"
done < <(rule::get "$rule_name" "block_ports")
# Remove block_ips
while IFS= read -r ip; do
[[ -z "$ip" ]] && continue
fw::unblock_ip "$client_ip" "$ip"
done < <(rule::get "$rule_name" "block_ips")
# Remove DNS redirect if applicable
local dns_redirect
dns_redirect=$(rule::get "$rule_name" "dns_redirect")
if [[ "$dns_redirect" == "true" ]]; then
local peer_name subnet
peer_name=$(peers::find_by_ip "$client_ip")
subnet=$(config::subnet_for "$(peers::get_meta "$peer_name" "subtype")")
rule::remove_dns_redirect "${subnet}.0/24"
fi
# Clear rule from meta
local peer_name
peer_name=$(peers::find_by_ip "$client_ip")
if [[ -n "$peer_name" ]]; then
peers::set_meta "$peer_name" "rule" ""
fi
log::debug "Removed rule '${rule_name}' from: ${client_ip}"
}
function rule::reapply_all() {
local rule_name="$1"
rule::require_exists "$rule_name" || return 1
local peers=()
mapfile -t peers < <(peers::with_rule "$rule_name")
[[ ${#peers[@]} -eq 0 ]] && return 0
local count=0
for peer_name in "${peers[@]}"; do
local client_ip
client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue
rule::unapply "$rule_name" "$client_ip"
rule::apply "$rule_name" "$client_ip" "$peer_name"
(( count++ )) || true
done
log::wg_success "Rule '${rule_name}' re-applied to ${count} peers"
}
function rule::restore_all() {
while IFS= read -r peer_name; do
local rule_name
rule_name=$(peers::get_meta "$peer_name" "rule")
[[ -z "$rule_name" ]] && continue
if ! rule::exists "$rule_name"; then
log::wg_warning "Rule '${rule_name}' not found for peer '${peer_name}', skipping"
continue
fi
local client_ip
client_ip=$(peers::get_ip "$peer_name")
[[ -z "$client_ip" ]] && continue
rule::apply "$rule_name" "$client_ip"
done < <(peers::all)
log::wg "Rules restored for all peers"
}
# ============================================
# Guest DNS Redirect (rule-level feature)
# ============================================
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"
}
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
}

6
wgctl
View file

@ -3,7 +3,7 @@ set -Eeuo pipefail
source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh" source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"
LOG_LEVEL=DEBUG # LOG_LEVEL=DEBUG
# ============================================ # ============================================
# Modules # Modules
@ -16,6 +16,8 @@ load_module keys
load_module peers load_module peers
load_module firewall load_module firewall
load_module monitor load_module monitor
load_module rule
load_module group
# ============================================ # ============================================
# Alias Map # Alias Map
@ -44,11 +46,11 @@ declare -A CMD_ALIASES=(
[stop]=service [stop]=service
[restart]=service [restart]=service
[status]=service [status]=service
[logs]=service
[enable]=service [enable]=service
[disable]=service [disable]=service
) )
# ============================================ # ============================================
# Dispatch # Dispatch
# ============================================ # ============================================