add wgctl

This commit is contained in:
root 2026-05-06 23:02:12 +00:00
commit 78f9caaf17
37 changed files with 9524 additions and 0 deletions

211
commands/add.command.sh Normal file
View file

@ -0,0 +1,211 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::add::on_load() {
flag::register --name
flag::register --type
flag::register --ip
flag::register --preset
flag::register --guest
flag::register --tunnel
flag::register --show-config
flag::register --show-qr
}
# ============================================
# Help
# ============================================
function cmd::add::help() {
cat <<EOF
Usage: wgctl add --name <name> --type <type> [options]
Add a new WireGuard client.
Options:
--name <name> Client name (e.g. nuno)
--type <type> Device type: desktop, laptop, phone, tablet, guest
--ip <ip> Override auto-assigned IP (optional)
--preset <name> Apply a firewall preset (repeatable)
--guest Shorthand for --type guest
--tunnel <mode> Tunnel mode: split (default) or full
--show-config Shows the WireGuard peer config
--show-qr Shows the WireGuard config in a QR Code
Device Types and Subnets:
desktop 10.1.1.x
laptop 10.1.2.x
phone 10.1.3.x
tablet 10.1.4.x
guest 10.1.100.x
Tunnel Modes:
split Route only VPN subnet + LAN through WireGuard (default)
full Route all traffic through WireGuard
Examples:
wgctl add --name nuno --type phone
wgctl add --name nuno --type laptop --ip 10.1.2.5
wgctl add --name nuno --type phone --tunnel full
wgctl add --name guest1 --type phone --guest
wgctl add --name restricted --type desktop --preset no-docker --preset no-proxmox
EOF
}
# ============================================
# Validation
# ============================================
function cmd::add::validate() {
local name="$1"
local type="$2"
local ip="$3"
local tunnel="$4"
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
if [[ -z "$type" ]]; then
log::error "Missing required flag: --type"
return 1
fi
if ! config::is_valid_type "$type"; then
log::error "Invalid device type: ${type}"
log::info "Valid types: $(config::device_types | tr ' ' ', ')"
return 1
fi
if [[ -n "$tunnel" && "$tunnel" != "split" && "$tunnel" != "full" ]]; then
log::error "Invalid tunnel mode: ${tunnel} (use 'split' or 'full')"
return 1
fi
if [[ -n "$ip" ]]; then
ip::require_valid "$ip"
fi
local full_name="${type}-${name}"
if [[ -f "$(ctx::clients)/${full_name}.conf" ]]; then
log::error "Client already exists: ${full_name}"
return 1
fi
}
# ============================================
# Display helpers
# ============================================
function cmd::add::is_mobile() {
local type="$1"
[[ "$type" == "phone" || "$type" == "tablet" ]]
}
# ============================================
# Run
# ============================================
function cmd::add::run() {
local name=""
local type=""
local ip=""
local tunnel=""
local guest=false
local presets=()
local show_config=false
local show_qr=false
# Parse flags
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$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-qr) show_qr=true; shift ;;
--help) cmd::add::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::add::help
return 1
;;
esac
done
# --guest shorthand
if $guest; then
type="guest"
fi
# Build full client name
local full_name="${type}-${name}"
# Validate
cmd::add::validate "$name" "$type" "$ip" "$tunnel" || return 1
# Resolve tunnel mode — flag > device default
if [[ -z "$tunnel" ]]; then
tunnel=$(config::default_tunnel_for "$type")
fi
local allowed_ips
allowed_ips=$(config::allowed_ips_for "$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
fi
log::wg_add "Name: ${full_name}"
log::wg_add "Type: ${type}"
log::wg_add "IP: ${ip}"
log::wg_add "Tunnel: ${tunnel} (${allowed_ips})"
# Generate keys
keys::generate_pair "$full_name" || return 1
# Create client config
peers::create_client_config "$full_name" "$type" "$ip" "$allowed_ips" || return 1
# 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
# Apply presets
for preset in "${presets[@]}"; do
firewall::apply_preset "$preset" "${ip}" || return 1
done
# Apply guest rules if guest type
if [[ "$type" == "guest" ]]; then
firewall::apply_guest_rules
fi
# Reload WireGuard
peers::reload || return 1
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
keys::qr "$full_name"
elif $show_config || ! cmd::add::is_mobile "$type"; then
log::section "Client Config"
cat "$(ctx::clients)/${full_name}.conf"
else
keys::qr "$full_name"
fi
}

168
commands/block.command.sh Normal file
View file

@ -0,0 +1,168 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::block::on_load() {
flag::register --name
flag::register --type
flag::register --ip
flag::register --port
flag::register --proto
flag::register --subnet
}
# ============================================
# Help
# ============================================
function cmd::block::help() {
cat <<EOF
Usage: wgctl block --name <name> [options]
Block a client entirely or restrict access to specific IPs/ports/subnets.
Block rules are persisted and restored on WireGuard restart.
Options:
--name <name> Client name (e.g. phone-nuno)
--ip <ip> Block access to specific IP (repeatable)
--subnet <cidr> Block access to subnet (repeatable)
--port <ip:port:proto> Block specific port, e.g. 10.0.0.210:9000:tcp (repeatable)
Examples:
wgctl block --name phone-nuno
wgctl block --name phone-nuno --ip 10.0.0.210
wgctl block --name phone-nuno --subnet 10.0.0.0/24
wgctl block --name phone-nuno --port 10.0.0.210:9000:tcp
wgctl ban --name phone-nuno --ip 10.0.0.100 --ip 10.0.0.200
EOF
}
# ============================================
# Helpers
# ============================================
function cmd::block::get_client_ip() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1
}
# ============================================
# Block Run
# ============================================
function cmd::block::run() {
local name=""
local type=""
local ips=()
local subnets=()
local ports=()
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;;
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--help) cmd::block::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::block::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
cmd::block::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
# Check if actually blocked
if peers::is_blocked "$name" || [[ -f "$(ctx::block::path "${name}.block")" ]]; then
log::wg_warning "Client is already blocked: ${name}"
return 0
fi
# Update cache first
monitor::update_endpoint_cache
local public_key
public_key=$(keys::public "$name") || return 1
local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key")
# Fall back to cache if live endpoint not available
if [[ -z "$endpoint" || "$endpoint" == "(none)" ]]; then
endpoint=$(monitor::get_cached_endpoint "$name")
fi
local client_ip
client_ip=$(cmd::block::get_client_ip "$name") || return 1
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
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"
# Watch real endpoint IP if available
if [[ -n "$endpoint" ]]; then
monitor::unwatch "$client_ip" # remove tunnel IP if added by block_all
monitor::watch "$endpoint" "$name"
fi
# Remove peer from server to kill active connection
peers::remove_from_server "$name"
peers::reload
log::wg_success "Client blocked: ${name}"
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"
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"
done
# Block specific ports
for entry in "${ports[@]}"; do
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
ip::require_valid "$target"
firewall::block_port "$client_ip" "$target" "$port" "$proto"
firewall::save_block "$name" "$client_ip" "$target" "$port" "$proto"
done
log::wg_success "Block rules applied for: ${name}"
}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::config::on_load() {
flag::register --name
flag::register --type
}
# ============================================
# Help
# ============================================
function cmd::config::help() {
cat <<EOF
Usage: wgctl config --name <name>
Show the WireGuard config file for a client.
Options:
--name <name> Client name (e.g. phone-nuno)
Examples:
wgctl config --name phone-nuno
wgctl config --name laptop-nuno
EOF
}
# ============================================
# Run
# ============================================
function cmd::config::run() {
local name=""
local type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--help) cmd::config::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::config::help
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 conf
conf="$(ctx::clients)/${name}.conf"
log::section "Client Config: ${name}"
cat "$conf"
}

