feat: date format config, batch optimizations, list refactor, fw:: rename, .wgctl data dir
This commit is contained in:
parent
78f9caaf17
commit
0efa6c3a9e
44 changed files with 5338 additions and 6002 deletions
|
|
@ -7,6 +7,8 @@
|
|||
function cmd::add::on_load() {
|
||||
flag::register --name
|
||||
flag::register --type
|
||||
flag::register --subtype
|
||||
flag::register --rule
|
||||
flag::register --ip
|
||||
flag::register --preset
|
||||
flag::register --guest
|
||||
|
|
@ -114,6 +116,8 @@ function cmd::add::is_mobile() {
|
|||
function cmd::add::run() {
|
||||
local name=""
|
||||
local type=""
|
||||
local subtype=""
|
||||
local rule=""
|
||||
local ip=""
|
||||
local tunnel=""
|
||||
local guest=false
|
||||
|
|
@ -126,11 +130,13 @@ function cmd::add::run() {
|
|||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--subtype) subtype="$2"; shift 2 ;;
|
||||
--rule) rule="$2"; shift 2 ;;
|
||||
--ip) ip="$2"; shift 2 ;;
|
||||
--preset) presets+=("$2"); shift 2 ;;
|
||||
--guest) guest=true; shift ;;
|
||||
--tunnel) tunnel="$2"; shift 2 ;;
|
||||
--show-config) show_config=true; shift ;;
|
||||
--show-config) show_config=true; shift ;;
|
||||
--show-qr) show_qr=true; shift ;;
|
||||
--help) cmd::add::help; return ;;
|
||||
*)
|
||||
|
|
@ -146,6 +152,18 @@ function cmd::add::run() {
|
|||
type="guest"
|
||||
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
|
||||
local full_name="${type}-${name}"
|
||||
|
||||
|
|
@ -157,14 +175,29 @@ function cmd::add::run() {
|
|||
tunnel=$(config::default_tunnel_for "$type")
|
||||
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
|
||||
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}"
|
||||
|
||||
# Auto-assign IP if not provided
|
||||
if [[ -z "$ip" ]]; then
|
||||
ip=$(ip::next_for_type "$type") || return 1
|
||||
ip=$(ip::next_for_type "$effective_type") || return 1
|
||||
fi
|
||||
|
||||
log::wg_add "Name: ${full_name}"
|
||||
|
|
@ -176,21 +209,28 @@ function cmd::add::run() {
|
|||
keys::generate_pair "$full_name" || return 1
|
||||
|
||||
# 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
|
||||
local public_key
|
||||
public_key=$(keys::public "$full_name") || return 1
|
||||
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
|
||||
|
||||
log::wg_add "Rule: ${rule:-none}"
|
||||
|
||||
# Apply presets
|
||||
for preset in "${presets[@]}"; do
|
||||
firewall::apply_preset "$preset" "${ip}" || return 1
|
||||
fw::apply_preset "$preset" "${ip}" || return 1
|
||||
done
|
||||
|
||||
# Apply guest rules if guest type
|
||||
if [[ "$type" == "guest" ]]; then
|
||||
firewall::apply_guest_rules
|
||||
# Apply rule
|
||||
if [[ -n "$rule" ]]; then
|
||||
rule::apply "$rule" "$ip" || return 1
|
||||
fi
|
||||
|
||||
# Reload WireGuard
|
||||
|
|
@ -199,13 +239,12 @@ function cmd::add::run() {
|
|||
log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]"
|
||||
|
||||
# Show QR for mobile by default, config for desktop/laptop
|
||||
# --show-config overrides to always show config
|
||||
if $show_qr; then
|
||||
local display_type="${subtype:-$type}"
|
||||
|
||||
if cmd::add::is_mobile "$display_type"; then
|
||||
keys::qr "$full_name"
|
||||
elif $show_config || ! cmd::add::is_mobile "$type"; then
|
||||
else
|
||||
log::section "Client Config"
|
||||
cat "$(ctx::clients)/${full_name}.conf"
|
||||
else
|
||||
keys::qr "$full_name"
|
||||
fi
|
||||
}
|
||||
182
commands/audit.command.sh
Normal file
182
commands/audit.command.sh
Normal 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
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
function cmd::block::on_load() {
|
||||
flag::register --name
|
||||
flag::register --type
|
||||
flag::register --force
|
||||
flag::register --quiet
|
||||
flag::register --ip
|
||||
flag::register --port
|
||||
flag::register --proto
|
||||
|
|
@ -66,12 +68,15 @@ function cmd::block::run() {
|
|||
local ips=()
|
||||
local subnets=()
|
||||
local ports=()
|
||||
local quiet=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--ip) ips+=("$2"); shift 2 ;;
|
||||
--force) force=true; shift ;;
|
||||
--quiet) quiet=true; shift ;;
|
||||
--subnet) subnets+=("$2"); shift 2 ;;
|
||||
--port) ports+=("$2"); shift 2 ;;
|
||||
--help) cmd::block::help; return ;;
|
||||
|
|
@ -114,17 +119,17 @@ function cmd::block::run() {
|
|||
local client_ip
|
||||
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
|
||||
if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 ]]; then
|
||||
# Get real endpoint IP before removing peer from server
|
||||
# Get real endpoint IP before removing peer from server
|
||||
local public_key endpoint
|
||||
public_key=$(keys::public "$name") || return 1
|
||||
endpoint=$(monitor::endpoint_for_key "$public_key")
|
||||
|
||||
firewall::block_all "$client_ip" "$name"
|
||||
firewall::save_block "$name" "$client_ip"
|
||||
fw::block_all "$client_ip" "$name"
|
||||
fw::save_block "$name" "$client_ip"
|
||||
|
||||
# Watch real endpoint IP if available
|
||||
if [[ -n "$endpoint" ]]; then
|
||||
|
|
@ -136,22 +141,22 @@ function cmd::block::run() {
|
|||
peers::remove_from_server "$name"
|
||||
peers::reload
|
||||
|
||||
log::wg_success "Client blocked: ${name}"
|
||||
$quiet || log::wg_success "${name} has been blocked."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Block specific IPs
|
||||
for ip in "${ips[@]}"; do
|
||||
ip::require_valid "$ip"
|
||||
firewall::block_ip "$client_ip" "$ip"
|
||||
firewall::save_block "$name" "$client_ip" "$ip"
|
||||
fw::block_ip "$client_ip" "$ip"
|
||||
fw::save_block "$name" "$client_ip" "$ip"
|
||||
done
|
||||
|
||||
# Block specific subnets
|
||||
for subnet in "${subnets[@]}"; do
|
||||
ip::require_valid "$subnet"
|
||||
firewall::block_subnet "$client_ip" "$subnet"
|
||||
firewall::save_block "$name" "$client_ip" "$subnet"
|
||||
fw::block_subnet "$client_ip" "$subnet"
|
||||
fw::save_block "$name" "$client_ip" "$subnet"
|
||||
done
|
||||
|
||||
# Block specific ports
|
||||
|
|
@ -160,9 +165,9 @@ function cmd::block::run() {
|
|||
IFS=":" read -r target port proto <<< "$entry"
|
||||
proto="${proto:-tcp}"
|
||||
ip::require_valid "$target"
|
||||
firewall::block_port "$client_ip" "$target" "$port" "$proto"
|
||||
firewall::save_block "$name" "$client_ip" "$target" "$port" "$proto"
|
||||
fw::block_port "$client_ip" "$target" "$port" "$proto"
|
||||
fw::save_block "$name" "$client_ip" "$target" "$port" "$proto"
|
||||
done
|
||||
|
||||
log::wg_success "Block rules applied for: ${name}"
|
||||
log::debug "Block rules applied for: ${name}"
|
||||
}
|
||||
|
|
|
|||
154
commands/fw.command.sh
Normal file
154
commands/fw.command.sh
Normal 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
761
commands/group.command.sh
Normal 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
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
function cmd::inspect::on_load() {
|
||||
flag::register --name
|
||||
flag::register --type
|
||||
flag::register --config
|
||||
flag::register --qr
|
||||
}
|
||||
|
||||
function cmd::inspect::help() {
|
||||
|
|
@ -15,18 +17,163 @@ Show detailed information for a single client.
|
|||
Options:
|
||||
--name <name> Client name
|
||||
--type <type> Device type (optional, combines with --name)
|
||||
--config Show raw client config
|
||||
--qr Show QR code
|
||||
|
||||
Examples:
|
||||
wgctl inspect --name phone-nuno
|
||||
wgctl inspect --name nuno --type phone
|
||||
wgctl inspect --name phone-nuno --config
|
||||
wgctl inspect --name phone-nuno --qr
|
||||
EOF
|
||||
}
|
||||
|
||||
function cmd::inspect::run() {
|
||||
local name=""
|
||||
local type=""
|
||||
# ============================================
|
||||
# Private helpers
|
||||
# ============================================
|
||||
|
||||
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
|
||||
name="$1"
|
||||
shift
|
||||
|
|
@ -34,9 +181,11 @@ function cmd::inspect::run() {
|
|||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--help) cmd::inspect::help; return ;;
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--config) show_config=true; shift ;;
|
||||
--qr) show_qr=true; shift ;;
|
||||
--help) cmd::inspect::help; return ;;
|
||||
*)
|
||||
log::error "Unknown flag: $1"
|
||||
cmd::inspect::help
|
||||
|
|
@ -54,5 +203,24 @@ function cmd::inspect::run() {
|
|||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||
|
||||
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"
|
||||
}
|
||||
|
|
@ -37,39 +37,61 @@ Options:
|
|||
|
||||
Examples:
|
||||
wgctl list
|
||||
wgctl ls --type phone
|
||||
wgctl list --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
|
||||
# Precompute 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::_precompute_wg() {
|
||||
# Returns two associative arrays via nameref
|
||||
local -n _handshakes="$1"
|
||||
local -n _endpoints="$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() {
|
||||
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::_precompute_block_status() {
|
||||
local -n _blocked="$1"
|
||||
local -n _restricted="$2"
|
||||
|
||||
# Blocked = not in wg server config
|
||||
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"
|
||||
local ts
|
||||
ts=$(cmd::list::last_handshake_ts "$public_key")
|
||||
# ============================================
|
||||
# Status / Display helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_is_connected() {
|
||||
local ts="$1"
|
||||
[[ -z "$ts" || "$ts" == "0" ]] && return 1
|
||||
local now diff
|
||||
now=$(date +%s)
|
||||
|
|
@ -77,138 +99,179 @@ function cmd::list::is_connected() {
|
|||
(( diff < 180 ))
|
||||
}
|
||||
|
||||
function cmd::list::is_attempting() {
|
||||
local name="$1"
|
||||
local ts
|
||||
ts=$(monitor::last_attempt "$name")
|
||||
[[ -z "$ts" ]] && return 1
|
||||
|
||||
function cmd::list::_is_attempting() {
|
||||
local last_ts="$1"
|
||||
[[ -z "$last_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)
|
||||
|
||||
attempt_ts=$(json::iso_to_ts "$last_ts")
|
||||
diff=$(( now - attempt_ts ))
|
||||
(( diff < 180 ))
|
||||
}
|
||||
|
||||
function cmd::list::is_blocked() {
|
||||
local name="$1"
|
||||
peers::is_blocked "$name"
|
||||
}
|
||||
function cmd::list::_format_last_seen() {
|
||||
local name="$1" pubkey="$2" is_blocked="$3"
|
||||
local last_ts="$4" last_evt="$5" handshake_ts="$6"
|
||||
|
||||
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
|
||||
if [[ "$is_blocked" == "true" ]]; then
|
||||
if [[ -n "$last_ts" ]]; then
|
||||
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")
|
||||
formatted=$(fmt::datetime "$last_ts")
|
||||
echo "${formatted} (dropped)"
|
||||
else
|
||||
echo "—"
|
||||
fi
|
||||
else
|
||||
local ts
|
||||
ts=$(cmd::list::last_handshake_ts "$public_key")
|
||||
if [[ -z "$ts" || "$ts" == "0" ]]; then
|
||||
if [[ -z "$handshake_ts" || "$handshake_ts" == "0" ]]; then
|
||||
echo "—"
|
||||
else
|
||||
local formatted
|
||||
formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts")
|
||||
formatted=$(fmt::datetime "$handshake_ts")
|
||||
echo "${formatted} (handshake)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::list::format_status() {
|
||||
local name="$1"
|
||||
local public_key="$2"
|
||||
local ip="$3" # new
|
||||
function cmd::list::_format_status() {
|
||||
local name="$1" pubkey="$2"
|
||||
local is_blocked="$3" is_restricted="$4"
|
||||
local handshake_ts="$5" last_ts="$6"
|
||||
|
||||
local connected=false
|
||||
local blocked=false
|
||||
local restricted=false
|
||||
local connected=false modifier="" color
|
||||
|
||||
cmd::list::is_blocked "$name" && blocked=true
|
||||
cmd::list::is_restricted "$name" && restricted=true
|
||||
|
||||
if $blocked; then
|
||||
cmd::list::is_attempting "$name" && connected=true
|
||||
if [[ "$is_blocked" == "true" ]]; then
|
||||
cmd::list::_is_attempting "$last_ts" && connected=true
|
||||
modifier=" (blocked)"
|
||||
elif $restricted; then
|
||||
cmd::list::is_connected "$public_key" && connected=true
|
||||
color="\033[1;31m"
|
||||
elif [[ "$is_restricted" == "true" ]]; then
|
||||
cmd::list::_is_connected "$handshake_ts" && connected=true
|
||||
modifier=" (restricted)"
|
||||
color="\033[1;33m"
|
||||
else
|
||||
cmd::list::is_connected "$public_key" && connected=true
|
||||
cmd::list::_is_connected "$handshake_ts" && connected=true
|
||||
modifier=""
|
||||
if $connected; then
|
||||
color="\033[1;32m"
|
||||
else
|
||||
color="\033[0;37m"
|
||||
fi
|
||||
fi
|
||||
|
||||
local conn_str
|
||||
$connected && conn_str="online" || conn_str="offline"
|
||||
echo -e "${color}${conn_str}${modifier}\033[0m"
|
||||
}
|
||||
|
||||
local status="${conn_str}${modifier}"
|
||||
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"
|
||||
}
|
||||
|
||||
local color
|
||||
if $blocked; then
|
||||
color="\033[1;31m"
|
||||
elif $restricted; then
|
||||
color="\033[1;33m"
|
||||
elif $connected; then
|
||||
color="\033[1;32m"
|
||||
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
|
||||
color="\033[0;37m"
|
||||
echo "$type"
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${color}${status}\033[0m"
|
||||
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() {
|
||||
local name="$1"
|
||||
local dir
|
||||
dir="$(ctx::clients)"
|
||||
local conf="${dir}/${name}.conf"
|
||||
local conf
|
||||
conf="$(ctx::clients)/${name}.conf"
|
||||
|
||||
if [[ ! -f "$conf" ]]; then
|
||||
log::error "Client not found: ${name}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local ip
|
||||
local ip allowed_ips public_key
|
||||
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 type
|
||||
type=$(cmd::list::_get_type "$ip")
|
||||
|
||||
local endpoint="—"
|
||||
if cmd::list::is_blocked "$name"; then
|
||||
if peers::is_blocked "$name"; then
|
||||
local ep
|
||||
ep=$(monitor::last_endpoint "$name")
|
||||
[[ -n "$ep" ]] && endpoint="$ep"
|
||||
|
|
@ -218,29 +281,28 @@ function cmd::list::show_client() {
|
|||
[[ -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
|
||||
# Get handshake and last attempt for status/last_seen
|
||||
local handshake_ts
|
||||
handshake_ts=$(wg show "$(config::interface)" latest-handshakes 2>/dev/null \
|
||||
| grep "^${public_key}" | awk '{print $2}')
|
||||
handshake_ts="${handshake_ts:-0}"
|
||||
|
||||
local status
|
||||
status=$(cmd::list::format_status "$name" "$public_key" "$ip")
|
||||
local last_ts
|
||||
last_ts=$(monitor::last_attempt "$name")
|
||||
|
||||
local last_seen
|
||||
last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip")
|
||||
local is_blocked="false"
|
||||
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
|
||||
block_file="$(ctx::block::path "${name}.block")"
|
||||
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
|
||||
if [[ -z "$target" ]]; then
|
||||
blocks+=" all traffic blocked\n"
|
||||
|
|
@ -252,47 +314,43 @@ if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
|
|||
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"
|
||||
ui::section "Client: ${name}"
|
||||
ui::row "IP" "$ip"
|
||||
ui::row "Type" "$type"
|
||||
ui::row "Status" "$(echo -e "$status")"
|
||||
ui::row "Endpoint" "$endpoint"
|
||||
ui::row "Last seen" "$last_seen"
|
||||
ui::row "AllowedIPs" "$allowed_ips"
|
||||
ui::row "Public key" "$public_key"
|
||||
|
||||
if [[ -z "$blocks" ]]; then
|
||||
printf " %-20s %s\n" "Blocks:" "none"
|
||||
ui::row "Blocks" "none"
|
||||
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
|
||||
printf " %-20s\n" "Blocks:"
|
||||
echo -e "$blocks"
|
||||
fi
|
||||
|
||||
echo " ${sep}"
|
||||
echo ""
|
||||
printf "\n"
|
||||
}
|
||||
|
||||
# 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
|
||||
# ============================================
|
||||
|
||||
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=""
|
||||
local online_only=false offline_only=false
|
||||
local restricted_only=false blocked_only=false allowed_only=false
|
||||
local detailed=false single_name=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
|
|
@ -313,7 +371,7 @@ function cmd::list::run() {
|
|||
esac
|
||||
done
|
||||
|
||||
# Single client detail card
|
||||
# Single detail card
|
||||
if [[ -n "$single_name" ]]; then
|
||||
cmd::list::show_client "$single_name"
|
||||
return
|
||||
|
|
@ -322,106 +380,163 @@ function cmd::list::run() {
|
|||
local dir
|
||||
dir="$(ctx::clients)"
|
||||
local confs=("${dir}"/*.conf)
|
||||
|
||||
if [[ ! -f "${confs[0]}" ]]; then
|
||||
log::wg_list "No clients configured"
|
||||
return 0
|
||||
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
|
||||
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
|
||||
cmd::list::_iter_confs "$filter_type" cmd::list::show_client
|
||||
return
|
||||
fi
|
||||
|
||||
# Normal table view
|
||||
log::section "WireGuard Clients"
|
||||
# ── Table view ─────────────────────────────
|
||||
|
||||
printf "\n %-28s %-15s %-10s %-22s %s\n" \
|
||||
"NAME" "IP" "TYPE" "STATUS" "LAST SEEN"
|
||||
printf " %s\n" "$(printf '─%.0s' {1..90})"
|
||||
log::section "WireGuard Clients"
|
||||
cmd::list::_render_header $has_groups
|
||||
|
||||
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
|
||||
[[ -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
|
||||
local ip="${p_ips[$client_name]:-}"
|
||||
if [[ -z "$ip" ]]; then
|
||||
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
|
||||
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")
|
||||
|
||||
printf " %-28s %-15s %-10s %-32b %s\n" \
|
||||
"$client_name" "$ip" "$type" "$status" "$last_seen"
|
||||
local type
|
||||
type=$(cmd::list::_get_type "$ip")
|
||||
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
|
||||
"$callback" "$client_name" "$ip" "$type"
|
||||
done
|
||||
}
|
||||
|
||||
printf "\n"
|
||||
function cmd::list::_render_row() {
|
||||
local client_name="$1" ip="$2" type="$3"
|
||||
|
||||
local pubkey="${p_pubkeys[$client_name]:-}"
|
||||
local handshake_ts="${wg_handshakes[$pubkey]:-0}"
|
||||
local is_blocked="${p_blocked[$client_name]:-false}"
|
||||
local is_restricted="${p_restricted[$client_name]:-false}"
|
||||
local last_ts="${p_last_ts[$client_name]:-}"
|
||||
|
||||
# Apply status filters
|
||||
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 $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
|
||||
if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
|
||||
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
|
||||
[[ "$is_restricted" == "true" ]]; }; then return 0; fi
|
||||
|
||||
# Format display values
|
||||
local status last_seen display_type rule group_display
|
||||
status=$(cmd::list::_format_status "$client_name" "$pubkey" \
|
||||
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
|
||||
last_seen=$(cmd::list::_format_last_seen "$client_name" "$pubkey" \
|
||||
"$is_blocked" "$last_ts" "" "$handshake_ts")
|
||||
display_type=$(cmd::list::display_type "$client_name" "$type" \
|
||||
"${p_subtypes[$client_name]:-}")
|
||||
rule="${p_rules[$client_name]:-—}"
|
||||
|
||||
# Update rule counts for summary (outer scope array)
|
||||
rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
|
||||
|
||||
# Pad status
|
||||
local padded_status
|
||||
padded_status=$(cmd::list::pad_status "$status" 25)
|
||||
|
||||
# Render row
|
||||
if $has_groups; then
|
||||
group_display="${peer_group_map[$client_name]:-—}"
|
||||
|
||||
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
|
||||
}
|
||||
560
commands/list.command.sh.bak
Normal file
560
commands/list.command.sh.bak
Normal 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
252
commands/logs.command.sh
Normal 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"
|
||||
}
|
||||
|
|
@ -76,13 +76,25 @@ function cmd::remove::run() {
|
|||
|
||||
log::section "Removing client: ${name}"
|
||||
|
||||
# Extract IP before removing anything
|
||||
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
|
||||
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
|
||||
peers::remove_from_server "$name" || return 1
|
||||
|
||||
|
|
@ -94,28 +106,12 @@ function cmd::remove::run() {
|
|||
|
||||
# Remove block rules only if client was fully blocked
|
||||
if [[ -n "$client_ip" ]] && $was_blocked; then
|
||||
firewall::unblock_all "$client_ip"
|
||||
fw::unblock_all "$client_ip"
|
||||
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
|
||||
# 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
|
||||
peers::remove_meta "$name" 2>/dev/null || true
|
||||
|
||||
# Reload WireGuard
|
||||
peers::reload || return 1
|
||||
|
|
|
|||
|
|
@ -115,6 +115,11 @@ function cmd::rename::run() {
|
|||
log::fs_write "Renamed block file"
|
||||
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
|
||||
peers::reload
|
||||
|
||||
|
|
|
|||
522
commands/rule.command.sh
Normal file
522
commands/rule.command.sh
Normal 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"
|
||||
}
|
||||
|
|
@ -70,15 +70,20 @@ function cmd::service::stop() {
|
|||
|
||||
function cmd::service::restart() {
|
||||
log::wg_start "Restarting WireGuard..."
|
||||
|
||||
# Flush firewall rules before restart so restore starts clean
|
||||
iptables -F FORWARD
|
||||
iptables -t nat -F PREROUTING
|
||||
|
||||
systemctl restart "wg-quick@$(config::interface)"
|
||||
firewall::restore_blocks
|
||||
fw::restore_blocks
|
||||
log::wg_success "WireGuard restarted"
|
||||
}
|
||||
|
||||
function cmd::service::reload() {
|
||||
log::wg_start "Reloading WireGuard config..."
|
||||
peers::reload
|
||||
firewall::restore_blocks
|
||||
fw::restore_blocks
|
||||
}
|
||||
|
||||
function cmd::service::status() {
|
||||
|
|
|
|||
164
commands/shell.command.sh
Normal file
164
commands/shell.command.sh
Normal 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
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
function cmd::unblock::on_load() {
|
||||
flag::register --name
|
||||
flag::register --type
|
||||
flag::register --force
|
||||
flag::register --quiet
|
||||
flag::register --ip
|
||||
flag::register --port
|
||||
flag::register --proto
|
||||
|
|
@ -66,12 +68,15 @@ function cmd::unblock::run() {
|
|||
local subnets=()
|
||||
local ports=()
|
||||
local all=false
|
||||
local quiet=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--ip) ips+=("$2"); shift 2 ;;
|
||||
--force) force=true; shift ;;
|
||||
--quiet) quiet=true; shift ;;
|
||||
--subnet) subnets+=("$2"); shift 2 ;;
|
||||
--port) ports+=("$2"); shift 2 ;;
|
||||
--all) all=true; shift ;;
|
||||
|
|
@ -106,11 +111,11 @@ function cmd::unblock::run() {
|
|||
local client_ip
|
||||
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
|
||||
firewall::unblock_all "$client_ip"
|
||||
firewall::remove_block_file "$name"
|
||||
fw::unblock_all "$client_ip"
|
||||
fw::remove_block_file "$name"
|
||||
monitor::unwatch_client "$name"
|
||||
|
||||
# Re-add peer to server if missing
|
||||
|
|
@ -121,18 +126,18 @@ function cmd::unblock::run() {
|
|||
peers::reload
|
||||
fi
|
||||
|
||||
log::wg_success "All block rules removed for: ${name}"
|
||||
$quiet || log::wg_success "${name} has been unblocked."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Unblock specific IPs
|
||||
for ip in "${ips[@]}"; do
|
||||
firewall::unblock_ip "$client_ip" "$ip"
|
||||
fw::unblock_ip "$client_ip" "$ip"
|
||||
done
|
||||
|
||||
# Unblock specific subnets
|
||||
for subnet in "${subnets[@]}"; do
|
||||
firewall::unblock_subnet "$client_ip" "$subnet"
|
||||
fw::unblock_subnet "$client_ip" "$subnet"
|
||||
done
|
||||
|
||||
# Unblock specific ports
|
||||
|
|
@ -140,8 +145,8 @@ function cmd::unblock::run() {
|
|||
local target port proto
|
||||
IFS=":" read -r target port proto <<< "$entry"
|
||||
proto="${proto:-tcp}"
|
||||
firewall::unblock_port "$client_ip" "$target" "$port" "$proto"
|
||||
fw::unblock_port "$client_ip" "$target" "$port" "$proto"
|
||||
done
|
||||
|
||||
log::wg_success "Unblock rules applied for: ${name}"
|
||||
$quiet || log::wg_success "Unblock rules applied for: ${name}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ function cmd::watch::poll_handshakes() {
|
|||
echo "$ts" > "$prev_ts_file"
|
||||
|
||||
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
|
||||
endpoint=$(monitor::endpoint_for_key "$public_key")
|
||||
|
|
@ -209,12 +209,7 @@ except:
|
|||
fi
|
||||
|
||||
local formatted_ts
|
||||
formatted_ts=$(python3 -c "
|
||||
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")
|
||||
formatted_ts=$(fmt::datetime_iso "$ts")
|
||||
|
||||
# Before printing the event
|
||||
local now
|
||||
|
|
|
|||
4
core.sh
4
core.sh
|
|
@ -11,3 +11,7 @@ source "${WGCTL_DIR}/core/utils.sh"
|
|||
source "${WGCTL_DIR}/core/module.sh"
|
||||
source "${WGCTL_DIR}/core/command.sh"
|
||||
source "${WGCTL_DIR}/core/flag.sh"
|
||||
source "${WGCTL_DIR}/core/json.sh"
|
||||
source "${WGCTL_DIR}/core/ui.sh"
|
||||
source "${WGCTL_DIR}/core/fmt.sh"
|
||||
source "${WGCTL_DIR}/core/test/test.sh"
|
||||
|
|
@ -5,13 +5,25 @@
|
|||
# ============================================
|
||||
|
||||
_CTX_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
_CTX_WG="/etc/wireguard"
|
||||
_CTX_CORE="${_CTX_ROOT}/core"
|
||||
_CTX_MODULES="${_CTX_ROOT}/modules"
|
||||
_CTX_COMMANDS="${_CTX_ROOT}/commands"
|
||||
_CTX_PRESETS="${_CTX_ROOT}/presets"
|
||||
_CTX_BLOCKS="${_CTX_ROOT}/blocks"
|
||||
_CTX_CLIENTS="/etc/wireguard/clients"
|
||||
_CTX_WG="/etc/wireguard"
|
||||
_CTX_CLIENTS="${_CTX_WG}/clients"
|
||||
_CTX_DATA="${_CTX_WG}/.wgctl"
|
||||
|
||||
# ============================================
|
||||
# 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::core() { echo "$_CTX_CORE"; }
|
||||
|
|
@ -19,8 +31,19 @@ function ctx::modules() { echo "$_CTX_MODULES"; }
|
|||
function ctx::commands() { echo "$_CTX_COMMANDS"; }
|
||||
function ctx::presets() { echo "$_CTX_PRESETS"; }
|
||||
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::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
|
||||
|
|
@ -36,7 +59,22 @@ function ctx::preset::path() {
|
|||
echo "$_CTX_PRESETS/$*"
|
||||
}
|
||||
|
||||
function ctx::meta::path() {
|
||||
local IFS="/"
|
||||
echo "$_CTX_META/$*"
|
||||
}
|
||||
|
||||
function ctx::block::path() {
|
||||
local IFS="/"
|
||||
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
55
core/fmt.sh
Normal 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
30
core/fmt_helper.py
Normal 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
29
core/json.sh
Normal 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
668
core/json_helper.py
Normal 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)
|
||||
|
|
@ -15,11 +15,11 @@ readonly _MODULE_AUTO_LOAD_HOOK="on_load"
|
|||
function module::loaded() { [[ -n "${_LOADED_MODULES["$1"]:-}" ]]; }
|
||||
|
||||
# Convert path-style name to namespace
|
||||
# e.g. firewall/iptables -> firewall::iptables
|
||||
# e.g. firewall/iptables -> fw::iptables
|
||||
function module::to_namespace() { echo "${1//\//:}"; }
|
||||
|
||||
# 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() {
|
||||
local namespace
|
||||
namespace=$(module::to_namespace "$1")
|
||||
|
|
|
|||
46
core/test/test.sh
Normal file
46
core/test/test.sh
Normal 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
55
core/ui.sh
Normal 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
|
||||
}
|
||||
|
|
@ -17,6 +17,22 @@ function core::call_if_exists() {
|
|||
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
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
5146
daemon/events.log
5146
daemon/events.log
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -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()
|
||||
|
|
@ -4,7 +4,7 @@ After=network.target wg-quick@wg0.service
|
|||
|
||||
[Service]
|
||||
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
|
||||
RestartSec=5
|
||||
Environment=WG_INTERFACE=eth0
|
||||
|
|
|
|||
68
install/install.sh
Normal file
68
install/install.sh
Normal 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
17
install/ulogd.conf
Normal 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"
|
||||
15
install/wgctl-monitor.service
Normal file
15
install/wgctl-monitor.service
Normal 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
|
||||
9
install/wgctl.conf.example
Normal file
9
install/wgctl.conf.example
Normal 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
|
||||
|
|
@ -5,22 +5,65 @@
|
|||
# ============================================
|
||||
|
||||
function config::on_load() {
|
||||
config::_init_defaults
|
||||
config::load
|
||||
config::validate
|
||||
fmt::set_date_format "${_FMT_DATE_FORMAT:-iso}"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Server
|
||||
# Defaults
|
||||
# ============================================
|
||||
|
||||
WG_INTERFACE="wg0"
|
||||
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_ENDPOINT="wg.krilio.net: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"
|
||||
function config::_init_defaults() {
|
||||
_WG_INTERFACE="${WG_INTERFACE:-wg0}"
|
||||
_WG_DNS="${WG_DNS:-10.0.0.103}"
|
||||
_WG_LAN="${WG_LAN:-10.0.0.0/24}"
|
||||
_WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}"
|
||||
_WG_PORT="${WG_PORT:-51820}"
|
||||
_WG_ENDPOINT="${WG_ENDPOINT:-}"
|
||||
|
||||
# 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
|
||||
|
|
@ -32,42 +75,44 @@ declare -gA DEVICE_SUBNETS=(
|
|||
[phone]="10.1.3"
|
||||
[tablet]="10.1.4"
|
||||
[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
|
||||
# ============================================
|
||||
|
||||
# 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=(
|
||||
[desktop]="split"
|
||||
[laptop]="split"
|
||||
[phone]="split"
|
||||
[tablet]="split"
|
||||
[guest]="split"
|
||||
[guest-desktop]="split"
|
||||
[guest-laptop]="split"
|
||||
[guest-phone]="split"
|
||||
[guest-tablet]="split"
|
||||
)
|
||||
|
||||
# ============================================
|
||||
# Accessors
|
||||
# ============================================
|
||||
|
||||
function config::interface() { echo "$WG_INTERFACE"; }
|
||||
function config::config_file() { echo "$WG_CONFIG"; }
|
||||
function config::endpoint() { echo "$WG_ENDPOINT"; }
|
||||
function config::dns() { echo "$WG_DNS"; }
|
||||
function config::listen_port() { echo "$WG_LISTEN_PORT"; }
|
||||
function config::subnet() { echo "$WG_SUBNET"; }
|
||||
function config::lan() { echo "$WG_LAN"; }
|
||||
function config::tunnel_split() { echo "$WG_TUNNEL_SPLIT"; }
|
||||
function config::tunnel_full() { echo "$WG_TUNNEL_FULL"; }
|
||||
function config::interface() { echo "$_WG_INTERFACE"; }
|
||||
function config::config_file() { echo "$_WG_CONFIG"; }
|
||||
function config::endpoint() { echo "$_WG_ENDPOINT"; }
|
||||
function config::dns() { echo "$_WG_DNS"; }
|
||||
function config::port() { echo "$_WG_PORT"; }
|
||||
function config::subnet() { echo "$_WG_SUBNET"; }
|
||||
function config::lan() { echo "$_WG_LAN"; }
|
||||
function config::tunnel_split() { echo "$_WG_TUNNEL_SPLIT"; }
|
||||
function config::tunnel_full() { echo "$_WG_TUNNEL_FULL"; }
|
||||
|
||||
function config::server_public_key() {
|
||||
cat "$WG_SERVER_PUBLIC_KEY_FILE"
|
||||
cat "$_WG_SERVER_PUBLIC_KEY_FILE"
|
||||
}
|
||||
|
||||
function config::device_types() {
|
||||
|
|
@ -83,6 +128,11 @@ function config::is_valid_type() {
|
|||
[[ -n "$subnet" ]]
|
||||
}
|
||||
|
||||
function config::is_guest_type() {
|
||||
local type="$1"
|
||||
[[ "$type" == "guest" || "$type" == guest-* ]]
|
||||
}
|
||||
|
||||
function config::subnet_for() {
|
||||
local type="$1"
|
||||
local result
|
||||
|
|
@ -101,14 +151,13 @@ function config::allowed_ips_for() {
|
|||
local type="$1"
|
||||
local tunnel="${2:-}"
|
||||
|
||||
# If tunnel mode not specified, use device default
|
||||
if [[ -z "$tunnel" ]]; then
|
||||
tunnel=$(config::default_tunnel_for "$type")
|
||||
fi
|
||||
|
||||
case "$tunnel" in
|
||||
full) echo "$WG_TUNNEL_FULL" ;;
|
||||
split) echo "$WG_TUNNEL_SPLIT" ;;
|
||||
full) echo "$_WG_TUNNEL_FULL" ;;
|
||||
split) echo "$_WG_TUNNEL_SPLIT" ;;
|
||||
*)
|
||||
log::error "Unknown tunnel mode: ${tunnel} (use 'split' or 'full')"
|
||||
return 1
|
||||
|
|
@ -121,18 +170,18 @@ function config::allowed_ips_for() {
|
|||
# ============================================
|
||||
|
||||
function config::validate() {
|
||||
if [[ ! -f "$WG_SERVER_PUBLIC_KEY_FILE" ]]; then
|
||||
log::error "Server public key not found: ${WG_SERVER_PUBLIC_KEY_FILE}"
|
||||
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
|
||||
log::error "Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$WG_SERVER_PRIVATE_KEY_FILE" ]]; then
|
||||
log::error "Server private key not found: ${WG_SERVER_PRIVATE_KEY_FILE}"
|
||||
if [[ ! -f "$_WG_SERVER_PRIVATE_KEY_FILE" ]]; then
|
||||
log::error "Server private key not found: ${_WG_SERVER_PRIVATE_KEY_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$WG_CONFIG" ]]; then
|
||||
log::error "WireGuard config not found: ${WG_CONFIG}"
|
||||
if [[ ! -f "$_WG_CONFIG" ]]; then
|
||||
log::error "WireGuard config not found: ${_WG_CONFIG}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
# Lifecycle
|
||||
# ============================================
|
||||
|
||||
function firewall::on_load() {
|
||||
function fw::on_load() {
|
||||
system::require_command iptables
|
||||
}
|
||||
|
||||
|
|
@ -12,158 +12,108 @@ function firewall::on_load() {
|
|||
# Rule Management
|
||||
# ============================================
|
||||
|
||||
function firewall::block_ip() {
|
||||
local client_ip="$1"
|
||||
local target_ip="$2"
|
||||
|
||||
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 fw::block_ip() {
|
||||
local client_ip="$1" 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:"
|
||||
}
|
||||
|
||||
function firewall::unblock_ip() {
|
||||
local client_ip="$1"
|
||||
local target_ip="$2"
|
||||
function fw::unblock_ip() {
|
||||
local client_ip="$1" 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 DROP 2>/dev/null || true
|
||||
log::wg_unblock "Unblocked ${client_ip} → ${target_ip}"
|
||||
}
|
||||
|
||||
function firewall::block_port() {
|
||||
local client_ip="$1"
|
||||
local target_ip="$2"
|
||||
local port="$3"
|
||||
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 fw::block_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 DROP
|
||||
iptables -I FORWARD 1 -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j NFLOG --nflog-group 1 --nflog-prefix "wgctl-drop:"
|
||||
}
|
||||
|
||||
function firewall::unblock_port() {
|
||||
local client_ip="$1"
|
||||
local target_ip="$2"
|
||||
local port="$3"
|
||||
local proto="${4:-tcp}"
|
||||
|
||||
function fw::unblock_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 DROP 2>/dev/null || true
|
||||
log::wg_unblock "Unblocked ${client_ip} → ${target_ip}:${port}/${proto}"
|
||||
}
|
||||
|
||||
function firewall::block_all() {
|
||||
local client_ip="$1"
|
||||
local client_name="$2"
|
||||
function fw::block_all() {
|
||||
local client_ip="$1" 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
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
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() {
|
||||
local client_ip="$1"
|
||||
local target_subnet="$2"
|
||||
function fw::block_subnet() {
|
||||
local client_ip="$1" target_subnet="$2"
|
||||
|
||||
iptables -A FORWARD -s "$client_ip" -d "$target_subnet" -j DROP
|
||||
log::wg_block "Blocked ${client_ip} → subnet ${target_subnet}"
|
||||
}
|
||||
|
||||
function firewall::unblock_subnet() {
|
||||
local client_ip="$1"
|
||||
local target_subnet="$2"
|
||||
function fw::unblock_subnet() {
|
||||
local client_ip="$1" target_subnet="$2"
|
||||
|
||||
iptables -D FORWARD -s "$client_ip" -d "$target_subnet" -j DROP 2>/dev/null || true
|
||||
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
|
||||
# ============================================
|
||||
|
||||
# Sensitive services blocked for all guest peers
|
||||
declare -ga GUEST_BLOCKED_SERVICES=(
|
||||
"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
|
||||
function fw::apply_dns_redirect() {
|
||||
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
|
||||
log::wg "Guest DNS redirect already applied"
|
||||
return 0
|
||||
fi
|
||||
|
|
@ -173,42 +123,42 @@ function firewall::apply_guest_dns_redirect() {
|
|||
dns="$(config::dns)"
|
||||
|
||||
# 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" \
|
||||
-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" \
|
||||
-j LOG --log-prefix "wgctl-dns-redirect: " --log-level 4
|
||||
|
||||
# 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 -i wg0 -s "$guest_subnet" -p tcp --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 -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"
|
||||
}
|
||||
|
||||
function firewall::remove_guest_dns_redirect() {
|
||||
function fw::remove_dns_redirect() {
|
||||
local guest_subnet dns
|
||||
guest_subnet="$(config::subnet_for "guest").0/24"
|
||||
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" \
|
||||
-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" \
|
||||
-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 -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 udp --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
|
||||
# ============================================
|
||||
|
||||
function firewall::save_block() {
|
||||
function fw::save_block() {
|
||||
local name="$1"
|
||||
local client_ip="$2"
|
||||
local target="${3:-}"
|
||||
|
|
@ -219,47 +169,40 @@ function firewall::save_block() {
|
|||
block_file="$(ctx::block::path "${name}.block")"
|
||||
|
||||
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 block_file
|
||||
block_file="$(ctx::block::path "${name}.block")"
|
||||
|
||||
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
|
||||
blocks_dir="$(ctx::blocks)"
|
||||
|
||||
# Restore guest rules if marker exists
|
||||
local marker="${blocks_dir}/_guest_rules.active"
|
||||
if [[ -f "$marker" ]]; then
|
||||
firewall::apply_guest_rules
|
||||
log::wg "Restored guest firewall rules"
|
||||
fi
|
||||
# Restore rules from meta files (new system)
|
||||
rule::restore_all
|
||||
|
||||
# Restore per-client block rules
|
||||
# Restore per-client full-blocks (wgctl block/unblock system)
|
||||
for block_file in "${blocks_dir}"/*.block; do
|
||||
[[ -f "$block_file" ]] || continue
|
||||
|
||||
local name
|
||||
name=$(basename "$block_file" .block)
|
||||
|
||||
while IFS=" " read -r client_ip target port proto; do
|
||||
if [[ -z "$target" ]]; then
|
||||
firewall::block_all "$client_ip" "$name"
|
||||
fw::block_all "$client_ip" "$name"
|
||||
elif [[ -n "$port" ]]; then
|
||||
firewall::block_port "$client_ip" "$target" "$port" "${proto:-tcp}"
|
||||
fw::block_port "$client_ip" "$target" "$port" "${proto:-tcp}"
|
||||
else
|
||||
firewall::block_ip "$client_ip" "$target"
|
||||
fw::block_ip "$client_ip" "$target"
|
||||
fi
|
||||
done < "$block_file"
|
||||
|
||||
log::wg "Restored block rules for: ${name}"
|
||||
log::debug "Restored block rules for: ${name}"
|
||||
done
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +210,7 @@ function firewall::restore_blocks() {
|
|||
# Preset Application
|
||||
# ============================================
|
||||
|
||||
function firewall::apply_preset() {
|
||||
function fw::apply_preset() {
|
||||
local name="$1"
|
||||
local client_ip="$2"
|
||||
local preset_file
|
||||
|
|
@ -282,15 +225,15 @@ function firewall::apply_preset() {
|
|||
|
||||
if [[ -n "${BLOCK_IPS:-}" ]]; then
|
||||
for ip in $BLOCK_IPS; do
|
||||
firewall::block_ip "$client_ip" "$ip"
|
||||
firewall::save_block "$client_ip" "$client_ip" "$ip"
|
||||
fw::block_ip "$client_ip" "$ip"
|
||||
fw::save_block "$client_ip" "$client_ip" "$ip"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${BLOCK_SUBNETS:-}" ]]; then
|
||||
for subnet in $BLOCK_SUBNETS; do
|
||||
firewall::block_subnet "$client_ip" "$subnet"
|
||||
firewall::save_block "$client_ip" "$client_ip" "$subnet"
|
||||
fw::block_subnet "$client_ip" "$subnet"
|
||||
fw::save_block "$client_ip" "$client_ip" "$subnet"
|
||||
done
|
||||
fi
|
||||
|
||||
|
|
@ -299,10 +242,10 @@ function firewall::apply_preset() {
|
|||
local target port proto
|
||||
IFS=":" read -r target port proto <<< "$entry"
|
||||
proto="${proto:-tcp}"
|
||||
firewall::block_port "$client_ip" "$target" "$port" "$proto"
|
||||
firewall::save_block "$name" "$client_ip" "$target" "$port" "$proto"
|
||||
fw::block_port "$client_ip" "$target" "$port" "$proto"
|
||||
fw::save_block "$name" "$client_ip" "$target" "$port" "$proto"
|
||||
done
|
||||
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
53
modules/group.module.sh
Normal 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)
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ function internal::get_log_priority() {
|
|||
case "$1" in
|
||||
DEBUG) echo 0 ;;
|
||||
INFO) echo 1 ;;
|
||||
SUCCESS) echo 1 ;; # FIX: SUCCESS is informational, not above ERROR
|
||||
SUCCESS) echo 1 ;;
|
||||
WARN) echo 2 ;;
|
||||
ERROR) echo 3 ;;
|
||||
*) echo 1 ;;
|
||||
|
|
@ -25,9 +25,15 @@ function internal::log() {
|
|||
local level="$1"
|
||||
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 message_priority
|
||||
|
||||
current_priority=$(internal::get_log_priority "$LOG_LEVEL")
|
||||
message_priority=$(internal::get_log_priority "$level")
|
||||
|
||||
|
|
@ -37,11 +43,11 @@ function internal::log() {
|
|||
|
||||
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
|
||||
DEBUG) color="\033[0;36m" ;;
|
||||
INFO) color="\033[1;34m" ;;
|
||||
WARN) color="\033[1;33m" ;;
|
||||
ERROR) color="\033[1;31m" ;;
|
||||
SUCCESS) color="\033[1;32m" ;;
|
||||
esac
|
||||
|
||||
echo -e "${color}=> ${level}:\033[0m $*"
|
||||
|
|
@ -103,14 +109,13 @@ function internal::icon() {
|
|||
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:unblock) echo "🔓 🔒 " ;;
|
||||
wg:key) echo "🔑 🔒 " ;;
|
||||
wg:success) echo "✅ 🔒 " ;;
|
||||
wg:warning) echo "⚠️ 🔒 " ;;
|
||||
|
|
@ -123,7 +128,7 @@ function internal::icon() {
|
|||
log:warn) echo "⚠️ " ;;
|
||||
log:error) echo "❌ " ;;
|
||||
log:success) echo "✅ " ;;
|
||||
log:debug) echo "🔍 " ;; # FIX: was missing, caused fallthrough
|
||||
log:debug) echo "🔍 " ;;
|
||||
|
||||
*) echo "🔹" ;;
|
||||
esac
|
||||
|
|
@ -138,8 +143,8 @@ function internal::get_context_icon() {
|
|||
env) echo "⚙️" ;;
|
||||
fs) echo "📁" ;;
|
||||
db) echo "🗄️" ;;
|
||||
wg) echo "🔒" ;; # ADDED: missing from original
|
||||
log) echo "🔹" ;; # ADDED: missing from original
|
||||
wg) echo "🔒" ;;
|
||||
log) echo "🔹" ;;
|
||||
*) echo "🔹" ;;
|
||||
esac
|
||||
}
|
||||
|
|
@ -159,36 +164,31 @@ function internal::log::debug() { internal::log DEBUG "$*"; }
|
|||
# ============================================
|
||||
|
||||
function log::context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
local context="$1" action="$2"
|
||||
shift 2
|
||||
internal::log::info "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::warn_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
local context="$1" action="$2"
|
||||
shift 2
|
||||
internal::log::warn "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::error_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
local context="$1" action="$2"
|
||||
shift 2
|
||||
internal::log::error "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::success_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
local context="$1" action="$2"
|
||||
shift 2
|
||||
internal::log::success "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::debug_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
local context="$1" action="$2"
|
||||
shift 2
|
||||
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::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
|
||||
function log::debug() { log::debug_context log debug "$@"; }
|
||||
|
||||
# ADDED: section separator for visual grouping in output
|
||||
function log::section() {
|
||||
core::is_quiet && return 0
|
||||
local label="$1"
|
||||
local width=48
|
||||
local line
|
||||
|
|
@ -214,16 +214,24 @@ function log::section() {
|
|||
echo -e "\033[1;34m${line}\033[0m"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Docker
|
||||
# ============================================
|
||||
|
||||
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_success() { log::success_context docker success "$@"; }
|
||||
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 "$@"; }
|
||||
|
||||
# ============================================
|
||||
# Build
|
||||
# ============================================
|
||||
|
||||
function log::build() { log::context build log "$@"; }
|
||||
function log::build_start() { log::context build start "$@"; }
|
||||
function log::build_stop() { log::context build stop "$@"; }
|
||||
|
|
@ -231,12 +239,20 @@ 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 "$@"; }
|
||||
# ============================================
|
||||
# Network
|
||||
# ============================================
|
||||
|
||||
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 "$@"; }
|
||||
|
||||
# ============================================
|
||||
# Auth
|
||||
# ============================================
|
||||
|
||||
function log::auth() { log::context auth log "$@"; }
|
||||
function log::auth_setup() { log::context auth setup "$@"; }
|
||||
|
|
@ -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_error() { log::error_context auth error "$@"; }
|
||||
|
||||
# ============================================
|
||||
# Env
|
||||
# ============================================
|
||||
|
||||
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 "$@"; }
|
||||
|
||||
# ============================================
|
||||
# Filesystem
|
||||
# ============================================
|
||||
|
||||
function log::fs() { log::context fs log "$@"; }
|
||||
function log::fs_read() { log::context fs read "$@"; }
|
||||
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_error() { log::error_context fs error "$@"; }
|
||||
|
||||
# ============================================
|
||||
# Database
|
||||
# ============================================
|
||||
|
||||
function log::db() { log::context db log "$@"; }
|
||||
function log::db_start() { log::context db start "$@"; }
|
||||
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_error() { log::error_context db error "$@"; }
|
||||
|
||||
# ADDED: WireGuard context helpers
|
||||
# ============================================
|
||||
# WireGuard
|
||||
# ============================================
|
||||
|
||||
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 "$@"; }
|
||||
|
|
@ -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_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() {
|
||||
local context="$1"
|
||||
local mode="strict"
|
||||
|
|
@ -309,9 +350,7 @@ function log::run_step() {
|
|||
local status=$?
|
||||
|
||||
if [[ $status -eq 0 ]]; then
|
||||
if [[ "$mode" == "info" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$mode" == "info" ]] && return 0
|
||||
internal::log::success "✅ $icon $description"
|
||||
return 0
|
||||
fi
|
||||
|
|
@ -323,4 +362,4 @@ function log::run_step() {
|
|||
|
||||
internal::log::error "❌ $icon $description → failed"
|
||||
return $status
|
||||
}
|
||||
}
|
||||
326
modules/log.module.sh.bak
Normal file
326
modules/log.module.sh.bak
Normal 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
|
||||
}
|
||||
|
|
@ -4,9 +4,11 @@
|
|||
# Config
|
||||
# ============================================
|
||||
|
||||
MONITOR_DIR="$(ctx::root)/daemon"
|
||||
MONITOR_DIR="$(ctx::daemon)"
|
||||
WATCHLIST_FILE="${MONITOR_DIR}/watchlist.json"
|
||||
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"
|
||||
|
||||
# ============================================
|
||||
|
|
@ -27,55 +29,25 @@ function monitor::on_load() {
|
|||
# ============================================
|
||||
|
||||
function monitor::watch() {
|
||||
local ip="$1"
|
||||
local client="$2"
|
||||
|
||||
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})"
|
||||
local ip="$1" client="$2"
|
||||
json::set "$WATCHLIST_FILE" "$ip" "$client"
|
||||
log::debug "Watching: ${client} (${ip})"
|
||||
}
|
||||
|
||||
function monitor::unwatch() {
|
||||
local ip="$1"
|
||||
|
||||
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}"
|
||||
json::delete "$WATCHLIST_FILE" "$ip"
|
||||
}
|
||||
|
||||
function monitor::is_watched() {
|
||||
local ip="$1"
|
||||
python3 -c "
|
||||
import json
|
||||
with open('${WATCHLIST_FILE}') as f:
|
||||
wl = json.load(f)
|
||||
exit(0 if '${ip}' in wl else 1)
|
||||
"
|
||||
json::has_key "$WATCHLIST_FILE" "$ip"
|
||||
}
|
||||
|
||||
function monitor::unwatch_client() {
|
||||
local name="$1"
|
||||
python3 -c "
|
||||
import json
|
||||
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}"
|
||||
json::filter_values "$WATCHLIST_FILE" "value" "$name"
|
||||
log::debug "Unwatched client: ${name}"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
|
|
@ -84,82 +56,18 @@ with open('${WATCHLIST_FILE}', 'w') as f:
|
|||
|
||||
function monitor::last_attempt() {
|
||||
local client="$1"
|
||||
python3 -c "
|
||||
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', ''))
|
||||
"
|
||||
json::last_event "$EVENTS_LOG" "client" "timestamp" "$client"
|
||||
}
|
||||
|
||||
function monitor::last_endpoint() {
|
||||
local client="$1"
|
||||
python3 -c "
|
||||
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', ''))
|
||||
"
|
||||
json::last_event "$EVENTS_LOG" "client" "endpoint" "$client"
|
||||
}
|
||||
|
||||
function monitor::events_for() {
|
||||
local ip="$1"
|
||||
local limit="${2:-50}"
|
||||
python3 -c "
|
||||
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}')
|
||||
"
|
||||
json::events_for "$EVENTS_LOG" "$ip" "$limit"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
|
|
@ -169,35 +77,13 @@ for e in events[-${limit}:]:
|
|||
ENDPOINT_CACHE="${WGCTL_DIR}/daemon/endpoint_cache.json"
|
||||
|
||||
function monitor::cache_endpoint() {
|
||||
local client="$1"
|
||||
local endpoint="$2"
|
||||
[[ -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)
|
||||
"
|
||||
local client="$1" ip="$2"
|
||||
json::set "$ENDPOINT_CACHE" "$client" "$ip"
|
||||
}
|
||||
|
||||
function monitor::get_cached_endpoint() {
|
||||
local client="$1"
|
||||
python3 -c "
|
||||
import json
|
||||
try:
|
||||
with open('${ENDPOINT_CACHE}') as f:
|
||||
cache = json.load(f)
|
||||
print(cache.get('${client}', ''))
|
||||
except:
|
||||
print('')
|
||||
"
|
||||
json::get "$ENDPOINT_CACHE" "$client"
|
||||
}
|
||||
|
||||
function monitor::update_endpoint_cache() {
|
||||
|
|
@ -229,17 +115,17 @@ function monitor::endpoint_for_key() {
|
|||
|
||||
function monitor::start() {
|
||||
systemctl start "$MONITOR_SERVICE"
|
||||
log::wg_start "Monitor daemon started"
|
||||
log::debug "Monitor daemon started"
|
||||
}
|
||||
|
||||
function monitor::stop() {
|
||||
systemctl stop "$MONITOR_SERVICE"
|
||||
log::wg_stop "Monitor daemon stopped"
|
||||
log::debug "Monitor daemon stopped"
|
||||
}
|
||||
|
||||
function monitor::restart() {
|
||||
systemctl restart "$MONITOR_SERVICE"
|
||||
log::wg_start "Monitor daemon restarted"
|
||||
log::debug "Monitor daemon restarted"
|
||||
}
|
||||
|
||||
function monitor::is_running() {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ PersistentKeepalive = 25
|
|||
EOF
|
||||
|
||||
chmod 600 "$conf"
|
||||
log::wg_add "Created client config: ${name} (${ip})"
|
||||
log::debug "Created client config: ${name} (${ip})"
|
||||
}
|
||||
|
||||
function peers::remove_client_config() {
|
||||
|
|
@ -52,7 +52,7 @@ function peers::remove_client_config() {
|
|||
fi
|
||||
|
||||
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
|
||||
EOF
|
||||
|
||||
log::wg_add "Added peer to server config: ${name}"
|
||||
log::debug "Added peer to server config: ${name}"
|
||||
}
|
||||
|
||||
function peers::remove_block() {
|
||||
|
|
@ -114,7 +114,7 @@ function peers::remove_from_server() {
|
|||
local name="$1"
|
||||
peers::remove_block "$name"
|
||||
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"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
# ============================================
|
||||
|
|
@ -224,11 +297,35 @@ function peers::resolve_and_require() {
|
|||
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
|
||||
# ============================================
|
||||
|
||||
function peers::reload() {
|
||||
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
292
modules/rule.module.sh
Normal 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
6
wgctl
|
|
@ -3,7 +3,7 @@ set -Eeuo pipefail
|
|||
|
||||
source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"
|
||||
|
||||
LOG_LEVEL=DEBUG
|
||||
# LOG_LEVEL=DEBUG
|
||||
|
||||
# ============================================
|
||||
# Modules
|
||||
|
|
@ -16,6 +16,8 @@ load_module keys
|
|||
load_module peers
|
||||
load_module firewall
|
||||
load_module monitor
|
||||
load_module rule
|
||||
load_module group
|
||||
|
||||
# ============================================
|
||||
# Alias Map
|
||||
|
|
@ -44,11 +46,11 @@ declare -A CMD_ALIASES=(
|
|||
[stop]=service
|
||||
[restart]=service
|
||||
[status]=service
|
||||
[logs]=service
|
||||
[enable]=service
|
||||
[disable]=service
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# Dispatch
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue