add wgctl
This commit is contained in:
commit
78f9caaf17
37 changed files with 9524 additions and 0 deletions
211
commands/add.command.sh
Normal file
211
commands/add.command.sh
Normal 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
168
commands/block.command.sh
Normal 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}"
|
||||
}
|
||||
64
commands/config.command.sh
Normal file
64
commands/config.command.sh
Normal 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"
|
||||
}
|
||||
58
commands/inspect.command.sh
Normal file
58
commands/inspect.command.sh
Normal 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
427
commands/list.command.sh
Normal 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
199
commands/preset.command.sh
Normal 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
62
commands/qr.command.sh
Normal 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
124
commands/remove.command.sh
Normal 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
122
commands/rename.command.sh
Normal 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
108
commands/service.command.sh
Normal 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
147
commands/unblock.command.sh
Normal 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
288
commands/watch.command.sh
Normal 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
13
core.sh
Normal 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
76
core/command.sh
Normal 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
42
core/context.sh
Normal 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
66
core/flag.sh
Normal 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
55
core/module.sh
Normal 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
52
core/utils.sh
Normal 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
|
||||
}
|
||||
3
daemon/endpoint_cache.json
Normal file
3
daemon/endpoint_cache.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"phone-nuno": "148.69.48.74"
|
||||
}
|
||||
5146
daemon/events.log
Normal file
5146
daemon/events.log
Normal file
File diff suppressed because it is too large
Load diff
1
daemon/watchlist.json
Normal file
1
daemon/watchlist.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
188
daemon/wgctl-monitor.py
Executable file
188
daemon/wgctl-monitor.py
Executable 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()
|
||||
15
daemon/wgctl-monitor.service
Normal file
15
daemon/wgctl-monitor.service
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=wgctl WireGuard Monitor Daemon
|
||||
After=network.target wg-quick@wg0.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /etc/wireguard/wgctl/daemon/wgctl-monitor.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=WG_INTERFACE=eth0
|
||||
Environment=WG_PORT=51820
|
||||
Environment=LOG_LEVEL=INFO
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
138
modules/config.module.sh
Normal file
138
modules/config.module.sh
Normal 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
|
||||
}
|
||||
109
modules/config.module.sh.bak
Normal file
109
modules/config.module.sh.bak
Normal 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
308
modules/firewall.module.sh
Normal 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
65
modules/ip.module.sh
Normal 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
111
modules/keys.module.sh
Normal 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
326
modules/log.module.sh
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# ============================================
|
||||
# Core
|
||||
# ============================================
|
||||
|
||||
LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
|
||||
# ============================================
|
||||
# Internal
|
||||
# ============================================
|
||||
|
||||
function internal::get_log_priority() {
|
||||
case "$1" in
|
||||
DEBUG) echo 0 ;;
|
||||
INFO) echo 1 ;;
|
||||
SUCCESS) echo 1 ;; # FIX: SUCCESS is informational, not above ERROR
|
||||
WARN) echo 2 ;;
|
||||
ERROR) echo 3 ;;
|
||||
*) echo 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
function internal::log() {
|
||||
local level="$1"
|
||||
shift
|
||||
|
||||
local current_priority
|
||||
local message_priority
|
||||
|
||||
current_priority=$(internal::get_log_priority "$LOG_LEVEL")
|
||||
message_priority=$(internal::get_log_priority "$level")
|
||||
|
||||
if (( message_priority < current_priority )); then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local color
|
||||
case "$level" in
|
||||
DEBUG) color="\033[0;36m" ;; # FIX: actual cyan (was white \033[0;37m)
|
||||
INFO) color="\033[1;34m" ;; # blue
|
||||
WARN) color="\033[1;33m" ;; # yellow
|
||||
ERROR) color="\033[1;31m" ;; # red
|
||||
SUCCESS) color="\033[1;32m" ;; # green
|
||||
esac
|
||||
|
||||
echo -e "${color}=> ${level}:\033[0m $*"
|
||||
}
|
||||
|
||||
function internal::icon() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
|
||||
case "$context:$action" in
|
||||
docker:log) echo "🐳 " ;;
|
||||
docker:start) echo "🟢 🐳 " ;;
|
||||
docker:stop) echo "🔴 🐳 " ;;
|
||||
docker:success) echo "✅ 🐳 " ;;
|
||||
docker:warning) echo "⚠️ 🐳 " ;;
|
||||
docker:error) echo "❌ 🐳 " ;;
|
||||
docker:logs) echo "📜 🐳 " ;;
|
||||
docker:list) echo "🔍 🐳 " ;;
|
||||
docker:build) echo "📦 🐳 " ;;
|
||||
|
||||
build:log) echo "🏗️ " ;;
|
||||
build:start) echo "🟢 🏗️ " ;;
|
||||
build:stop) echo "🔴 🏗️ " ;;
|
||||
build:success) echo "✅ 🏗️ " ;;
|
||||
build:warning) echo "⚠️ 🏗️ " ;;
|
||||
build:error) echo "❌ 🏗️ " ;;
|
||||
|
||||
network:log) echo "🌐 " ;;
|
||||
network:setup) echo "⚙️ 🌐 " ;;
|
||||
network:stop) echo "🔴 🌐 " ;;
|
||||
network:success) echo "✅ 🌐 " ;;
|
||||
network:warning) echo "⚠️ 🌐 " ;;
|
||||
network:error) echo "❌ 🌐 " ;;
|
||||
|
||||
auth:log) echo "🔑 " ;;
|
||||
auth:setup) echo "⚙️ 🔑 " ;;
|
||||
auth:login) echo "🔐 🔑 " ;;
|
||||
auth:success) echo "✅ 🔑 " ;;
|
||||
auth:warning) echo "⚠️ 🔑 " ;;
|
||||
auth:error) echo "❌ 🔑 " ;;
|
||||
|
||||
env:log) echo "⚙️ " ;;
|
||||
env:load) echo "📥 ⚙️ " ;;
|
||||
env:success) echo "✅ ⚙️ " ;;
|
||||
env:warning) echo "⚠️ ⚙️ " ;;
|
||||
env:error) echo "❌ ⚙️ " ;;
|
||||
|
||||
fs:log) echo "📁 " ;;
|
||||
fs:read) echo "📥 📁 " ;;
|
||||
fs:write) echo "📤 📁 " ;;
|
||||
fs:success) echo "✅ 📁 " ;;
|
||||
fs:warning) echo "⚠️ 📁 " ;;
|
||||
fs:error) echo "❌ 📁 " ;;
|
||||
|
||||
db:log) echo "🗄️ " ;;
|
||||
db:start) echo "🟢 🗄️ " ;;
|
||||
db:migrate) echo "📜 🗄️ " ;;
|
||||
db:success) echo "✅ 🗄️ " ;;
|
||||
db:warning) echo "⚠️ 🗄️ " ;;
|
||||
db:error) echo "❌ 🗄️ " ;;
|
||||
|
||||
# ADDED: WireGuard context
|
||||
wg:log) echo "🔒 " ;;
|
||||
wg:start) echo "🟢 🔒 " ;;
|
||||
wg:stop) echo "🔴 🔒 " ;;
|
||||
wg:add) echo "➕ 🔒 " ;;
|
||||
wg:remove) echo "➖ 🔒 " ;;
|
||||
wg:block) echo "🚫 🔒 " ;;
|
||||
wg:unblock) echo "✅ 🔒 " ;;
|
||||
wg:key) echo "🔑 🔒 " ;;
|
||||
wg:success) echo "✅ 🔒 " ;;
|
||||
wg:warning) echo "⚠️ 🔒 " ;;
|
||||
wg:error) echo "❌ 🔒 " ;;
|
||||
wg:list) echo "🔍 🔒 " ;;
|
||||
wg:qr) echo "📱 🔒 " ;;
|
||||
wg:preset) echo "📋 🔒 " ;;
|
||||
|
||||
log:info) echo "🔹 " ;;
|
||||
log:warn) echo "⚠️ " ;;
|
||||
log:error) echo "❌ " ;;
|
||||
log:success) echo "✅ " ;;
|
||||
log:debug) echo "🔍 " ;; # FIX: was missing, caused fallthrough
|
||||
|
||||
*) echo "🔹" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
function internal::get_context_icon() {
|
||||
case "$1" in
|
||||
docker) echo "🐳" ;;
|
||||
build) echo "🏗️" ;;
|
||||
network) echo "🌐" ;;
|
||||
auth) echo "🔑" ;;
|
||||
env) echo "⚙️" ;;
|
||||
fs) echo "📁" ;;
|
||||
db) echo "🗄️" ;;
|
||||
wg) echo "🔒" ;; # ADDED: missing from original
|
||||
log) echo "🔹" ;; # ADDED: missing from original
|
||||
*) echo "🔹" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Loggers
|
||||
# ============================================
|
||||
|
||||
function internal::log::info() { internal::log INFO "$*"; }
|
||||
function internal::log::warn() { internal::log WARN "$*"; }
|
||||
function internal::log::error() { internal::log ERROR "$*"; }
|
||||
function internal::log::success() { internal::log SUCCESS "$*"; }
|
||||
function internal::log::debug() { internal::log DEBUG "$*"; }
|
||||
|
||||
# ============================================
|
||||
# Context Loggers
|
||||
# ============================================
|
||||
|
||||
function log::context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
shift 2
|
||||
internal::log::info "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::warn_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
shift 2
|
||||
internal::log::warn "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::error_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
shift 2
|
||||
internal::log::error "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::success_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
shift 2
|
||||
internal::log::success "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
function log::debug_context() {
|
||||
local context="$1"
|
||||
local action="$2"
|
||||
shift 2
|
||||
internal::log::debug "$(internal::icon "$context" "$action") $*"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Logger Helpers
|
||||
# ============================================
|
||||
|
||||
function log::info() { log::context log info "$@"; }
|
||||
function log::warn() { log::warn_context log warn "$@"; }
|
||||
function log::error() { log::error_context log error "$@"; }
|
||||
function log::success() { log::success_context log success "$@"; }
|
||||
function log::debug() { log::debug_context log debug "$@"; } # FIX: was calling warn_context
|
||||
|
||||
# ADDED: section separator for visual grouping in output
|
||||
function log::section() {
|
||||
local label="$1"
|
||||
local width=48
|
||||
local line
|
||||
line=$(printf '─%.0s' $(seq 1 $width))
|
||||
echo -e "\n\033[1;34m${line}\033[0m"
|
||||
echo -e "\033[1;34m $label\033[0m"
|
||||
echo -e "\033[1;34m${line}\033[0m"
|
||||
}
|
||||
|
||||
function log::docker() { log::context docker log "$@"; }
|
||||
function log::docker_start() { log::context docker start "$@"; }
|
||||
function log::docker_stop() { log::context docker stop "$@"; }
|
||||
function log::docker_success() { log::success_context docker success "$@"; } # FIX: was using context not success_context
|
||||
function log::docker_logs() { log::context docker logs "$@"; }
|
||||
function log::docker_list() { log::context docker list "$@"; }
|
||||
function log::docker_build() { log::context docker build "$@"; }
|
||||
function log::docker_warning() { log::warn_context docker warning "$@"; }
|
||||
function log::docker_error() { log::error_context docker error "$@"; }
|
||||
|
||||
function log::build() { log::context build log "$@"; }
|
||||
function log::build_start() { log::context build start "$@"; }
|
||||
function log::build_stop() { log::context build stop "$@"; }
|
||||
function log::build_success() { log::success_context build success "$@"; }
|
||||
function log::build_warning() { log::warn_context build warning "$@"; }
|
||||
function log::build_error() { log::error_context build error "$@"; }
|
||||
|
||||
function log::network() { log::context network log "$@"; }
|
||||
function log::network_setup() { log::context network setup "$@"; }
|
||||
function log::network_stop() { log::context network stop "$@"; }
|
||||
function log::network_success() { log::success_context network success "$@"; }
|
||||
function log::network_warning() { log::warn_context network warning "$@"; }
|
||||
function log::network_error() { log::error_context network error "$@"; }
|
||||
|
||||
function log::auth() { log::context auth log "$@"; }
|
||||
function log::auth_setup() { log::context auth setup "$@"; }
|
||||
function log::auth_login() { log::context auth login "$@"; }
|
||||
function log::auth_success() { log::success_context auth success "$@"; }
|
||||
function log::auth_warning() { log::warn_context auth warning "$@"; }
|
||||
function log::auth_error() { log::error_context auth error "$@"; }
|
||||
|
||||
function log::env() { log::context env log "$@"; }
|
||||
function log::env_load() { log::context env load "$@"; }
|
||||
function log::env_success() { log::success_context env success "$@"; }
|
||||
function log::env_warning() { log::warn_context env warning "$@"; }
|
||||
function log::env_error() { log::error_context env error "$@"; }
|
||||
|
||||
function log::fs() { log::context fs log "$@"; }
|
||||
function log::fs_read() { log::context fs read "$@"; }
|
||||
function log::fs_write() { log::context fs write "$@"; }
|
||||
function log::fs_success() { log::success_context fs success "$@"; }
|
||||
function log::fs_warning() { log::warn_context fs warning "$@"; }
|
||||
function log::fs_error() { log::error_context fs error "$@"; }
|
||||
|
||||
function log::db() { log::context db log "$@"; }
|
||||
function log::db_start() { log::context db start "$@"; }
|
||||
function log::db_migrate() { log::context db migrate "$@"; }
|
||||
function log::db_success() { log::success_context db success "$@"; }
|
||||
function log::db_warning() { log::warn_context db warning "$@"; }
|
||||
function log::db_error() { log::error_context db error "$@"; }
|
||||
|
||||
# ADDED: WireGuard context helpers
|
||||
function log::wg() { log::context wg log "$@"; }
|
||||
function log::wg_start() { log::context wg start "$@"; }
|
||||
function log::wg_stop() { log::context wg stop "$@"; }
|
||||
function log::wg_add() { log::context wg add "$@"; }
|
||||
function log::wg_remove() { log::context wg remove "$@"; }
|
||||
function log::wg_block() { log::context wg block "$@"; }
|
||||
function log::wg_unblock() { log::context wg unblock "$@"; }
|
||||
function log::wg_key() { log::context wg key "$@"; }
|
||||
function log::wg_list() { log::context wg list "$@"; }
|
||||
function log::wg_qr() { log::context wg qr "$@"; }
|
||||
function log::wg_preset() { log::context wg preset "$@"; }
|
||||
function log::wg_success() { log::success_context wg success "$@"; }
|
||||
function log::wg_warning() { log::warn_context wg warning "$@"; }
|
||||
function log::wg_error() { log::error_context wg error "$@"; }
|
||||
|
||||
function log::run_step() {
|
||||
local context="$1"
|
||||
local mode="strict"
|
||||
local description
|
||||
|
||||
shift
|
||||
|
||||
if [[ "$1" == "soft" || "$1" == "strict" || "$1" == "info" ]]; then
|
||||
mode="$1"
|
||||
shift
|
||||
fi
|
||||
|
||||
description="$1"
|
||||
shift
|
||||
|
||||
local icon
|
||||
icon=$(internal::get_context_icon "$context")
|
||||
|
||||
if [[ "$mode" == "info" ]]; then
|
||||
internal::log::info "$icon $description"
|
||||
else
|
||||
internal::log::info "🔄 $icon $description"
|
||||
fi
|
||||
|
||||
"$@"
|
||||
local status=$?
|
||||
|
||||
if [[ $status -eq 0 ]]; then
|
||||
if [[ "$mode" == "info" ]]; then
|
||||
return 0
|
||||
fi
|
||||
internal::log::success "✅ $icon $description"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "soft" || "$mode" == "info" ]]; then
|
||||
internal::log::warn "⚠️ $icon $description → skipped"
|
||||
return 0
|
||||
fi
|
||||
|
||||
internal::log::error "❌ $icon $description → failed"
|
||||
return $status
|
||||
}
|
||||
247
modules/monitor.module.sh
Normal file
247
modules/monitor.module.sh
Normal 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
234
modules/peers.module.sh
Normal 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
192
modules/peers.module.sh.bak
Normal 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
5
presets/guest.preset
Normal 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
5
presets/no-docker.preset
Normal 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=""
|
||||
5
presets/no-internet.preset
Normal file
5
presets/no-internet.preset
Normal 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=""
|
||||
5
presets/no-proxmox.preset
Normal file
5
presets/no-proxmox.preset
Normal 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
139
wgctl
Executable 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 "$@"
|
||||
Loading…
Add table
Reference in a new issue