View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
function cmd::inspect::on_load() {
flag::register --name
flag::register --type
}
function cmd::inspect::help() {
cat <<EOF
Usage: wgctl inspect --name <name> [--type <type>]
wgctl inspect <full-name>
Show detailed information for a single client.
Options:
--name <name> Client name
--type <type> Device type (optional, combines with --name)
Examples:
wgctl inspect --name phone-nuno
wgctl inspect --name nuno --type phone
EOF
}
function cmd::inspect::run() {
local name=""
local type=""
# Support positional argument: wgctl inspect phone-nuno
if [[ $# -gt 0 && "$1" != "--"* ]]; then
name="$1"
shift
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--help) cmd::inspect::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::inspect::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
cmd::inspect::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
load_command list
cmd::list::run --name "$name"
}

427
commands/list.command.sh Normal file
View file

@ -0,0 +1,427 @@
#!/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"
}
# ============================================
# 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
# 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"
printf "\n %-28s %-15s %-10s %-22s %s\n" \
"NAME" "IP" "TYPE" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..90})"
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")
printf " %-28s %-15s %-10s %-32b %s\n" \
"$client_name" "$ip" "$type" "$status" "$last_seen"
done
printf "\n"
}

199
commands/preset.command.sh Normal file
View file

@ -0,0 +1,199 @@
#!/usr/bin/env bash
# ============================================
# Help
# ============================================
function cmd::preset::help() {
cat <<EOF
Usage: wgctl preset <subcommand> [options]
Manage firewall presets.
Subcommands:
list, ls List available presets
add, new, create Add a new preset
remove, rm, del Remove a preset
Options for add:
--name <name> Preset name (e.g. no-jellyfin)
--desc <description> Human readable description
--block-ip <ip> Block specific IP (repeatable)
--block-subnet <cidr> Block subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable)
Examples:
wgctl preset list
wgctl preset add --name no-jellyfin --desc "Block Jellyfin" --block-ip 10.0.0.210 --block-port 10.0.0.210:8096:tcp
wgctl preset remove --name no-jellyfin
EOF
}
# ============================================
# Run
# ============================================
function cmd::preset::run() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
list|ls) cmd::preset::list "$@" ;;
add|new|create) cmd::preset::add "$@" ;;
remove|rm|del|delete) cmd::preset::remove "$@" ;;
help) cmd::preset::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::preset::help
return 1
;;
esac
}
# ============================================
# List
# ============================================
function cmd::preset::list() {
local dir
dir="$(ctx::presets)"
local presets=("${dir}"/*.preset)
if [[ ! -f "${presets[0]}" ]]; then
log::wg_preset "No presets configured"
return 0
fi
log::section "Available Presets"
printf "\n %-25s %-40s %s\n" "NAME" "DESCRIPTION" "RULES"
printf " %s\n" "$(printf '─%.0s' {1..75})"
for preset_file in "${dir}"/*.preset; do
[[ -f "$preset_file" ]] || continue
# Reset vars before sourcing
local PRESET_NAME="" PRESET_DESC=""
local BLOCK_IPS="" BLOCK_SUBNETS="" BLOCK_PORTS=""
source "$preset_file"
local rules=""
[[ -n "$BLOCK_IPS" ]] && rules+="IPs:$(echo "$BLOCK_IPS" | wc -w) "
[[ -n "$BLOCK_SUBNETS" ]] && rules+="Subnets:$(echo "$BLOCK_SUBNETS" | wc -w) "
[[ -n "$BLOCK_PORTS" ]] && rules+="Ports:$(echo "$BLOCK_PORTS" | wc -w)"
printf " %-25s %-40s %s\n" \
"$PRESET_NAME" \
"${PRESET_DESC:-}" \
"${rules:-}"
done
printf "\n"
}
# ============================================
# Add
# ============================================
function cmd::preset::add() {
local name=""
local desc=""
local block_ips=()
local block_subnets=()
local block_ports=()
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--desc) desc="$2"; shift 2 ;;
--block-ip) block_ips+=("$2"); shift 2 ;;
--block-subnet) block_subnets+=("$2"); shift 2 ;;
--block-port) block_ports+=("$2"); shift 2 ;;
--help) cmd::preset::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::preset::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
if [[ ${#block_ips[@]} -eq 0 && ${#block_subnets[@]} -eq 0 && ${#block_ports[@]} -eq 0 ]]; then
log::error "At least one of --block-ip, --block-subnet, or --block-port is required"
return 1
fi
local preset_file
preset_file="$(ctx::preset::path "${name}.preset")"
if [[ -f "$preset_file" ]]; then
log::error "Preset already exists: ${name}"
return 1
fi
cat > "$preset_file" <<EOF
# wgctl preset — ${name}
PRESET_NAME="${name}"
PRESET_DESC="${desc}"
BLOCK_IPS="${block_ips[*]:-}"
BLOCK_SUBNETS="${block_subnets[*]:-}"
BLOCK_PORTS="${block_ports[*]:-}"
EOF
log::wg_success "Preset created: ${name}"
}
# ============================================
# Remove
# ============================================
function cmd::preset::remove() {
local name=""
local force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--force) force=true; shift ;;
--help) cmd::preset::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::preset::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
return 1
fi
local preset_file
preset_file="$(ctx::preset::path "${name}.preset")"
if [[ ! -f "$preset_file" ]]; then
log::error "Preset not found: ${name}"
return 1
fi
if ! $force; then
read -r -p "Are you sure you want to remove preset '${name}'? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*)
log::info "Aborted"
return 0
;;
esac
fi
rm -f "$preset_file"
log::wg_success "Preset removed: ${name}"
}

62
commands/qr.command.sh Normal file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::qr::on_load() {
flag::register --name
flag::register --type
}
# ============================================
# Help
# ============================================
function cmd::qr::help() {
cat <<EOF
Usage: wgctl qr --name <name>
Display QR code for a client config.
Useful for adding clients to mobile devices.
Options:
--name <name> Full client name (e.g. phone-nuno)
Examples:
wgctl qr --name phone-nuno
wgctl qr --name tablet-nuno
EOF
}
# ============================================
# Run
# ============================================
function cmd::qr::run() {
local name=""
local type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--help) cmd::qr::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::qr::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
cmd::qr::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
keys::qr "$name"
}

124
commands/remove.command.sh Normal file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::remove::on_load() {
flag::register --name
flag::register --type
flag::register --force
}
# ============================================
# Help
# ============================================
function cmd::remove::help() {
cat <<EOF
Usage: wgctl remove --name <name> [options]
Permanently remove a WireGuard client.
This will delete the client config, keys, and remove it from the server.
Options:
--name <name> Full client name (e.g. phone-nuno)
--force Skip confirmation prompt
Examples:
wgctl remove --name phone-nuno
wgctl rm --name phone-nuno --force
EOF
}
# ============================================
# Run
# ============================================
function cmd::remove::run() {
local name=""
local type=""
local 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::remove::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::remove::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
cmd::remove::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
# Confirmation prompt unless --force
if ! $force; then
read -r -p "Are you sure you want to permanently remove '${name}'? [y/N] " confirm
case "$confirm" in
[yY][eE][sS]|[yY]) ;;
*)
log::info "Aborted"
return 0
;;
esac
fi
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)
local was_blocked=false
peers::is_blocked "$name" && was_blocked=true
# Remove peer from server config
peers::remove_from_server "$name" || return 1
# Remove client config
peers::remove_client_config "$name" || return 1
# Remove keys
keys::remove "$name" || return 1
# Remove block rules only if client was fully blocked
if [[ -n "$client_ip" ]] && $was_blocked; then
firewall::unblock_all "$client_ip"
fi
firewall::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
# Reload WireGuard
peers::reload || return 1
log::wg_success "Client removed: ${name}"
}

122
commands/rename.command.sh Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::rename::on_load() {
flag::register --name
flag::register --type
flag::register --new-name
flag::register --new-type
}
# ============================================
# Help
# ============================================
function cmd::rename::help() {
cat <<EOF
Usage: wgctl rename --name <name> --new-name <new-name>
Rename an existing WireGuard client.
The client IP and keys are preserved, only the name changes.
Options:
--name <name> Current client name (e.g. phone-phone-nuno)
--new-name <name> New client name (e.g. phone-nuno)
Examples:
wgctl rename --name phone-phone-nuno --new-name phone-nuno
wgctl mv --name laptop-old --new-name laptop-nuno
EOF
}
# ============================================
# Run
# ============================================
function cmd::rename::run() {
local name=""
local type=""
local new_name=""
local new_type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--new-name) new_name="$2"; shift 2 ;;
--new-type) new_type="$2"; shift 2 ;;
--help) cmd::rename::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::rename::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
cmd::rename::help
return 1
fi
if [[ -z "$new_name" ]]; then
log::error "Missing required flag: --new-name"
cmd::rename::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
# Resolve new name if provided
if [[ -n "$new_name" || -n "$new_type" ]]; then
# If only new_type provided, keep same base name
if [[ -z "$new_name" ]]; then
new_name=$(echo "$name" | cut -d'-' -f2-)
fi
new_name=$(peers::resolve_name "$new_name" "$new_type") || return 1
fi
local dir
dir="$(ctx::clients)"
if [[ ! -f "${dir}/${name}.conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
if [[ -f "${dir}/${new_name}.conf" ]]; then
log::error "Client already exists: ${new_name}"
return 1
fi
log::section "Renaming client: ${name}${new_name}"
# Rename client files
mv "${dir}/${name}.conf" "${dir}/${new_name}.conf"
mv "${dir}/${name}_private.key" "${dir}/${new_name}_private.key"
mv "${dir}/${name}_public.key" "${dir}/${new_name}_public.key"
log::fs_write "Renamed client files"
# Update comment in wg0.conf
sed -i "s/^# ${name}$/# ${new_name}/" "$(config::config_file)"
log::fs_write "Updated server config"
# Update block file if exists
local block_file
block_file="$(ctx::block::path "${name}.block")"
if [[ -f "$block_file" ]]; then
mv "$block_file" "$(ctx::block::path "${new_name}.block")"
log::fs_write "Renamed block file"
fi
# Reload WireGuard
peers::reload
log::wg_success "Client renamed: ${name}${new_name}"
}

108
commands/service.command.sh Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# ============================================
# Help
# ============================================
function cmd::service::help() {
cat <<EOF
Usage: wgctl <subcommand>
Manage the WireGuard service.
Subcommands:
start, up Start WireGuard
stop, down Stop WireGuard
restart, reload Restart WireGuard
status, stat Show WireGuard status
logs, log Show WireGuard logs
enable Enable WireGuard on boot
disable Disable WireGuard on boot
Examples:
wgctl start
wgctl logs
wgctl status
EOF
}
# ============================================
# Run
# ============================================
function cmd::service::run() {
local subcmd="${1:-help}"
shift || true
case "$subcmd" in
start) cmd::service::start ;;
stop) cmd::service::stop ;;
restart) cmd::service::restart ;;
reload) cmd::service::reload ;;
status) cmd::service::status ;;
logs) cmd::service::logs ;;
enable) cmd::service::enable ;;
disable) cmd::service::disable ;;
help) cmd::service::help ;;
*)
log::error "Unknown subcommand: '${subcmd}'"
cmd::service::help
return 1
;;
esac
}
# ============================================
# Subcommands
# ============================================
function cmd::service::start() {
log::wg_start "Starting WireGuard..."
systemctl start "wg-quick@$(config::interface)"
log::wg_success "WireGuard started"
}
function cmd::service::stop() {
log::wg_stop "Stopping WireGuard..."
systemctl stop "wg-quick@$(config::interface)"
log::wg_success "WireGuard stopped"
}
function cmd::service::restart() {
log::wg_start "Restarting WireGuard..."
systemctl restart "wg-quick@$(config::interface)"
firewall::restore_blocks
log::wg_success "WireGuard restarted"
}
function cmd::service::reload() {
log::wg_start "Reloading WireGuard config..."
peers::reload
firewall::restore_blocks
}
function cmd::service::status() {
log::section "WireGuard Status"
echo ""
systemctl status "wg-quick@$(config::interface)" --no-pager
echo ""
log::section "Active Peers"
wg show "$(config::interface)"
}
function cmd::service::logs() {
log::section "WireGuard Logs"
journalctl -u "wg-quick@$(config::interface)" -f --no-pager
}
function cmd::service::enable() {
systemctl enable "wg-quick@$(config::interface)"
log::wg_success "WireGuard enabled on boot"
}
function cmd::service::disable() {
systemctl disable "wg-quick@$(config::interface)"
log::wg_success "WireGuard disabled on boot"
}

147
commands/unblock.command.sh Normal file
View file

@ -0,0 +1,147 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::unblock::on_load() {
flag::register --name
flag::register --type
flag::register --ip
flag::register --port
flag::register --proto
flag::register --subnet
flag::register --all
}
# ============================================
# Help
# ============================================
function cmd::unblock::help() {
cat <<EOF
Usage: wgctl unblock --name <name> [options]
Remove block rules for a client.
Options:
--name <name> Client name (e.g. phone-nuno)
--ip <ip> Unblock specific IP (repeatable)
--subnet <cidr> Unblock specific subnet (repeatable)
--port <ip:port:proto> Unblock specific port (repeatable)
--all Remove all block rules for this client
Examples:
wgctl unblock --name phone-nuno --all
wgctl unblock --name phone-nuno --ip 10.0.0.210
wgctl unban --name phone-nuno --all
EOF
}
# ============================================
# Helpers
# ============================================
function cmd::unblock::get_client_ip() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1
}
# ============================================
# Unblock Run
# ============================================
function cmd::unblock::run() {
local name=""
local type=""
local ips=()
local subnets=()
local ports=()
local all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--ip) ips+=("$2"); shift 2 ;;
--subnet) subnets+=("$2"); shift 2 ;;
--port) ports+=("$2"); shift 2 ;;
--all) all=true; shift ;;
--help) cmd::unblock::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::unblock::help
return 1
;;
esac
done
if [[ -z "$name" ]]; then
log::error "Missing required flag: --name"
cmd::unblock::help
return 1
fi
name=$(peers::resolve_and_require "$name" "$type") || return 1
# Check if actually blocked
if ! peers::is_blocked "$name" && [[ ! -f "$(ctx::block::path "${name}.block")" ]]; then
log::wg_warning "Client is not blocked: ${name}"
return 0
fi
# Default to full unblock if no specific flags given
if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 ]]; then
all=true
fi
local client_ip
client_ip=$(cmd::unblock::get_client_ip "$name") || return 1
log::section "Unblocking client: ${name} (${client_ip})"
if $all; then
firewall::unblock_all "$client_ip"
firewall::remove_block_file "$name"
monitor::unwatch_client "$name"
# Re-add peer to server if missing
if ! peers::exists_in_server "$name"; then
local public_key
public_key=$(keys::public "$name") || return 1
peers::add_to_server "$name" "$public_key" "$client_ip"
peers::reload
fi
log::wg_success "All block rules removed for: ${name}"
return 0
fi
# Unblock specific IPs
for ip in "${ips[@]}"; do
firewall::unblock_ip "$client_ip" "$ip"
done
# Unblock specific subnets
for subnet in "${subnets[@]}"; do
firewall::unblock_subnet "$client_ip" "$subnet"
done
# Unblock specific ports
for entry in "${ports[@]}"; do
local target port proto
IFS=":" read -r target port proto <<< "$entry"
proto="${proto:-tcp}"
firewall::unblock_port "$client_ip" "$target" "$port" "$proto"
done
log::wg_success "Unblock rules applied for: ${name}"
}

288
commands/watch.command.sh Normal file
View file

@ -0,0 +1,288 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::watch::on_load() {
flag::register --type
flag::register --name
flag::register --blocked
flag::register --restricted
flag::register --allowed
}
# ============================================
# Help
# ============================================
function cmd::watch::help() {
cat <<EOF
Usage: wgctl watch [options]
Live monitor of WireGuard activity.
Shows handshakes from active clients and connection attempts from blocked clients.
Options:
--type <type> Filter by device type
--name <name> Filter by client name
--allowed Show only allowed client handshakes
--restricted Show only restricted client events
--blocked Show only blocked client attempts
Examples:
wgctl watch
wgctl watch --blocked
wgctl watch --allowed
wgctl watch --type phone
wgctl watch --name phone-nuno
EOF
}
# ============================================
# Helpers
# ============================================
function cmd::watch::format_event() {
local ts="$1"
local client="$2"
local endpoint="$3"
local event="$4"
local status="$5"
local event_color
case "$event" in
attempt) event_color="\033[1;31m" ;; # red
handshake) event_color="\033[1;32m" ;; # green
*) event_color="\033[0;37m" ;; # grey
esac
local status_str=""
if [[ -n "$status" ]]; then
case "$status" in
blocked) status_str=" \033[1;31mblocked\033[0m" ;;
allowed) status_str=" \033[1;32mallowed\033[0m" ;;
*) status_str="" ;;
esac
fi
printf " %-20s %-25s %-20s ${event_color}%-12s\033[0m%b\n" \
"$ts" "$client" "${endpoint:-}" "$event" "$status_str"
}
function cmd::watch::header() {
log::section "wgctl — Live Monitor (Ctrl+C to stop)"
printf "\n %-20s %-25s %-20s %-12s %s\n" \
"TIME" "CLIENT" "ENDPOINT" "EVENT" "STATUS"
printf " %s\n\n" "$(printf '─%.0s' {1..85})"
}
# ============================================
# Handshake Poller
# ============================================
declare -A _WATCH_LAST_HANDSHAKES=()
function cmd::watch::poll_handshakes() {
local filter_name="$1"
local filter_type="$2"
local allowed_only="$3"
while IFS= read -r line; do
local public_key ts
public_key=$(echo "$line" | awk '{print $1}')
ts=$(echo "$line" | awk '{print $2}')
[[ -z "$ts" || "$ts" == "0" ]] && continue
# Find client by public key
local client_name=""
for conf in "$(ctx::clients)"/*.conf; do
[[ -f "$conf" ]] || continue
local name
name=$(basename "$conf" .conf)
local key
key=$(keys::public "$name" 2>/dev/null || echo "")
if [[ "$key" == "$public_key" ]]; then
client_name="$name"
break
fi
done
[[ -z "$client_name" ]] && continue
# Apply filters
[[ -n "$filter_name" && "$client_name" != "$filter_name" ]] && continue
if [[ -n "$filter_type" ]]; then
local ip
ip=$(grep "^Address" "$(ctx::clients)/${client_name}.conf" | awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
string::starts_with "$ip" "$subnet" || continue
fi
# Only emit if handshake is new
local safe_key
safe_key=$(echo "$public_key" | md5sum | cut -d' ' -f1)
local prev_ts_file="/tmp/wgctl_hs_${safe_key}"
local prev_ts="0"
[[ -f "$prev_ts_file" ]] && prev_ts=$(cat "$prev_ts_file")
if [[ "$ts" != "$prev_ts" ]]; then
echo "$ts" > "$prev_ts_file"
local formatted_ts
formatted_ts=$(date -d "@${ts}" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$ts")
local endpoint
endpoint=$(monitor::endpoint_for_key "$public_key")
cmd::watch::format_event \
"$formatted_ts" "$client_name" "${endpoint:-}" "handshake" "allowed"
fi
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
}
# ============================================
# Event Tailer
# ============================================
function cmd::watch::tail_events() {
local filter_name="$1"
local filter_type="$2"
local blocked_only="$3"
local restricted_only="$4"
local allowed_only="$5"
declare -A _WATCH_LAST_ATTEMPT=()
tail -f "$(ctx::root)/daemon/events.log" 2>/dev/null | while IFS= read -r line; do
[[ -z "$line" ]] && continue
local event_data
event_data=$(python3 -c "
import json, sys
try:
e = json.loads('${line//\'/\'\\\'\'}')
print(e.get('timestamp',''), e.get('client',''), e.get('endpoint',''), e.get('event',''))
except:
pass
" 2>/dev/null)
[[ -z "$event_data" ]] && continue
if $restricted_only; then
local conf
conf="$(ctx::clients)/${client}.conf"
[[ -f "$conf" ]] || continue
cmd::list::is_restricted "$client" || continue
fi
local ts client endpoint event
read -r ts client endpoint event <<< "$event_data"
# Apply filters
[[ -n "$filter_name" && "$client" != "$filter_name" ]] && continue
if [[ -n "$filter_type" ]]; then
local conf
conf="$(ctx::clients)/${client}.conf"
[[ -f "$conf" ]] || continue
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
string::starts_with "$ip" "$subnet" || continue
fi
# Filter by status
if $allowed_only && [[ "$event" != "handshake" ]]; then
continue
fi
if $restricted_only; then
local conf
conf="$(ctx::clients)/${client}.conf"
[[ -f "$conf" ]] || continue
cmd::list::is_restricted "$client" || continue
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")
# Before printing the event
local now
now=$(date +%s)
local safe_client="${client//[-.]/_}"
local last="${_WATCH_LAST_ATTEMPT[$safe_client]:-0}"
local diff=$(( now - last ))
if (( diff < 30 )); then
continue
fi
_WATCH_LAST_ATTEMPT[$safe_client]="$now"
cmd::watch::format_event \
"$formatted_ts" "$client" "${endpoint:-}" "$event" "blocked"
done
}
# ============================================
# Run
# ============================================
function cmd::watch::run() {
local filter_name=""
local filter_type=""
local blocked_only=false
local allowed_only=false
local restricted_only=false
# Clean up any stale temp files from previous runs
rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_* 2>/dev/null || true
while [[ $# -gt 0 ]]; do
case "$1" in
--name) filter_name="$2"; shift 2 ;;
--type) filter_type="$2"; shift 2 ;;
--blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
--help) cmd::watch::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::watch::help
return 1
;;
esac
done
cmd::watch::header
# Start event tailer in background unless --blocked not set
if ! $blocked_only && ! $restricted_only; then
# Poll handshakes every 5 seconds in background
(
while true; do
cmd::watch::poll_handshakes "$filter_name" "$filter_type" "$allowed_only"
sleep 5
done
) &
local poller_pid=$!
fi
# Tail events log for blocked attempts
cmd::watch::tail_events "$filter_name" "$filter_type" "$blocked_only" "$restricted_only" "$allowed_only" &
local tailer_pid=$!
# Trap Ctrl+C to clean up background processes
trap "kill $tailer_pid ${poller_pid:-} 2>/dev/null; rm -f /tmp/wgctl_hs_* /tmp/wgctl_attempt_*; echo ''; exit 0" INT TERM
# Keep main process alive
wait
}

13
core.sh Normal file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# ============================================
# Core Bootstrap
# ============================================
WGCTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${WGCTL_DIR}/core/context.sh"
source "${WGCTL_DIR}/core/utils.sh"
source "${WGCTL_DIR}/core/module.sh"
source "${WGCTL_DIR}/core/command.sh"
source "${WGCTL_DIR}/core/flag.sh"

76
core/command.sh Normal file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# ============================================
# Command Registry
# ============================================
declare -A _LOADED_COMMANDS=()
readonly _COMMAND_NAMESPACE="cmd"
readonly _COMMAND_AUTO_LOAD_HOOK="on_load"
# ============================================
# Helpers
# ============================================
function command::loaded() { [[ -n "${_LOADED_COMMANDS["$1"]:-}" ]]; }
# Convert path-style name to namespace
# e.g. service/wireguard -> service::wireguard
function command::to_namespace() { echo "${1//\//:}"; }
# Build fully qualified function name
# e.g. command::fn "add" "run" -> cmd::add::run
# e.g. command::fn "service/wg" "run" -> cmd::service::wg::run
function command::fn() {
local name namespace
namespace=$(command::to_namespace "$1")
echo "${_COMMAND_NAMESPACE}::${namespace}::${2}"
}
function command::has_function() { declare -F "$(command::fn "$1" "$2")" >/dev/null 2>&1; }
function command::is_auto_load() { declare -F "$(command::fn "$1" on_load)" >/dev/null 2>&1; }
function command::exists() { command::has_function "$1" run; }
# ============================================
# Runner
# ============================================
function command::run() {
local cmd="$1"
shift
local fn
fn=$(command::fn "$cmd" run)
core::call_function "$fn" "$@"
}
function core::call_function() {
local fn="$1"
shift
"$fn" "$@"
}
# ============================================
# Loader
# ============================================
function load_command() {
local name="$1"
command::loaded "$name" && return 0
local path
path="$(ctx::commands)/${name}.command.sh"
if [[ ! -f "$path" ]]; then
log::error "Command not found: ${name} (${path})"
return 1
fi
source "$path"
_LOADED_COMMANDS["$name"]=1
core::call_if_exists "$(command::fn "$name" on_load)"
return 0
}

42
core/context.sh Normal file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# ============================================
# Static Context — resolved once at source time
# ============================================
_CTX_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
_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"
function ctx::root() { echo "$_CTX_ROOT"; }
function ctx::core() { echo "$_CTX_CORE"; }
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::clients() { echo "$_CTX_CLIENTS"; }
function ctx::wg() { echo "$_CTX_WG"; }
# ============================================
# Path Helpers
# ============================================
function ctx::client::path() {
local IFS="/"
echo "$_CTX_CLIENTS/$*"
}
function ctx::preset::path() {
local IFS="/"
echo "$_CTX_PRESETS/$*"
}
function ctx::block::path() {
local IFS="/"
echo "$_CTX_BLOCKS/$*"
}

66
core/flag.sh Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# ============================================
# Flag Registry
# ============================================
declare -A _REGISTERED_FLAGS=()
# ============================================
# Registration
# ============================================
function flag::register() {
local flag="$1"
_REGISTERED_FLAGS["$flag"]=1
}
function flag::registered() {
[[ -n "${_REGISTERED_FLAGS["$1"]:-}" ]]
}
# ============================================
# Parsing
# ============================================
# Parse flags from args into associative array
# Usage: flag::parse "$@"
# Access: flag::get --name
declare -A _FLAG_VALUES=()
function flag::parse() {
_FLAG_VALUES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--*)
local key="$1"
# Boolean flag (no value follows, or next arg is another flag)
if [[ $# -eq 1 || "$2" == --* ]]; then
_FLAG_VALUES["$key"]="true"
shift
else
_FLAG_VALUES["$key"]="$2"
shift 2
fi
;;
*)
shift
;;
esac
done
}
function flag::get() {
echo "${_FLAG_VALUES["$1"]:-}"
}
function flag::enabled() {
[[ "${_FLAG_VALUES["$1"]:-}" == "true" ]]
}
function flag::set() {
local key="$1"
local value="$2"
_FLAG_VALUES["$key"]="$value"
}

55
core/module.sh Normal file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# ============================================
# Module Registry
# ============================================
declare -A _LOADED_MODULES=()
readonly _MODULE_AUTO_LOAD_HOOK="on_load"
# ============================================
# Helpers
# ============================================
function module::loaded() { [[ -n "${_LOADED_MODULES["$1"]:-}" ]]; }
# Convert path-style name to namespace
# e.g. firewall/iptables -> firewall::iptables
function module::to_namespace() { echo "${1//\//:}"; }
# Build fully qualified function name
# e.g. module::fn "firewall/iptables" "on_load" -> firewall::iptables::on_load
function module::fn() {
local namespace
namespace=$(module::to_namespace "$1")
echo "${namespace}::${2}"
}
function module::has_function() { declare -F "$(module::fn "$1" "$2")" >/dev/null 2>&1; }
function module::is_auto_load() { declare -F "$(module::fn "$1" on_load)" >/dev/null 2>&1; }
# ============================================
# Loader
# ============================================
function load_module() {
local name="$1"
module::loaded "$name" && return 0
local path
path="$(ctx::modules)/${name}.module.sh"
if [[ ! -f "$path" ]]; then
log::error "Module not found: ${name} (${path})"
return 1
fi
source "$path"
_LOADED_MODULES["$name"]=1
core::call_if_exists "$(module::fn "$name" on_load)"
return 0
}

52
core/utils.sh Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# ============================================
# Core — shell introspection
# ============================================
function core::function_exists() { declare -F "$1" >/dev/null 2>&1; }
function core::variable_exists() { declare -p "$1" &>/dev/null; }
function core::array_exists() { [[ "$(declare -p "$1" 2>/dev/null)" == "declare -a"* ]]; }
function core::call_if_exists() {
local fn="$1"
shift
if declare -F "$fn" >/dev/null 2>&1; then
"$fn" "$@"
fi
return 0
}
# ============================================
# String
# ============================================
function string::trim() { echo "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; }
function string::to_lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; }
function string::to_upper() { echo "$1" | tr '[:lower:]' '[:upper:]'; }
function string::contains() { [[ "$1" == *"$2"* ]]; }
function string::starts_with() { [[ "$1" == "$2"* ]]; }
function string::ends_with() { [[ "$1" == *"$2" ]]; }
function string::is_empty() { [[ -z "$1" ]]; }
# ============================================
# System
# ============================================
function system::require_root() {
if [[ "$EUID" -ne 0 ]]; then
log::error "wgctl must be run as root"
exit 1
fi
}
function system::command_exists() {
command -v "$1" >/dev/null 2>&1
}
function system::require_command() {
if ! system::command_exists "$1"; then
log::error "Required command not found: $1"
exit 1
fi
}

View file

@ -0,0 +1,3 @@
{
"phone-nuno": "148.69.48.74"
}

5146
daemon/events.log Normal file

File diff suppressed because it is too large Load diff

1
daemon/watchlist.json Normal file
View file

@ -0,0 +1 @@
{}

188
daemon/wgctl-monitor.py Executable file
View file

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

View file

@ -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

138
modules/config.module.sh Normal file
View file

@ -0,0 +1,138 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function config::on_load() {
config::validate
}
# ============================================
# Server
# ============================================
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"
# ============================================
# Device Type → Subnet Mapping
# ============================================
declare -gA DEVICE_SUBNETS=(
[desktop]="10.1.1"
[laptop]="10.1.2"
[phone]="10.1.3"
[tablet]="10.1.4"
[guest]="10.1.100"
)
# ============================================
# 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"
)
# ============================================
# 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::server_public_key() {
cat "$WG_SERVER_PUBLIC_KEY_FILE"
}
function config::device_types() {
local types
{ set +u; types="${!DEVICE_SUBNETS[@]}"; set -u; }
echo "$types"
}
function config::is_valid_type() {
local type="$1"
local subnet
subnet=$(config::subnet_for "$type")
[[ -n "$subnet" ]]
}
function config::subnet_for() {
local type="$1"
local result
{ set +u; result="${DEVICE_SUBNETS[$type]:-}"; set -u; }
echo "$result"
}
function config::default_tunnel_for() {
local type="$1"
local result
{ set +u; result="${DEVICE_TUNNEL_MODE[$type]:-split}"; set -u; }
echo "$result"
}
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" ;;
*)
log::error "Unknown tunnel mode: ${tunnel} (use 'split' or 'full')"
return 1
;;
esac
}
# ============================================
# Validation
# ============================================
function config::validate() {
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}"
exit 1
fi
if [[ ! -f "$WG_CONFIG" ]]; then
log::error "WireGuard config not found: ${WG_CONFIG}"
exit 1
fi
}

View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function config::on_load() {
config::validate
}
# ============================================
# Server
# ============================================
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"
# ============================================
# Device Type → Subnet Mapping
# ============================================
declare -gA DEVICE_SUBNETS=(
[desktop]="10.1.1"
[laptop]="10.1.2"
[phone]="10.1.3"
[tablet]="10.1.4"
[guest]="10.1.100"
)
# ============================================
# Device Type → Default AllowedIPs
# ============================================
declare -gA DEVICE_ALLOWED_IPS=(
[desktop]="0.0.0.0/0"
[laptop]="0.0.0.0/0"
[phone]="0.0.0.0/0"
[tablet]="0.0.0.0/0"
[guest]="0.0.0.0/0"
)
# ============================================
# 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::server_public_key() {
cat "$WG_SERVER_PUBLIC_KEY_FILE"
}
function config::device_types() {
local types
{ set +u; types="${!DEVICE_SUBNETS[@]}"; set -u; }
echo "$types"
}
function config::is_valid_type() {
local type="$1"
local subnet
subnet=$(config::subnet_for "$type")
[[ -n "$subnet" ]]
}
function config::subnet_for() {
local type="$1"
local result
{ set +u; result="${DEVICE_SUBNETS[$type]:-}"; set -u; }
echo "$result"
}
function config::allowed_ips_for() {
local type="$1"
local result
{ set +u; result="${DEVICE_ALLOWED_IPS[$type]:-0.0.0.0/0}"; set -u; }
echo "$result"
}
# ============================================
# Validation
# ============================================
function config::validate() {
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}"
exit 1
fi
if [[ ! -f "$WG_CONFIG" ]]; then
log::error "WireGuard config not found: ${WG_CONFIG}"
exit 1
fi
}

308
modules/firewall.module.sh Normal file
View file

@ -0,0 +1,308 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function firewall::on_load() {
system::require_command iptables
}
# ============================================
# 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 firewall::unblock_ip() {
local client_ip="$1"
local target_ip="$2"
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -j LOG --log-prefix "wgctl-dropped: " --log-level 4 2>/dev/null || true
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -j 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 firewall::unblock_port() {
local client_ip="$1"
local target_ip="$2"
local port="$3"
local proto="${4:-tcp}"
iptables -D FORWARD -s "$client_ip" -d "$target_ip" -p "$proto" --dport "$port" -j DROP 2>/dev/null || true
log::wg_unblock "Unblocked ${client_ip}${target_ip}:${port}/${proto}"
}
function firewall::block_all() {
local client_ip="$1"
local client_name="$2"
iptables -A FORWARD -s "$client_ip" -j LOG --log-prefix "wgctl-dropped: " --log-level 4
iptables -A FORWARD -s "$client_ip" -j DROP
log::wg_block "Blocked all traffic from: ${client_ip}"
}
function firewall::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}"
}
function firewall::block_subnet() {
local client_ip="$1"
local 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"
iptables -D FORWARD -s "$client_ip" -d "$target_subnet" -j DROP 2>/dev/null || true
log::wg_unblock "Unblocked ${client_ip} → subnet ${target_subnet}"
}
# ============================================
# 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
log::wg "Guest DNS redirect already applied"
return 0
fi
local guest_subnet dns
guest_subnet="$(config::subnet_for "guest").0/24"
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 \
! -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 \
! -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"
log::wg_block "Guest DNS redirected to Pi-hole (${dns}), bypass attempts will be logged"
}
function firewall::remove_guest_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 \
! -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 \
! -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
log::wg_unblock "Removed guest DNS redirect"
}
# ============================================
# Persistence — block files
# ============================================
function firewall::save_block() {
local name="$1"
local client_ip="$2"
local target="${3:-}"
local port="${4:-}"
local proto="${5:-}"
local block_file
block_file="$(ctx::block::path "${name}.block")"
echo "${client_ip} ${target} ${port} ${proto}" >> "$block_file"
log::wg_block "Persisted block rule for: ${name}"
}
function firewall::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}"
}
function firewall::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 per-client block rules
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"
elif [[ -n "$port" ]]; then
firewall::block_port "$client_ip" "$target" "$port" "${proto:-tcp}"
else
firewall::block_ip "$client_ip" "$target"
fi
done < "$block_file"
log::wg "Restored block rules for: ${name}"
done
}
# ============================================
# Preset Application
# ============================================
function firewall::apply_preset() {
local name="$1"
local client_ip="$2"
local preset_file
preset_file="$(ctx::preset::path "${name}.preset")"
if [[ ! -f "$preset_file" ]]; then
log::error "Preset not found: ${name}"
return 1
fi
source "$preset_file"
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"
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"
done
fi
if [[ -n "${BLOCK_PORTS:-}" ]]; then
for entry in $BLOCK_PORTS; do
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"
done
fi
log::wg_preset "Applied preset '${name}' to: ${client_ip}"
}

65
modules/ip.module.sh Normal file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# ============================================
# IP Assignment
# ============================================
function ip::assigned() {
grep -h "^Address" "$(ctx::clients)"/*.conf 2>/dev/null \
| awk '{print $3}' \
| cut -d'/' -f1
}
function ip::is_assigned() {
local candidate="$1"
ip::assigned | grep -q "^${candidate}$"
}
function ip::next_for_type() {
local type="$1"
local subnet
subnet=$(config::subnet_for "$type")
if [[ -z "$subnet" ]]; then
log::error "Unknown device type: ${type}"
return 1
fi
for i in $(seq 1 254); do
local candidate="${subnet}.${i}"
if ! ip::is_assigned "$candidate"; then
echo "$candidate"
return 0
fi
done
log::error "No available IPs in subnet ${subnet}.0/24"
return 1
}
# ============================================
# Validation
# ============================================
function ip::is_valid() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$ ]]
}
function ip::is_cidr() {
[[ "$1" == *"/"* ]]
}
function ip::validate() {
local ip="$1"
if ! ip::is_valid "$ip"; then
log::error "Invalid IP or CIDR: ${ip}"
return 1
fi
return 0
}
function ip::require_valid() {
local ip="$1"
ip::validate "$ip" || exit 1
}

111
modules/keys.module.sh Normal file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function keys::on_load() {
system::require_command wg
system::require_command qrencode
}
# ============================================
# Generation
# ============================================
function keys::generate_pair() {
local name="$1"
local dir
dir="$(ctx::clients)"
local private_key_file="${dir}/${name}_private.key"
local public_key_file="${dir}/${name}_public.key"
if [[ -f "$private_key_file" ]] || [[ -f "$public_key_file" ]]; then
log::wg_warning "Keys already exist for client: ${name}"
return 1
fi
wg genkey | tee "$private_key_file" | wg pubkey > "$public_key_file"
chmod 600 "$private_key_file"
log::wg_key "Generated key pair for: ${name}"
}
function keys::private() {
local name="$1"
local file
file="$(ctx::clients)/${name}_private.key"
if [[ ! -f "$file" ]]; then
log::error "Private key not found for: ${name}"
return 1
fi
cat "$file"
}
function keys::public() {
local name="$1"
local file
file="$(ctx::clients)/${name}_public.key"
if [[ ! -f "$file" ]]; then
log::error "Public key not found for: ${name}"
return 1
fi
cat "$file"
}
# ============================================
# Query
# ============================================
function keys::find_by_public() {
local public_key="$1"
local clients_dir
clients_dir="$(ctx::clients)"
for pubkey_file in "${clients_dir}"/*_public.key; do
[[ -f "$pubkey_file" ]] || continue
if [[ "$(cat "$pubkey_file")" == "$public_key" ]]; then
basename "$pubkey_file" _public.key
return 0
fi
done
return 1
}
# ============================================
# Removal
# ============================================
function keys::remove() {
local name="$1"
local dir
dir="$(ctx::clients)"
rm -f "${dir}/${name}_private.key"
rm -f "${dir}/${name}_public.key"
log::wg_key "Removed keys for: ${name}"
}
# ============================================
# QR Code
# ============================================
function keys::qr() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client config not found: ${name}"
return 1
fi
log::wg_qr "QR code for: ${name}"
qrencode -t ansiutf8 < "$conf"
}

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

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

247
modules/monitor.module.sh Normal file
View file

@ -0,0 +1,247 @@
#!/usr/bin/env bash
# ============================================
# Config
# ============================================
MONITOR_DIR="$(ctx::root)/daemon"
WATCHLIST_FILE="${MONITOR_DIR}/watchlist.json"
EVENTS_LOG="${MONITOR_DIR}/events.log"
MONITOR_SERVICE="wgctl-monitor"
# ============================================
# Lifecycle
# ============================================
function monitor::on_load() {
if [[ ! -f "$WATCHLIST_FILE" ]]; then
echo '{}' > "$WATCHLIST_FILE"
fi
if [[ ! -f "$EVENTS_LOG" ]]; then
touch "$EVENTS_LOG"
fi
}
# ============================================
# Watchlist
# ============================================
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})"
}
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}"
}
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)
"
}
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}"
}
# ============================================
# Events
# ============================================
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', ''))
"
}
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', ''))
"
}
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}')
"
}
# ============================================
# Endpoint Cache (for blocked clients)
# ============================================
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)
"
}
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('')
"
}
function monitor::update_endpoint_cache() {
while IFS=$'\t' read -r key endpoint; do
[[ "$endpoint" == "(none)" ]] && continue
local ip
ip=$(echo "$endpoint" | cut -d':' -f1)
local client
client=$(keys::find_by_public "$key") || continue
monitor::cache_endpoint "$client" "$ip"
done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
}
# ============================================
# Endpoint (from wg show, for active clients)
# ============================================
function monitor::endpoint_for_key() {
local public_key="$1"
wg show "$(config::interface)" endpoints 2>/dev/null \
| grep "^${public_key}" \
| awk '{print $2}' \
| cut -d':' -f1
}
# ============================================
# Service
# ============================================
function monitor::start() {
systemctl start "$MONITOR_SERVICE"
log::wg_start "Monitor daemon started"
}
function monitor::stop() {
systemctl stop "$MONITOR_SERVICE"
log::wg_stop "Monitor daemon stopped"
}
function monitor::restart() {
systemctl restart "$MONITOR_SERVICE"
log::wg_start "Monitor daemon restarted"
}
function monitor::is_running() {
systemctl is-active --quiet "$MONITOR_SERVICE"
}

234
modules/peers.module.sh Normal file
View file

@ -0,0 +1,234 @@
#!/usr/bin/env bash
# ============================================
# Client Config
# ============================================
function peers::create_client_config() {
local name="$1"
local type="$2"
local ip="$3"
local allowed_ips="${4:-$(config::allowed_ips_for "$type" "$(config::default_tunnel_for "$type")")}"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ -f "$conf" ]]; then
log::wg_warning "Client config already exists: ${name}"
return 1
fi
local private_key
private_key=$(keys::private "$name")
local server_public_key
server_public_key=$(config::server_public_key)
cat > "$conf" <<EOF
[Interface]
PrivateKey = ${private_key}
Address = ${ip}/32
DNS = $(config::dns)
[Peer]
PublicKey = ${server_public_key}
Endpoint = $(config::endpoint)
AllowedIPs = ${allowed_ips}
PersistentKeepalive = 25
EOF
chmod 600 "$conf"
log::wg_add "Created client config: ${name} (${ip})"
}
function peers::remove_client_config() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::wg_warning "Client config not found: ${name}"
return 1
fi
rm -f "$conf"
log::wg_remove "Removed client config: ${name}"
}
# ============================================
# Server Config (wg0.conf) Peer Management
# ============================================
function peers::cleanup_config() {
local config
config=$(config::config_file)
python3 -c "
import re
config = open('${config}').read()
# Normalize multiple blank lines to single blank line
config = re.sub(r'\n{3,}', '\n\n', config)
# Ensure file ends with single newline
config = config.rstrip('\n') + '\n'
open('${config}', 'w').write(config)
"
}
function peers::add_to_server() {
local name="$1"
local public_key="$2"
local ip="$3"
local config
config=$(config::config_file)
cat >> "$config" <<EOF
[Peer]
# ${name}
PublicKey = ${public_key}
AllowedIPs = ${ip}/32
EOF
log::wg_add "Added peer to server config: ${name}"
}
function peers::remove_block() {
local name="$1"
local config
config=$(config::config_file)
python3 -c "
import re
config = open('${config}').read()
pattern = r'\n\[Peer\]\n# ${name}\n[^\n]+\n[^\n]+\n'
result = re.sub(pattern, '\n', config)
open('${config}', 'w').write(result)
"
}
function peers::remove_from_server() {
local name="$1"
peers::remove_block "$name"
peers::cleanup_config
log::wg_remove "Removed peer from server config: ${name}"
}
# ============================================
# Listing
# ============================================
function peers::list() {
local dir
dir="$(ctx::clients)"
if [[ -z "$(ls -A "$dir"/*.conf 2>/dev/null)" ]]; then
log::wg_list "No clients configured"
return 0
fi
for conf in "${dir}"/*.conf; do
local client_name
client_name=$(basename "$conf" .conf)
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local public_key
public_key=$(keys::public "$client_name" 2>/dev/null || echo "unknown")
# Determine type from IP
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
printf " %-30s %-15s %-10s %s\n" \
"$client_name" "$ip" "$type" "$public_key"
done
}
function peers::list_by_type() {
local filter_type="$1"
local dir
dir="$(ctx::clients)"
for conf in "${dir}"/*.conf; do
local client_name
client_name=$(basename "$conf" .conf)
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
if string::starts_with "$ip" "$subnet"; then
printf " %-30s %-15s\n" "$client_name" "$ip"
fi
done
}
function peers::exists_in_server() {
local name="$1"
grep -q "^# ${name}$" "$(config::config_file)"
}
function peers::is_blocked() {
local name="$1"
! peers::exists_in_server "$name"
}
# ============================================
# Name + Type Parsing
# ============================================
function peers::resolve_name() {
local name="$1"
local type="${2:-}"
if [[ -n "$type" ]]; then
if ! config::is_valid_type "$type"; then
log::error "Invalid device type: ${type}"
return 1
fi
echo "${type}-${name}"
else
echo "$name"
fi
}
function peers::require_exists() {
local name="$1"
if [[ ! -f "$(ctx::clients)/${name}.conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
}
function peers::resolve_and_require() {
local name="$1"
local type="${2:-}"
local resolved
resolved=$(peers::resolve_name "$name" "$type") || return 1
peers::require_exists "$resolved" || return 1
echo "$resolved"
}
# ============================================
# Live Reload
# ============================================
function peers::reload() {
wg syncconf "$(config::interface)" <(wg-quick strip "$(config::interface)")
log::wg_success "WireGuard config reloaded"
}

192
modules/peers.module.sh.bak Normal file
View file

@ -0,0 +1,192 @@
#!/usr/bin/env bash
# ============================================
# Client Config
# ============================================
function peers::create_client_config() {
local name="$1"
local type="$2"
local ip="$3"
local allowed_ips="${4:-$(config::allowed_ips_for "$type")}"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ -f "$conf" ]]; then
log::wg_warning "Client config already exists: ${name}"
return 1
fi
local private_key
private_key=$(keys::private "$name")
local server_public_key
server_public_key=$(config::server_public_key)
cat > "$conf" <<EOF
[Interface]
PrivateKey = ${private_key}
Address = ${ip}/32
DNS = $(config::dns)
[Peer]
PublicKey = ${server_public_key}
Endpoint = $(config::endpoint)
AllowedIPs = ${allowed_ips}
PersistentKeepalive = 25
EOF
chmod 600 "$conf"
log::wg_add "Created client config: ${name} (${ip})"
}
function peers::remove_client_config() {
local name="$1"
local conf
conf="$(ctx::clients)/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::wg_warning "Client config not found: ${name}"
return 1
fi
rm -f "$conf"
log::wg_remove "Removed client config: ${name}"
}
# ============================================
# Server Config (wg0.conf) Peer Management
# ============================================
function peers::cleanup_config() {
local config
config=$(config::config_file)
python3 -c "
import re
config = open('${config}').read()
# Normalize multiple blank lines to single blank line
config = re.sub(r'\n{3,}', '\n\n', config)
# Ensure file ends with single newline
config = config.rstrip('\n') + '\n'
open('${config}', 'w').write(config)
"
}
function peers::add_to_server() {
local name="$1"
local public_key="$2"
local ip="$3"
local config
config=$(config::config_file)
cat >> "$config" <<EOF
[Peer]
# ${name}
PublicKey = ${public_key}
AllowedIPs = ${ip}/32
EOF
log::wg_add "Added peer to server config: ${name}"
}
function peers::remove_block() {
local name="$1"
local config
config=$(config::config_file)
python3 -c "
import re
config = open('${config}').read()
pattern = r'\n\[Peer\]\n# ${name}\n[^\n]+\n[^\n]+\n'
result = re.sub(pattern, '\n', config)
open('${config}', 'w').write(result)
"
}
function peers::remove_from_server() {
local name="$1"
peers::remove_block "$name"
peers::cleanup_config
log::wg_remove "Removed peer from server config: ${name}"
}
# ============================================
# Listing
# ============================================
function peers::list() {
local dir
dir="$(ctx::clients)"
if [[ -z "$(ls -A "$dir"/*.conf 2>/dev/null)" ]]; then
log::wg_list "No clients configured"
return 0
fi
for conf in "${dir}"/*.conf; do
local client_name
client_name=$(basename "$conf" .conf)
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local public_key
public_key=$(keys::public "$client_name" 2>/dev/null || echo "unknown")
# Determine type from IP
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
printf " %-30s %-15s %-10s %s\n" \
"$client_name" "$ip" "$type" "$public_key"
done
}
function peers::list_by_type() {
local filter_type="$1"
local dir
dir="$(ctx::clients)"
for conf in "${dir}"/*.conf; do
local client_name
client_name=$(basename "$conf" .conf)
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local subnet
subnet=$(config::subnet_for "$filter_type")
if string::starts_with "$ip" "$subnet"; then
printf " %-30s %-15s\n" "$client_name" "$ip"
fi
done
}
function peers::exists_in_server() {
local name="$1"
grep -q "^# ${name}$" "$(config::config_file)"
}
# ============================================
# Live Reload
# ============================================
function peers::reload() {
wg syncconf "$(config::interface)" <(wg-quick strip "$(config::interface)")
log::wg_success "WireGuard config reloaded"
}

5
presets/guest.preset Normal file
View file

@ -0,0 +1,5 @@
PRESET_NAME="guest"
PRESET_DESC="Internet only, no LAN access"
BLOCK_IPS=""
BLOCK_SUBNETS="10.0.0.0/24"
BLOCK_PORTS=""

5
presets/no-docker.preset Normal file
View file

@ -0,0 +1,5 @@
PRESET_NAME="no-docker"
PRESET_DESC="Block access to Docker host"
BLOCK_IPS="10.0.0.210"
BLOCK_SUBNETS=""
BLOCK_PORTS=""

View file

@ -0,0 +1,5 @@
PRESET_NAME="no-internet"
PRESET_DESC="LAN access only, no internet"
BLOCK_IPS=""
BLOCK_SUBNETS="0.0.0.0/0"
BLOCK_PORTS=""

View file

@ -0,0 +1,5 @@
PRESET_NAME="no-proxmox"
PRESET_DESC="Block access to Proxmox"
BLOCK_IPS="10.0.0.100"
BLOCK_SUBNETS=""
BLOCK_PORTS=""

139
wgctl Executable file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"
LOG_LEVEL=DEBUG
# ============================================
# Modules
# ============================================
load_module log
load_module config
load_module ip
load_module keys
load_module peers
load_module firewall
load_module monitor
# ============================================
# Alias Map
# ============================================
declare -A CMD_ALIASES=(
# Client
[new]=add
[create]=add
[rm]=remove
[del]=remove
[delete]=remove
[mv]=rename
[ls]=list
[show]=list
[monitor]=watch
[ban]=block
# Service
[up]=service
[down]=service
[reload]=service
[stat]=service
[log]=service
[start]=service
[stop]=service
[restart]=service
[status]=service
[logs]=service
[enable]=service
[disable]=service
)
# ============================================
# Dispatch
# ============================================
function wgctl::resolve_alias() {
local cmd="$1"
echo "${CMD_ALIASES[$cmd]:-$cmd}"
}
function wgctl::dispatch() {
local raw_cmd="${1:-help}"
shift || true
local cmd
cmd="$(wgctl::resolve_alias "$raw_cmd")"
case "$cmd" in
help) wgctl::help; return ;;
esac
# If alias resolved to service, pass original cmd as subcommand
if [[ "$cmd" == "service" ]]; then
load_command service
command::run service "$raw_cmd" "$@"
return
fi
if load_command "$cmd"; then
if command::exists "$cmd"; then
command::run "$cmd" "$@"
else
log::error "Command '${cmd}' loaded but $(command::fn "$cmd" run) is not defined"
exit 1
fi
else
log::error "Unknown command: '${raw_cmd}'"
echo "Run 'wgctl help' to see available commands."
exit 1
fi
}
# ============================================
# Help
# ============================================
function wgctl::help() {
cat <<EOF
$(log::section "wgctl — WireGuard Management")
Usage: wgctl <command> [options]
Client Commands:
add, new, create Add a new client
remove, rm, del Remove a client
list, ls, show List all clients
qr Show QR code for a client
block, ban Block a client or add restrictions
unblock, unban Restore client access
Service Commands:
start, up Start WireGuard
stop, down Stop WireGuard
restart, reload Restart WireGuard
status, stat Show WireGuard status
logs, log Show WireGuard logs
enable Enable WireGuard on boot
disable Disable WireGuard on boot
Preset Commands:
preset list List available presets
preset add Add a new preset
preset remove Remove a preset
Run 'wgctl <command> --help' for command-specific help.
EOF
}
# ============================================
# Main
# ============================================
function wgctl::main() {
system::require_root
wgctl::dispatch "$@"
}
wgctl::main "$@"