Merge feature/identity-subnet-policy into main
This commit is contained in:
commit
bc06c5df31
42 changed files with 5663 additions and 1453 deletions
|
|
@ -3,150 +3,145 @@
|
|||
# ============================================
|
||||
# Lifecycle
|
||||
# ============================================
|
||||
|
||||
|
||||
function cmd::add::on_load() {
|
||||
load_module subnet
|
||||
load_module identity
|
||||
load_module policy
|
||||
|
||||
flag::register --name
|
||||
flag::register --identity
|
||||
flag::register --type
|
||||
flag::register --subtype
|
||||
flag::register --subnet
|
||||
flag::register --rule
|
||||
flag::register --group
|
||||
flag::register --ip
|
||||
flag::register --guest
|
||||
flag::register --tunnel
|
||||
flag::register --show-config
|
||||
flag::register --show-qr
|
||||
|
||||
# Dynamically register --<subnet_name> as shorthand flags
|
||||
local subnet_name
|
||||
while IFS= read -r subnet_name; do
|
||||
[[ -n "$subnet_name" ]] && flag::register "--${subnet_name}"
|
||||
done < <(subnet::list_names)
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Help
|
||||
# ============================================
|
||||
|
||||
|
||||
function cmd::add::help() {
|
||||
cat <<EOF
|
||||
Usage: wgctl add --name <name> --type <type> [options]
|
||||
|
||||
or: wgctl add --identity <identity> --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
|
||||
--subtype <subtype> Guest subtype: desktop, laptop, phone, tablet (mostly used for guest)
|
||||
--ip <ip> Override auto-assigned IP (optional)
|
||||
--guest Shorthand for --type guest
|
||||
--tunnel <mode> Tunnel mode: split|full (default: split)
|
||||
--rule <rule> Assign rule on creation (default: user, guest types: guest)
|
||||
--group <group> Add to group on creation (group must exist)
|
||||
--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
|
||||
|
||||
Rules:
|
||||
Automatically assigned based on type (guest → guest rule, others → user rule).
|
||||
Override with --rule. Manage rules with: wgctl rule help
|
||||
|
||||
Tunnel Modes:
|
||||
split Route only VPN subnet + LAN through WireGuard (default)
|
||||
full Route all traffic through WireGuard
|
||||
|
||||
--name <name> Client name (e.g. nuno) — combined with type: phone-nuno
|
||||
--identity <name> Identity name — auto-names peer with next available index
|
||||
--type <type> Device type: desktop, laptop, phone, tablet, server, iot
|
||||
--subnet <subnet> Subnet to allocate from (default: type-native)
|
||||
--ip <ip> Override auto-assigned IP (optional)
|
||||
--tunnel <mode> Tunnel mode: split|full (overrides policy)
|
||||
--rule <rule> Peer rule (default: from policy default_rule or none)
|
||||
--group <group> Add to group on creation (group must exist)
|
||||
--show-qr Show the WireGuard config as a QR code after creation
|
||||
|
||||
Subnet shorthands (equivalent to --subnet <name>):
|
||||
--guests, --servers, --iot, ... (see: wgctl subnet list)
|
||||
|
||||
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
|
||||
wgctl add --identity nuno --type phone
|
||||
wgctl add --name zephyr --type desktop --guests
|
||||
wgctl add --identity zephyr --type desktop --guests
|
||||
wgctl add --name visitor --type phone --guests --show-qr
|
||||
wgctl add --name dev --type laptop --rule dev-01
|
||||
wgctl add --name visitor --type guest --show-qr
|
||||
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"
|
||||
|
||||
function cmd::add::_validate() {
|
||||
local name="$1" identity="$2" type="$3" ip="$4" tunnel="$5"
|
||||
|
||||
if [[ -z "$name" && -z "$identity" ]]; then
|
||||
log::error "Missing required flag: --name or --identity"
|
||||
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 ' ' ', ')"
|
||||
|
||||
if ! json::subnet_exists "$(ctx::subnets)" "$type" 2>/dev/null; then
|
||||
log::error "Unknown device type: '${type}'"
|
||||
log::info "Use 'wgctl subnet list' to see valid types and subnets"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$tunnel" && "$tunnel" != "split" && "$tunnel" != "full" ]]; then
|
||||
log::error "Invalid tunnel mode: ${tunnel} (use 'split' or 'full')"
|
||||
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}"
|
||||
|
||||
}
|
||||
|
||||
function cmd::add::_validate_not_exists() {
|
||||
local full_name="$1"
|
||||
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 subtype=""
|
||||
local rule=""
|
||||
local group=""
|
||||
local ip=""
|
||||
local tunnel=""
|
||||
local guest=false
|
||||
local show_config=false
|
||||
local show_qr=false
|
||||
|
||||
# Parse flags
|
||||
local name="" identity="" type="" subnet_name="" rule="" \
|
||||
group="" ip="" tunnel="" show_qr=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--subtype) subtype="$2"; shift 2 ;;
|
||||
--rule) rule="$2"; shift 2 ;;
|
||||
--group) group="$2"; shift 2 ;;
|
||||
--ip) ip="$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 ;;
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--identity) identity="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--subnet) subnet_name="$2"; shift 2 ;;
|
||||
--rule) rule="$2"; shift 2 ;;
|
||||
--group) group="$2"; shift 2 ;;
|
||||
--ip) ip="$2"; shift 2 ;;
|
||||
--tunnel) tunnel="$2"; shift 2 ;;
|
||||
--show-qr) show_qr=true; shift ;;
|
||||
--help) cmd::add::help; return ;;
|
||||
--*)
|
||||
local flag_name="${1#--}"
|
||||
if subnet::exists "$flag_name" 2>/dev/null; then
|
||||
subnet_name="$flag_name"
|
||||
shift
|
||||
else
|
||||
log::error "Unknown flag: $1"
|
||||
cmd::add::help
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log::error "Unknown flag: $1"
|
||||
cmd::add::help
|
||||
|
|
@ -154,82 +149,165 @@ function cmd::add::run() {
|
|||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
$guest && type="guest"
|
||||
|
||||
local effective_type
|
||||
effective_type=$(cmd::add::_resolve_type "$type" "$subtype") || return 1
|
||||
|
||||
local full_name="${type}-${name}"
|
||||
cmd::add::validate "$name" "$type" "$ip" "$tunnel" || return 1
|
||||
|
||||
[[ -z "$tunnel" ]] && tunnel=$(config::default_tunnel_for "$type")
|
||||
[[ -z "$rule" ]] && rule=$(cmd::add::_default_rule "$type")
|
||||
|
||||
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
|
||||
|
||||
local allowed_ips
|
||||
allowed_ips=$(config::allowed_ips_for "$effective_type" "$tunnel") || return 1
|
||||
|
||||
log::section "Adding client: ${full_name}"
|
||||
[[ -z "$ip" ]] && ip=$(ip::next_for_type "$effective_type") || return 1
|
||||
|
||||
log::wg_add "Name: ${full_name}"
|
||||
log::wg_add "Type: ${type}"
|
||||
log::wg_add "IP: ${ip}"
|
||||
log::wg_add "Tunnel: ${tunnel} (${allowed_ips})"
|
||||
log::wg_add "Endpoint: $(config::endpoint)"
|
||||
log::wg_add "Rule: ${rule:-none}"
|
||||
|
||||
keys::generate_pair "$full_name" || return 1
|
||||
peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1
|
||||
[[ -n "$subtype" ]] && peers::set_meta "$full_name" "subtype" "$subtype"
|
||||
|
||||
if [[ -n "$group" ]]; then
|
||||
if ! group::exists "$group"; then
|
||||
log::wg_warning "Group '${group}' not found — skipping group assignment"
|
||||
else
|
||||
group::add_peer "$group" "$full_name"
|
||||
log::wg "Added to group: ${group}"
|
||||
fi
|
||||
fi
|
||||
|
||||
local public_key
|
||||
public_key=$(keys::public "$full_name") || return 1
|
||||
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
|
||||
|
||||
[[ -n "$rule" ]] && rule::apply "$rule" "$ip" || return 1
|
||||
peers::reload || return 1
|
||||
|
||||
log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]"
|
||||
cmd::add::_show_result "$full_name" "${subtype:-$type}"
|
||||
}
|
||||
|
||||
function cmd::add::_resolve_type() {
|
||||
local type="$1" subtype="$2"
|
||||
if [[ "$type" == "guest" && -n "$subtype" ]]; then
|
||||
local valid_subtypes="desktop laptop phone tablet"
|
||||
if ! echo "$valid_subtypes" | grep -qw "$subtype"; then
|
||||
log::error "Invalid subtype: ${subtype} (valid: desktop, laptop, phone, tablet)"
|
||||
return 1
|
||||
fi
|
||||
echo "guest-${subtype}"
|
||||
|
||||
cmd::add::_validate "$name" "$identity" "$type" "$ip" "$tunnel" || return 1
|
||||
|
||||
# Resolve full peer name
|
||||
local full_name
|
||||
if [[ -n "$identity" ]]; then
|
||||
full_name=$(identity::next_peer_name "$identity" "$type") || return 1
|
||||
log::info "Auto-named: ${full_name}"
|
||||
else
|
||||
echo "$type"
|
||||
full_name="${type}-${name}"
|
||||
fi
|
||||
|
||||
cmd::add::_validate_not_exists "$full_name" || return 1
|
||||
|
||||
# Resolve subnet CIDR and canonical type
|
||||
local resolved_cidr resolved_type
|
||||
resolved_cidr=$(subnet::resolve_for_add "$type" "$subnet_name") || return 1
|
||||
resolved_type=$(subnet::type_for_add "$type" "$subnet_name") || return 1
|
||||
|
||||
# Resolve effective policy
|
||||
local identity_name="${identity:-$(identity::get_name "$full_name")}"
|
||||
local effective_policy
|
||||
effective_policy=$(policy::effective "$subnet_name" "$resolved_type" "$identity_name")
|
||||
|
||||
# Resolve tunnel mode — flag overrides policy
|
||||
if [[ -z "$tunnel" ]]; then
|
||||
tunnel=$(policy::tunnel_mode "$effective_policy")
|
||||
fi
|
||||
|
||||
# Resolve peer rule — explicit flag overrides policy default_rule
|
||||
if [[ -z "$rule" ]]; then
|
||||
rule=$(policy::default_rule "$effective_policy")
|
||||
fi
|
||||
|
||||
# Validate rule if set
|
||||
if [[ -n "$rule" ]]; then
|
||||
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
|
||||
fi
|
||||
|
||||
local allowed_ips
|
||||
allowed_ips=$(config::allowed_ips_for "$tunnel") || return 1
|
||||
|
||||
log::section "Adding client: ${full_name}"
|
||||
|
||||
# Allocate IP
|
||||
if [[ -n "$ip" ]]; then
|
||||
subnet::require_ip_valid_for "$resolved_cidr" "$ip" || return 1
|
||||
else
|
||||
ip=$(ip::next_for_subnet "$resolved_cidr") || return 1
|
||||
fi
|
||||
|
||||
cmd::add::_log_plan "$full_name" "$type" "$resolved_type" \
|
||||
"$subnet_name" "$resolved_cidr" "$ip" "$tunnel" \
|
||||
"$allowed_ips" "${rule:---}" "$effective_policy"
|
||||
|
||||
keys::generate_pair "$full_name" || return 1
|
||||
peers::create_client_config "$full_name" "$resolved_type" "$ip" "$allowed_ips" || return 1
|
||||
|
||||
# Write meta — type, subnet, rule (if set)
|
||||
peers::set_meta "$full_name" "type" "$resolved_type"
|
||||
if [[ -n "$subnet_name" ]]; then
|
||||
peers::set_meta "$full_name" "subnet" "$subnet_name"
|
||||
fi
|
||||
if [[ -n "$rule" ]]; then
|
||||
peers::set_meta "$full_name" "rule" "$rule"
|
||||
fi
|
||||
|
||||
cmd::add::_assign_group "$full_name" "$group"
|
||||
|
||||
local public_key
|
||||
public_key=$(keys::public "$full_name") || return 1
|
||||
peers::add_to_server "$full_name" "$public_key" "$ip" || return 1
|
||||
|
||||
# Apply peer rule if set
|
||||
if [[ -n "$rule" ]]; then
|
||||
rule::apply "$rule" "$ip" "$full_name" || return 1
|
||||
fi
|
||||
|
||||
# Auto-attach to identity and apply identity rule if set
|
||||
identity::auto_attach "$full_name" "$resolved_type"
|
||||
cmd::add::_apply_identity_rule "$full_name" "$ip" "$identity_name" "$effective_policy" "$rule"
|
||||
|
||||
peers::reload || return 1
|
||||
|
||||
log::wg_success "Client added: ${full_name} (${ip}) [${tunnel} tunnel]"
|
||||
cmd::add::_show_result "$full_name" "$resolved_type" "$show_qr"
|
||||
}
|
||||
|
||||
function cmd::add::_default_rule() {
|
||||
local type="$1"
|
||||
config::is_guest_type "$type" && echo "guest" || echo "user"
|
||||
|
||||
# ============================================
|
||||
# Internal helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::add::_log_plan() {
|
||||
local full_name="${1:-}" type="${2:-}" resolved_type="${3:-}" \
|
||||
subnet_name="${4:-}" resolved_cidr="${5:-}" ip="${6:-}" \
|
||||
tunnel="${7:-}" allowed_ips="${8:-}" rule="${9:-}" policy="${10:-}"
|
||||
|
||||
log::wg_add "Name: ${full_name}"
|
||||
log::wg_add "Type: ${resolved_type}"
|
||||
[[ -n "$subnet_name" ]] && log::wg_add "Subnet: ${subnet_name} (${resolved_cidr})"
|
||||
log::wg_add "IP: ${ip}"
|
||||
log::wg_add "Tunnel: ${tunnel} (${allowed_ips})"
|
||||
log::wg_add "Endpoint: $(config::endpoint)"
|
||||
log::wg_add "Rule: ${rule}"
|
||||
log::wg_add "Policy: ${policy}"
|
||||
}
|
||||
|
||||
function cmd::add::_assign_group() {
|
||||
local full_name="${1:-}" group="${2:-}"
|
||||
[[ -z "$group" ]] && return 0
|
||||
if ! group::exists "$group"; then
|
||||
log::wg_warning "Group '${group}' not found — skipping group assignment"
|
||||
return 0
|
||||
fi
|
||||
group::add_peer "$group" "$full_name"
|
||||
log::wg "Added to group: ${group}"
|
||||
}
|
||||
|
||||
function cmd::add::_apply_identity_rule() {
|
||||
local full_name="${1:-}" ip="${2:-}" identity_name="${3:-}" \
|
||||
effective_policy="${4:-}" peer_rule="${5:-}"
|
||||
|
||||
[[ -z "$identity_name" ]] && return 0
|
||||
|
||||
local rules
|
||||
rules=$(identity::rules "$identity_name")
|
||||
|
||||
if [[ -z "$rules" ]]; then
|
||||
# No identity rules — warn if no peer rule either
|
||||
if [[ -z "$peer_rule" ]]; then
|
||||
policy::warn_no_rule "$full_name"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Apply all identity rules
|
||||
rule::_apply_identity_rule "$full_name" "$ip"
|
||||
|
||||
# Warn based on strict_rule
|
||||
local strict
|
||||
strict=$(identity::rule_flags "$identity_name" "strict_rule")
|
||||
if [[ "$strict" == "true" ]]; then
|
||||
local rule_list
|
||||
rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//')
|
||||
policy::warn_strict_rule "$identity_name" "$effective_policy" "$rule_list"
|
||||
elif [[ -n "$peer_rule" ]]; then
|
||||
local rule_list
|
||||
rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//')
|
||||
policy::warn_additive_rule "$identity_name" "$rule_list" "$peer_rule"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::add::_show_result() {
|
||||
local full_name="$1" display_type="$2"
|
||||
if cmd::add::is_mobile "$display_type"; then
|
||||
local full_name="${1:-}" type="${2:-}" show_qr="${3:-false}"
|
||||
if $show_qr || cmd::add::is_mobile "$type"; then
|
||||
log::section "Client QR"
|
||||
keys::qr "$full_name"
|
||||
else
|
||||
log::section "Client Config"
|
||||
cat "$(ctx::clients)/${full_name}.conf"
|
||||
fi
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
function cmd::block::on_load() {
|
||||
flag::register --name
|
||||
flag::register --identity
|
||||
flag::register --type
|
||||
flag::register --force
|
||||
flag::register --quiet
|
||||
|
|
@ -14,8 +15,6 @@ function cmd::block::on_load() {
|
|||
flag::register --proto
|
||||
flag::register --subnet
|
||||
flag::register --block-name
|
||||
|
||||
# System - NET Services
|
||||
flag::register --service
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +25,7 @@ function cmd::block::on_load() {
|
|||
function cmd::block::help() {
|
||||
cat <<EOF
|
||||
Usage: wgctl block --name <name> [options]
|
||||
or: wgctl block --identity <identity> [options]
|
||||
|
||||
Block a client entirely or restrict access to specific IPs/ports/subnets/services.
|
||||
All block rules are persisted and restored on WireGuard restart.
|
||||
|
|
@ -34,39 +34,38 @@ but have specific traffic restricted (shown as 'restricted' in list).
|
|||
|
||||
Options:
|
||||
--name <name> Client name (e.g. phone-nuno)
|
||||
--identity <name> Block all peers belonging to an identity
|
||||
--type <type> Device type (optional, combines with --name)
|
||||
--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)
|
||||
--service <name> Block a named service (e.g. proxmox, truenas:web-ui) (repeatable)
|
||||
--port <ip:port:proto> Block specific port (repeatable)
|
||||
--service <name> Block a named service (repeatable)
|
||||
--block-name <name> Optional name for this block rule
|
||||
--quiet Suppress output (used by group block)
|
||||
|
||||
Examples:
|
||||
wgctl block --name phone-nuno
|
||||
wgctl block --identity nuno
|
||||
wgctl block --name nuno --type phone
|
||||
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 block --name phone-nuno --service proxmox
|
||||
wgctl block --name phone-nuno --service truenas:web-ui --block-name "no truenas ui"
|
||||
wgctl ban --name phone-nuno
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Block Run
|
||||
# Run
|
||||
# ============================================
|
||||
|
||||
function cmd::block::run() {
|
||||
local name="" type="" block_name=""
|
||||
local name="" identity="" type="" block_name=""
|
||||
local ips=() subnets=() ports=() services=()
|
||||
local quiet=false force=false
|
||||
local changed=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--identity) identity="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--ip) ips+=("$2"); shift 2 ;;
|
||||
--block-name) block_name="$2"; shift 2 ;;
|
||||
|
|
@ -75,16 +74,27 @@ function cmd::block::run() {
|
|||
--quiet) quiet=true; shift ;;
|
||||
--subnet) subnets+=("$2"); shift 2 ;;
|
||||
--port) ports+=("$2"); shift 2 ;;
|
||||
--help) cmd::block::help; return ;;
|
||||
--help) cmd::block::help; return ;;
|
||||
*)
|
||||
log::error "Unknown flag: $1"
|
||||
cmd::block::help
|
||||
return 1 ;;
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && log::error "Missing required flag: --name" && \
|
||||
cmd::block::help && return 1
|
||||
# --identity: block all peers for this identity
|
||||
if [[ -n "$identity" ]]; then
|
||||
cmd::block::_block_identity "$identity" "$quiet" \
|
||||
"${ips[@]+"${ips[@]}"}" || return 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ -z "$name" ]] && {
|
||||
log::error "Missing required flag: --name or --identity"
|
||||
cmd::block::help
|
||||
return 1
|
||||
}
|
||||
|
||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||
|
||||
|
|
@ -113,6 +123,8 @@ function cmd::block::run() {
|
|||
fi
|
||||
fi
|
||||
|
||||
local changed=false
|
||||
|
||||
# Block specific IPs
|
||||
for ip in "${ips[@]}"; do
|
||||
ip::require_valid "$ip"
|
||||
|
|
@ -137,7 +149,7 @@ function cmd::block::run() {
|
|||
fw::block_port "$client_ip" "$b_target" "$b_port" "${b_proto:-tcp}"
|
||||
block::add_rule "$name" "$client_ip" "port" "${block_name:-}" \
|
||||
"$b_target" "$b_port" "${b_proto:-tcp}"
|
||||
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
|
||||
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been blocked for ${name}"
|
||||
done
|
||||
|
||||
# Block services
|
||||
|
|
@ -149,13 +161,12 @@ function cmd::block::run() {
|
|||
return 1
|
||||
fi
|
||||
|
||||
# Check if already blocked
|
||||
local already_blocked=true
|
||||
for resolved in "${resolved_lines[@]}"; do
|
||||
if [[ "$resolved" == *:*:* ]]; then
|
||||
local b_ip b_port b_proto
|
||||
IFS=":" read -r b_ip b_port b_proto <<< "$resolved"
|
||||
fw::has_block_rule "$client_ip" "$b_ip" "$b_port" "$b_proto" 2>/dev/null || \
|
||||
fw::has_block_rule "$client_ip" "$b_ip" "$b_port" "$b_proto" 2>/dev/null || \
|
||||
{ already_blocked=false; break; }
|
||||
else
|
||||
fw::has_block_rule "$client_ip" "$resolved" 2>/dev/null || \
|
||||
|
|
@ -167,7 +178,7 @@ function cmd::block::run() {
|
|||
$quiet || log::wg_warning "${svc} is already blocked for ${name}"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
for resolved in "${resolved_lines[@]}"; do
|
||||
if [[ "$resolved" == *:*:* ]]; then
|
||||
local b_ip b_port b_proto
|
||||
|
|
@ -186,10 +197,8 @@ function cmd::block::run() {
|
|||
done
|
||||
|
||||
[[ ${#ips[@]} -gt 0 || ${#ports[@]} -gt 0 || \
|
||||
${#subnets[@]} -gt 0 ]] && changed=true
|
||||
${#subnets[@]} -gt 0 ]] && changed=true
|
||||
|
||||
# Reapply in correct order: rule ACCEPT first, then peer DROP rules
|
||||
# Only reorder if rules were actually added
|
||||
if $changed; then
|
||||
local peer_rule
|
||||
peer_rule=$(peers::get_meta "$name" "rule")
|
||||
|
|
@ -203,6 +212,45 @@ function cmd::block::run() {
|
|||
return 0
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Identity block
|
||||
# ============================================
|
||||
|
||||
function cmd::block::_block_identity() {
|
||||
local identity_name="${1:-}" quiet="${2:-false}"
|
||||
shift 2 || true
|
||||
|
||||
identity::require_exists "$identity_name" || return 1
|
||||
identity::require_has_peers "$identity_name" || return 1
|
||||
|
||||
local peers blocked=0 failed=0
|
||||
peers=$(identity::peers "$identity_name")
|
||||
|
||||
while IFS= read -r peer_name; do
|
||||
[[ -z "$peer_name" ]] && continue
|
||||
if peers::is_blocked "$peer_name"; then
|
||||
$quiet || log::wg_warning "${peer_name} is already blocked"
|
||||
continue
|
||||
fi
|
||||
local client_ip
|
||||
client_ip=$(peers::get_ip "$peer_name") || continue
|
||||
monitor::update_endpoint_cache
|
||||
if cmd::block::_block_all "$peer_name" "$client_ip" true; then
|
||||
blocked=$(( blocked + 1 ))
|
||||
else
|
||||
failed=$(( failed + 1 ))
|
||||
fi
|
||||
done <<< "$peers"
|
||||
|
||||
log::ok "Blocked ${blocked} peer(s) for identity '${identity_name}'"
|
||||
[[ $failed -gt 0 ]] && log::warn "${failed} peer(s) failed to block"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::block::_get_endpoint() {
|
||||
local name="$1" public_key="$2"
|
||||
local endpoint
|
||||
|
|
@ -218,10 +266,7 @@ function cmd::block::_block_all() {
|
|||
local client_ip="${2:?client_ip required}"
|
||||
local quiet="${3:-false}"
|
||||
|
||||
# Apply fw rules and remove from server
|
||||
block::apply_full "$name" "$client_ip"
|
||||
|
||||
# Mark as directly blocked
|
||||
block::set_direct "$name" "$client_ip" "true"
|
||||
|
||||
$quiet || log::wg_success "${name} has been blocked."
|
||||
|
|
|
|||
536
commands/identity.command.sh
Normal file
536
commands/identity.command.sh
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
#!/usr/bin/env bash
|
||||
# identity.command.sh — manage peer identities
|
||||
#
|
||||
# Subcommands:
|
||||
# wgctl identity list
|
||||
# wgctl identity show --name <name>
|
||||
# wgctl identity add --name <name> --peer <peer>
|
||||
# wgctl identity remove --name <name>
|
||||
# wgctl identity migrate [--dry-run]
|
||||
|
||||
# ============================================
|
||||
# Lifecycle
|
||||
# ============================================
|
||||
|
||||
function cmd::identity::on_load() {
|
||||
load_module identity
|
||||
load_module policy
|
||||
|
||||
flag::register --name
|
||||
flag::register --peer
|
||||
flag::register --dry-run
|
||||
flag::register --force
|
||||
# rule subcommand flags
|
||||
flag::register --rule
|
||||
# options subcommand flags
|
||||
flag::register --policy
|
||||
flag::register --set-strict-rule
|
||||
flag::register --unset-strict-rule
|
||||
flag::register --set-auto-apply
|
||||
flag::register --unset-auto-apply
|
||||
flag::register --field
|
||||
flag::register --value
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Help
|
||||
# ============================================
|
||||
|
||||
function cmd::identity::help() {
|
||||
cat <<EOF
|
||||
Usage: wgctl identity <subcommand> [options]
|
||||
|
||||
Manage peer identities.
|
||||
|
||||
Subcommands:
|
||||
list List all identities
|
||||
show --name <name> Show identity details and device status
|
||||
add --name <name> Manually attach a peer to an identity
|
||||
--peer <peer>
|
||||
remove --name <name> Remove identity and all associated peers
|
||||
migrate [--dry-run] Create identities from existing peer names
|
||||
|
||||
rule assign --name <name> Assign a rule to an identity
|
||||
--rule <rule>
|
||||
rule unassign --name <name> Remove rule from an identity
|
||||
rule show --name <name> Show current identity rule
|
||||
|
||||
options --name <name> Set identity options
|
||||
[--policy <policy>]
|
||||
[--set-strict-rule | --unset-strict-rule]
|
||||
[--set-auto-apply | --unset-auto-apply]
|
||||
|
||||
Examples:
|
||||
wgctl identity list
|
||||
wgctl identity show --name nuno
|
||||
wgctl identity rule assign --name nuno --rule admin
|
||||
wgctl identity rule unassign --name nuno
|
||||
wgctl identity options --name guests-identity --policy guest
|
||||
wgctl identity options --name nuno --set-strict-rule
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Run
|
||||
# ============================================
|
||||
|
||||
function cmd::identity::run() {
|
||||
local subcmd="${1:-list}"
|
||||
shift || true
|
||||
|
||||
case "$subcmd" in
|
||||
list) cmd::identity::_list "$@" ;;
|
||||
show) cmd::identity::_show "$@" ;;
|
||||
add) cmd::identity::_add "$@" ;;
|
||||
remove) cmd::identity::_remove "$@" ;;
|
||||
migrate) cmd::identity::_migrate "$@" ;;
|
||||
rule) cmd::identity::_rule "$@" ;;
|
||||
options) cmd::identity::_options "$@" ;;
|
||||
--help) cmd::identity::help ;;
|
||||
*)
|
||||
log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, remove, migrate, rule, options"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Subcommands
|
||||
# ============================================
|
||||
|
||||
function cmd::identity::_list() {
|
||||
local data
|
||||
data=$(identity::list_data)
|
||||
|
||||
if [[ -z "$data" ]]; then
|
||||
log::info "No identities found. Run 'wgctl identity migrate' to create from existing peers."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
while IFS='|' read -r name peer_count types rules policy; do
|
||||
local rules_display
|
||||
rules_display=$(echo "$rules" | sed 's/,/, /g')
|
||||
ui::identity::list_row_compact "$name" "$peer_count" "$rules_display" "$policy"
|
||||
done <<< "$data"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function cmd::identity::_show() {
|
||||
local name=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--help) cmd::identity::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
identity::require_exists "$name" || return 1
|
||||
|
||||
# Gather identity-level metadata
|
||||
local policy strict auto rules_list peer_count
|
||||
policy=$(identity::policy "$name")
|
||||
strict=$(identity::rule_flags "$name" "strict_rule")
|
||||
auto=$(identity::rule_flags "$name" "auto_apply")
|
||||
rules_list=$(identity::rules "$name" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g')
|
||||
|
||||
local data
|
||||
data=$(identity::show_data "$name")
|
||||
peer_count=$(echo "$data" | grep '^peer_count|' | cut -d'|' -f2)
|
||||
|
||||
# Precompute handshakes once for all peers in this identity
|
||||
declare -A _id_handshakes=()
|
||||
while IFS=$'\t' read -r pk ts; do
|
||||
[[ -n "$pk" ]] && _id_handshakes["$pk"]="$ts"
|
||||
done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
|
||||
|
||||
# Header
|
||||
echo ""
|
||||
ui::row "Identity" "$name"
|
||||
ui::row "Policy" "$policy"
|
||||
ui::row "Rules" "${rules_list:-—}"
|
||||
ui::row "Strict rule" "$(ui::bool "$strict")"
|
||||
ui::row "Auto apply" "$(ui::bool "$auto")"
|
||||
ui::row "Peers" "$peer_count"
|
||||
echo ""
|
||||
|
||||
# Device list
|
||||
while IFS='|' read -r key val type_val index_val; do
|
||||
case "$key" in
|
||||
name|peer_count) ;;
|
||||
device)
|
||||
local status=""
|
||||
status=$(cmd::identity::_device_status "$val" _id_handshakes)
|
||||
ui::identity::device_row "$val" "$type_val" "$index_val" "$status"
|
||||
;;
|
||||
esac
|
||||
done <<< "$data"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
function cmd::identity::_device_status() {
|
||||
local peer_name="${1:-}"
|
||||
local -n _handshakes="${2:-__empty_map}"
|
||||
|
||||
local peer_ip
|
||||
peer_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || return 0
|
||||
[[ -z "$peer_ip" ]] && return 0
|
||||
|
||||
local is_blocked is_restricted pubkey handshake_ts
|
||||
peers::is_blocked "$peer_name" && is_blocked="true" || is_blocked="false"
|
||||
peers::is_restricted "$peer_name" && is_restricted="true" || is_restricted="false"
|
||||
|
||||
pubkey="$(keys::public "$peer_name")"
|
||||
handshake_ts="${_handshakes[$pubkey]:-0}"
|
||||
|
||||
local last_ts
|
||||
last_ts=$(peers::get_meta "$peer_name" "last_ts" 2>/dev/null) || last_ts=""
|
||||
|
||||
local status
|
||||
status=$(peers::format_status_verbose \
|
||||
"$peer_name" "$pubkey" "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
|
||||
echo " — ${status}"
|
||||
}
|
||||
|
||||
function cmd::identity::_add() {
|
||||
local name="" peer=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--peer) peer="$2"; shift 2 ;;
|
||||
--help) cmd::identity::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
[[ -z "$peer" ]] && { log::error "Missing required flag: --peer"; return 1; }
|
||||
|
||||
cmd::identity::_require_peer_exists "$peer" || return 1
|
||||
|
||||
local peer_type index
|
||||
peer_type=$(cmd::identity::_resolve_peer_type "$peer" "$name")
|
||||
index=$(identity::next_index "$name" "$peer_type")
|
||||
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${name}.identity")
|
||||
json::identity_add_peer "$id_file" "$name" "$peer" "$peer_type" "$index" </dev/null
|
||||
log::ok "Added '${peer}' to identity '${name}' (${peer_type} #${index})"
|
||||
}
|
||||
|
||||
function cmd::identity::_require_peer_exists() {
|
||||
local peer="${1:-}"
|
||||
if [[ ! -f "$(ctx::clients)/${peer}.conf" ]]; then
|
||||
log::error "Peer '${peer}' not found"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::identity::_resolve_peer_type() {
|
||||
local peer="${1:-}" identity_name="${2:-}"
|
||||
local inferred
|
||||
inferred=$(identity::infer "$peer")
|
||||
if [[ -n "$inferred" ]]; then
|
||||
echo "$inferred" | cut -d'|' -f2
|
||||
else
|
||||
peers::get_meta "$peer" "type" 2>/dev/null || echo "none"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::identity::_remove() {
|
||||
local name="" force=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--force) force=true; shift ;;
|
||||
--help) cmd::identity::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
identity::require_exists "$name" || return 1
|
||||
|
||||
local peers
|
||||
peers=$(identity::peers "$name")
|
||||
|
||||
if [[ -n "$peers" ]]; then
|
||||
local peer_list="${peers//$'\n'/, }"
|
||||
log::warn "This will permanently remove identity '${name}' and ALL associated peers:"
|
||||
log::warn " ${peer_list}"
|
||||
|
||||
if ! $force; then
|
||||
ui::confirm "Continue?" || { log::info "Aborted"; return 0; }
|
||||
fi
|
||||
|
||||
cmd::identity::_remove_all_peers "$peers"
|
||||
peers::reload || return 1
|
||||
fi
|
||||
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${name}.identity")
|
||||
json::identity_remove "$id_file" </dev/null
|
||||
log::ok "Identity '${name}' removed"
|
||||
}
|
||||
|
||||
function cmd::identity::_remove_all_peers() {
|
||||
local peers="${1:-}"
|
||||
while IFS= read -r peer_name; do
|
||||
[[ -z "$peer_name" ]] && continue
|
||||
cmd::identity::_remove_peer "$peer_name"
|
||||
done <<< "$peers"
|
||||
}
|
||||
|
||||
function cmd::identity::_remove_peer() {
|
||||
local peer_name="${1:-}"
|
||||
local client_ip was_blocked=false
|
||||
|
||||
client_ip=$(peers::get_ip "$peer_name" 2>/dev/null) || client_ip=""
|
||||
peers::is_blocked "$peer_name" && was_blocked=true
|
||||
|
||||
peers::purge "$peer_name" "$client_ip" "$was_blocked" || return 1
|
||||
log::ok "Removed peer '${peer_name}'"
|
||||
}
|
||||
|
||||
function cmd::identity::_migrate() {
|
||||
local dry_run="false"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run="true"; shift ;;
|
||||
--help) cmd::identity::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$dry_run" == "true" ]] && log::info "Dry run — no files will be written"
|
||||
echo ""
|
||||
|
||||
[[ "$dry_run" == "false" ]] && mkdir -p "$(ctx::identities)"
|
||||
|
||||
local created=0 skipped=0 output
|
||||
output=$(json::identity_migrate \
|
||||
"$(ctx::identities)" \
|
||||
"$(ctx::clients)" \
|
||||
"$(ctx::meta)" \
|
||||
"$dry_run")
|
||||
|
||||
while IFS='|' read -r action identity_name peer_name peer_type index; do
|
||||
case "$action" in
|
||||
create)
|
||||
ui::identity::migrate_create "$peer_name" "$identity_name" "$peer_type" "$index"
|
||||
(( created++ )) || true
|
||||
;;
|
||||
skip)
|
||||
ui::identity::migrate_skip "$peer_name"
|
||||
(( skipped++ )) || true
|
||||
;;
|
||||
esac
|
||||
done <<< "$output"
|
||||
|
||||
ui::identity::migrate_summary "$created" "$skipped" "$dry_run"
|
||||
}
|
||||
|
||||
function cmd::identity::_rule() {
|
||||
local subcmd="${1:-show}"
|
||||
shift || true
|
||||
|
||||
case "$subcmd" in
|
||||
assign) cmd::identity::_rule_assign "$@" ;;
|
||||
unassign) cmd::identity::_rule_unassign "$@" ;;
|
||||
show) cmd::identity::_rule_show "$@" ;;
|
||||
*)
|
||||
log::error "Unknown rule subcommand '${subcmd}'. Available: assign, unassign, show"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
function cmd::identity::_rule_assign() {
|
||||
local name="" rule=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--rule) rule="$2"; shift 2 ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
[[ -z "$rule" ]] && { log::error "Missing required flag: --rule"; return 1; }
|
||||
identity::require_exists "$name" || return 1
|
||||
rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; }
|
||||
|
||||
local exit_code
|
||||
identity::add_rule "$name" "$rule" || exit_code=$?
|
||||
|
||||
if [[ $exit_code -eq 2 ]]; then
|
||||
log::warn "Rule '${rule}' is already assigned to identity '${name}'"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log::ok "Rule '${rule}' assigned to identity '${name}'"
|
||||
|
||||
# Reapply rules if auto_apply
|
||||
local auto
|
||||
auto=$(identity::rule_flags "$name" "auto_apply")
|
||||
if [[ "$auto" != "false" ]]; then
|
||||
log::info "Reapplying rules for all peers in identity '${name}'..."
|
||||
identity::reapply_rules "$name"
|
||||
log::ok "Rules reapplied"
|
||||
fi
|
||||
|
||||
# Warn about strict_rule
|
||||
if policy::strict_rule "$(identity::policy "$name")"; then
|
||||
log::warn "Strict rule is enabled for identity '${name}' — peer rules will not be additive"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::identity::_rule_unassign() {
|
||||
local name="" rule="" all=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--rule) rule="$2"; shift 2 ;;
|
||||
--all) all=true; shift ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
identity::require_exists "$name" || return 1
|
||||
|
||||
if $all; then
|
||||
local rules
|
||||
rules=$(identity::rules "$name")
|
||||
if [[ -z "$rules" ]]; then
|
||||
log::warn "Identity '${name}' has no rules assigned"
|
||||
return 0
|
||||
fi
|
||||
identity::clear_rules "$name"
|
||||
log::ok "All rules removed from identity '${name}'"
|
||||
cmd::identity::_reapply_after_unassign "$name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ -z "$rule" ]] && {
|
||||
log::error "Missing required flag: --rule (or use --all to remove all)"
|
||||
return 1
|
||||
}
|
||||
|
||||
identity::remove_rule "$name" "$rule"
|
||||
local exit_code=$?
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
log::error "Rule '${rule}' is not assigned to identity '${name}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log::ok "Rule '${rule}' removed from identity '${name}'"
|
||||
cmd::identity::_reapply_after_unassign "$name"
|
||||
}
|
||||
|
||||
function cmd::identity::_reapply_after_unassign() {
|
||||
local name="${1:-}"
|
||||
local auto
|
||||
auto=$(identity::rule_flags "$name" "auto_apply")
|
||||
if [[ "$auto" != "false" ]]; then
|
||||
log::info "Reapplying rules for all peers in identity '${name}'..."
|
||||
identity::reapply_rules "$name"
|
||||
log::ok "Rules reapplied"
|
||||
else
|
||||
log::info "Note: auto_apply is disabled — run 'wgctl audit --fix' to update fw rules"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::identity::_rule_show() {
|
||||
local name=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
identity::require_exists "$name" || return 1
|
||||
|
||||
local rules policy strict auto
|
||||
rules=$(identity::rules "$name")
|
||||
policy=$(identity::policy "$name")
|
||||
strict=$(identity::rule_flags "$name" "strict_rule")
|
||||
auto=$(identity::rule_flags "$name" "auto_apply")
|
||||
|
||||
echo ""
|
||||
ui::row "Identity" "$name"
|
||||
ui::row "Policy" "$policy"
|
||||
ui::row "Strict rule" "$(ui::bool "$strict")"
|
||||
ui::row "Auto apply" "$(ui::bool "$auto")"
|
||||
echo ""
|
||||
|
||||
if [[ -z "$rules" ]]; then
|
||||
ui::row "Rules" "— none assigned"
|
||||
else
|
||||
printf " %-20s\n" "Rules:"
|
||||
while IFS= read -r rule_name; do
|
||||
[[ -z "$rule_name" ]] && continue
|
||||
printf " · %s\n" "$rule_name"
|
||||
done <<< "$rules"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
function cmd::identity::_options() {
|
||||
local name="" new_policy=""
|
||||
local set_strict="" set_auto=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--policy) new_policy="$2"; shift 2 ;;
|
||||
--set-strict-rule) set_strict="true"; shift ;;
|
||||
--unset-strict-rule) set_strict="false"; shift ;;
|
||||
--set-auto-apply) set_auto="true"; shift ;;
|
||||
--unset-auto-apply) set_auto="false"; shift ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
identity::require_exists "$name" || return 1
|
||||
|
||||
local changed=false
|
||||
|
||||
if [[ -n "$new_policy" ]]; then
|
||||
policy::require_exists "$new_policy" || return 1
|
||||
identity::set_policy "$name" "$new_policy"
|
||||
log::ok "Policy set to '${new_policy}' for identity '${name}'"
|
||||
changed=true
|
||||
fi
|
||||
|
||||
if [[ -n "$set_strict" ]]; then
|
||||
identity::set_rule_flag "$name" "strict_rule" "$set_strict"
|
||||
if [[ "$set_strict" == "true" ]]; then
|
||||
log::ok "Strict rule enabled for identity '${name}' — peer rules will not be additive"
|
||||
else
|
||||
log::ok "Strict rule disabled for identity '${name}' — peer rules will be additive"
|
||||
fi
|
||||
changed=true
|
||||
fi
|
||||
|
||||
if [[ -n "$set_auto" ]]; then
|
||||
identity::set_rule_flag "$name" "auto_apply" "$set_auto"
|
||||
if [[ "$set_auto" == "true" ]]; then
|
||||
log::ok "Auto apply enabled for identity '${name}'"
|
||||
else
|
||||
log::ok "Auto apply disabled for identity '${name}'"
|
||||
fi
|
||||
changed=true
|
||||
fi
|
||||
|
||||
if ! $changed; then
|
||||
cmd::identity::_rule_show --name "$name"
|
||||
fi
|
||||
}
|
||||
|
|
@ -89,12 +89,9 @@ function cmd::inspect::_peer_info() {
|
|||
local activity_current
|
||||
activity_current=$(peers::format_activity_current "$public_key")
|
||||
|
||||
local subtype
|
||||
subtype=$(peers::get_meta "$name" "subtype")
|
||||
|
||||
local rule_file=""
|
||||
local rule_extends=""
|
||||
if [[ -n "$rule" ]]; then
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule" 2>/dev/null)" || true
|
||||
if [[ -n "$rule_file" ]]; then
|
||||
local ext=()
|
||||
|
|
@ -117,8 +114,8 @@ function cmd::inspect::_peer_info() {
|
|||
printf "\n"
|
||||
ui::row "Name" "$name" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "IP" "$ip" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "Type" "$(peers::display_type "$type" "$subtype")" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "Type" "$(peers::display_type "$type")" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "Rule" "$rule_display" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "Status" "$(echo -e "$status")" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "Endpoint" "${endpoint:-—}" "${INSPECT_LABEL_WIDTH}"
|
||||
ui::row "Last seen" "$last_seen" "${INSPECT_LABEL_WIDTH}"
|
||||
|
|
@ -130,22 +127,81 @@ function cmd::inspect::_peer_info() {
|
|||
return 0
|
||||
}
|
||||
|
||||
# function cmd::inspect::_rule_info() {
|
||||
# local name="${1:-}"
|
||||
# local rule
|
||||
# rule=$(peers::get_meta "$name" "rule")
|
||||
# [[ -z "$rule" ]] && return 0
|
||||
# rule::exists "$rule" || return 0
|
||||
|
||||
# cmd::inspect::_section "Rule: ${rule}"
|
||||
|
||||
# if ui::rule::tree "$rule"; then
|
||||
# # printf "\n"
|
||||
# : # no-op
|
||||
# else
|
||||
# # No inheritance — flat view
|
||||
# rule::render_flat "$rule"
|
||||
# fi
|
||||
# return 0
|
||||
# }
|
||||
|
||||
function cmd::inspect::_rule_separator() {
|
||||
local line_width=20
|
||||
local total=$INSPECT_WIDTH
|
||||
local pad=$(( (total - line_width) / 2 ))
|
||||
printf "\n%*s\033[2m%s\033[0m\n\n" "$pad" "" "$(printf '─%.0s' $(seq 1 $line_width))"
|
||||
}
|
||||
|
||||
function cmd::inspect::_rule_info() {
|
||||
local name="${1:-}"
|
||||
local rule
|
||||
rule=$(peers::get_meta "$name" "rule")
|
||||
[[ -z "$rule" ]] && return 0
|
||||
rule::exists "$rule" || return 0
|
||||
|
||||
cmd::inspect::_section "Rule: ${rule}"
|
||||
|
||||
if rule::render_extends_tree "$rule"; then
|
||||
# printf "\n"
|
||||
: # no-op
|
||||
else
|
||||
# No inheritance — flat view
|
||||
rule::render_flat "$rule"
|
||||
|
||||
local identity_name identity_rules strict
|
||||
identity_name=$(identity::get_name "$name")
|
||||
if [[ -n "$identity_name" ]]; then
|
||||
identity_rules=$(identity::rules "$identity_name")
|
||||
strict=$(identity::rule_flags "$identity_name" "strict_rule")
|
||||
fi
|
||||
|
||||
# Skip section entirely if nothing to show
|
||||
[[ -z "$rule" && -z "$identity_rules" ]] && return 0
|
||||
|
||||
# Build section header
|
||||
local header="Rules"
|
||||
[[ -n "$rule" ]] && header="${header}: ${rule}"
|
||||
[[ -n "$identity_name" && -n "$identity_rules" ]] && \
|
||||
header="${header} · identity:${identity_name}"
|
||||
|
||||
cmd::inspect::_section "$header"
|
||||
|
||||
# Identity block first
|
||||
if [[ -n "$identity_name" && -n "$identity_rules" ]]; then
|
||||
ui::rule::identity_block "$identity_name" "$strict"
|
||||
fi
|
||||
|
||||
# Peer rule block — only if set and not suppressed
|
||||
if [[ -n "$rule" ]]; then
|
||||
rule::exists "$rule" || return 0
|
||||
|
||||
if [[ -n "$identity_rules" ]]; then
|
||||
# Both identity and peer rules exist — show peer block with same pattern
|
||||
printf "\n \033[0;37m· peer:%s\033[0m\n" "$name"
|
||||
ui::rule::_peer_rule_entry "$rule"
|
||||
else
|
||||
# Only peer rule — render directly without peer: label
|
||||
printf "\n"
|
||||
if rule::render_extends_tree "$rule"; then
|
||||
:
|
||||
else
|
||||
rule::render_flat "$rule"
|
||||
fi
|
||||
fi
|
||||
elif [[ "$strict" == "true" && -n "$rule" ]]; then
|
||||
printf "\n \033[2mpeer rule '%s' suppressed by strict policy\033[0m\n" "$rule"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@
|
|||
# ============================================
|
||||
|
||||
function cmd::list::on_load() {
|
||||
load_module identity
|
||||
load_module ui
|
||||
|
||||
flag::register --type
|
||||
flag::register --rule
|
||||
flag::register --group
|
||||
flag::register --identity
|
||||
flag::register --online
|
||||
flag::register --offline
|
||||
flag::register --restricted
|
||||
|
|
@ -28,87 +32,31 @@ Usage: wgctl list [options]
|
|||
List all WireGuard clients.
|
||||
|
||||
Options:
|
||||
--type <type> Filter by device type (desktop, laptop, phone, tablet, guest)
|
||||
--type <type> Filter by device type
|
||||
--rule <rule> Filter by assigned rule
|
||||
--group <group> Filter by group membership
|
||||
--identity <name> Filter by identity
|
||||
--online Show only connected clients
|
||||
--offline Show only disconnected clients
|
||||
--blocked Show only fully blocked clients (removed from WireGuard)
|
||||
--restricted Show only restricted clients (specific IP/port blocks applied)
|
||||
--blocked Show only fully blocked clients
|
||||
--restricted Show only restricted clients
|
||||
--allowed Show only unrestricted clients
|
||||
--detailed Show full detail cards for all clients
|
||||
--detailed Show detailed view grouped by identity
|
||||
--name <name> Show detail card for a single client
|
||||
|
||||
Status values:
|
||||
online Connected (recent handshake)
|
||||
offline Not connected
|
||||
blocked Removed from WireGuard server (wgctl block --name)
|
||||
restricted In WireGuard but with specific access rules (wgctl block --ip/--service)
|
||||
|
||||
Examples:
|
||||
wgctl list
|
||||
wgctl list --type guest
|
||||
wgctl list --rule user
|
||||
wgctl list --group family
|
||||
wgctl list --type phone
|
||||
wgctl list --identity nuno
|
||||
wgctl list --online
|
||||
wgctl list --blocked
|
||||
wgctl list --restricted
|
||||
wgctl list --detailed
|
||||
wgctl list --name phone-nuno
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Header / Footer
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_render_header() {
|
||||
local has_groups="$1"
|
||||
if $has_groups; then
|
||||
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
|
||||
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
|
||||
printf " %s\n" "$(printf '─%.0s' {1..135})"
|
||||
else
|
||||
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
|
||||
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
|
||||
printf " %s\n" "$(printf '─%.0s' {1..107})"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::list::_render_footer() {
|
||||
local has_groups="$1"
|
||||
if $has_groups; then
|
||||
printf " %s\n" "$(printf '─%.0s' {1..135})"
|
||||
else
|
||||
printf " %s\n" "$(printf '─%.0s' {1..107})"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::list::_render_summary() {
|
||||
local group_summary="${1:-}"
|
||||
local -n _rule_counts="$2"
|
||||
|
||||
# Count total from rule_counts (only filtered peers)
|
||||
local total=0
|
||||
for r in "${!_rule_counts[@]}"; do
|
||||
(( total += _rule_counts[$r] )) || true
|
||||
done
|
||||
|
||||
local summary=""
|
||||
for r in "${!_rule_counts[@]}"; do
|
||||
summary+="${_rule_counts[$r]} ${r}, "
|
||||
done
|
||||
summary="${summary%, }"
|
||||
|
||||
if [[ -n "$group_summary" ]]; then
|
||||
printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary"
|
||||
else
|
||||
printf "\n Showing %s peers [%s]\n\n" "$total" "$summary"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Detail Card (show_client)
|
||||
# Detail Card
|
||||
# ============================================
|
||||
|
||||
function cmd::list::show_client() {
|
||||
|
|
@ -131,10 +79,8 @@ function cmd::list::show_client() {
|
|||
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
|
||||
|
||||
local type
|
||||
type=$(peers::get_type_from_ip "$ip")
|
||||
|
||||
local subtype
|
||||
subtype=$(peers::get_meta "$name" "subtype")
|
||||
type=$(peers::get_meta "$name" "type" 2>/dev/null)
|
||||
[[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip")
|
||||
|
||||
local endpoint="—"
|
||||
local ep
|
||||
|
|
@ -155,7 +101,7 @@ function cmd::list::show_client() {
|
|||
last_ts=$(monitor::last_attempt "$name")
|
||||
|
||||
local status
|
||||
status=$(peers::format_status "$name" "$public_key" \
|
||||
status=$(peers::format_status_verbose "$name" "$public_key" \
|
||||
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
|
||||
|
||||
local last_seen
|
||||
|
|
@ -174,7 +120,7 @@ function cmd::list::show_client() {
|
|||
|
||||
ui::section "Client: ${name}"
|
||||
ui::row "IP" "$ip"
|
||||
ui::row "Type" "$(peers::display_type "$type" "$subtype")"
|
||||
ui::row "Type" "$(peers::display_type "$type")"
|
||||
ui::row "Status" "$(echo -e "$status")"
|
||||
ui::row "Endpoint" "$endpoint"
|
||||
ui::row "Last seen" "$last_seen"
|
||||
|
|
@ -197,7 +143,7 @@ function cmd::list::show_client() {
|
|||
# ============================================
|
||||
|
||||
function cmd::list::run() {
|
||||
local filter_type="" filter_rule="" filter_group=""
|
||||
local filter_type="" filter_rule="" filter_group="" filter_identity=""
|
||||
local online_only=false offline_only=false
|
||||
local restricted_only=false blocked_only=false allowed_only=false
|
||||
local detailed=false single_name=""
|
||||
|
|
@ -207,6 +153,7 @@ function cmd::list::run() {
|
|||
--type) filter_type="$2"; shift 2 ;;
|
||||
--rule) filter_rule="$2"; shift 2 ;;
|
||||
--group) filter_group="$2"; shift 2 ;;
|
||||
--identity) filter_identity="$2"; shift 2 ;;
|
||||
--online) online_only=true; shift ;;
|
||||
--offline) offline_only=true; shift ;;
|
||||
--restricted) restricted_only=true; shift ;;
|
||||
|
|
@ -223,7 +170,6 @@ function cmd::list::run() {
|
|||
esac
|
||||
done
|
||||
|
||||
# Single detail card
|
||||
if [[ -n "$single_name" ]]; then
|
||||
cmd::list::show_client "$single_name"
|
||||
return 0
|
||||
|
|
@ -237,40 +183,55 @@ function cmd::list::run() {
|
|||
return 0
|
||||
fi
|
||||
|
||||
# ── Precompute everything ──────────────────
|
||||
cmd::list::_precompute_all
|
||||
|
||||
# ── Detailed mode ──────────────────────────
|
||||
if $detailed; then
|
||||
log::section "WireGuard Clients"
|
||||
cmd::list::_iter_confs "$filter_type" cmd::list::_show_client_safe
|
||||
# Resolve identity filter
|
||||
declare -gA p_identity_filter=()
|
||||
if [[ -n "$filter_identity" ]]; then
|
||||
identity::require_exists "$filter_identity" || return 1
|
||||
while IFS= read -r peer_name; do
|
||||
[[ -n "$peer_name" ]] && p_identity_filter["$peer_name"]=1
|
||||
done < <(identity::peers "$filter_identity")
|
||||
if [[ ${#p_identity_filter[@]} -eq 0 ]]; then
|
||||
log::wg_warning "Identity '${filter_identity}' has no peers"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log::section "WireGuard Clients"
|
||||
|
||||
# Collect all filtered rows first (needed for dynamic column widths)
|
||||
local collected_rows=""
|
||||
collected_rows=$(cmd::list::_collect_all_rows | ui::sort_rows)
|
||||
|
||||
if [[ -z "$collected_rows" ]]; then
|
||||
log::wg_warning "No results found"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Build filter description ───────────────
|
||||
local filter_desc=""
|
||||
cmd::list::_build_filter_desc
|
||||
|
||||
# ── Table view ─────────────────────────────
|
||||
declare -A rule_counts=() group_counts=()
|
||||
_list_header_printed=false
|
||||
|
||||
cmd::list::_iter_confs "$filter_type" cmd::list::_render_row
|
||||
|
||||
if [[ "$_list_header_printed" == "true" ]]; then
|
||||
cmd::list::_render_footer $has_groups
|
||||
local group_summary=""
|
||||
cmd::list::_build_group_summary
|
||||
cmd::list::_render_summary "$group_summary" rule_counts "$filter_desc"
|
||||
else
|
||||
log::wg_warning "No results found${filter_desc:+ for: ${filter_desc}}"
|
||||
if $detailed; then
|
||||
cmd::list::_render_detailed "$collected_rows"
|
||||
cmd::list::_render_summary_from_rows "$collected_rows"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local style
|
||||
style=$(ui::peer::list_style)
|
||||
|
||||
case "$style" in
|
||||
table) cmd::list::_render_table ;;
|
||||
compact) cmd::list::_render_compact "$collected_rows" ;;
|
||||
*) cmd::list::_render_compact "$collected_rows" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
function cmd::list::_iter_confs() {
|
||||
# Usage: cmd::list::_iter_confs <filter_type> <callback>
|
||||
local filter_type="$1"
|
||||
local callback="$2"
|
||||
# ============================================
|
||||
# Row collection (single pass, all filters)
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_collect_all_rows() {
|
||||
# Outputs pipe-delimited rows for peers that pass all filters
|
||||
# Fields: name|ip|type|rule|group|status|last_seen|is_blocked|is_restricted
|
||||
local dir
|
||||
dir="$(ctx::clients)"
|
||||
|
||||
|
|
@ -278,86 +239,238 @@ function cmd::list::_iter_confs() {
|
|||
[[ -f "$conf" ]] || continue
|
||||
local client_name
|
||||
client_name=$(basename "$conf" .conf)
|
||||
local ip="${p_ips[$client_name]:-}"
|
||||
if [[ -z "$ip" ]]; then
|
||||
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
|
||||
[[ -z "$client_name" ]] && continue
|
||||
|
||||
# Identity filter
|
||||
if [[ ${#p_identity_filter[@]} -gt 0 && \
|
||||
-z "${p_identity_filter[$client_name]:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
local type
|
||||
type=$(peers::get_type_from_ip "$ip")
|
||||
|
||||
local ip="${p_ips[$client_name]:-}"
|
||||
[[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
|
||||
[[ -z "$ip" ]] && continue
|
||||
|
||||
local type="${p_types[$client_name]:-unknown}"
|
||||
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
|
||||
"$callback" "$client_name" "$ip" "$type"
|
||||
|
||||
local pubkey="${p_pubkeys[$client_name]:-}"
|
||||
local handshake_ts="${wg_handshakes[$pubkey]:-0}"
|
||||
local is_blocked="${p_blocked[$client_name]:-false}"
|
||||
local is_restricted="${p_restricted[$client_name]:-false}"
|
||||
local last_ts="${p_last_ts[$client_name]:-}"
|
||||
local rule="${p_rules[$client_name]:-}"
|
||||
local group="${p_main_groups[$client_name]:-}"
|
||||
|
||||
# Apply status filters
|
||||
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || continue; fi
|
||||
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || continue; fi
|
||||
if $restricted_only && [[ "$is_restricted" != "true" ]]; then continue; fi
|
||||
if $blocked_only && [[ "$is_blocked" != "true" ]]; then continue; fi
|
||||
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
|
||||
[[ "$is_restricted" == "true" ]]; }; then continue; fi
|
||||
|
||||
# Apply rule/group filters
|
||||
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then continue; fi
|
||||
if [[ -n "$filter_group" ]]; then
|
||||
local all_groups="${peer_group_map[$client_name]:-}"
|
||||
[[ "$all_groups" != *"$filter_group"* ]] && continue
|
||||
fi
|
||||
|
||||
# Resolve status
|
||||
local state
|
||||
state=$(peers::connection_state "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
|
||||
local status="${state%%|*}"
|
||||
|
||||
# Resolve last seen
|
||||
local last_seen="—"
|
||||
if [[ "$is_blocked" == "true" && -n "$last_ts" && "$last_ts" != "0" ]]; then
|
||||
local attempt_ts
|
||||
attempt_ts=$(json::iso_to_ts "$last_ts")
|
||||
last_seen=$(fmt::datetime_short "$attempt_ts")
|
||||
elif [[ -n "$handshake_ts" && "$handshake_ts" != "0" ]]; then
|
||||
last_seen=$(fmt::datetime_short "$handshake_ts")
|
||||
fi
|
||||
|
||||
printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\n" \
|
||||
"$client_name" "$ip" "$type" \
|
||||
"${rule:--}" "${group:--}" \
|
||||
"$status" "$last_seen" \
|
||||
"$is_blocked" "$is_restricted"
|
||||
done
|
||||
}
|
||||
|
||||
# function cmd::list::_render_row() {
|
||||
# local client_name="$1" ip="$2" type="$3"
|
||||
# ============================================
|
||||
# Compact render
|
||||
# ============================================
|
||||
|
||||
# local pubkey="${p_pubkeys[$client_name]:-}"
|
||||
# local handshake_ts="${wg_handshakes[$pubkey]:-0}"
|
||||
# local is_blocked="${p_blocked[$client_name]:-false}"
|
||||
# local is_restricted="${p_restricted[$client_name]:-false}"
|
||||
# local last_ts="${p_last_ts[$client_name]:-}"
|
||||
function cmd::list::_render_compact() {
|
||||
local rows="${1:-}"
|
||||
|
||||
# # Apply status filters
|
||||
# if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
|
||||
# if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
|
||||
# if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
|
||||
# if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
|
||||
# if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
|
||||
# [[ "$is_restricted" == "true" ]]; }; then return 0; fi
|
||||
# Measure column widths from pure values (fields 1-5, no labels)
|
||||
local w_name w_ip w_type w_rule w_group
|
||||
w_name=$(ui::measure_col "$rows" 1 14)
|
||||
w_ip=$(ui::measure_col "$rows" 2 13)
|
||||
w_type=$(ui::measure_col "$rows" 3 7)
|
||||
w_rule=$(ui::measure_col "$rows" 4 4)
|
||||
w_group=$(ui::measure_col "$rows" 5 4)
|
||||
|
||||
# if [[ -n "$filter_group" ]]; then
|
||||
# local peer_group="${peer_group_map[$client_name]:-}"
|
||||
# [[ "$peer_group" != "$filter_group" ]] && return 0
|
||||
# fi
|
||||
echo ""
|
||||
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
||||
[[ -z "$name" ]] && continue
|
||||
ui::peer::list_row_compact \
|
||||
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" \
|
||||
"$name" "$ip" "$type" "$rule" "$group" \
|
||||
"$status" "$last_seen" "$is_blocked" "$is_restricted"
|
||||
done <<< "$rows"
|
||||
echo ""
|
||||
|
||||
# # Format display values
|
||||
# local status last_seen display_type rule group_display
|
||||
# status=$(peers::format_status "$client_name" "$pubkey" \
|
||||
# "$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
|
||||
# last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \
|
||||
# "$is_blocked" "$last_ts" "" "$handshake_ts")
|
||||
# display_type=$(peers::display_type "$type" "${p_subtypes[$client_name]:-}")
|
||||
# rule="${p_rules[$client_name]:-—}"
|
||||
cmd::list::_render_summary_from_rows "$rows"
|
||||
}
|
||||
|
||||
# if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi
|
||||
# ============================================
|
||||
# Table render (kept for config switching)
|
||||
# ============================================
|
||||
|
||||
# # Print header on first match
|
||||
# if [[ "${_list_header_printed:-false}" == "false" ]]; then
|
||||
# log::section "WireGuard Clients"
|
||||
# cmd::list::_render_header $has_groups
|
||||
# _list_header_printed=true
|
||||
# fi
|
||||
function cmd::list::_render_table() {
|
||||
declare -A rule_counts=() group_counts=()
|
||||
_list_header_printed=false
|
||||
|
||||
# # Update rule counts for summary (outer scope array)
|
||||
# rule_counts["$rule"]=$(( ${rule_counts[$rule]:-0} + 1 )) || true
|
||||
cmd::list::_iter_confs_table
|
||||
|
||||
# # Pad status
|
||||
# local padded_status
|
||||
# padded_status=$(ui::pad_status "$status" 25)
|
||||
if [[ "$_list_header_printed" == "true" ]]; then
|
||||
cmd::list::_render_footer $has_groups
|
||||
local group_summary=""
|
||||
cmd::list::_build_group_summary
|
||||
printf "\n Showing peers\n\n"
|
||||
else
|
||||
log::wg_warning "No results found"
|
||||
fi
|
||||
}
|
||||
|
||||
# # Render row
|
||||
# if $has_groups; then
|
||||
# group_display="${peer_group_map[$client_name]:-—}"
|
||||
function cmd::list::_iter_confs_table() {
|
||||
local dir
|
||||
dir="$(ctx::clients)"
|
||||
for conf in "${dir}"/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
local client_name
|
||||
client_name=$(basename "$conf" .conf)
|
||||
[[ -z "$client_name" ]] && continue
|
||||
|
||||
# if [[ -n "${peer_group_map[$client_name]:-}" ]]; then
|
||||
# group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true
|
||||
# fi
|
||||
if [[ ${#p_identity_filter[@]} -gt 0 && \
|
||||
-z "${p_identity_filter[$client_name]:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# local rule_col_width=12 group_col_width=12
|
||||
# [[ "$rule" == "—" ]] && rule_col_width=14
|
||||
# [[ "$group_display" == "—" ]] && group_col_width=14
|
||||
# printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
|
||||
# "$client_name" "$ip" "$display_type" "$rule" \
|
||||
# "$group_display" "$padded_status" "$last_seen"
|
||||
# else
|
||||
# local rule_col_width=12
|
||||
# [[ "$rule" == "—" ]] && rule_col_width=14
|
||||
# printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \
|
||||
# "$client_name" "$ip" "$display_type" "$rule" \
|
||||
# "$padded_status" "$last_seen"
|
||||
# fi
|
||||
# }
|
||||
local ip="${p_ips[$client_name]:-}"
|
||||
[[ -z "$ip" ]] && ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
|
||||
|
||||
local type="${p_types[$client_name]:-unknown}"
|
||||
[[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
|
||||
|
||||
cmd::list::_render_row "$client_name" "$ip" "$type"
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Detailed render (grouped by identity)
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_render_detailed() {
|
||||
local rows="${1:-}"
|
||||
|
||||
# Measure widths
|
||||
local w_name w_ip w_type w_rule w_group w_subnet
|
||||
w_name=$(ui::measure_col "$rows" 1 14)
|
||||
w_ip=$(ui::measure_col "$rows" 2 13)
|
||||
w_type=$(ui::measure_col "$rows" 3 7)
|
||||
w_rule=$(ui::measure_col "$rows" 4 4)
|
||||
w_group=$(ui::measure_col "$rows" 5 4)
|
||||
# subnet not in rows — use fixed width
|
||||
w_subnet=10
|
||||
|
||||
# Group by identity
|
||||
declare -A identity_rows=()
|
||||
local no_identity_rows=""
|
||||
|
||||
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
||||
[[ -z "$name" ]] && continue
|
||||
local id_name
|
||||
id_name=$(identity::get_name "$name")
|
||||
local row="${name}|${ip}|${type}|${rule}|${group}|${status}|${last_seen}|${is_blocked}|${is_restricted}"
|
||||
if [[ -n "$id_name" ]]; then
|
||||
identity_rows["$id_name"]+="${row}"$'\n'
|
||||
else
|
||||
no_identity_rows+="${row}"$'\n'
|
||||
fi
|
||||
done <<< "$rows"
|
||||
|
||||
echo ""
|
||||
|
||||
# Render identity groups (sorted)
|
||||
for id_name in $(echo "${!identity_rows[@]}" | tr ' ' '\n' | sort); do
|
||||
ui::peer::list_identity_header "$id_name"
|
||||
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
||||
[[ -z "$name" ]] && continue
|
||||
local subnet
|
||||
subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null) || subnet="-"
|
||||
[[ -z "$subnet" ]] && subnet="-"
|
||||
ui::peer::list_row_detailed \
|
||||
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
|
||||
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \
|
||||
"$status" "$last_seen" "$is_blocked" "$is_restricted"
|
||||
done < <(echo "${identity_rows[$id_name]}" | ui::sort_rows)
|
||||
done
|
||||
|
||||
# Render peers without identity (no "other" header if empty)
|
||||
if [[ -n "$no_identity_rows" ]]; then
|
||||
local trimmed
|
||||
trimmed=$(echo "$no_identity_rows" | grep -v '^$')
|
||||
if [[ -n "$trimmed" ]]; then
|
||||
ui::peer::list_identity_header "other"
|
||||
while IFS='|' read -r name ip type rule group status last_seen is_blocked is_restricted; do
|
||||
[[ -z "$name" ]] && continue
|
||||
local subnet
|
||||
subnet=$(peers::get_meta "$name" "subnet" 2>/dev/null) || subnet="—"
|
||||
[[ -z "$subnet" ]] && subnet="—"
|
||||
ui::peer::list_row_detailed \
|
||||
"$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \
|
||||
"$name" "$ip" "$type" "$rule" "$group" "$subnet" \
|
||||
"$status" "$last_seen" "$is_blocked" "$is_restricted"
|
||||
done <<< "$trimmed"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Summary
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_render_summary_from_rows() {
|
||||
local rows="${1:-}"
|
||||
declare -A rule_counts=()
|
||||
local total=0
|
||||
|
||||
while IFS='|' read -r name ip type rule rest; do
|
||||
[[ -z "$name" ]] && continue
|
||||
(( total++ )) || true
|
||||
rule_counts["${rule:-—}"]=$(( ${rule_counts[${rule:-—}]:-0} + 1 )) || true
|
||||
done <<< "$rows"
|
||||
|
||||
local summary=""
|
||||
for r in "${!rule_counts[@]}"; do
|
||||
summary+="${rule_counts[$r]} ${r}, "
|
||||
done
|
||||
summary="${summary%, }"
|
||||
|
||||
printf " Showing %s peers [%s]\n\n" "$total" "$summary"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Table row rendering
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_render_row() {
|
||||
local client_name="$1" ip="$2" type="$3"
|
||||
|
|
@ -368,9 +481,8 @@ function cmd::list::_render_row() {
|
|||
local is_restricted="${p_restricted[$client_name]:-false}"
|
||||
local last_ts="${p_last_ts[$client_name]:-}"
|
||||
|
||||
# Apply status filters
|
||||
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
|
||||
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
|
||||
if $online_only; then peers::is_online "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
|
||||
if $offline_only; then peers::is_offline "$client_name" "$handshake_ts" "$last_ts" || return 0; fi
|
||||
if $restricted_only && [[ "$is_restricted" != "true" ]]; then return 0; fi
|
||||
if $blocked_only && [[ "$is_blocked" != "true" ]]; then return 0; fi
|
||||
if $allowed_only && { [[ "$is_blocked" == "true" ]] || \
|
||||
|
|
@ -381,20 +493,17 @@ function cmd::list::_render_row() {
|
|||
[[ "$all_groups" != *"$filter_group"* ]] && return 0
|
||||
fi
|
||||
|
||||
# Format display values
|
||||
local status last_seen display_type rule group_display
|
||||
status=$(peers::format_status "$client_name" "$pubkey" \
|
||||
local status last_seen display_type rule
|
||||
status=$(peers::format_status_verbose "$client_name" "$pubkey" \
|
||||
"$is_blocked" "$is_restricted" "$handshake_ts" "$last_ts")
|
||||
last_seen=$(peers::format_last_seen "$client_name" "$pubkey" \
|
||||
"$is_blocked" "$last_ts" "" "$handshake_ts")
|
||||
display_type=$(peers::display_type "$type" "${p_subtypes[$client_name]:-}")
|
||||
display_type=$(peers::display_type "$type")
|
||||
rule="${p_rules[$client_name]:-—}"
|
||||
|
||||
if [[ -n "$filter_rule" && "$rule" != "$filter_rule" ]]; then return 0; fi
|
||||
|
||||
# Print header on first match
|
||||
if [[ "${_list_header_printed:-false}" == "false" ]]; then
|
||||
log::section "WireGuard Clients"
|
||||
cmd::list::_render_header $has_groups
|
||||
_list_header_printed=true
|
||||
fi
|
||||
|
|
@ -405,51 +514,39 @@ function cmd::list::_render_row() {
|
|||
padded_status=$(ui::pad_status "$status" 25)
|
||||
|
||||
if $has_groups; then
|
||||
# Use main group for display, fall back to first group, then —
|
||||
local main_group="${p_main_groups[$client_name]:-}"
|
||||
if [[ -n "$main_group" ]]; then
|
||||
group_display="$main_group"
|
||||
else
|
||||
group_display="${peer_group_map[$client_name]:-—}"
|
||||
fi
|
||||
|
||||
if [[ -n "${peer_group_map[$client_name]:-}" ]]; then
|
||||
group_counts["$group_display"]=$(( ${group_counts[$group_display]:-0} + 1 )) || true
|
||||
fi
|
||||
|
||||
local rule_col_width=12 group_col_width=12
|
||||
[[ "$rule" == "—" ]] && rule_col_width=14
|
||||
[[ "$group_display" == "—" ]] && group_col_width=14
|
||||
printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
|
||||
local group_display="${main_group:-${peer_group_map[$client_name]:-—}}"
|
||||
printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \
|
||||
"$client_name" "$ip" "$display_type" "$rule" \
|
||||
"$group_display" "$padded_status" "$last_seen"
|
||||
else
|
||||
local rule_col_width=12
|
||||
[[ "$rule" == "—" ]] && rule_col_width=14
|
||||
printf " %-28s %-15s %-13s %-${rule_col_width}s %s %s\n" \
|
||||
printf " %-28s %-15s %-13s %-12s %s %s\n" \
|
||||
"$client_name" "$ip" "$display_type" "$rule" \
|
||||
"$padded_status" "$last_seen"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Private helpers
|
||||
# Precompute
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_precompute_all() {
|
||||
# Peer data
|
||||
declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=() p_main_groups=()
|
||||
while IFS="|" read -r name ip rule subtype last_ts last_evt main_group; do
|
||||
declare -gA p_ips=() p_rules=() p_types=() p_last_ts=() p_last_evt=() p_main_groups=()
|
||||
while IFS="|" read -r name ip rule type last_ts last_evt main_group; do
|
||||
[[ -z "$name" ]] && continue
|
||||
p_ips["$name"]="$ip"
|
||||
p_rules["$name"]="${rule:-—}"
|
||||
p_subtypes["$name"]="$subtype"
|
||||
p_rules["$name"]="${rule:-}"
|
||||
p_types["$name"]="${type:-}"
|
||||
p_last_ts["$name"]="$last_ts"
|
||||
p_last_evt["$name"]="$last_evt"
|
||||
p_main_groups["$name"]="${main_group:-}"
|
||||
done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
|
||||
|
||||
# WireGuard handshakes + endpoints
|
||||
for name in "${!p_ips[@]}"; do
|
||||
[[ -n "${p_types[$name]:-}" ]] && continue
|
||||
p_types["$name"]=$(peers::get_type_from_ip "${p_ips[$name]}")
|
||||
done
|
||||
|
||||
declare -gA wg_handshakes=() wg_endpoints=()
|
||||
while IFS=$'\t' read -r pubkey ts; do
|
||||
[[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts"
|
||||
|
|
@ -458,11 +555,9 @@ function cmd::list::_precompute_all() {
|
|||
[[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint"
|
||||
done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
|
||||
|
||||
# Block/restricted status
|
||||
declare -gA p_blocked=() p_restricted=()
|
||||
cmd::list::_precompute_block_status p_blocked p_restricted
|
||||
|
||||
# Public keys
|
||||
declare -gA p_pubkeys=()
|
||||
local dir
|
||||
dir="$(ctx::clients)"
|
||||
|
|
@ -473,7 +568,6 @@ function cmd::list::_precompute_all() {
|
|||
p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
|
||||
done
|
||||
|
||||
# Groups + main group
|
||||
has_groups=false
|
||||
declare -gA peer_group_map=()
|
||||
local groups_dir
|
||||
|
|
@ -486,75 +580,10 @@ function cmd::list::_precompute_all() {
|
|||
done < <(json::peer_group_map "$groups_dir")
|
||||
fi
|
||||
|
||||
# Transfer/activity data — keyed by pubkey
|
||||
declare -gA p_rx=() p_tx=() p_activity=()
|
||||
while IFS="|" read -r pubkey rx tx level; do
|
||||
[[ -z "$pubkey" ]] && continue
|
||||
p_rx["$pubkey"]="$rx"
|
||||
p_tx["$pubkey"]="$tx"
|
||||
p_activity["$pubkey"]="$level"
|
||||
done < <(json::peer_transfer "$(config::interface)")
|
||||
# Identity precompute (for --identity filter)
|
||||
declare -gA p_identity_filter=()
|
||||
}
|
||||
|
||||
# function cmd::list::_precompute_all() {
|
||||
# # Peer data
|
||||
# declare -gA p_ips=() p_rules=() p_subtypes=() p_last_ts=() p_last_evt=()
|
||||
# while IFS="|" read -r name ip rule subtype last_ts last_evt; do
|
||||
# [[ -z "$name" ]] && continue
|
||||
# p_ips["$name"]="$ip"
|
||||
# p_rules["$name"]="${rule:-—}"
|
||||
# p_subtypes["$name"]="$subtype"
|
||||
# p_last_ts["$name"]="$last_ts"
|
||||
# p_last_evt["$name"]="$last_evt"
|
||||
# done < <(json::peer_data "$(ctx::clients)" "$(ctx::meta)" "$(ctx::events_log)")
|
||||
|
||||
# # WireGuard handshakes + endpoints
|
||||
# declare -gA wg_handshakes=() wg_endpoints=()
|
||||
# while IFS=$'\t' read -r pubkey ts; do
|
||||
# [[ -n "$pubkey" ]] && wg_handshakes["$pubkey"]="$ts"
|
||||
# done < <(wg show "$(config::interface)" latest-handshakes 2>/dev/null)
|
||||
# while IFS=$'\t' read -r pubkey endpoint; do
|
||||
# [[ -n "$pubkey" ]] && wg_endpoints["$pubkey"]="$endpoint"
|
||||
# done < <(wg show "$(config::interface)" endpoints 2>/dev/null)
|
||||
|
||||
# # Block/restricted status
|
||||
# declare -gA p_blocked=() p_restricted=()
|
||||
# cmd::list::_precompute_block_status p_blocked p_restricted
|
||||
|
||||
# # Public keys
|
||||
# declare -gA p_pubkeys=()
|
||||
# local dir
|
||||
# dir="$(ctx::clients)"
|
||||
# for kf in "${dir}"/*_public.key; do
|
||||
# [[ -f "$kf" ]] || continue
|
||||
# local kname
|
||||
# kname=$(basename "$kf" _public.key)
|
||||
# p_pubkeys["$kname"]=$(cat "$kf" 2>/dev/null || echo "")
|
||||
# done
|
||||
|
||||
# # Groups
|
||||
# has_groups=false
|
||||
# declare -gA peer_group_map=()
|
||||
# local groups_dir
|
||||
# groups_dir="$(ctx::groups)"
|
||||
# local group_files=("${groups_dir}"/*.group)
|
||||
# if [[ -f "${group_files[0]}" ]]; then
|
||||
# has_groups=true
|
||||
# while IFS=":" read -r peer_name group_name; do
|
||||
# [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
|
||||
# done < <(json::peer_group_map "$groups_dir")
|
||||
# fi
|
||||
|
||||
# # Transfer/activity data — keyed by pubkey
|
||||
# declare -gA p_rx=() p_tx=() p_activity=()
|
||||
# while IFS="|" read -r pubkey rx tx level; do
|
||||
# [[ -z "$pubkey" ]] && continue
|
||||
# p_rx["$pubkey"]="$rx"
|
||||
# p_tx["$pubkey"]="$tx"
|
||||
# p_activity["$pubkey"]="$level"
|
||||
# done < <(json::peer_transfer "$(config::interface)")
|
||||
# }
|
||||
|
||||
function cmd::list::_precompute_block_status() {
|
||||
local -n _blocked="$1"
|
||||
local -n _restricted="$2"
|
||||
|
|
@ -569,7 +598,6 @@ function cmd::list::_precompute_block_status() {
|
|||
_restricted["$name"]=false
|
||||
fi
|
||||
|
||||
# Blocked = removed from WG server
|
||||
local pubkey
|
||||
pubkey=$(keys::public "$name" 2>/dev/null || echo "")
|
||||
if [[ -n "$pubkey" ]] && ! echo "$wg_peers" | grep -qF "$pubkey"; then
|
||||
|
|
@ -580,15 +608,16 @@ function cmd::list::_precompute_block_status() {
|
|||
done < <(peers::all)
|
||||
}
|
||||
|
||||
function cmd::list::_build_filter_desc() {
|
||||
filter_desc=""
|
||||
[[ -n "$filter_type" ]] && filter_desc+="type=${filter_type} "
|
||||
[[ -n "$filter_rule" ]] && filter_desc+="rule=${filter_rule} "
|
||||
[[ -n "$filter_group" ]] && filter_desc+="group=${filter_group} "
|
||||
$online_only && filter_desc+="online "
|
||||
$offline_only && filter_desc+="offline "
|
||||
$blocked_only && filter_desc+="blocked "
|
||||
filter_desc="${filter_desc% }"
|
||||
# ============================================
|
||||
# Header / Footer (table layout)
|
||||
# ============================================
|
||||
|
||||
function cmd::list::_render_header() {
|
||||
ui::peer::list_header_table "$1"
|
||||
}
|
||||
|
||||
function cmd::list::_render_footer() {
|
||||
ui::peer::list_footer_table "$1"
|
||||
}
|
||||
|
||||
function cmd::list::_build_group_summary() {
|
||||
|
|
|
|||
247
commands/policy.command.sh
Normal file
247
commands/policy.command.sh
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
#!/usr/bin/env bash
|
||||
# policy.command.sh — manage policies
|
||||
#
|
||||
# Subcommands:
|
||||
# wgctl policy list
|
||||
# wgctl policy show --name <name>
|
||||
# wgctl policy add --name <name> [--tunnel-mode split|full]
|
||||
# [--default-rule <rule>] [--strict-rule] [--no-auto-apply]
|
||||
# [--desc <desc>]
|
||||
# wgctl policy rm --name <name>
|
||||
# wgctl policy set --name <name> --field <field> --value <value>
|
||||
|
||||
# ============================================
|
||||
# Lifecycle
|
||||
# ============================================
|
||||
|
||||
function cmd::policy::on_load() {
|
||||
load_module policy
|
||||
|
||||
flag::register --name
|
||||
flag::register --tunnel-mode
|
||||
flag::register --default-rule
|
||||
flag::register --strict-rule
|
||||
flag::register --no-strict-rule
|
||||
flag::register --auto-apply
|
||||
flag::register --no-auto-apply
|
||||
flag::register --desc
|
||||
flag::register --field
|
||||
flag::register --value
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Help
|
||||
# ============================================
|
||||
|
||||
function cmd::policy::help() {
|
||||
cat <<EOF
|
||||
Usage: wgctl policy <subcommand> [options]
|
||||
|
||||
Manage policies. Policies define behavioral flags for subnets and identities.
|
||||
|
||||
Subcommands:
|
||||
list List all policies
|
||||
show --name <name> Show policy details
|
||||
add --name <name> Add a new policy
|
||||
[--tunnel-mode split|full]
|
||||
[--default-rule <rule>]
|
||||
[--strict-rule]
|
||||
[--no-auto-apply]
|
||||
[--desc <desc>]
|
||||
rm --name <name> Remove a policy (built-ins cannot be removed)
|
||||
set --name <name> Set a single field on a policy
|
||||
--field <field>
|
||||
--value <value>
|
||||
|
||||
Fields:
|
||||
tunnel_mode split|full
|
||||
default_rule rule name (or empty to clear)
|
||||
strict_rule true|false
|
||||
auto_apply true|false
|
||||
desc description string
|
||||
|
||||
Built-in policies (cannot be removed): default, guest, trusted, server, iot
|
||||
|
||||
Examples:
|
||||
wgctl policy list
|
||||
wgctl policy show --name guest
|
||||
wgctl policy add --name contractor --default-rule contractor --strict-rule
|
||||
wgctl policy set --name contractor --field tunnel-mode --value full
|
||||
wgctl policy rm --name contractor
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Run
|
||||
# ============================================
|
||||
|
||||
function cmd::policy::run() {
|
||||
local subcmd="${1:-list}"
|
||||
shift || true
|
||||
|
||||
case "$subcmd" in
|
||||
list) cmd::policy::_list "$@" ;;
|
||||
show) cmd::policy::_show "$@" ;;
|
||||
add) cmd::policy::_add "$@" ;;
|
||||
rm) cmd::policy::_rm "$@" ;;
|
||||
set) cmd::policy::_set "$@" ;;
|
||||
--help) cmd::policy::help ;;
|
||||
*)
|
||||
log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, rm, set"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Subcommands
|
||||
# ============================================
|
||||
|
||||
function cmd::policy::_list() {
|
||||
local data
|
||||
data=$(policy::list_data)
|
||||
|
||||
if [[ -z "$data" ]]; then
|
||||
log::info "No policies defined."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
while IFS='|' read -r name tunnel default_rule strict auto desc; do
|
||||
ui::policy::list_row "$name" "$default_rule" "$strict" "$auto"
|
||||
done <<< "$data"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function cmd::policy::_show() {
|
||||
local name=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--help) cmd::policy::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
policy::require_exists "$name" || return 1
|
||||
|
||||
local dr tunnel strict auto
|
||||
dr=$(policy::default_rule "$name")
|
||||
tunnel=$(policy::tunnel_mode "$name")
|
||||
strict=$(policy::strict_rule "$name" && echo "yes" || echo "no")
|
||||
auto=$(policy::auto_apply "$name" && echo "yes" || echo "no")
|
||||
|
||||
local rule_val="-"
|
||||
[[ -n "$dr" ]] && rule_val="$dr"
|
||||
|
||||
local strict_padded
|
||||
strict_padded=$(printf "%-4s" "$strict")
|
||||
|
||||
# First line — mirrors list format
|
||||
echo ""
|
||||
printf " \033[1m%-14s\033[0m \033[2mrule:\033[0m %-16s \033[2mstrict:\033[0m %s\n" \
|
||||
"$name" "$rule_val" "$strict_padded"
|
||||
echo ""
|
||||
|
||||
# Detail section
|
||||
local desc
|
||||
desc=$(policy::get "$name" "desc")
|
||||
[[ -n "$desc" ]] && printf " \033[2mDescription:\033[0m %s\n" "$desc"
|
||||
printf " \033[2mTunnel:\033[0m %s\n" "$tunnel"
|
||||
printf " \033[2mAuto apply:\033[0m %s\n" "$auto"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function cmd::policy::_add() {
|
||||
local name="" tunnel_mode="split" default_rule="" \
|
||||
strict_rule="false" auto_apply="true" desc=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--tunnel-mode) tunnel_mode="$2"; shift 2 ;;
|
||||
--default-rule) default_rule="$2"; shift 2 ;;
|
||||
--strict-rule) strict_rule="true"; shift ;;
|
||||
--no-strict-rule) strict_rule="false"; shift ;;
|
||||
--no-auto-apply) auto_apply="false"; shift ;;
|
||||
--auto-apply) auto_apply="true"; shift ;;
|
||||
--desc) desc="$2"; shift 2 ;;
|
||||
--help) cmd::policy::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
|
||||
case "$tunnel_mode" in
|
||||
split|full) ;;
|
||||
*) log::error "Invalid --tunnel-mode '${tunnel_mode}'. Use: split, full"; return 1 ;;
|
||||
esac
|
||||
|
||||
json::policy_add "$(ctx::policies)" "$name" "$tunnel_mode" \
|
||||
"$default_rule" "$strict_rule" "$auto_apply" "$desc"
|
||||
log::ok "Policy '${name}' added"
|
||||
}
|
||||
|
||||
function cmd::policy::_rm() {
|
||||
local name=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--help) cmd::policy::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
policy::require_exists "$name" || return 1
|
||||
|
||||
json::policy_remove "$(ctx::policies)" "$name"
|
||||
log::ok "Policy '${name}' removed"
|
||||
}
|
||||
|
||||
function cmd::policy::_set() {
|
||||
local name="" field="" value=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--field) field="$2"; shift 2 ;;
|
||||
--value) value="$2"; shift 2 ;;
|
||||
--help) cmd::policy::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
[[ -z "$field" ]] && { log::error "Missing required flag: --field"; return 1; }
|
||||
[[ -z "$value" ]] && { log::error "Missing required flag: --value"; return 1; }
|
||||
|
||||
policy::require_exists "$name" || return 1
|
||||
|
||||
# Normalise field name (allow tunnel-mode as well as tunnel_mode)
|
||||
field="${field//-/_}"
|
||||
|
||||
case "$field" in
|
||||
tunnel_mode)
|
||||
case "$value" in
|
||||
split|full) ;;
|
||||
*) log::error "Invalid value '${value}' for tunnel_mode. Use: split, full"; return 1 ;;
|
||||
esac
|
||||
;;
|
||||
strict_rule|auto_apply)
|
||||
case "$value" in
|
||||
true|false) ;;
|
||||
*) log::error "Invalid value '${value}' for ${field}. Use: true, false"; return 1 ;;
|
||||
esac
|
||||
;;
|
||||
default_rule|desc) ;;
|
||||
*)
|
||||
log::error "Unknown field '${field}'. Valid: tunnel_mode, default_rule, strict_rule, auto_apply, desc"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value"
|
||||
log::ok "Policy '${name}': ${field} = ${value}"
|
||||
}
|
||||
|
|
@ -58,5 +58,6 @@ function cmd::qr::run() {
|
|||
|
||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||
|
||||
log::section "Client QR: ${name}"
|
||||
keys::qr "$name"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,16 +36,14 @@ EOF
|
|||
# ============================================
|
||||
|
||||
function cmd::remove::run() {
|
||||
local name=""
|
||||
local type=""
|
||||
local force=false
|
||||
local name="" type="" force=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--force) force=true; shift ;;
|
||||
--help) cmd::remove::help; return ;;
|
||||
--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
|
||||
|
|
@ -62,7 +60,6 @@ function cmd::remove::run() {
|
|||
|
||||
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
|
||||
|
|
@ -76,27 +73,20 @@ function cmd::remove::run() {
|
|||
|
||||
log::section "Removing client: ${name}"
|
||||
|
||||
local client_ip
|
||||
local client_ip was_blocked=false
|
||||
client_ip=$(peers::get_ip "$name")
|
||||
|
||||
local was_blocked=false
|
||||
peers::is_blocked "$name" && was_blocked=true
|
||||
|
||||
cmd::remove::_cleanup "$name" "$client_ip" "$was_blocked" || return 1
|
||||
peers::purge "$name" "$client_ip" "$was_blocked" || return 1
|
||||
|
||||
# Detach from identity after successful removal
|
||||
identity::auto_detach "$name"
|
||||
|
||||
log::wg_success "Client removed: ${name}"
|
||||
}
|
||||
|
||||
# _cleanup kept as a shim — callers should prefer peers::purge directly
|
||||
function cmd::remove::_cleanup() {
|
||||
local name="${1:-}" client_ip="${2:-}" was_blocked="${3:-false}"
|
||||
|
||||
[[ -n "$client_ip" ]] && fw::flush_peer "$client_ip"
|
||||
peers::remove_from_server "$name" || return 1
|
||||
peers::remove_client_config "$name" || return 1
|
||||
keys::remove "$name" || return 1
|
||||
group::remove_peer_from_all "$name" || return 1
|
||||
|
||||
[[ -n "$client_ip" ]] && $was_blocked && fw::unblock_all "$client_ip"
|
||||
block::remove_file "$name" 2>/dev/null || true
|
||||
peers::remove_meta "$name" 2>/dev/null || true
|
||||
peers::reload || return 1
|
||||
peers::purge "$name" "$client_ip" "$was_blocked"
|
||||
}
|
||||
|
|
@ -23,8 +23,8 @@ 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)
|
||||
--name <name> Current client name (e.g. phone-nuno)
|
||||
--new-name <name> New client name (e.g. laptop-nuno)
|
||||
|
||||
Examples:
|
||||
wgctl rename --name phone-phone-nuno --new-name phone-nuno
|
||||
|
|
@ -37,10 +37,7 @@ EOF
|
|||
# ============================================
|
||||
|
||||
function cmd::rename::run() {
|
||||
local name=""
|
||||
local type=""
|
||||
local new_name=""
|
||||
local new_type=""
|
||||
local name="" type="" new_name="" new_type=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
|
|
@ -69,7 +66,7 @@ function cmd::rename::run() {
|
|||
return 1
|
||||
fi
|
||||
|
||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||
new_name=$(peers::resolve_name "$new_name" "$new_type") || return 1
|
||||
|
||||
local dir
|
||||
|
|
@ -88,10 +85,11 @@ function cmd::rename::run() {
|
|||
log::section "Renaming client: ${name} → ${new_name}"
|
||||
|
||||
cmd::rename::_rename_files "$name" "$new_name"
|
||||
|
||||
# Reload WireGuard
|
||||
peers::reload
|
||||
|
||||
# Update identity entry after successful rename
|
||||
identity::rename_peer "$name" "$new_name"
|
||||
|
||||
log::wg_success "Client renamed: ${name} → ${new_name}"
|
||||
}
|
||||
|
||||
|
|
@ -107,9 +105,5 @@ function cmd::rename::_rename_files() {
|
|||
sed -i "s/^# ${name}$/# ${new_name}/" "$(config::config_file)"
|
||||
|
||||
block::rename "$name" "$new_name"
|
||||
|
||||
local old_meta new_meta
|
||||
old_meta=$(peers::meta_path "$name")
|
||||
new_meta=$(peers::meta_path "$new_name")
|
||||
[[ -f "$old_meta" ]] && mv "$old_meta" "$new_meta"
|
||||
peers::rename_meta "$name" "$new_name"
|
||||
}
|
||||
|
|
@ -358,13 +358,15 @@ function cmd::rule::show() {
|
|||
ui::row "Group" "${group:-—}"
|
||||
ui::row "DNS" "$dns_display"
|
||||
|
||||
printf "\n"
|
||||
# ── Extends + own rules ────────────────────────
|
||||
if rule::render_extends_tree "$name"; then
|
||||
# Has inheritance — tree already rendered
|
||||
printf "\n"
|
||||
:
|
||||
else
|
||||
# No inheritance — flat view
|
||||
rule::render_flat "$name"
|
||||
printf "\n"
|
||||
fi
|
||||
|
||||
# ── Resolved ──────────────────────────────────
|
||||
|
|
@ -381,7 +383,6 @@ function cmd::rule::show() {
|
|||
while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "-" "$e"; done \
|
||||
<<< "$res_block_ips"$'\n'"$res_block_ports"
|
||||
printf "\n"
|
||||
|
||||
fi
|
||||
|
||||
# ── Peers ─────────────────────────────────────
|
||||
|
|
@ -389,7 +390,10 @@ function cmd::rule::show() {
|
|||
mapfile -t peer_list < <(peers::with_rule "$name")
|
||||
|
||||
local peer_count=${#peer_list[@]}
|
||||
|
||||
ui::empty "$peer_count" && return 0
|
||||
|
||||
printf "\n"
|
||||
printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \
|
||||
"$(color::gray "${peer_count}")" \
|
||||
"$(printf '\033[0;37m─%.0s' {1..35})"
|
||||
|
|
|
|||
294
commands/subnet.command.sh
Normal file
294
commands/subnet.command.sh
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
#!/usr/bin/env bash
|
||||
# subnet.command.sh — manage the subnet map (subnets.json)
|
||||
#
|
||||
# Subcommands:
|
||||
# wgctl subnet list
|
||||
# wgctl subnet show --name <name>
|
||||
# wgctl subnet add --name <name> --subnet <cidr> [--type <type>]
|
||||
# [--tunnel-mode split|full] [--desc <desc>]
|
||||
# [--group <parent>]
|
||||
# wgctl subnet rm --name <name>
|
||||
# wgctl subnet rename --name <old> --new-name <new>
|
||||
|
||||
# ============================================
|
||||
# Lifecycle
|
||||
# ============================================
|
||||
|
||||
function cmd::subnet::on_load() {
|
||||
flag::register --name
|
||||
flag::register --subnet
|
||||
flag::register --type
|
||||
flag::register --tunnel-mode
|
||||
flag::register --desc
|
||||
flag::register --group
|
||||
flag::register --new-name
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Help
|
||||
# ============================================
|
||||
|
||||
function cmd::subnet::help() {
|
||||
cat <<EOF
|
||||
Usage: wgctl subnet <subcommand> [options]
|
||||
|
||||
Manage the subnet map.
|
||||
|
||||
Subcommands:
|
||||
list List all configured subnets
|
||||
show --name <name> Show details for a subnet
|
||||
add --name <name> Add a new subnet entry
|
||||
--subnet <cidr>
|
||||
[--type <type>]
|
||||
[--tunnel-mode split|full]
|
||||
[--desc <desc>]
|
||||
[--group <parent>]
|
||||
rm --name <name> Remove a subnet (refused if in use)
|
||||
rename --name <old> Rename a subnet (refused if in use)
|
||||
--new-name <new>
|
||||
|
||||
Examples:
|
||||
wgctl subnet list
|
||||
wgctl subnet show --name guests
|
||||
wgctl subnet add --name iot-cctv --subnet 10.1.211.0/24 --type iot
|
||||
wgctl subnet add --name desktop --subnet 10.1.101.0/24 --group guests
|
||||
wgctl subnet rm --name iot-cctv
|
||||
wgctl subnet rename --name iot-cctv --new-name cctv
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Run
|
||||
# ============================================
|
||||
|
||||
function cmd::subnet::run() {
|
||||
local subcmd="${1:-list}"
|
||||
shift || true
|
||||
|
||||
case "$subcmd" in
|
||||
list) cmd::subnet::_list "$@" ;;
|
||||
show) cmd::subnet::_show "$@" ;;
|
||||
add) cmd::subnet::_add "$@" ;;
|
||||
rm) cmd::subnet::_rm "$@" ;;
|
||||
rename) cmd::subnet::_rename "$@" ;;
|
||||
--help) cmd::subnet::help ;;
|
||||
*)
|
||||
log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, rm, rename"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Subcommands
|
||||
# ============================================
|
||||
|
||||
function cmd::subnet::_list() {
|
||||
local data
|
||||
data=$(subnet::list_data)
|
||||
|
||||
if [[ -z "$data" ]]; then
|
||||
log::info "No subnets defined."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local prev_group=""
|
||||
while IFS='|' read -r display_name subnet type_key tunnel_mode desc is_group group_parent; do
|
||||
if [[ "$is_group" == "true" ]]; then
|
||||
# Print group parent header when we encounter first child
|
||||
if [[ "$group_parent" != "$prev_group" ]]; then
|
||||
[[ -n "$prev_group" ]] && ui::subnet::group_separator
|
||||
ui::subnet::row_group_parent "$group_parent"
|
||||
prev_group="$group_parent"
|
||||
fi
|
||||
ui::subnet::row_group_child "$type_key" "$subnet" "$tunnel_mode"
|
||||
else
|
||||
# Scalar entry
|
||||
[[ -n "$prev_group" ]] && ui::subnet::group_separator
|
||||
prev_group=""
|
||||
ui::subnet::row_scalar "$display_name" "$subnet" "$tunnel_mode"
|
||||
fi
|
||||
done <<< "$data"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function cmd::subnet::_maybe_group_separator() {
|
||||
local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}"
|
||||
if [[ "$is_group" == "true" && "$group_parent" != "$prev_group" && -n "$prev_group" ]]; then
|
||||
ui::subnet::group_separator
|
||||
elif [[ "$is_group" == "false" && -n "$prev_group" ]]; then
|
||||
ui::subnet::group_separator
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::subnet::_update_prev_group() {
|
||||
local is_group="${1:-}" group_parent="${2:-}" prev_group="${3:-}"
|
||||
if [[ "$is_group" == "true" ]]; then
|
||||
echo "$group_parent"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::subnet::_show() {
|
||||
local name=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--help) cmd::subnet::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
subnet::require_exists "$name" || return 1
|
||||
|
||||
local data
|
||||
data=$(subnet::show_data "$name")
|
||||
|
||||
local is_group="false"
|
||||
local show_name="" show_subnet="" show_tunnel="" show_desc=""
|
||||
|
||||
while IFS='|' read -r key val rest; do
|
||||
case "$key" in
|
||||
name) show_name="$val" ;;
|
||||
is_group) is_group="$val" ;;
|
||||
subnet) show_subnet="$val" ;;
|
||||
tunnel_mode) show_tunnel="$val" ;;
|
||||
desc) show_desc="$val" ;;
|
||||
esac
|
||||
done <<< "$data"
|
||||
|
||||
if [[ "$is_group" == "true" ]]; then
|
||||
# Group display
|
||||
ui::subnet::show_group "$show_name"
|
||||
|
||||
while IFS='|' read -r key val rest; do
|
||||
[[ "$key" != "child" ]] && continue
|
||||
local c_type="$val"
|
||||
local c_subnet c_tunnel c_desc
|
||||
c_subnet=$(echo "$rest" | cut -d'|' -f1)
|
||||
c_tunnel=$(echo "$rest" | cut -d'|' -f2)
|
||||
c_desc=$(echo "$rest" | cut -d'|' -f3)
|
||||
ui::subnet::show_child_row "$c_type" "$c_subnet" "$c_tunnel" "$c_desc"
|
||||
done <<< "$data"
|
||||
|
||||
local peers_using
|
||||
peers_using=$(subnet::peers_using "$name")
|
||||
ui::subnet::show_peers_annotated "$peers_using" "$(ctx::subnets)"
|
||||
else
|
||||
# Scalar display
|
||||
ui::subnet::show_scalar "$show_name" "$show_subnet" "$show_tunnel" "$show_desc"
|
||||
|
||||
local peers_using
|
||||
peers_using=$(subnet::peers_using "$name")
|
||||
ui::subnet::show_peers "$peers_using"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
function cmd::subnet::_add() {
|
||||
local name="" cidr="" type_key="" tunnel_mode="split" desc="" group_parent=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--subnet) cidr="$2"; shift 2 ;;
|
||||
--type) type_key="$2"; shift 2 ;;
|
||||
--tunnel-mode) tunnel_mode="$2"; shift 2 ;;
|
||||
--desc) desc="$2"; shift 2 ;;
|
||||
--group) group_parent="$2"; shift 2 ;;
|
||||
--help) cmd::subnet::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
[[ -z "$cidr" ]] && { log::error "Missing required flag: --subnet"; return 1; }
|
||||
|
||||
cmd::subnet::_validate_tunnel_mode "$tunnel_mode" || return 1
|
||||
cmd::subnet::_validate_cidr "$cidr" || return 1
|
||||
|
||||
json::subnet_add "$(ctx::subnets)" "$name" "$cidr" \
|
||||
"${type_key:-$name}" "$tunnel_mode" "$desc" "$group_parent"
|
||||
|
||||
log::ok "Subnet '${name}' added (${cidr})"
|
||||
}
|
||||
|
||||
function cmd::subnet::_rm() {
|
||||
local name=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--help) cmd::subnet::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
subnet::require_exists "$name" || return 1
|
||||
|
||||
local peers_using
|
||||
peers_using=$(subnet::peers_using "$name")
|
||||
|
||||
if [[ -n "$peers_using" ]]; then
|
||||
log::error "Cannot remove subnet '${name}' — in use by: ${peers_using//,/, }"
|
||||
log::error "Migrate or remove those peers first."
|
||||
return 1
|
||||
fi
|
||||
|
||||
json::subnet_remove "$(ctx::subnets)" "$name" ""
|
||||
log::ok "Subnet '${name}' removed"
|
||||
}
|
||||
|
||||
function cmd::subnet::_rename() {
|
||||
local name="" new_name=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--new-name) new_name="$2"; shift 2 ;;
|
||||
--help) cmd::subnet::help; return ;;
|
||||
*) log::error "Unknown flag: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; }
|
||||
[[ -z "$new_name" ]] && { log::error "Missing required flag: --new-name"; return 1; }
|
||||
subnet::require_exists "$name" || return 1
|
||||
|
||||
local peers_using
|
||||
peers_using=$(subnet::peers_using "$name")
|
||||
if [[ -n "$peers_using" ]]; then
|
||||
log::error "Cannot rename subnet '${name}' — configs already distributed to: ${peers_using//,/, }"
|
||||
log::error "Client configs reference the subnet CIDR which cannot change after distribution."
|
||||
return 1
|
||||
fi
|
||||
|
||||
json::subnet_rename "$(ctx::subnets)" "$name" "$new_name" ""
|
||||
log::ok "Subnet '${name}' renamed to '${new_name}'"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Validation Helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::subnet::_validate_tunnel_mode() {
|
||||
local mode="${1:-}"
|
||||
case "$mode" in
|
||||
split|full) return 0 ;;
|
||||
*)
|
||||
log::error "Invalid --tunnel-mode '${mode}'. Use: split, full"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
function cmd::subnet::_validate_cidr() {
|
||||
local cidr="${1:-}"
|
||||
if ! echo "$cidr" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then
|
||||
log::error "Invalid CIDR format: '${cidr}'"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
WGCTL_BINARY="$(command -v wgctl)"
|
||||
# test.command.sh — wgctl test suite dispatcher
|
||||
# Delegates to commands/test/{integration,unit,destructive,fn}.sh
|
||||
|
||||
# ============================================
|
||||
# Lifecycle
|
||||
|
|
@ -11,8 +11,15 @@ function cmd::test::on_load() {
|
|||
flag::register --section
|
||||
flag::register --fn
|
||||
flag::register --function
|
||||
flag::register --unit
|
||||
flag::register --integration
|
||||
flag::register --verbose
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Help
|
||||
# ============================================
|
||||
|
||||
function cmd::test::help() {
|
||||
cat <<EOF
|
||||
Usage: wgctl test [options]
|
||||
|
|
@ -20,440 +27,61 @@ Usage: wgctl test [options]
|
|||
Run the wgctl test suite.
|
||||
|
||||
Options:
|
||||
--destructive Include tests that modify state (add/remove/block)
|
||||
--section <name> Run only a specific section (list, rules, groups, audit, logs, fw)
|
||||
--unit Run unit tests (pure function tests, no side effects)
|
||||
--integration Run integration tests against the live binary (default)
|
||||
--destructive Include destructive tests (add/remove/block state changes)
|
||||
--section <name> Run only a specific section
|
||||
--fn <function> Run a specific function test block
|
||||
--verbose Show command output on failure
|
||||
|
||||
Integration sections:
|
||||
list, inspect, config, rules, groups, audit, logs, fw, net, subnet, identity
|
||||
|
||||
Unit sections:
|
||||
subnet, identity, ip
|
||||
|
||||
Examples:
|
||||
wgctl test
|
||||
wgctl test --section rules
|
||||
wgctl test --unit
|
||||
wgctl test --unit --section subnet
|
||||
wgctl test --integration --section rules
|
||||
wgctl test --destructive
|
||||
wgctl test --fn cmd::block::run
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Test helpers
|
||||
# Loader
|
||||
# ============================================
|
||||
|
||||
function cmd::test::run_cmd() {
|
||||
local desc="$1"
|
||||
local expected="${2:-}"
|
||||
shift 2
|
||||
|
||||
local tmp exit_code
|
||||
tmp=$(mktemp)
|
||||
|
||||
set +e # disable exit on error (return 1)
|
||||
|
||||
timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1 &
|
||||
local pid=$!
|
||||
wait $pid
|
||||
exit_code=$?
|
||||
|
||||
set -e # re-enable exit on error
|
||||
|
||||
if [[ $exit_code -eq 124 ]]; then
|
||||
test::warn "${desc} (timed out after 30s)"
|
||||
rm -f "$tmp"
|
||||
function cmd::test::_load() {
|
||||
local name="${1:-}"
|
||||
local path
|
||||
path="$(ctx::commands)/test/${name}.sh"
|
||||
if [[ ! -f "$path" ]]; then
|
||||
log::error "Test file not found: ${path}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
test::fail "${desc}"
|
||||
if [[ "${WGCTL_TEST_VERBOSE:-false}" == "true" ]]; then
|
||||
printf " Output: %s\n" "$(cat "$tmp")"
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$expected" ]] && ! grep -qF "$expected" "$tmp"; then
|
||||
local actual
|
||||
actual=$(head -3 "$tmp" | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100)
|
||||
test::fail "${desc} (expected '${expected}', got: '${actual}')"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
test::pass "$desc"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
function cmd::test::run_cmd_fails() {
|
||||
local desc="$1"
|
||||
shift
|
||||
|
||||
set +e # disable exit on error (return 1)
|
||||
|
||||
local tmp exit_code
|
||||
tmp=$(mktemp)
|
||||
timeout 10 setsid "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
|
||||
exit_code=$?
|
||||
|
||||
set -e # re-enable exit on error
|
||||
|
||||
rm -f "$tmp"
|
||||
|
||||
if [[ $exit_code -eq 124 ]]; then
|
||||
test::warn "${desc} (timed out)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
test::fail "${desc} (expected failure but succeeded)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
test::pass "$desc"
|
||||
}
|
||||
|
||||
function cmd::test::run_function() {
|
||||
local fn="$1"
|
||||
|
||||
local namespace
|
||||
namespace=$(echo "$fn" | cut -d':' -f3)
|
||||
load_command "$namespace" 2>/dev/null || true
|
||||
|
||||
test::reset
|
||||
log::section "Function Test: ${fn}"
|
||||
|
||||
case "$fn" in
|
||||
cmd::block::run) cmd::test::fn_block ;;
|
||||
cmd::unblock::run) cmd::test::fn_unblock ;;
|
||||
cmd::remove::run) cmd::test::fn_remove ;;
|
||||
cmd::rule::assign) cmd::test::fn_rule_assign ;;
|
||||
cmd::rename::run) cmd::test::fn_rename ;;
|
||||
cmd::remove::run) cmd::test::fn_remove ;;
|
||||
cmd::unblock::run) cmd::test::fn_unblock ;;
|
||||
*)
|
||||
log::error "No function test defined for: ${fn}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
test::summary
|
||||
source "$path"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Test sections
|
||||
# ============================================
|
||||
|
||||
function cmd::test::section_list() {
|
||||
test::section "List"
|
||||
cmd::test::run_cmd "list" "WireGuard Clients" list
|
||||
cmd::test::run_cmd "list --online" "" list --online
|
||||
cmd::test::run_cmd "list --offline" "" list --offline
|
||||
cmd::test::run_cmd "list --blocked" "" list --blocked
|
||||
cmd::test::run_cmd "list --type phone" "" list --type phone
|
||||
cmd::test::run_cmd "list --type guest" "" list --type guest
|
||||
cmd::test::run_cmd "list --detailed" "Client:" list --detailed
|
||||
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
|
||||
}
|
||||
|
||||
function cmd::test::section_inspect() {
|
||||
test::section "Inspect"
|
||||
cmd::test::run_cmd "inspect --name phone-nuno" "IP:" inspect --name phone-nuno
|
||||
cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone
|
||||
cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
|
||||
cmd::test::run_cmd_fails "inspect nonexistent peer" inspect --name nonexistent-peer
|
||||
}
|
||||
|
||||
function cmd::test::section_config() {
|
||||
test::section "Config & QR"
|
||||
cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno
|
||||
cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone
|
||||
cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno
|
||||
}
|
||||
|
||||
function cmd::test::section_rules() {
|
||||
test::section "Rules"
|
||||
cmd::test::run_cmd "rule list" "guest" rule list
|
||||
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
|
||||
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
|
||||
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
|
||||
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
|
||||
}
|
||||
|
||||
function cmd::test::section_groups() {
|
||||
test::section "Groups"
|
||||
cmd::test::run_cmd "group list" "Groups" group list
|
||||
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
|
||||
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
|
||||
}
|
||||
|
||||
function cmd::test::section_audit() {
|
||||
test::section "Audit"
|
||||
cmd::test::run_cmd "audit" "passed" audit
|
||||
cmd::test::run_cmd "audit --peer phone-nuno" "passed" audit --peer phone-nuno
|
||||
cmd::test::run_cmd "audit --type phone" "passed" audit --type phone
|
||||
}
|
||||
|
||||
function cmd::test::section_logs() {
|
||||
test::section "Logs"
|
||||
cmd::test::run_cmd "logs" "Activity" logs
|
||||
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
|
||||
cmd::test::run_cmd "logs --type guest" "Activity" logs --type guest
|
||||
cmd::test::run_cmd "logs --fw" "Activity" logs --fw
|
||||
cmd::test::run_cmd "logs --wg" "Activity" logs --wg
|
||||
}
|
||||
|
||||
function cmd::test::section_fw() {
|
||||
test::section "Firewall"
|
||||
cmd::test::run_cmd "fw list" "FORWARD" fw list
|
||||
cmd::test::run_cmd "fw list --peer phone-nuno" "" fw list --peer phone-nuno
|
||||
cmd::test::run_cmd "fw list --no-nflog" "" fw list --no-nflog
|
||||
cmd::test::run_cmd "fw list --no-accept" "" fw list --no-accept
|
||||
cmd::test::run_cmd "fw list --no-drop" "" fw list --no-drop
|
||||
cmd::test::run_cmd "fw nat" "PREROUTING" fw nat
|
||||
cmd::test::run_cmd "fw count" "TOTAL" fw count
|
||||
}
|
||||
function cmd::test::section_net() {
|
||||
test::section "Net"
|
||||
|
||||
"$WGCTL_BINARY" net rm --name test-svc --force > /dev/null 2>&1 || true
|
||||
|
||||
cmd::test::run_cmd "net add service" "added" \
|
||||
net add --name test-svc --ip 10.0.0.99 --desc "Test service"
|
||||
cmd::test::run_cmd "net add port" "Added" \
|
||||
net add --name test-svc:web --port 9999:tcp
|
||||
cmd::test::run_cmd "net list" "test-svc" \
|
||||
net list
|
||||
cmd::test::run_cmd "net list --detailed" "web" \
|
||||
net list --detailed
|
||||
cmd::test::run_cmd "net show" "9999" \
|
||||
net show --name test-svc
|
||||
cmd::test::run_cmd "net rm port" "Removed" \
|
||||
net rm --name test-svc:web --force
|
||||
cmd::test::run_cmd "net add port again" "Added" \
|
||||
net add --name test-svc:web --port 9999:tcp
|
||||
cmd::test::run_cmd "net rm all ports" "Removed" \
|
||||
net rm --name test-svc:ports --force
|
||||
cmd::test::run_cmd "net rm service" "Removed" \
|
||||
net rm --name test-svc --force
|
||||
cmd::test::run_cmd_fails "net show nonexistent" \
|
||||
net show --name nonexistent-svc
|
||||
cmd::test::run_cmd_fails "net add port no service" \
|
||||
net add --name nonexistent:web --port 80:tcp
|
||||
}
|
||||
|
||||
function cmd::test::section_destructive() {
|
||||
test::section "Destructive (modifying state)"
|
||||
|
||||
# ── Cleanup from any previous failed run ──
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" group remove --name testgroup --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
|
||||
|
||||
# ── Add test peer ──────────────────────────
|
||||
cmd::test::run_cmd "add phone peer" "added successfully" \
|
||||
add --name testunit --type phone
|
||||
|
||||
# ── Direct block/unblock ───────────────────
|
||||
cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit
|
||||
cmd::test::run_cmd "list shows blocked" "blocked" list --blocked
|
||||
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit
|
||||
|
||||
# ── Specific IP block/unblock ──────────────
|
||||
cmd::test::run_cmd "block peer --ip" "blocked for" \
|
||||
block --name phone-testunit --ip 10.0.0.99
|
||||
cmd::test::run_cmd "list shows restricted" "restricted" \
|
||||
list --name phone-testunit
|
||||
cmd::test::run_cmd "unblock peer --ip" "unblocked" \
|
||||
unblock --name phone-testunit --ip 10.0.0.99
|
||||
|
||||
# ── Service block/unblock ──────────────────
|
||||
"$WGCTL_BINARY" net add --name test-block-svc \
|
||||
--ip 10.0.0.99 > /dev/null 2>&1
|
||||
"$WGCTL_BINARY" net add --name test-block-svc:web \
|
||||
--port 9999:tcp > /dev/null 2>&1
|
||||
cmd::test::run_cmd "block peer --service (ip)" "blocked" \
|
||||
block --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "block already blocked service" "already" \
|
||||
block --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "unblock peer --service (ip)" "unblocked" \
|
||||
unblock --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "unblock not blocked service" "not blocked" \
|
||||
unblock --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "block peer --service (port)" "blocked" \
|
||||
block --name phone-testunit --service test-block-svc:web
|
||||
cmd::test::run_cmd "unblock peer --service (port)" "unblocked" \
|
||||
unblock --name phone-testunit --service test-block-svc:web
|
||||
"$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true
|
||||
|
||||
# ── Rule assign/unassign ───────────────────
|
||||
cmd::test::run_cmd "rule assign" "Assigned" \
|
||||
rule assign --name user --peer phone-testunit
|
||||
cmd::test::run_cmd "rule unassign" "Unassigned" \
|
||||
rule unassign --peer phone-testunit
|
||||
"$WGCTL_BINARY" rule assign --name user --peer phone-testunit \
|
||||
> /dev/null 2>&1 || true
|
||||
|
||||
# ── Group basic operations ─────────────────
|
||||
cmd::test::run_cmd "group add" "created" \
|
||||
group add --name testgroup --desc "Test group"
|
||||
cmd::test::run_cmd "group peer add" "Added" \
|
||||
group peer add --name testgroup --peer phone-testunit
|
||||
cmd::test::run_cmd "group block" "blocked" \
|
||||
group block --name testgroup
|
||||
cmd::test::run_cmd "group unblock" "unblocked" \
|
||||
group unblock --name testgroup
|
||||
|
||||
# ── M:N group block tracking ───────────────
|
||||
"$WGCTL_BINARY" group add --name testgroup2 \
|
||||
--desc "Test group 2" > /dev/null 2>&1
|
||||
"$WGCTL_BINARY" group peer add --name testgroup2 \
|
||||
--peer phone-testunit > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "group block first group" "blocked" \
|
||||
group block --name testgroup
|
||||
cmd::test::run_cmd "group block second group" "blocked" \
|
||||
group block --name testgroup2
|
||||
|
||||
"$WGCTL_BINARY" group unblock --name testgroup > /dev/null 2>&1
|
||||
cmd::test::run_cmd "peer stays blocked after partial unblock" "blocked" \
|
||||
list --blocked
|
||||
|
||||
"$WGCTL_BINARY" group unblock --name testgroup2 > /dev/null 2>&1
|
||||
cmd::test::run_cmd "peer unblocked after all groups unblock" "phone-testunit" \
|
||||
list --allowed
|
||||
|
||||
# ── Direct block overrides group block ─────
|
||||
"$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1
|
||||
cmd::test::run_cmd "direct unblock overrides group block" "unblocked" \
|
||||
unblock --name phone-testunit
|
||||
|
||||
# ── Cleanup ────────────────────────────────
|
||||
cmd::test::run_cmd "group remove" "removed" \
|
||||
group remove --name testgroup --force
|
||||
"$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true
|
||||
|
||||
cmd::test::run_cmd "remove phone peer" "removed" \
|
||||
remove --name phone-testunit --force
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Function Blocks
|
||||
# ============================================
|
||||
|
||||
function cmd::test::fn_block() {
|
||||
test::section "cmd::block::run"
|
||||
|
||||
# Setup
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
|
||||
# Tests
|
||||
cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit
|
||||
cmd::test::run_cmd "block already blocked" "already" block --name phone-testunit
|
||||
|
||||
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
|
||||
cmd::test::run_cmd "block with --type" "blocked" block --name testunit --type phone
|
||||
|
||||
cmd::test::run_cmd_fails "block nonexistent" block --name truly-nonexistent-xyz
|
||||
|
||||
# Cleanup
|
||||
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::fn_remove() {
|
||||
test::section "cmd::remove::run"
|
||||
|
||||
# Setup
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force \
|
||||
> /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone \
|
||||
> /dev/null 2>&1
|
||||
|
||||
# Tests
|
||||
# Skip the interactive prompt test — it hangs waiting for input
|
||||
# cmd::test::run_cmd_fails "remove without --force" \
|
||||
# remove --name phone-testunit
|
||||
cmd::test::run_cmd "remove with --force" "removed" \
|
||||
remove --name phone-testunit --force
|
||||
cmd::test::run_cmd_fails "remove nonexistent" \
|
||||
remove --name nonexistent-peer --force
|
||||
cmd::test::run_cmd_fails "remove missing --name" \
|
||||
remove --force
|
||||
|
||||
# Cleanup already done by tests
|
||||
}
|
||||
|
||||
function cmd::test::fn_rename() {
|
||||
test::section "cmd::rename::run"
|
||||
|
||||
# Setup
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force \
|
||||
> /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" remove --name phone-testunit2 --force \
|
||||
> /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone \
|
||||
> /dev/null 2>&1
|
||||
|
||||
# Tests
|
||||
cmd::test::run_cmd "rename peer" "renamed" \
|
||||
rename --name phone-testunit --new-name phone-testunit2
|
||||
cmd::test::run_cmd_fails "rename to existing" \
|
||||
rename --name phone-testunit2 --new-name phone-nuno
|
||||
cmd::test::run_cmd_fails "rename nonexistent" \
|
||||
rename --name phone-nonexistent --new-name phone-testunit
|
||||
cmd::test::run_cmd_fails "rename missing --new-name" \
|
||||
rename --name phone-testunit2
|
||||
|
||||
# Cleanup
|
||||
"$WGCTL_BINARY" remove --name phone-testunit2 --force \
|
||||
> /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::fn_unblock() {
|
||||
test::section "cmd::unblock::run"
|
||||
|
||||
# Setup — add and block a peer
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
"$WGCTL_BINARY" block --name phone-testunit > /dev/null 2>&1
|
||||
|
||||
# Tests
|
||||
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit
|
||||
cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name phone-testunit
|
||||
cmd::test::run_cmd_fails "unblock nonexistent" unblock --name nonexistent-peer
|
||||
cmd::test::run_cmd_fails "unblock missing --name" unblock
|
||||
|
||||
# Cleanup
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::fn_rule_assign() {
|
||||
test::section "cmd::rule::assign"
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "rule assign" "Assigned" \
|
||||
rule assign --name admin --peer phone-testunit
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Run
|
||||
# Run (entrypoint)
|
||||
# ============================================
|
||||
|
||||
function cmd::test::run() {
|
||||
local destructive=false section=""
|
||||
local fn=""
|
||||
local unit=false integration=false destructive=false
|
||||
local section="" fn=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--destructive) destructive=true; shift ;;
|
||||
--section)
|
||||
util::require_flag "--section" "${2:-}" || return 1
|
||||
section="$2"; shift 2
|
||||
;;
|
||||
--fn|--function) fn="$2"; shift 2 ;;
|
||||
--verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;;
|
||||
--help) cmd::test::help; return ;;
|
||||
--unit) unit=true; shift ;;
|
||||
--integration) integration=true; shift ;;
|
||||
--destructive) destructive=true; shift ;;
|
||||
--section) section="$2"; shift 2 ;;
|
||||
--fn|--function) fn="$2"; shift 2 ;;
|
||||
--verbose|-v) WGCTL_TEST_VERBOSE=true; shift ;;
|
||||
--help) cmd::test::help; return ;;
|
||||
*)
|
||||
log::error "Unknown flag: $1"
|
||||
return 1
|
||||
|
|
@ -461,46 +89,77 @@ function cmd::test::run() {
|
|||
esac
|
||||
done
|
||||
|
||||
# After flag parsing:
|
||||
# Function test — load fn.sh + integration.sh (needs run_cmd helpers)
|
||||
if [[ -n "$fn" ]]; then
|
||||
cmd::test::run_function "$fn"
|
||||
cmd::test::_load integration || return 1
|
||||
cmd::test::_load fn || return 1
|
||||
test::reset
|
||||
log::section "Function Test: ${fn}"
|
||||
cmd::test::_dispatch_fn "$fn"
|
||||
test::summary
|
||||
return
|
||||
fi
|
||||
|
||||
# Unit tests
|
||||
if $unit; then
|
||||
cmd::test::_load unit || return 1
|
||||
test::reset
|
||||
log::section "wgctl Unit Tests"
|
||||
cmd::test::_dispatch_unit "$section"
|
||||
test::summary
|
||||
return
|
||||
fi
|
||||
|
||||
# Integration tests (default, also when --integration is explicit)
|
||||
cmd::test::_load integration || return 1
|
||||
cmd::test::_load destructive || return 1
|
||||
test::reset
|
||||
log::section "wgctl Test Suite"
|
||||
|
||||
if [[ -n "$section" ]]; then
|
||||
case "$section" in
|
||||
list) cmd::test::section_list ;;
|
||||
inspect) cmd::test::section_inspect ;;
|
||||
config) cmd::test::section_config ;;
|
||||
rules) cmd::test::section_rules ;;
|
||||
groups) cmd::test::section_groups ;;
|
||||
audit) cmd::test::section_audit ;;
|
||||
logs) cmd::test::section_logs ;;
|
||||
fw) cmd::test::section_fw ;;
|
||||
net) cmd::test::section_net ;;
|
||||
destructive) cmd::test::section_destructive ;;
|
||||
*)
|
||||
log::error "Unknown section: $section"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
cmd::test::section_list
|
||||
cmd::test::section_inspect
|
||||
cmd::test::section_config
|
||||
cmd::test::section_rules
|
||||
cmd::test::section_groups
|
||||
cmd::test::section_audit
|
||||
cmd::test::section_logs
|
||||
cmd::test::section_fw
|
||||
fi
|
||||
|
||||
if $destructive; then
|
||||
cmd::test::section_destructive
|
||||
fi
|
||||
|
||||
cmd::test::_dispatch_integration "$section"
|
||||
$destructive && cmd::test::section_destructive
|
||||
test::summary
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Dispatch Helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::test::_dispatch_unit() {
|
||||
local section="${1:-}"
|
||||
if [[ -n "$section" ]]; then
|
||||
local fn="cmd::test::unit_${section}"
|
||||
if ! declare -f "$fn" > /dev/null 2>&1; then
|
||||
log::error "No unit section: ${section}"
|
||||
return 1
|
||||
fi
|
||||
"$fn"
|
||||
else
|
||||
cmd::test::run_all_unit_sections
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::test::_dispatch_integration() {
|
||||
local section="${1:-}"
|
||||
if [[ -n "$section" ]]; then
|
||||
if [[ "$section" == "destructive" ]]; then
|
||||
cmd::test::section_destructive
|
||||
return
|
||||
fi
|
||||
local fn="cmd::test::section_${section}"
|
||||
if ! declare -f "$fn" > /dev/null 2>&1; then
|
||||
log::error "No integration section: ${section}"
|
||||
return 1
|
||||
fi
|
||||
"$fn"
|
||||
else
|
||||
cmd::test::run_all_integration_sections
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::test::_dispatch_fn() {
|
||||
local fn="${1:-}"
|
||||
local namespace
|
||||
namespace=$(echo "$fn" | cut -d':' -f3)
|
||||
load_command "$namespace" 2>/dev/null || true
|
||||
cmd::test::run_function "$fn"
|
||||
}
|
||||
127
commands/test/destructive.sh
Normal file
127
commands/test/destructive.sh
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env bash
|
||||
# test/destructive.sh — tests that modify system state
|
||||
# Sourced by test.command.sh — do not execute directly.
|
||||
# Requires run_cmd / run_cmd_fails from integration.sh to be sourced first.
|
||||
|
||||
function cmd::test::section_destructive() {
|
||||
test::section "Destructive (modifying state)"
|
||||
|
||||
# Cleanup from any previous failed run
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" remove --name laptop-testunit2 --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" group remove --name testgroup --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
|
||||
|
||||
cmd::test::_destructive_peer
|
||||
cmd::test::_destructive_block_unblock
|
||||
cmd::test::_destructive_service_block
|
||||
cmd::test::_destructive_rule
|
||||
cmd::test::_destructive_groups
|
||||
cmd::test::_destructive_identity
|
||||
cmd::test::_destructive_cleanup
|
||||
}
|
||||
|
||||
function cmd::test::_destructive_peer() {
|
||||
cmd::test::run_cmd "add phone peer" "added" \
|
||||
add --name testunit --type phone
|
||||
}
|
||||
|
||||
function cmd::test::_destructive_block_unblock() {
|
||||
cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit
|
||||
cmd::test::run_cmd "list shows blocked" "phone-testunit" list --blocked
|
||||
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit
|
||||
|
||||
cmd::test::run_cmd "block peer --ip" "blocked for" \
|
||||
block --name phone-testunit --ip 10.0.0.99
|
||||
cmd::test::run_cmd "list shows restricted" "restricted" \
|
||||
list --name phone-testunit
|
||||
cmd::test::run_cmd "unblock peer --ip" "unblocked" \
|
||||
unblock --name phone-testunit --ip 10.0.0.99
|
||||
}
|
||||
|
||||
function cmd::test::_destructive_service_block() {
|
||||
"$WGCTL_BINARY" net add --name test-block-svc \
|
||||
--ip 10.0.0.99 > /dev/null 2>&1
|
||||
"$WGCTL_BINARY" net add --name test-block-svc:web \
|
||||
--port 9999:tcp > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "block peer --service (ip)" "blocked" block --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "block already blocked service" "already" block --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "unblock peer --service (ip)" "unblocked" unblock --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "unblock not blocked service" "not blocked" unblock --name phone-testunit --service test-block-svc
|
||||
cmd::test::run_cmd "block peer --service (port)" "blocked" block --name phone-testunit --service test-block-svc:web
|
||||
cmd::test::run_cmd "unblock peer --service (port)" "unblocked" unblock --name phone-testunit --service test-block-svc:web
|
||||
|
||||
"$WGCTL_BINARY" net rm --name test-block-svc --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::_destructive_rule() {
|
||||
cmd::test::run_cmd "rule assign" "Assigned" rule assign --name user --peer phone-testunit
|
||||
cmd::test::run_cmd "rule unassign" "Unassigned" rule unassign --peer phone-testunit
|
||||
"$WGCTL_BINARY" rule assign --name user --peer phone-testunit > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::_destructive_groups() {
|
||||
cmd::test::run_cmd "group add" "created" group add --name testgroup --desc "Test group"
|
||||
cmd::test::run_cmd "group peer add" "Added" group peer add --name testgroup --peer phone-testunit
|
||||
cmd::test::run_cmd "group block" "blocked" group block --name testgroup
|
||||
cmd::test::run_cmd "group unblock" "unblocked" group unblock --name testgroup
|
||||
|
||||
"$WGCTL_BINARY" group add --name testgroup2 --desc "Test group 2" > /dev/null 2>&1
|
||||
"$WGCTL_BINARY" group peer add --name testgroup2 --peer phone-testunit > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "group block first" "blocked" group block --name testgroup
|
||||
cmd::test::run_cmd "group block second" "blocked" group block --name testgroup2
|
||||
|
||||
"$WGCTL_BINARY" group unblock --name testgroup > /dev/null 2>&1
|
||||
cmd::test::run_cmd "peer stays blocked after partial unblock" "phone-testunit" list --blocked
|
||||
|
||||
"$WGCTL_BINARY" group unblock --name testgroup2 > /dev/null 2>&1
|
||||
cmd::test::run_cmd "peer unblocked after all groups unblock" "phone-testunit" list --allowed
|
||||
|
||||
"$WGCTL_BINARY" group block --name testgroup > /dev/null 2>&1
|
||||
cmd::test::run_cmd "direct unblock overrides group block" "unblocked" unblock --name phone-testunit
|
||||
|
||||
cmd::test::run_cmd "group remove" "removed" group remove --name testgroup --force
|
||||
"$WGCTL_BINARY" group remove --name testgroup2 --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::_destructive_identity() {
|
||||
test::section "Destructive: identity auto-attach/detach"
|
||||
|
||||
# Cleanup from any previous failed run
|
||||
"$WGCTL_BINARY" remove --name laptop-testunit2 --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" remove --name laptop-testunit2b --force > /dev/null 2>&1 || true
|
||||
|
||||
# Add — verify auto-attach to identity "testunit2"
|
||||
cmd::test::run_cmd "add attaches to identity" "added" \
|
||||
add --name testunit2 --type laptop
|
||||
|
||||
cmd::test::run_cmd "identity created for testunit2" "laptop-testunit2" \
|
||||
identity show --name testunit2
|
||||
|
||||
# Rename — verify identity::rename_peer moves peer to new identity "testunit2b"
|
||||
cmd::test::run_cmd "rename peer" "renamed" \
|
||||
rename --name laptop-testunit2 --new-name laptop-testunit2b
|
||||
|
||||
cmd::test::run_cmd "identity reflects rename (new identity)" "laptop-testunit2b" \
|
||||
identity show --name testunit2b
|
||||
|
||||
cmd::test::run_cmd_fails "old identity gone after rename" \
|
||||
identity show --name testunit2
|
||||
|
||||
# Remove — verify auto-detach cleans up identity file
|
||||
cmd::test::run_cmd "remove detaches from identity" "removed" \
|
||||
remove --name laptop-testunit2b --force
|
||||
|
||||
cmd::test::run_cmd_fails "identity cleaned up after remove" \
|
||||
identity show --name testunit2b
|
||||
}
|
||||
|
||||
function cmd::test::_destructive_cleanup() {
|
||||
cmd::test::run_cmd "remove phone peer" "removed" \
|
||||
remove --name phone-testunit --force
|
||||
}
|
||||
88
commands/test/fn.sh
Normal file
88
commands/test/fn.sh
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env bash
|
||||
# test/fn.sh — individual function test blocks
|
||||
# Sourced by test.command.sh — do not execute directly.
|
||||
# Requires run_cmd / run_cmd_fails from integration.sh to be sourced first.
|
||||
|
||||
function cmd::test::run_function() {
|
||||
local fn="$1"
|
||||
case "$fn" in
|
||||
cmd::block::run) cmd::test::fn_block ;;
|
||||
cmd::unblock::run) cmd::test::fn_unblock ;;
|
||||
cmd::remove::run) cmd::test::fn_remove ;;
|
||||
cmd::rename::run) cmd::test::fn_rename ;;
|
||||
cmd::rule::assign) cmd::test::fn_rule_assign ;;
|
||||
*)
|
||||
log::error "No function test defined for: ${fn}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
function cmd::test::fn_block() {
|
||||
test::section "cmd::block::run"
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "block peer" "blocked" block --name phone-testunit
|
||||
cmd::test::run_cmd "block already blocked" "already" block --name phone-testunit
|
||||
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
|
||||
cmd::test::run_cmd "block with --type" "blocked" block --name testunit --type phone
|
||||
cmd::test::run_cmd_fails "block nonexistent" block --name truly-nonexistent-xyz
|
||||
|
||||
"$WGCTL_BINARY" unblock --name phone-testunit > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::fn_unblock() {
|
||||
test::section "cmd::unblock::run"
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
"$WGCTL_BINARY" block --name phone-testunit > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "unblock peer" "unblocked" unblock --name phone-testunit
|
||||
cmd::test::run_cmd "unblock not blocked" "not blocked" unblock --name phone-testunit
|
||||
cmd::test::run_cmd_fails "unblock nonexistent" unblock --name nonexistent-peer
|
||||
cmd::test::run_cmd_fails "unblock missing --name" unblock
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::fn_remove() {
|
||||
test::section "cmd::remove::run"
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "remove with --force" "removed" remove --name phone-testunit --force
|
||||
cmd::test::run_cmd_fails "remove nonexistent" remove --name nonexistent-peer --force
|
||||
cmd::test::run_cmd_fails "remove missing --name" remove --force
|
||||
}
|
||||
|
||||
function cmd::test::fn_rename() {
|
||||
test::section "cmd::rename::run"
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" remove --name phone-testunit2 --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "rename peer" "renamed" rename --name phone-testunit --new-name phone-testunit2
|
||||
cmd::test::run_cmd_fails "rename to existing" rename --name phone-testunit2 --new-name phone-nuno
|
||||
cmd::test::run_cmd_fails "rename nonexistent" rename --name phone-nonexistent --new-name phone-testunit
|
||||
cmd::test::run_cmd_fails "rename missing --new-name" rename --name phone-testunit2
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit2 --force > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
function cmd::test::fn_rule_assign() {
|
||||
test::section "cmd::rule::assign"
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" add --name testunit --type phone > /dev/null 2>&1
|
||||
|
||||
cmd::test::run_cmd "rule assign" "Assigned" \
|
||||
rule assign --name admin --peer phone-testunit
|
||||
|
||||
"$WGCTL_BINARY" remove --name phone-testunit --force > /dev/null 2>&1 || true
|
||||
}
|
||||
252
commands/test/integration.sh
Normal file
252
commands/test/integration.sh
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
#!/usr/bin/env bash
|
||||
# test/integration.sh — integration test sections
|
||||
# Tests run against the live wgctl binary.
|
||||
# Sourced by test.command.sh — do not execute directly.
|
||||
|
||||
WGCTL_BINARY="$(command -v wgctl)"
|
||||
|
||||
# ============================================
|
||||
# Helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::test::_strip_ansi() {
|
||||
sed 's/\x1b\[[0-9;]*m//g'
|
||||
}
|
||||
|
||||
function cmd::test::run_cmd() {
|
||||
local desc="$1" expected="${2:-}"
|
||||
shift 2
|
||||
|
||||
local tmp exit_code
|
||||
tmp=$(mktemp)
|
||||
|
||||
set +e
|
||||
timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
# Reset terminal color in case command output left ANSI state dirty
|
||||
printf "\033[0m" >&2
|
||||
|
||||
if [[ $exit_code -eq 124 ]]; then
|
||||
test::warn "${desc} (timed out after 30s)"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local clean
|
||||
clean=$(cmd::test::_strip_ansi < "$tmp")
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
local msg="${desc}"
|
||||
[[ -n "$expected" ]] && msg="${desc} (expected '${expected}', command failed)"
|
||||
test::fail "$msg"
|
||||
if [[ "${WGCTL_TEST_VERBOSE:-false}" == "true" ]]; then
|
||||
printf " Output: %s\n" "$(echo "$clean" | head -3 | tr '\n' ' ')"
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$expected" ]] && ! echo "$clean" | grep -qF "$expected"; then
|
||||
local actual
|
||||
actual=$(echo "$clean" | head -3 | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100)
|
||||
test::fail "${desc} (expected '${expected}', got: '${actual}')"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
test::pass "$desc"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
function cmd::test::run_cmd_any() {
|
||||
local desc="$1" expected="${2:-}"
|
||||
shift 2
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
|
||||
set +e
|
||||
timeout 30 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
|
||||
set -e
|
||||
|
||||
printf "\033[0m" >&2
|
||||
|
||||
local clean
|
||||
clean=$(cmd::test::_strip_ansi < "$tmp")
|
||||
|
||||
if [[ -n "$expected" ]] && ! echo "$clean" | grep -qF "$expected"; then
|
||||
local actual
|
||||
actual=$(echo "$clean" | head -3 | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-100)
|
||||
test::fail "${desc} (expected '${expected}', got: '${actual}')"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
test::pass "$desc"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
function cmd::test::run_cmd_fails() {
|
||||
local desc="$1"
|
||||
shift
|
||||
|
||||
local tmp exit_code
|
||||
tmp=$(mktemp)
|
||||
|
||||
set +e
|
||||
timeout 10 "$WGCTL_BINARY" "$@" > "$tmp" 2>&1
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
printf "\033[0m" >&2
|
||||
rm -f "$tmp"
|
||||
|
||||
if [[ $exit_code -eq 124 ]]; then
|
||||
test::warn "${desc} (timed out)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
test::fail "${desc} (expected failure but succeeded)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
test::pass "$desc"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Sections
|
||||
# ============================================
|
||||
|
||||
function cmd::test::run_all_integration_sections() {
|
||||
cmd::test::section_list
|
||||
cmd::test::section_inspect
|
||||
cmd::test::section_config
|
||||
cmd::test::section_rules
|
||||
cmd::test::section_groups
|
||||
cmd::test::section_audit
|
||||
cmd::test::section_logs
|
||||
cmd::test::section_fw
|
||||
cmd::test::section_net
|
||||
cmd::test::section_subnet
|
||||
cmd::test::section_identity
|
||||
}
|
||||
|
||||
function cmd::test::section_list() {
|
||||
test::section "List"
|
||||
cmd::test::run_cmd "list" "rule:" list
|
||||
cmd::test::run_cmd "list --online" "" list --online
|
||||
cmd::test::run_cmd "list --offline" "" list --offline
|
||||
cmd::test::run_cmd "list --blocked" "" list --blocked
|
||||
cmd::test::run_cmd "list --type phone" "phone" list --type phone
|
||||
cmd::test::run_cmd "list --detailed" "rule:" list --detailed
|
||||
cmd::test::run_cmd "list --name phone-nuno" "phone-nuno" list --name phone-nuno
|
||||
}
|
||||
|
||||
function cmd::test::section_inspect() {
|
||||
test::section "Inspect"
|
||||
cmd::test::run_cmd "inspect --name phone-nuno" "IP:" inspect --name phone-nuno
|
||||
cmd::test::run_cmd "inspect --name nuno --type phone" "IP:" inspect --name nuno --type phone
|
||||
cmd::test::run_cmd "inspect --name phone-nuno --config" "PrivateKey" inspect --name phone-nuno --config
|
||||
cmd::test::run_cmd_fails "inspect nonexistent" inspect --name nonexistent-peer
|
||||
}
|
||||
|
||||
function cmd::test::section_config() {
|
||||
test::section "Config & QR"
|
||||
cmd::test::run_cmd "config --name phone-nuno" "PrivateKey" config --name phone-nuno
|
||||
cmd::test::run_cmd "config --name nuno --type phone" "PrivateKey" config --name nuno --type phone
|
||||
cmd::test::run_cmd "qr --name phone-nuno" "" qr --name phone-nuno
|
||||
}
|
||||
|
||||
function cmd::test::section_rules() {
|
||||
test::section "Rules"
|
||||
cmd::test::run_cmd "rule list" "user" rule list
|
||||
cmd::test::run_cmd "rule show --name guest" "Description" rule show --name guest
|
||||
cmd::test::run_cmd "rule show --name user" "Description" rule show --name user
|
||||
cmd::test::run_cmd "rule show --name admin" "Description" rule show --name admin
|
||||
cmd::test::run_cmd_fails "rule show nonexistent" rule show --name nonexistent
|
||||
}
|
||||
|
||||
function cmd::test::section_groups() {
|
||||
test::section "Groups"
|
||||
cmd::test::run_cmd "group list" "Groups" group list
|
||||
cmd::test::run_cmd "group show --name family" "Peers:" group show --name family
|
||||
cmd::test::run_cmd_fails "group show nonexistent" group show --name nonexistent
|
||||
}
|
||||
|
||||
function cmd::test::section_audit() {
|
||||
test::section "Audit"
|
||||
cmd::test::run_cmd_any "audit" "passed" audit
|
||||
cmd::test::run_cmd_any "audit --peer phone-nuno" "passed" audit --peer phone-nuno
|
||||
cmd::test::run_cmd_any "audit --type phone" "passed" audit --type phone
|
||||
}
|
||||
|
||||
function cmd::test::section_logs() {
|
||||
test::section "Logs"
|
||||
cmd::test::run_cmd "logs" "Activity" logs
|
||||
cmd::test::run_cmd "logs --name phone-nuno" "Activity" logs --name phone-nuno
|
||||
cmd::test::run_cmd "logs --fw" "Activity" logs --fw
|
||||
cmd::test::run_cmd "logs --wg" "Activity" logs --wg
|
||||
}
|
||||
|
||||
function cmd::test::section_fw() {
|
||||
test::section "Firewall"
|
||||
cmd::test::run_cmd "fw list" "FORWARD" fw list
|
||||
cmd::test::run_cmd "fw list --peer phone-nuno" "" fw list --peer phone-nuno
|
||||
cmd::test::run_cmd "fw list --no-nflog" "" fw list --no-nflog
|
||||
cmd::test::run_cmd "fw list --no-accept" "" fw list --no-accept
|
||||
cmd::test::run_cmd "fw list --no-drop" "" fw list --no-drop
|
||||
cmd::test::run_cmd "fw nat" "PREROUTING" fw nat
|
||||
cmd::test::run_cmd "fw count" "TOTAL" fw count
|
||||
}
|
||||
|
||||
function cmd::test::section_net() {
|
||||
test::section "Net"
|
||||
"$WGCTL_BINARY" net rm --name test-svc --force > /dev/null 2>&1 || true
|
||||
|
||||
cmd::test::run_cmd "net add service" "added" net add --name test-svc --ip 10.0.0.99 --desc "Test service"
|
||||
cmd::test::run_cmd "net add port" "Added" net add --name test-svc:web --port 9999:tcp
|
||||
cmd::test::run_cmd "net list" "test-svc" net list
|
||||
cmd::test::run_cmd "net list --detailed" "web" net list --detailed
|
||||
cmd::test::run_cmd "net show" "9999" net show --name test-svc
|
||||
cmd::test::run_cmd "net rm port" "Removed" net rm --name test-svc:web --force
|
||||
cmd::test::run_cmd "net add port again" "Added" net add --name test-svc:web --port 9999:tcp
|
||||
cmd::test::run_cmd "net rm all ports" "Removed" net rm --name test-svc:ports --force
|
||||
cmd::test::run_cmd "net rm service" "Removed" net rm --name test-svc --force
|
||||
cmd::test::run_cmd_fails "net show nonexistent" net show --name nonexistent-svc
|
||||
cmd::test::run_cmd_fails "net add port no service" net add --name nonexistent:web --port 80:tcp
|
||||
}
|
||||
|
||||
function cmd::test::section_subnet() {
|
||||
test::section "Subnet"
|
||||
"$WGCTL_BINARY" subnet rm --name test-subnet-2 > /dev/null 2>&1 || true
|
||||
"$WGCTL_BINARY" subnet rm --name test-subnet > /dev/null 2>&1 || true
|
||||
|
||||
cmd::test::run_cmd "subnet list" "desktop" subnet list
|
||||
cmd::test::run_cmd "subnet show desktop" "tunnel:" subnet show --name desktop
|
||||
cmd::test::run_cmd "subnet show guests group" "guests" subnet show --name guests
|
||||
cmd::test::run_cmd_fails "subnet show nonexistent" subnet show --name nonexistent
|
||||
|
||||
cmd::test::run_cmd "subnet add" "added" \
|
||||
subnet add --name test-subnet --subnet 10.1.250.0/24 --type iot --desc "Test"
|
||||
cmd::test::run_cmd "subnet list shows new" "test-subnet" \
|
||||
subnet list
|
||||
cmd::test::run_cmd_fails "subnet rename in-use (desktop)" \
|
||||
subnet rename --name desktop --new-name workstation
|
||||
cmd::test::run_cmd "subnet rename unused" "renamed" \
|
||||
subnet rename --name test-subnet --new-name test-subnet-2
|
||||
cmd::test::run_cmd "subnet rm" "removed" \
|
||||
subnet rm --name test-subnet-2
|
||||
cmd::test::run_cmd_fails "subnet rm nonexistent" \
|
||||
subnet rm --name nonexistent-subnet
|
||||
}
|
||||
|
||||
function cmd::test::section_identity() {
|
||||
test::section "Identity"
|
||||
cmd::test::run_cmd "identity list" "" identity list
|
||||
cmd::test::run_cmd "identity migrate --dry-run" "Dry run" identity migrate --dry-run
|
||||
cmd::test::run_cmd "identity show nuno" "nuno" identity show --name nuno
|
||||
cmd::test::run_cmd_fails "identity show nonexistent" identity show --name nonexistent
|
||||
}
|
||||
105
commands/test/unit.sh
Normal file
105
commands/test/unit.sh
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env bash
|
||||
# test/unit.sh — unit test sections
|
||||
# Tests pure functions directly — no binary, no state changes.
|
||||
# Sourced by test.command.sh — do not execute directly.
|
||||
|
||||
# ============================================
|
||||
# Helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::test::assert() {
|
||||
local desc="${1:-}" result="${2:-}" expected="${3:-}"
|
||||
if [[ "$result" == "$expected" ]]; then
|
||||
test::pass "$desc"
|
||||
else
|
||||
test::fail "${desc} (expected '${expected}', got '${result}')"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::test::assert_true() {
|
||||
local desc="${1:-}"
|
||||
shift
|
||||
if "$@" 2>/dev/null; then
|
||||
test::pass "$desc"
|
||||
else
|
||||
test::fail "$desc (expected true, got false)"
|
||||
fi
|
||||
}
|
||||
|
||||
function cmd::test::assert_false() {
|
||||
local desc="${1:-}"
|
||||
shift
|
||||
if ! "$@" 2>/dev/null; then
|
||||
test::pass "$desc"
|
||||
else
|
||||
test::fail "$desc (expected false, got true)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Sections
|
||||
# ============================================
|
||||
|
||||
function cmd::test::run_all_unit_sections() {
|
||||
cmd::test::unit_subnet
|
||||
cmd::test::unit_ip
|
||||
cmd::test::unit_identity
|
||||
}
|
||||
|
||||
function cmd::test::unit_subnet() {
|
||||
test::section "Unit: subnet CIDR utilities"
|
||||
load_module subnet
|
||||
|
||||
# subnet::prefix
|
||||
cmd::test::assert "subnet::prefix /24" "$(subnet::prefix '10.1.3.0/24')" "10.1.3"
|
||||
cmd::test::assert "subnet::prefix /16" "$(subnet::prefix '10.1.0.0/16')" "10.1.0"
|
||||
cmd::test::assert "subnet::mask /24" "$(subnet::mask '10.1.3.0/24')" "24"
|
||||
cmd::test::assert "subnet::mask /16" "$(subnet::mask '10.1.0.0/16')" "16"
|
||||
cmd::test::assert "subnet::base_ip" "$(subnet::base_ip '10.1.3.0/24')" "10.1.3.0"
|
||||
|
||||
# subnet::contains
|
||||
cmd::test::assert_true "subnet::contains inside" subnet::contains "10.1.3.0/24" "10.1.3.5"
|
||||
cmd::test::assert_true "subnet::contains boundary" subnet::contains "10.1.3.0/24" "10.1.3.254"
|
||||
cmd::test::assert_false "subnet::contains outside" subnet::contains "10.1.3.0/24" "10.1.4.1"
|
||||
cmd::test::assert_false "subnet::contains wrong net" subnet::contains "10.1.3.0/24" "192.168.1.1"
|
||||
|
||||
# subnet::is_valid_cidr
|
||||
cmd::test::assert_true "is_valid_cidr valid" subnet::is_valid_cidr "10.1.3.0/24"
|
||||
cmd::test::assert_true "is_valid_cidr /16" subnet::is_valid_cidr "10.1.0.0/16"
|
||||
cmd::test::assert_false "is_valid_cidr no mask" subnet::is_valid_cidr "10.1.3.0"
|
||||
cmd::test::assert_false "is_valid_cidr bad octet" subnet::is_valid_cidr "999.1.3.0/24"
|
||||
cmd::test::assert_false "is_valid_cidr empty" subnet::is_valid_cidr ""
|
||||
|
||||
# subnet::ip_valid_for
|
||||
cmd::test::assert_true "ip_valid_for valid host" subnet::ip_valid_for "10.1.3.0/24" "10.1.3.5"
|
||||
cmd::test::assert_false "ip_valid_for network addr" subnet::ip_valid_for "10.1.3.0/24" "10.1.3.0"
|
||||
cmd::test::assert_false "ip_valid_for broadcast" subnet::ip_valid_for "10.1.3.0/24" "10.1.3.255"
|
||||
cmd::test::assert_false "ip_valid_for wrong subnet" subnet::ip_valid_for "10.1.3.0/24" "10.1.4.1"
|
||||
cmd::test::assert_false "ip_valid_for invalid ip" subnet::ip_valid_for "10.1.3.0/24" "not-an-ip"
|
||||
}
|
||||
|
||||
function cmd::test::unit_ip() {
|
||||
test::section "Unit: ip validation"
|
||||
load_module ip
|
||||
|
||||
cmd::test::assert_true "ip::is_valid plain" ip::is_valid "10.1.3.5"
|
||||
cmd::test::assert_true "ip::is_valid cidr" ip::is_valid "10.1.3.0/24"
|
||||
cmd::test::assert_false "ip::is_valid empty" ip::is_valid ""
|
||||
cmd::test::assert_false "ip::is_valid hostname" ip::is_valid "phone-nuno"
|
||||
cmd::test::assert_false "ip::is_valid bad oct" ip::is_valid "999.1.3.5"
|
||||
|
||||
cmd::test::assert_true "ip::is_cidr with mask" ip::is_cidr "10.1.3.0/24"
|
||||
cmd::test::assert_false "ip::is_cidr without mask" ip::is_cidr "10.1.3.5"
|
||||
}
|
||||
|
||||
function cmd::test::unit_identity() {
|
||||
test::section "Unit: identity inference"
|
||||
load_module identity
|
||||
|
||||
cmd::test::assert "infer phone-nuno" "$(identity::infer 'phone-nuno')" "nuno|phone|1"
|
||||
cmd::test::assert "infer phone-nuno-2" "$(identity::infer 'phone-nuno-2')" "nuno|phone|2"
|
||||
cmd::test::assert "infer desktop-zephyr" "$(identity::infer 'desktop-zephyr')" "zephyr|desktop|1"
|
||||
cmd::test::assert "infer laptop-nuno" "$(identity::infer 'laptop-nuno')" "nuno|laptop|1"
|
||||
cmd::test::assert "infer no convention" "$(identity::infer 'roboclean')" ""
|
||||
cmd::test::assert "infer guest-zephyr" "$(identity::infer 'guest-zephyr')" ""
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
function cmd::unblock::on_load() {
|
||||
flag::register --name
|
||||
flag::register --identity
|
||||
flag::register --type
|
||||
flag::register --force
|
||||
flag::register --quiet
|
||||
|
|
@ -14,8 +15,6 @@ function cmd::unblock::on_load() {
|
|||
flag::register --proto
|
||||
flag::register --subnet
|
||||
flag::register --all
|
||||
|
||||
# System - NET Services
|
||||
flag::register --service
|
||||
}
|
||||
|
||||
|
|
@ -26,12 +25,14 @@ function cmd::unblock::on_load() {
|
|||
function cmd::unblock::help() {
|
||||
cat <<EOF
|
||||
Usage: wgctl unblock --name <name> [options]
|
||||
or: wgctl unblock --identity <identity> [options]
|
||||
|
||||
Remove block rules for a client. Without specific flags, performs a full unblock.
|
||||
Direct unblock overrides any group blocks.
|
||||
|
||||
Options:
|
||||
--name <name> Client name (e.g. phone-nuno)
|
||||
--identity <name> Unblock all peers belonging to an identity
|
||||
--type <type> Device type (optional, combines with --name)
|
||||
--ip <ip> Unblock specific IP (repeatable)
|
||||
--subnet <cidr> Unblock specific subnet (repeatable)
|
||||
|
|
@ -42,40 +43,36 @@ Options:
|
|||
|
||||
Examples:
|
||||
wgctl unblock --name phone-nuno
|
||||
wgctl unblock --identity nuno
|
||||
wgctl unblock --name nuno --type phone
|
||||
wgctl unblock --name phone-nuno --ip 10.0.0.210
|
||||
wgctl unblock --name phone-nuno --service proxmox
|
||||
wgctl unblock --name phone-nuno --service truenas:web-ui
|
||||
wgctl unban --name phone-nuno
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Unblock Run
|
||||
# Run
|
||||
# ============================================
|
||||
|
||||
function cmd::unblock::run() {
|
||||
local name=""
|
||||
local type=""
|
||||
local ips=()
|
||||
local subnets=()
|
||||
local ports=()
|
||||
local services=()
|
||||
local all=false
|
||||
local quiet=false
|
||||
|
||||
local name="" identity="" type=""
|
||||
local ips=() subnets=() ports=() services=()
|
||||
local all=false quiet=false force=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--ip) ips+=("$2"); shift 2 ;;
|
||||
--force) force=true; shift ;;
|
||||
--quiet) quiet=true; shift ;;
|
||||
--subnet) subnets+=("$2"); shift 2 ;;
|
||||
--port) ports+=("$2"); shift 2 ;;
|
||||
--service) services+=("$2"); shift 2 ;;
|
||||
--all) all=true; shift ;;
|
||||
--help) cmd::unblock::help; return ;;
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--identity) identity="$2"; shift 2 ;;
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--ip) ips+=("$2"); shift 2 ;;
|
||||
--force) force=true; shift ;;
|
||||
--quiet) quiet=true; shift ;;
|
||||
--subnet) subnets+=("$2"); shift 2 ;;
|
||||
--port) ports+=("$2"); shift 2 ;;
|
||||
--service) services+=("$2"); shift 2 ;;
|
||||
--all) all=true; shift ;;
|
||||
--help) cmd::unblock::help; return ;;
|
||||
*)
|
||||
log::error "Unknown flag: $1"
|
||||
cmd::unblock::help
|
||||
|
|
@ -84,30 +81,33 @@ function cmd::unblock::run() {
|
|||
esac
|
||||
done
|
||||
|
||||
# --identity: unblock all peers for this identity
|
||||
if [[ -n "$identity" ]]; then
|
||||
cmd::unblock::_unblock_identity "$identity" "$quiet" || return 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z "$name" ]]; then
|
||||
log::error "Missing required flag: --name"
|
||||
log::error "Missing required flag: --name or --identity"
|
||||
cmd::unblock::help
|
||||
return 1
|
||||
fi
|
||||
|
||||
name=$(peers::resolve_and_require "$name" "$type") || return 1
|
||||
|
||||
# Check if actually blocked
|
||||
if ! peers::is_blocked "$name" && ! block::has_file "$name"; then
|
||||
log::wg_warning "Client is not blocked: ${name}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Default to full unblock if no specific flags given
|
||||
if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && ${#ports[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
|
||||
if [[ ${#ips[@]} -eq 0 && ${#subnets[@]} -eq 0 && \
|
||||
${#ports[@]} -eq 0 && ${#services[@]} -eq 0 ]]; then
|
||||
all=true
|
||||
fi
|
||||
|
||||
local client_ip
|
||||
client_ip=$(peers::get_ip "$name") || return 1
|
||||
|
||||
# $quiet || log::section "Unblocking client: ${name} (${client_ip})"
|
||||
|
||||
if $all; then
|
||||
cmd::unblock::_unblock_all "$name" "$client_ip" "$quiet"
|
||||
return 0
|
||||
|
|
@ -117,14 +117,14 @@ function cmd::unblock::run() {
|
|||
for ip in "${ips[@]}"; do
|
||||
fw::unblock_ip "$client_ip" "$ip"
|
||||
block::remove_rule "$name" "ip" "$ip"
|
||||
$quiet || log::wg_success "${ip} has been unblocked for ${name}"
|
||||
$quiet || log::wg_success "${ip} has been unblocked for ${name}"
|
||||
done
|
||||
|
||||
# Unblock specific subnets
|
||||
for subnet in "${subnets[@]}"; do
|
||||
fw::unblock_subnet "$client_ip" "$subnet"
|
||||
block::remove_rule "$name" "subnet" "$subnet"
|
||||
$quiet || log::wg_success "${subnet} has been unblocked for ${name}"
|
||||
$quiet || log::wg_success "${subnet} has been unblocked for ${name}"
|
||||
done
|
||||
|
||||
# Unblock specific ports
|
||||
|
|
@ -133,8 +133,8 @@ function cmd::unblock::run() {
|
|||
IFS=":" read -r target port proto <<< "$entry"
|
||||
proto="${proto:-tcp}"
|
||||
fw::unblock_port "$client_ip" "$target" "$port" "$proto"
|
||||
block::remove_rule "$name" "port" "$b_target" "$b_port" "$b_proto"
|
||||
$quiet || log::wg_success "${client_ip}:${b_port}:${b_proto:-tcp} has been unblocked for ${name}"
|
||||
block::remove_rule "$name" "port" "$target" "$port" "$proto"
|
||||
$quiet || log::wg_success "${target}:${port}:${proto} has been unblocked for ${name}"
|
||||
done
|
||||
|
||||
# Unblock services
|
||||
|
|
@ -146,7 +146,6 @@ function cmd::unblock::run() {
|
|||
return 1
|
||||
fi
|
||||
|
||||
# Check if actually blocked
|
||||
local is_blocked=false
|
||||
for resolved in "${resolved_lines[@]}"; do
|
||||
if [[ "$resolved" == *:*:* ]]; then
|
||||
|
|
@ -180,27 +179,66 @@ function cmd::unblock::run() {
|
|||
$quiet || log::wg_success "${svc} has been unblocked for ${name}"
|
||||
done
|
||||
|
||||
# Clean up block file if now empty
|
||||
block::cleanup "$name"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Identity unblock
|
||||
# ============================================
|
||||
|
||||
function cmd::unblock::_unblock_identity() {
|
||||
local identity_name="${1:-}" quiet="${2:-false}"
|
||||
|
||||
identity::require_exists "$identity_name" || return 1
|
||||
identity::require_has_peers "$identity_name" || return 1
|
||||
|
||||
local peers unblocked=0 skipped=0
|
||||
peers=$(identity::peers "$identity_name")
|
||||
|
||||
while IFS= read -r peer_name; do
|
||||
[[ -z "$peer_name" ]] && continue
|
||||
if ! peers::is_blocked "$peer_name" && ! block::has_file "$peer_name"; then
|
||||
skipped=$(( skipped + 1 ))
|
||||
continue
|
||||
fi
|
||||
local client_ip
|
||||
client_ip=$(peers::get_ip "$peer_name") || continue
|
||||
|
||||
if cmd::unblock::_unblock_all "$peer_name" "$client_ip" true; then
|
||||
unblocked=$(( unblocked + 1 ))
|
||||
else
|
||||
skipped=$(( skipped + 1 ))
|
||||
fi
|
||||
done <<< "$peers"
|
||||
|
||||
if [[ $unblocked -eq 0 ]]; then
|
||||
log::wg_warning "No peers were blocked for identity '${identity_name}'"
|
||||
elif [[ $skipped -gt 0 ]]; then
|
||||
log::ok "Unblocked ${unblocked} peer(s) for identity '${identity_name}' (${skipped} were not blocked)"
|
||||
else
|
||||
log::ok "Unblocked ${unblocked} peer(s) for identity '${identity_name}'"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Helpers
|
||||
# ============================================
|
||||
|
||||
function cmd::unblock::_unblock_all() {
|
||||
local name="${1:?}" client_ip="${2:?}" quiet="${3:-false}"
|
||||
|
||||
# Direct unblock overrides everything — clear all block state
|
||||
block::set_direct "$name" "$client_ip" "false"
|
||||
block::clear_full_block "$name"
|
||||
|
||||
block::restore_peer "$name" "$client_ip"
|
||||
block::cleanup "$name"
|
||||
|
||||
local rule
|
||||
rule=$(peers::get_meta "$name" "rule")
|
||||
|
||||
[[ -n "$rule" ]] && rule::exists "$rule" && \
|
||||
if [[ -n "$rule" ]] && rule::exists "$rule"; then
|
||||
rule::apply "$rule" "$client_ip" "$name"
|
||||
fi
|
||||
|
||||
local groups
|
||||
groups=$(block::get_groups "$name")
|
||||
|
|
@ -209,6 +247,5 @@ function cmd::unblock::_unblock_all() {
|
|||
fi
|
||||
|
||||
$quiet || log::wg_success "${name} has been unblocked."
|
||||
|
||||
return 0
|
||||
}
|
||||
1
core.sh
1
core.sh
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
WGCTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "${WGCTL_DIR}/core/log.sh"
|
||||
source "${WGCTL_DIR}/core/context.sh"
|
||||
source "${WGCTL_DIR}/core/utils.sh"
|
||||
source "${WGCTL_DIR}/core/module.sh"
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ function color::gray() { printf "\033[0;37m%s\033[0m" "${1:-}"; }
|
|||
function color::bold() { printf "\033[1;37m%s\033[0m" "${1:-}"; }
|
||||
function color::cyan() { printf "\033[0;36m%s\033[0m" "${1:-}"; }
|
||||
function color::yellow() { printf "\033[1;33m%s\033[0m" "${1:-}"; }
|
||||
function color::dim() { printf "\033[2m%s\033[0m" "$*"; }
|
||||
|
|
@ -21,6 +21,7 @@ _CTX_RULES_BASE="${_CTX_RULES}/base"
|
|||
_CTX_GROUPS="${_CTX_DATA}/groups"
|
||||
_CTX_BLOCKS="${_CTX_DATA}/blocks"
|
||||
_CTX_META="${_CTX_DATA}/meta"
|
||||
_CTX_IDENTITY="${_CTX_DATA}/identities"
|
||||
_CTX_DAEMON="${_CTX_DATA}/daemon"
|
||||
_CTX_NET="${_CTX_DATA}/services.json"
|
||||
|
||||
|
|
@ -43,6 +44,8 @@ function ctx::blocks() { echo "$_CTX_BLOCKS"; }
|
|||
function ctx::meta() { echo "$_CTX_META"; }
|
||||
function ctx::daemon() { echo "$_CTX_DAEMON"; }
|
||||
function ctx::net() { echo "$_CTX_NET"; }
|
||||
function ctx::identities() { echo "${_CTX_IDENTITY}"; }
|
||||
function ctx::subnets() { echo "${_CTX_DATA}/subnets.json"; }
|
||||
function ctx::events_log() { echo "$(ctx::daemon)/events.log"; }
|
||||
function ctx::fw_events_log() { echo "$(ctx::daemon)/fw_events.log"; }
|
||||
|
||||
|
|
@ -60,6 +63,11 @@ function ctx::meta::path() {
|
|||
echo "$_CTX_META/$*"
|
||||
}
|
||||
|
||||
function ctx::identity::path() {
|
||||
local IFS="/"
|
||||
echo "$_CTX_IDENTITY/$*"
|
||||
}
|
||||
|
||||
function ctx::block::path() {
|
||||
local IFS="/"
|
||||
echo "$_CTX_BLOCKS/$*"
|
||||
|
|
|
|||
22
core/fmt.sh
22
core/fmt.sh
|
|
@ -34,6 +34,28 @@ function fmt::datetime_iso() {
|
|||
python3 "$FMT_HELPER" fmt_datetime "$iso" "$FMT_DATETIME" </dev/null
|
||||
}
|
||||
|
||||
# fmt::datetime_short <unix_timestamp>
|
||||
# Returns a compact datetime — just time if today, short date+time if older.
|
||||
# Respects configured date format. Returns "—" for empty/zero timestamps.
|
||||
function fmt::datetime_short() {
|
||||
local ts="${1:-}"
|
||||
[[ -z "$ts" || "$ts" == "0" ]] && echo "—" && return 0
|
||||
|
||||
local today ts_day
|
||||
today=$(date +%Y-%m-%d)
|
||||
ts_day=$(date -d "@${ts}" +%Y-%m-%d 2>/dev/null) || { echo "—"; return 0; }
|
||||
|
||||
if [[ "$ts_day" == "$today" ]]; then
|
||||
date -d "@${ts}" +"%H:%M" 2>/dev/null || echo "—"
|
||||
else
|
||||
case "$_FMT_DATE_FORMAT" in
|
||||
iso) date -d "@${ts}" +"%m-%d %H:%M" 2>/dev/null || echo "—" ;;
|
||||
eu*) date -d "@${ts}" +"%d/%m %H:%M" 2>/dev/null || echo "—" ;;
|
||||
*) date -d "@${ts}" +"%m-%d %H:%M" 2>/dev/null || echo "—" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
function fmt::set_date_format() {
|
||||
local format="$1"
|
||||
case "$format" in
|
||||
|
|
|
|||
45
core/json.sh
45
core/json.sh
|
|
@ -60,6 +60,51 @@ function json::net_resolve() { python3 "$JSON_HELPER" net_resolve
|
|||
function json::net_reverse_lookup() { python3 "$JSON_HELPER" net_reverse_lookup "$@" </dev/null; }
|
||||
function json::block_is_empty() { python3 "$JSON_HELPER" block_is_empty "$@" </dev/null; }
|
||||
function json::group_has_peer() { python3 "$JSON_HELPER" group_has_peer "$@" </dev/null; }
|
||||
function json::get_nested() { python3 "$JSON_HELPER" get_nested "$@" </dev/null; }
|
||||
function json::set_nested() { python3 "$JSON_HELPER" set_nested "$@" </dev/null; }
|
||||
|
||||
# Subnet wrappers
|
||||
function json::subnet_lookup() { python3 "$JSON_HELPER" subnet_lookup "$@" </dev/null; }
|
||||
function json::subnet_type() { python3 "$JSON_HELPER" subnet_type "$@" </dev/null; }
|
||||
function json::subnet_tunnel_mode() { python3 "$JSON_HELPER" subnet_tunnel_mode "$@" </dev/null; }
|
||||
function json::subnet_for_ip() { python3 "$JSON_HELPER" subnet_for_ip "$@" </dev/null; }
|
||||
function json::subnet_list() { python3 "$JSON_HELPER" subnet_list "$@" </dev/null; }
|
||||
function json::subnet_show() { python3 "$JSON_HELPER" subnet_show "$@" </dev/null; }
|
||||
function json::subnet_add() { python3 "$JSON_HELPER" subnet_add "$@" </dev/null; }
|
||||
function json::subnet_remove() { python3 "$JSON_HELPER" subnet_remove "$@" </dev/null; }
|
||||
function json::subnet_rename() { python3 "$JSON_HELPER" subnet_rename "$@" </dev/null; }
|
||||
function json::subnet_peers() { python3 "$JSON_HELPER" subnet_peers "$@" </dev/null; }
|
||||
function json::subnet_exists() { python3 "$JSON_HELPER" subnet_exists "$@" </dev/null; }
|
||||
function json::subnet_default_rule() { python3 "$JSON_HELPER" subnet_default_rule "$@" </dev/null; }
|
||||
function json::subnet_list_names() { python3 "$JSON_HELPER" subnet_list_names "$@" </dev/null; }
|
||||
|
||||
# Identity wrappers
|
||||
function json::identity_list() { python3 "$JSON_HELPER" identity_list "$@" </dev/null; }
|
||||
function json::identity_show() { python3 "$JSON_HELPER" identity_show "$@" </dev/null; }
|
||||
function json::identity_add_peer() { python3 "$JSON_HELPER" identity_add_peer "$@" </dev/null; }
|
||||
function json::identity_remove_peer() { python3 "$JSON_HELPER" identity_remove_peer "$@" </dev/null; }
|
||||
function json::identity_remove() { python3 "$JSON_HELPER" identity_remove "$@" </dev/null; }
|
||||
function json::identity_next_index() { python3 "$JSON_HELPER" identity_next_index "$@" </dev/null; }
|
||||
function json::identity_peers() { python3 "$JSON_HELPER" identity_peers "$@" </dev/null; }
|
||||
function json::identity_migrate() { python3 "$JSON_HELPER" identity_migrate "$@" </dev/null; }
|
||||
function json::identity_infer() { python3 "$JSON_HELPER" identity_infer "$@" </dev/null; }
|
||||
function json::identity_exists() { python3 "$JSON_HELPER" identity_exists "$@" </dev/null; }
|
||||
|
||||
# Identity rule wrappers (1:N)
|
||||
function json::identity_rules() { python3 "$JSON_HELPER" identity_rules "$@" </dev/null; }
|
||||
function json::identity_add_rule() { python3 "$JSON_HELPER" identity_add_rule "$@" </dev/null; }
|
||||
function json::identity_remove_rule() { python3 "$JSON_HELPER" identity_remove_rule "$@" </dev/null; }
|
||||
function json::identity_clear_rules() { python3 "$JSON_HELPER" identity_clear_rules "$@" </dev/null; }
|
||||
function json::identity_has_rule() { python3 "$JSON_HELPER" identity_has_rule "$@" </dev/null; }
|
||||
|
||||
# Policy wrappers — append to json.sh
|
||||
function json::policy_get() { python3 "$JSON_HELPER" policy_get "$@" </dev/null; }
|
||||
function json::policy_list() { python3 "$JSON_HELPER" policy_list "$@" </dev/null; }
|
||||
function json::policy_exists() { python3 "$JSON_HELPER" policy_exists "$@" </dev/null; }
|
||||
function json::policy_add() { python3 "$JSON_HELPER" policy_add "$@" </dev/null; }
|
||||
function json::policy_remove() { python3 "$JSON_HELPER" policy_remove "$@" </dev/null; }
|
||||
function json::policy_set_field() { python3 "$JSON_HELPER" policy_set_field "$@" </dev/null; }
|
||||
function json::subnet_policy() { python3 "$JSON_HELPER" subnet_policy "$@" </dev/null; }
|
||||
|
||||
function json::peer_transfer() {
|
||||
ACTIVITY_TOTAL_LOW="$(config::activity_total_low)" \
|
||||
|
|
|
|||
1065
core/json_helper.py
1065
core/json_helper.py
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ function internal::get_log_priority() {
|
|||
DEBUG) echo 0 ;;
|
||||
INFO) echo 1 ;;
|
||||
SUCCESS) echo 1 ;;
|
||||
OK) echo 1 ;;
|
||||
WARN) echo 2 ;;
|
||||
ERROR) echo 3 ;;
|
||||
*) echo 1 ;;
|
||||
|
|
@ -48,6 +49,7 @@ function internal::log() {
|
|||
WARN) color="\033[1;33m" ;;
|
||||
ERROR) color="\033[1;31m" ;;
|
||||
SUCCESS) color="\033[1;32m" ;;
|
||||
OK) color="\033[1;32m" ;;
|
||||
esac
|
||||
|
||||
echo -e "${color}=> ${level}:\033[0m $*"
|
||||
|
|
@ -156,7 +158,7 @@ function internal::get_context_icon() {
|
|||
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::success() { internal::log OK "$*"; }
|
||||
function internal::log::debug() { internal::log DEBUG "$*"; }
|
||||
|
||||
# ============================================
|
||||
|
|
@ -200,7 +202,8 @@ function log::debug_context() {
|
|||
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::ok() { internal::log OK "$@"; }
|
||||
function log::success() { log::ok "$@"; }
|
||||
function log::debug() { log::debug_context log debug "$@"; }
|
||||
|
||||
function log::section() {
|
||||
|
|
@ -36,6 +36,12 @@ function module::is_auto_load() { declare -F "$(module::fn "$1" on_load)" >/dev/
|
|||
function load_module() {
|
||||
local name="$1"
|
||||
|
||||
# Wildcard: load all submodules in a directory
|
||||
if [[ "$name" == *"/*" ]]; then
|
||||
_load_module_dir "${name%/*}"
|
||||
return $?
|
||||
fi
|
||||
|
||||
module::loaded "$name" && return 0
|
||||
|
||||
local path
|
||||
|
|
@ -53,3 +59,20 @@ function load_module() {
|
|||
|
||||
return 0
|
||||
}
|
||||
|
||||
function _load_module_dir() {
|
||||
local dir="${1:-}"
|
||||
local module_dir
|
||||
module_dir="$(ctx::modules)/${dir}"
|
||||
|
||||
if [[ ! -d "$module_dir" ]]; then
|
||||
log::error "Module directory not found: ${dir} (${module_dir})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for file in "${module_dir}"/*.module.sh; do
|
||||
[[ -f "$file" ]] || continue
|
||||
local subname="${dir}/$(basename "${file%.module.sh}")"
|
||||
load_module "$subname"
|
||||
done
|
||||
}
|
||||
70
core/ui.sh
70
core/ui.sh
|
|
@ -77,6 +77,48 @@ function ui::center() {
|
|||
printf "%${pad}s%s%${rpad}s" "" "$text" ""
|
||||
}
|
||||
|
||||
# ui::measure_col <data> <field_index> [min_width]
|
||||
# Scans pipe-delimited data and returns the max visible width
|
||||
# of the field at field_index (1-based), with optional minimum.
|
||||
# Strips ANSI codes before measuring.
|
||||
# Usage:
|
||||
# name_width=$(ui::measure_col "$data" 1 10)
|
||||
# ip_width=$(ui::measure_col "$data" 2 14)
|
||||
function ui::measure_col() {
|
||||
local data="${1:-}" field_index="${2:-1}" min_width="${3:-0}"
|
||||
local max=$min_width
|
||||
|
||||
while IFS='|' read -r line; do
|
||||
local val
|
||||
val=$(echo "$line" | cut -d'|' -f"$field_index")
|
||||
# Strip ANSI codes for accurate measurement
|
||||
local clean
|
||||
clean=$(echo "$val" | sed 's/\x1b\[[0-9;]*m//g')
|
||||
local len=${#clean}
|
||||
(( len > max )) && max=$len
|
||||
done <<< "$data"
|
||||
|
||||
echo $max
|
||||
}
|
||||
|
||||
# ui::measure_cols <data> <field_indices...>
|
||||
# Measure multiple columns at once, returns space-separated widths.
|
||||
# Usage: read -r w1 w2 w3 <<< $(ui::measure_cols "$data" 1 2 3)
|
||||
function ui::measure_cols() {
|
||||
local data="${1:-}"
|
||||
shift
|
||||
local widths=()
|
||||
for idx in "$@"; do
|
||||
widths+=("$(ui::measure_col "$data" "$idx")")
|
||||
done
|
||||
echo "${widths[*]}"
|
||||
}
|
||||
|
||||
function ui::sort_rows() {
|
||||
local field="${1:-1}"
|
||||
sort -t'|' -k"${field},${field}V"
|
||||
}
|
||||
|
||||
function ui::firewall_rule() {
|
||||
local rule="$1"
|
||||
if [[ "$rule" =~ ACCEPT|DNAT ]]; then
|
||||
|
|
@ -109,7 +151,29 @@ function ui::skip_if_empty() {
|
|||
}
|
||||
|
||||
function ui::empty() {
|
||||
# ui::empty "$var" && return 0
|
||||
# ui::empty "${array[*]}" && return 0
|
||||
[[ -z "${1// }" ]]
|
||||
local val="${1:-}"
|
||||
# Empty string or whitespace only
|
||||
[[ -z "${val// }" ]] && return 0
|
||||
# Numeric zero
|
||||
[[ "$val" =~ ^[0-9]+$ ]] && [[ "$val" -eq 0 ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Usage: ui::bool "$value" [yes_label] [no_label]
|
||||
# Default labels: yes / no
|
||||
function ui::bool() {
|
||||
local val="${1:-}" yes="${2:-yes}" no="${3:-no}"
|
||||
[[ "$val" == "true" ]] && echo "$yes" || echo "$no"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Prompt
|
||||
# ============================================
|
||||
|
||||
function ui::confirm() {
|
||||
local prompt="${1:-Are you sure?}"
|
||||
local response
|
||||
printf " %s [y/N] " "$prompt"
|
||||
read -r response
|
||||
[[ "${response,,}" == "y" || "${response,,}" == "yes" ]]
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"phone-fred": "94.63.0.129",
|
||||
"phone-fred": "176.223.61.130",
|
||||
"phone-helena": "148.69.46.73",
|
||||
"phone-nuno": "94.63.0.129",
|
||||
"tablet-nuno": "148.69.202.5",
|
||||
|
|
@ -7,5 +7,7 @@
|
|||
"guest-zephyr-test": "94.63.0.129",
|
||||
"desktop-roboclean": "46.189.215.231",
|
||||
"laptop-nuno": "94.63.0.129",
|
||||
"phone-luis": "176.223.61.15"
|
||||
"phone-luis": "176.223.61.15",
|
||||
"phone-helena-2": "148.69.192.130",
|
||||
"desktop-zephyr": "86.120.152.74"
|
||||
}
|
||||
|
|
@ -104,6 +104,8 @@ function block::rename() {
|
|||
old_file=$(block::file "$name")
|
||||
new_file=$(block::file "$new_name")
|
||||
[[ -f "$old_file" ]] && mv "$old_file" "$new_file"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function block::clear_full_block() {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,18 @@ function config::_init_defaults() {
|
|||
function config::validate() {
|
||||
local errors=()
|
||||
|
||||
# Required fields
|
||||
# Server key and config files
|
||||
if [[ ! -f "$_WG_SERVER_PUBLIC_KEY_FILE" ]]; then
|
||||
errors+=("Server public key not found: ${_WG_SERVER_PUBLIC_KEY_FILE}")
|
||||
fi
|
||||
if [[ ! -f "$_WG_SERVER_PRIVATE_KEY_FILE" ]]; then
|
||||
errors+=("Server private key not found: ${_WG_SERVER_PRIVATE_KEY_FILE}")
|
||||
fi
|
||||
if [[ ! -f "$_WG_CONFIG" ]]; then
|
||||
errors+=("WireGuard config not found: ${_WG_CONFIG}")
|
||||
fi
|
||||
|
||||
# Required config values
|
||||
local endpoint
|
||||
endpoint=$(config::endpoint)
|
||||
if [[ -z "$endpoint" ]]; then
|
||||
|
|
@ -60,9 +71,9 @@ function config::validate() {
|
|||
local port
|
||||
port=$(config::port)
|
||||
if [[ -z "$port" ]]; then
|
||||
errors+=("WG_LISTEN_PORT is not set")
|
||||
errors+=("WG_PORT is not set")
|
||||
elif ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
|
||||
errors+=("WG_LISTEN_PORT must be a valid port number (1-65535)")
|
||||
errors+=("WG_PORT must be a valid port number (1-65535)")
|
||||
fi
|
||||
|
||||
local dns
|
||||
|
|
@ -79,13 +90,7 @@ function config::validate() {
|
|||
errors+=("WG_SUBNET is not set — required for IP allocation")
|
||||
fi
|
||||
|
||||
local interface
|
||||
interface=$(config::interface)
|
||||
if [[ -z "$interface" ]]; then
|
||||
errors+=("WG_INTERFACE is not set, defaulting to wg0")
|
||||
fi
|
||||
|
||||
# Warn-only fields
|
||||
# Warn-only
|
||||
local lan
|
||||
lan=$(config::lan)
|
||||
if [[ -z "$lan" ]]; then
|
||||
|
|
@ -140,38 +145,6 @@ function config::load() {
|
|||
_WG_TUNNEL_SPLIT="${_WG_SUBNET}, ${_WG_LAN}"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 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"
|
||||
[guest-desktop]="10.1.101"
|
||||
[guest-laptop]="10.1.102"
|
||||
[guest-phone]="10.1.103"
|
||||
[guest-tablet]="10.1.104"
|
||||
)
|
||||
|
||||
# ============================================
|
||||
# Tunnel Modes
|
||||
# ============================================
|
||||
|
||||
declare -gA DEVICE_TUNNEL_MODE=(
|
||||
[desktop]="split"
|
||||
[laptop]="split"
|
||||
[phone]="split"
|
||||
[tablet]="split"
|
||||
[guest]="split"
|
||||
[guest-desktop]="split"
|
||||
[guest-laptop]="split"
|
||||
[guest-phone]="split"
|
||||
[guest-tablet]="split"
|
||||
)
|
||||
|
||||
# ============================================
|
||||
# Accessors
|
||||
# ============================================
|
||||
|
|
@ -194,46 +167,8 @@ function config::activity_current_med() { echo "$_ACTIVITY_TOTAL_MED_BYTES"; }
|
|||
function config::activity_current_high() { echo "$_ACTIVITY_TOTAL_HIGH_BYTES"; }
|
||||
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::is_guest_type() {
|
||||
local type="$1"
|
||||
[[ "$type" == "guest" || "$type" == guest-* ]]
|
||||
}
|
||||
|
||||
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 [[ -z "$tunnel" ]]; then
|
||||
tunnel=$(config::default_tunnel_for "$type")
|
||||
fi
|
||||
|
||||
local tunnel="${2:-split}"
|
||||
case "$tunnel" in
|
||||
full) echo "$_WG_TUNNEL_FULL" ;;
|
||||
split) echo "$_WG_TUNNEL_SPLIT" ;;
|
||||
|
|
@ -242,25 +177,4 @@ function config::allowed_ips_for() {
|
|||
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
|
||||
}
|
||||
}
|
||||
422
modules/identity.module.sh
Normal file
422
modules/identity.module.sh
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
#!/usr/bin/env bash
|
||||
# identity.module.sh — identity file management and peer-name inference
|
||||
|
||||
declare -gA __empty_map=()
|
||||
|
||||
# ===========================================================================
|
||||
# Path helpers
|
||||
# ===========================================================================
|
||||
|
||||
function identity::path() {
|
||||
local name="${1:-}"
|
||||
echo "$(ctx::identities)/${name}.identity"
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Existence checks
|
||||
# ===========================================================================
|
||||
|
||||
function identity::exists() {
|
||||
local name="${1:-}"
|
||||
json::identity_exists "$(identity::path "$name")" 2>/dev/null
|
||||
}
|
||||
|
||||
function identity::require_exists() {
|
||||
local name="${1:-}"
|
||||
if ! identity::exists "$name"; then
|
||||
log::error "Identity '${name}' not found. Use 'wgctl identity list' to see all identities."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function identity::require_not_exists() {
|
||||
local name="${1:-}"
|
||||
if identity::exists "$name"; then
|
||||
log::error "Identity '${name}' already exists."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# identity::require_exists_for_flag <identity_name>
|
||||
# Used by commands to validate --identity value before proceeding.
|
||||
function identity::require_exists_for_flag() {
|
||||
local identity_name="${1:-}"
|
||||
[[ -z "$identity_name" ]] && {
|
||||
log::error "Missing value for --identity"
|
||||
return 1
|
||||
}
|
||||
# Identity may not exist yet for add (it will be created)
|
||||
# Only require existence for commands that read from it
|
||||
return 0
|
||||
}
|
||||
|
||||
# identity::require_has_peers <identity_name>
|
||||
# Used by block/unblock/list to ensure identity has peers to operate on.
|
||||
function identity::require_has_peers() {
|
||||
local identity_name="${1:-}"
|
||||
local peers
|
||||
peers=$(identity::peers "$identity_name")
|
||||
if [[ -z "$peers" ]]; then
|
||||
log::error "Identity '${identity_name}' has no peers"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Peer name inference
|
||||
# ===========================================================================
|
||||
|
||||
# identity::infer <peer_name>
|
||||
# Parses a peer name and returns "identity_name|type|index" if it matches
|
||||
# the naming convention, or empty string if not.
|
||||
# phone-nuno -> "nuno|phone|1"
|
||||
# phone-nuno-2 -> "nuno|phone|2"
|
||||
# roboclean -> "" (no type prefix)
|
||||
function identity::infer() {
|
||||
local peer_name="${1:-}"
|
||||
json::identity_infer "$peer_name" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# identity::next_index <identity_name> <type>
|
||||
# Returns the next available device index for a type within an identity.
|
||||
# If identity doesn't exist yet, returns 1.
|
||||
function identity::next_index() {
|
||||
local identity_name="${1:-}" peer_type="${2:-}"
|
||||
local id_file
|
||||
id_file=$(identity::path "$identity_name")
|
||||
if [[ ! -f "$id_file" ]]; then
|
||||
echo 1
|
||||
return 0
|
||||
fi
|
||||
json::identity_next_index "$id_file" "$peer_type" 2>/dev/null || echo 1
|
||||
}
|
||||
|
||||
# identity::next_peer_name <identity_name> <type>
|
||||
# Returns the full peer name for the next device of a given type
|
||||
# for an identity. Creates the name with the correct index.
|
||||
# e.g. identity::next_peer_name helena phone → phone-helena-2
|
||||
# (if phone-helena already exists, index 1 is taken)
|
||||
function identity::next_peer_name() {
|
||||
local identity_name="${1:-}" peer_type="${2:-}"
|
||||
[[ -z "$identity_name" || -z "$peer_type" ]] && return 1
|
||||
|
||||
local index
|
||||
index=$(identity::next_index "$identity_name" "$peer_type")
|
||||
|
||||
if [[ "$index" -eq 1 ]]; then
|
||||
echo "${peer_type}-${identity_name}"
|
||||
else
|
||||
echo "${peer_type}-${identity_name}-${index}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Auto-attach (called from wgctl add)
|
||||
# ===========================================================================
|
||||
|
||||
# identity::auto_attach <peer_name> <peer_type>
|
||||
# Infers identity from peer name and adds the peer to the identity file.
|
||||
# Creates the identity file if it doesn't exist.
|
||||
# Silent — no output. Logs a note on success, silently skips if no match.
|
||||
function identity::auto_attach() {
|
||||
local peer_name="${1:-}" peer_type="${2:-}"
|
||||
local inferred
|
||||
inferred=$(identity::infer "$peer_name")
|
||||
[[ -z "$inferred" ]] && return 0
|
||||
|
||||
local identity_name type_inferred index
|
||||
identity_name=$(echo "$inferred" | cut -d'|' -f1)
|
||||
type_inferred=$(echo "$inferred" | cut -d'|' -f2)
|
||||
index=$(echo "$inferred" | cut -d'|' -f3)
|
||||
|
||||
# Use the explicit type if provided, otherwise use inferred type
|
||||
local final_type="${peer_type:-$type_inferred}"
|
||||
|
||||
local id_file
|
||||
id_file=$(identity::path "$identity_name")
|
||||
|
||||
json::identity_add_peer "$id_file" "$identity_name" "$peer_name" "$final_type" "$index" </dev/null
|
||||
log::info "Attached '${peer_name}' to identity '${identity_name}' (${final_type} #${index})"
|
||||
}
|
||||
|
||||
# identity::auto_detach <peer_name>
|
||||
# Removes a peer from its identity file when the peer is deleted.
|
||||
# If the identity has no remaining peers, removes the identity file too.
|
||||
function identity::auto_detach() {
|
||||
local peer_name="${1:-}"
|
||||
local inferred
|
||||
inferred=$(identity::infer "$peer_name")
|
||||
[[ -z "$inferred" ]] && return 0
|
||||
|
||||
local identity_name
|
||||
identity_name=$(echo "$inferred" | cut -d'|' -f1)
|
||||
local id_file
|
||||
id_file=$(identity::path "$identity_name")
|
||||
[[ ! -f "$id_file" ]] && return 0
|
||||
|
||||
json::identity_remove_peer "$id_file" "$peer_name" </dev/null
|
||||
|
||||
# Remove identity file if now empty
|
||||
local remaining
|
||||
remaining=$(json::identity_peers "$id_file" 2>/dev/null) || true
|
||||
if [[ -z "$remaining" ]]; then
|
||||
rm -f "$id_file"
|
||||
log::info "Identity '${identity_name}' removed (no remaining peers)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Peer queries
|
||||
# ===========================================================================
|
||||
|
||||
# identity::peers <identity_name> [type_filter]
|
||||
# Returns peer names belonging to an identity, one per line.
|
||||
# Optional type_filter limits to peers of a specific type.
|
||||
function identity::peers() {
|
||||
local identity_name="${1:-}" type_filter="${2:-}"
|
||||
local id_file
|
||||
id_file=$(identity::path "$identity_name")
|
||||
json::identity_peers "$id_file" "$type_filter" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# identity::get_name <peer_name>
|
||||
# Returns the identity name for a given peer (via inference).
|
||||
function identity::get_name() {
|
||||
local peer_name="${1:-}"
|
||||
local inferred
|
||||
inferred=$(identity::infer "$peer_name")
|
||||
[[ -n "$inferred" ]] && echo "${inferred%%|*}"
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Data for commands
|
||||
# ===========================================================================
|
||||
|
||||
function identity::list_data() {
|
||||
json::identity_list "$(ctx::identities)" 2>/dev/null || true
|
||||
}
|
||||
|
||||
function identity::show_data() {
|
||||
local name="${1:-}"
|
||||
json::identity_show "$(identity::path "$name")" 2>/dev/null
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Rename helper (called from rename.command.sh)
|
||||
# ===========================================================================
|
||||
|
||||
# identity::rename_peer <old_peer_name> <new_peer_name>
|
||||
# Updates identity file entry when a peer is renamed.
|
||||
# Re-infers identity from old name, removes old entry, adds new entry.
|
||||
function identity::rename_peer() {
|
||||
local old_name="${1:-}" new_name="${2:-}"
|
||||
|
||||
local old_inferred
|
||||
old_inferred=$(identity::infer "$old_name")
|
||||
[[ -z "$old_inferred" ]] && return 0
|
||||
|
||||
local identity_name old_type old_index
|
||||
identity_name=$(echo "$old_inferred" | cut -d'|' -f1)
|
||||
old_type=$(echo "$old_inferred" | cut -d'|' -f2)
|
||||
old_index=$(echo "$old_inferred" | cut -d'|' -f3)
|
||||
|
||||
local id_file
|
||||
id_file=$(identity::path "$identity_name")
|
||||
[[ ! -f "$id_file" ]] && return 0
|
||||
|
||||
# Infer new identity context from new name
|
||||
local new_inferred new_identity new_type new_index
|
||||
new_inferred=$(identity::infer "$new_name")
|
||||
if [[ -n "$new_inferred" ]]; then
|
||||
new_identity=$(echo "$new_inferred" | cut -d'|' -f1)
|
||||
new_type=$(echo "$new_inferred" | cut -d'|' -f2)
|
||||
new_index=$(echo "$new_inferred" | cut -d'|' -f3)
|
||||
else
|
||||
# New name doesn't match convention — detach cleanly
|
||||
json::identity_remove_peer "$id_file" "$old_name" </dev/null
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Remove old entry
|
||||
json::identity_remove_peer "$id_file" "$old_name" </dev/null
|
||||
|
||||
if [[ "$new_identity" == "$identity_name" ]]; then
|
||||
# Same identity — update in place
|
||||
json::identity_add_peer "$id_file" "$identity_name" "$new_name" "$new_type" "$new_index" </dev/null
|
||||
else
|
||||
# Identity changed (e.g. phone-nuno -> phone-helena) — move to new identity file
|
||||
local new_id_file
|
||||
new_id_file=$(identity::path "$new_identity")
|
||||
json::identity_add_peer "$new_id_file" "$new_identity" "$new_name" "$new_type" "$new_index" </dev/null
|
||||
# Clean up old identity if empty
|
||||
local remaining
|
||||
remaining=$(json::identity_peers "$id_file" 2>/dev/null) || true
|
||||
if [[ -z "$remaining" ]]; then
|
||||
rm -f "$id_file"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# identity::policy <identity_name>
|
||||
# Returns the policy name assigned to an identity, or "default".
|
||||
function identity::policy() {
|
||||
local identity_name="${1:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
[[ ! -f "$id_file" ]] && echo "default" && return 0
|
||||
local result
|
||||
result=$(json::get "$id_file" "policy" 2>/dev/null) || true
|
||||
echo "${result:-default}"
|
||||
}
|
||||
|
||||
# identity::set_policy <identity_name> <policy_name>
|
||||
# Sets the policy on an identity file.
|
||||
function identity::set_policy() {
|
||||
local identity_name="${1:-}" policy_name="${2:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
if [[ ! -f "$id_file" ]]; then
|
||||
log::error "Identity '${identity_name}' not found"
|
||||
return 1
|
||||
fi
|
||||
json::set "$id_file" "policy" "$policy_name"
|
||||
}
|
||||
|
||||
# identity::rule_flags <identity_name> <flag>
|
||||
# Returns a specific rule_flag from the identity file.
|
||||
# Falls back to the policy's value if not explicitly set on the identity.
|
||||
function identity::rule_flags() {
|
||||
local identity_name="${1:-}" flag="${2:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
|
||||
local result
|
||||
result=$(json::get_nested "$id_file" "rule_flags" "$flag" 2>/dev/null) || true
|
||||
|
||||
if [[ -n "$result" ]]; then
|
||||
echo "$result"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fall back to policy value
|
||||
local policy_name
|
||||
policy_name=$(identity::policy "$identity_name")
|
||||
policy::get "$policy_name" "$flag"
|
||||
}
|
||||
|
||||
# identity::set_rule_flag <identity_name> <flag> <value>
|
||||
# Sets a rule_flag directly on the identity file.
|
||||
function identity::set_rule_flag() {
|
||||
local identity_name="${1:-}" flag="${2:-}" value="${3:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
if [[ ! -f "$id_file" ]]; then
|
||||
log::error "Identity '${identity_name}' not found"
|
||||
return 1
|
||||
fi
|
||||
json::set_nested "$id_file" "rule_flags" "$flag" "$value"
|
||||
}
|
||||
|
||||
# identity::reapply_rules <identity_name>
|
||||
# Reapply the identity rule to all peers in this identity.
|
||||
# Respects auto_apply flag — if false, does nothing.
|
||||
function identity::reapply_rules() {
|
||||
local identity_name="${1:-}"
|
||||
|
||||
# Check auto_apply
|
||||
local auto
|
||||
auto=$(identity::rule_flags "$identity_name" "auto_apply")
|
||||
[[ "$auto" == "false" ]] && return 0
|
||||
|
||||
local identity_rule
|
||||
identity_rule=$(identity::rule "$identity_name")
|
||||
[[ -z "$identity_rule" ]] && return 0
|
||||
|
||||
local peers
|
||||
peers=$(identity::peers "$identity_name")
|
||||
[[ -z "$peers" ]] && return 0
|
||||
|
||||
while IFS= read -r peer_name; do
|
||||
[[ -z "$peer_name" ]] && continue
|
||||
local client_ip
|
||||
client_ip=$(peers::get_ip "$peer_name") || continue
|
||||
rule::full_restore_peer "$peer_name" "$client_ip"
|
||||
done <<< "$peers"
|
||||
}
|
||||
|
||||
# identity::rules <identity_name>
|
||||
# Returns all rules assigned to an identity, one per line.
|
||||
# Empty output if no rules assigned.
|
||||
function identity::rules() {
|
||||
local identity_name="${1:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
[[ ! -f "$id_file" ]] && return 0
|
||||
json::identity_rules "$id_file" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# identity::has_rule <identity_name> <rule_name>
|
||||
# Returns 0 if identity has this rule, 1 otherwise.
|
||||
function identity::has_rule() {
|
||||
local identity_name="${1:-}" rule_name="${2:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
json::identity_has_rule "$id_file" "$rule_name" 2>/dev/null
|
||||
}
|
||||
|
||||
# identity::add_rule <identity_name> <rule_name>
|
||||
# Adds a rule to an identity. Warns if already present (exit 2).
|
||||
function identity::add_rule() {
|
||||
local identity_name="${1:-}" rule_name="${2:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
|
||||
local exit_code=0
|
||||
json::identity_add_rule "$id_file" "$identity_name" "$rule_name" 2>/dev/null || exit_code=$?
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
# identity::remove_rule <identity_name> <rule_name>
|
||||
# Removes a specific rule from an identity.
|
||||
function identity::remove_rule() {
|
||||
local identity_name="${1:-}" rule_name="${2:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
json::identity_remove_rule "$id_file" "$rule_name" 2>/dev/null
|
||||
}
|
||||
|
||||
# identity::clear_rules <identity_name>
|
||||
# Removes all rules from an identity.
|
||||
function identity::clear_rules() {
|
||||
local identity_name="${1:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
json::identity_clear_rules "$id_file" 2>/dev/null
|
||||
}
|
||||
|
||||
# identity::reapply_rules <identity_name>
|
||||
# Reapply all identity rules to all peers in this identity.
|
||||
# Respects auto_apply flag.
|
||||
function identity::reapply_rules() {
|
||||
local identity_name="${1:-}"
|
||||
|
||||
local auto
|
||||
auto=$(identity::rule_flags "$identity_name" "auto_apply")
|
||||
[[ "$auto" == "false" ]] && return 0
|
||||
|
||||
local rules
|
||||
rules=$(identity::rules "$identity_name")
|
||||
[[ -z "$rules" ]] && return 0
|
||||
|
||||
local peers
|
||||
peers=$(identity::peers "$identity_name")
|
||||
[[ -z "$peers" ]] && return 0
|
||||
|
||||
while IFS= read -r peer_name; do
|
||||
[[ -z "$peer_name" ]] && continue
|
||||
local client_ip
|
||||
client_ip=$(peers::get_ip "$peer_name") || continue
|
||||
rule::full_restore_peer "$peer_name" "$client_ip"
|
||||
done <<< "$peers"
|
||||
}
|
||||
|
|
@ -15,40 +15,74 @@ function ip::is_assigned() {
|
|||
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}"
|
||||
# ip::next_for_subnet <cidr>
|
||||
# Finds the next unassigned host IP within a CIDR.
|
||||
# Replaces ip::next_for_type for the subnet-aware allocation path.
|
||||
function ip::next_for_subnet() {
|
||||
local cidr="${1:-}"
|
||||
|
||||
if [[ -z "$cidr" ]]; then
|
||||
log::error "No subnet CIDR provided for IP allocation"
|
||||
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
|
||||
|
||||
local prefix
|
||||
prefix=$(subnet::prefix "$cidr")
|
||||
|
||||
local candidate
|
||||
for i in $(subnet::host_range "$cidr"); do
|
||||
candidate="${prefix}.${i}"
|
||||
ip::is_assigned "$candidate" || { echo "$candidate"; return 0; }
|
||||
done
|
||||
|
||||
log::error "No available IPs in subnet ${subnet}.0/24"
|
||||
|
||||
log::error "No available IPs in subnet ${cidr}"
|
||||
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]))?$ ]]
|
||||
}
|
||||
local ip="${1:-}"
|
||||
[[ -z "$ip" ]] && return 1
|
||||
|
||||
# Strip CIDR mask if present
|
||||
local addr="${ip%%/*}"
|
||||
|
||||
# Structural check — 4 octets, optional /mask
|
||||
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$ ]] || return 1
|
||||
|
||||
# Octet range check — each must be 0-255
|
||||
local IFS='.'
|
||||
local -a octets
|
||||
read -ra octets <<< "$addr"
|
||||
for octet in "${octets[@]}"; do
|
||||
(( octet >= 0 && octet <= 255 )) || return 1
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
function ip::is_cidr() {
|
||||
[[ "$1" == *"/"* ]]
|
||||
}
|
||||
|
||||
# ip::is_valid_for_subnet <cidr> <ip>
|
||||
# Convenience wrapper — validates an IP against a specific subnet.
|
||||
# Delegates to subnet::ip_valid_for which handles all the checks.
|
||||
function ip::is_valid_for_subnet() {
|
||||
local cidr="${1:-}" ip="${2:-}"
|
||||
subnet::ip_valid_for "$cidr" "$ip"
|
||||
}
|
||||
|
||||
# ip::require_valid_for_subnet <cidr> <ip>
|
||||
# Errors and returns 1 if the IP is not valid for the subnet.
|
||||
# Used when a manual --ip override is provided.
|
||||
function ip::require_valid_for_subnet() {
|
||||
local cidr="${1:-}" ip="${2:-}"
|
||||
subnet::require_ip_valid_for "$cidr" "$ip"
|
||||
}
|
||||
|
||||
function ip::validate() {
|
||||
local ip="$1"
|
||||
|
|
|
|||
|
|
@ -106,6 +106,5 @@ function keys::qr() {
|
|||
return 1
|
||||
fi
|
||||
|
||||
log::wg_qr "QR code for: ${name}"
|
||||
qrencode -t ansiutf8 < "$conf"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ 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 allowed_ips="${4:-$(config::allowed_ips_for "split")}"
|
||||
|
||||
local conf
|
||||
conf="$(ctx::clients)/${name}.conf"
|
||||
|
|
@ -118,16 +118,9 @@ function peers::list() {
|
|||
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
|
||||
local type
|
||||
type=$(peers::get_meta "$client_name" "type" 2>/dev/null)
|
||||
[[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip")
|
||||
|
||||
printf " %-30s %-15s %-10s %s\n" \
|
||||
"$client_name" "$ip" "$type" "$public_key"
|
||||
|
|
@ -146,12 +139,12 @@ function peers::list_by_type() {
|
|||
local ip
|
||||
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
|
||||
|
||||
local subnet
|
||||
subnet=$(config::subnet_for "$filter_type")
|
||||
local type
|
||||
type=$(peers::get_meta "$client_name" "type" 2>/dev/null)
|
||||
[[ -z "$type" ]] && type=$(peers::get_type_from_ip "$ip")
|
||||
|
||||
if string::starts_with "$ip" "$subnet"; then
|
||||
[[ "$type" == "$filter_type" ]] && \
|
||||
printf " %-30s %-15s\n" "$client_name" "$ip"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
|
@ -160,11 +153,6 @@ function peers::exists_in_server() {
|
|||
grep -q "^# ${name}$" "$(config::config_file)"
|
||||
}
|
||||
|
||||
# function peers::is_blocked() {
|
||||
# local name="${1:-}"
|
||||
# peers::exists_in_server "$name" && return 1 || return 0
|
||||
# }
|
||||
|
||||
function peers::is_blocked() {
|
||||
local name="${1:-}"
|
||||
block::is_blocked "$name"
|
||||
|
|
@ -183,25 +171,12 @@ function peers::get_type() {
|
|||
local name="$1"
|
||||
local ip
|
||||
ip=$(peers::get_ip "$name")
|
||||
[[ -z "$ip" ]] && echo "unknown" && return
|
||||
|
||||
local type="unknown"
|
||||
for t in $(config::device_types); do
|
||||
local subnet
|
||||
subnet=$(config::subnet_for "$t")
|
||||
if string::starts_with "$ip" "${subnet}."; then
|
||||
type="$t"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "$type"
|
||||
[[ -z "$ip" ]] && echo "unknown" && return 0
|
||||
peers::get_type_from_ip "$ip"
|
||||
}
|
||||
|
||||
function peers::default_rule() {
|
||||
local name="$1"
|
||||
local type
|
||||
type=$(peers::get_type "$name")
|
||||
config::is_guest_type "$type" && echo "guest" || echo "user"
|
||||
echo "user"
|
||||
}
|
||||
|
||||
function peers::effective_rule() {
|
||||
|
|
@ -305,7 +280,7 @@ function peers::resolve_name() {
|
|||
local type="${2:-}"
|
||||
|
||||
if [[ -n "$type" ]]; then
|
||||
if ! config::is_valid_type "$type"; then
|
||||
if ! subnet::exists "$type"; then
|
||||
log::error "Invalid device type: ${type}"
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -333,6 +308,38 @@ function peers::resolve_and_require() {
|
|||
echo "$resolved"
|
||||
}
|
||||
|
||||
function peers::rename_meta() {
|
||||
local name="${1:-}" new_name="${2:-}"
|
||||
local old_meta new_meta
|
||||
old_meta=$(peers::meta_path "$name")
|
||||
new_meta=$(peers::meta_path "$new_name")
|
||||
[[ -f "$old_meta" ]] && mv "$old_meta" "$new_meta"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Cleanup
|
||||
# ============================================
|
||||
|
||||
function peers::purge() {
|
||||
local name="${1:-}" client_ip="${2:-}" was_blocked="${3:-false}"
|
||||
|
||||
[[ -n "$client_ip" ]] && fw::flush_peer "$client_ip"
|
||||
peers::remove_from_server "$name" || return 1
|
||||
peers::remove_client_config "$name" || return 1
|
||||
keys::remove "$name" || return 1
|
||||
group::remove_peer_from_all "$name" || return 1
|
||||
|
||||
if [[ -n "$client_ip" ]] && $was_blocked; then
|
||||
fw::unblock_all "$client_ip"
|
||||
fi
|
||||
|
||||
block::remove_file "$name" 2>/dev/null || true
|
||||
peers::remove_meta "$name" 2>/dev/null || true
|
||||
peers::reload || return 1
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Display / Formatting
|
||||
# ============================================
|
||||
|
|
@ -354,22 +361,6 @@ function peers::format_last_seen() {
|
|||
esac
|
||||
}
|
||||
|
||||
# function peers::format_status() {
|
||||
# local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
|
||||
# local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
|
||||
|
||||
# local state
|
||||
# state=$(peers::connection_state "$is_blocked" "$is_restricted" \
|
||||
# "$handshake_ts" "$last_ts")
|
||||
|
||||
# local conn_str modifier color
|
||||
# IFS="|" read -r conn_str modifier color <<< "$state"
|
||||
|
||||
# local display="$conn_str"
|
||||
# [[ -n "$modifier" ]] && display="${conn_str} (${modifier})"
|
||||
# echo -e "${color}${display}\033[0m"
|
||||
# }
|
||||
|
||||
function peers::format_status() {
|
||||
local name="${1:-}" public_key="${2:-}" is_blocked="${3:-false}"
|
||||
local is_restricted="${4:-false}" handshake_ts="${5:-0}" last_ts="${6:-}"
|
||||
|
|
@ -425,14 +416,8 @@ function peers::format_status_verbose() {
|
|||
}
|
||||
|
||||
function peers::display_type() {
|
||||
local type="${1:-}" subtype="${2:-}"
|
||||
if config::is_guest_type "$type" && [[ -n "$subtype" && "$subtype" != "0" ]]; then
|
||||
echo "guest/${subtype}"
|
||||
elif config::is_guest_type "$type"; then
|
||||
echo "guest"
|
||||
else
|
||||
echo "$type"
|
||||
fi
|
||||
local type="${1:-}" _subtype="${2:-}"
|
||||
echo "${type:-unknown}"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
|
|
@ -493,17 +478,10 @@ function peers::last_seen_data() {
|
|||
function peers::get_type_from_ip() {
|
||||
local ip="${1:-}"
|
||||
[[ -z "$ip" ]] && echo "unknown" && return 0
|
||||
local type="unknown"
|
||||
for t in $(config::device_types); do
|
||||
local subnet
|
||||
subnet=$(config::subnet_for "$t")
|
||||
string::starts_with "$ip" "${subnet}." && type="$t" && break
|
||||
done
|
||||
echo "$type"
|
||||
subnet::type_from_ip "$ip"
|
||||
}
|
||||
|
||||
|
||||
|
||||
# ============================================
|
||||
# Activity
|
||||
# ============================================
|
||||
|
|
|
|||
200
modules/policy.module.sh
Normal file
200
modules/policy.module.sh
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env bash
|
||||
# policy.module.sh — policy system
|
||||
# Policies define behavioral flags for subnets, identities, and future contexts.
|
||||
# Chain: Subnet → Policy → Identity → Peer
|
||||
|
||||
# ======================================================
|
||||
# Hardcoded Fallbacks
|
||||
# Mirror of policies.json built-in policies.
|
||||
# Used when policies.json lookup fails.
|
||||
# ======================================================
|
||||
|
||||
declare -gA _POLICY_TUNNEL_MODE=(
|
||||
[default]="split"
|
||||
[guest]="split"
|
||||
[trusted]="split"
|
||||
[server]="split"
|
||||
[iot]="split"
|
||||
)
|
||||
|
||||
declare -gA _POLICY_DEFAULT_RULE=(
|
||||
[default]=""
|
||||
[guest]="guest"
|
||||
[trusted]=""
|
||||
[server]=""
|
||||
[iot]=""
|
||||
)
|
||||
|
||||
declare -gA _POLICY_STRICT_RULE=(
|
||||
[default]="false"
|
||||
[guest]="true"
|
||||
[trusted]="false"
|
||||
[server]="false"
|
||||
[iot]="false"
|
||||
)
|
||||
|
||||
declare -gA _POLICY_AUTO_APPLY=(
|
||||
[default]="true"
|
||||
[guest]="true"
|
||||
[trusted]="true"
|
||||
[server]="true"
|
||||
[iot]="true"
|
||||
)
|
||||
|
||||
function policy::_hardcoded_field() {
|
||||
local name="${1:-}" field="${2:-}"
|
||||
case "$field" in
|
||||
tunnel_mode) echo "${_POLICY_TUNNEL_MODE[$name]:-split}" ;;
|
||||
default_rule) echo "${_POLICY_DEFAULT_RULE[$name]:-}" ;;
|
||||
strict_rule) echo "${_POLICY_STRICT_RULE[$name]:-false}" ;;
|
||||
auto_apply) echo "${_POLICY_AUTO_APPLY[$name]:-true}" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Core Accessors
|
||||
# ======================================================
|
||||
|
||||
function ctx::policies() { echo "${_CTX_DATA}/policies.json"; }
|
||||
|
||||
function policy::exists() {
|
||||
local name="${1:-}"
|
||||
json::policy_exists "$(ctx::policies)" "$name" 2>/dev/null
|
||||
}
|
||||
|
||||
function policy::require_exists() {
|
||||
local name="${1:-}"
|
||||
if ! policy::exists "$name"; then
|
||||
log::error "Policy '${name}' not found. Use 'wgctl policy list' to see available policies."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function policy::get() {
|
||||
local name="${1:-}" field="${2:-}"
|
||||
local result
|
||||
result=$(json::policy_get "$(ctx::policies)" "$name" "$field" 2>/dev/null) || true
|
||||
if [[ -n "$result" ]]; then
|
||||
echo "$result"
|
||||
return 0
|
||||
fi
|
||||
# Fallback to hardcoded
|
||||
[[ -n "$field" ]] && policy::_hardcoded_field "$name" "$field"
|
||||
}
|
||||
|
||||
function policy::tunnel_mode() {
|
||||
local name="${1:-default}"
|
||||
policy::get "$name" "tunnel_mode"
|
||||
}
|
||||
|
||||
function policy::default_rule() {
|
||||
local name="${1:-default}"
|
||||
policy::get "$name" "default_rule"
|
||||
}
|
||||
|
||||
function policy::strict_rule() {
|
||||
local name="${1:-default}"
|
||||
local val
|
||||
val=$(policy::get "$name" "strict_rule")
|
||||
[[ "$val" == "true" ]]
|
||||
}
|
||||
|
||||
function policy::auto_apply() {
|
||||
local name="${1:-default}"
|
||||
local val
|
||||
val=$(policy::get "$name" "auto_apply")
|
||||
[[ "$val" != "false" ]]
|
||||
}
|
||||
|
||||
function policy::list_data() {
|
||||
json::policy_list "$(ctx::policies)" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Subnet Policy Resolution
|
||||
# ======================================================
|
||||
|
||||
# policy::for_subnet <subnet_name> [type_key]
|
||||
# Returns the policy name for a subnet entry.
|
||||
# Falls back to "default" if no policy set on the subnet.
|
||||
function policy::for_subnet() {
|
||||
local subnet_name="${1:-}" type_key="${2:-}"
|
||||
local result
|
||||
result=$(json::subnet_policy "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true
|
||||
echo "${result:-default}"
|
||||
}
|
||||
|
||||
# policy::resolve_for_add <subnet_name> [type_key]
|
||||
# Returns the fully resolved policy for use during wgctl add.
|
||||
# Output: policy_name
|
||||
function policy::resolve_for_add() {
|
||||
local subnet_name="${1:-}" type_key="${2:-}"
|
||||
if [[ -z "$subnet_name" ]]; then
|
||||
echo "default"
|
||||
return 0
|
||||
fi
|
||||
policy::for_subnet "$subnet_name" "$type_key"
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Identity Policy Resolution
|
||||
# ======================================================
|
||||
|
||||
# policy::for_identity <identity_name>
|
||||
# Returns the policy name stored in an identity file.
|
||||
# Falls back to "default".
|
||||
function policy::for_identity() {
|
||||
local identity_name="${1:-}"
|
||||
local id_file
|
||||
id_file=$(ctx::identity::path "${identity_name}.identity")
|
||||
local result
|
||||
result=$(json::get "$id_file" "policy" 2>/dev/null) || true
|
||||
echo "${result:-default}"
|
||||
}
|
||||
|
||||
# policy::effective <subnet_name> <type_key> <identity_name>
|
||||
# Returns the effective policy name for a peer being added.
|
||||
# Priority: identity policy > subnet policy > default
|
||||
function policy::effective() {
|
||||
local subnet_name="${1:-}" type_key="${2:-}" identity_name="${3:-}"
|
||||
|
||||
# Identity policy takes precedence if explicitly set
|
||||
if [[ -n "$identity_name" ]]; then
|
||||
local id_policy
|
||||
id_policy=$(policy::for_identity "$identity_name")
|
||||
if [[ "$id_policy" != "default" ]]; then
|
||||
echo "$id_policy"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Subnet policy next
|
||||
if [[ -n "$subnet_name" ]]; then
|
||||
local subnet_policy
|
||||
subnet_policy=$(policy::for_subnet "$subnet_name" "$type_key")
|
||||
echo "$subnet_policy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "default"
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Warning Helpers (called from add.command.sh)
|
||||
# ======================================================
|
||||
|
||||
function policy::warn_strict_rule() {
|
||||
local identity_name="${1:-}" policy_name="${2:-}" identity_rule="${3:-}"
|
||||
log::warn "Identity '${identity_name}' has policy '${policy_name}' with strict rule enabled — peer rule will not be applied, '${identity_rule}' is the only active rule"
|
||||
}
|
||||
|
||||
function policy::warn_additive_rule() {
|
||||
local identity_name="${1:-}" identity_rule="${1:-}" peer_rule="${2:-}"
|
||||
log::info "Identity '${identity_name}' has strict rule disabled — '${identity_rule}' and '${peer_rule}' will both apply"
|
||||
}
|
||||
|
||||
function policy::warn_no_rule() {
|
||||
local peer_name="${1:-}"
|
||||
log::warn "'${peer_name}' has no rule assigned — peer has unrestricted access"
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ function rule::is_applied() {
|
|||
local target port proto
|
||||
IFS=":" read -r target port proto <<< "$first_port"
|
||||
proto="${proto:-tcp}"
|
||||
fw::has_block_rule "$client_ip" "$target" "$proto" "$port"
|
||||
fw::has_block_rule "$client_ip" "$target" "$proto" "$port"
|
||||
return $?
|
||||
fi
|
||||
|
||||
|
|
@ -99,33 +99,21 @@ function rule::is_applied() {
|
|||
# Rule Application
|
||||
# ============================================
|
||||
|
||||
function rule::apply() {
|
||||
local rule_name="${1:?rule_name required}"
|
||||
local client_ip="${2:?client_ip required}"
|
||||
local peer_name="${3:-}"
|
||||
function rule::_apply_entries() {
|
||||
local rule_name="${1:?}" client_ip="${2:?}"
|
||||
|
||||
rule::require_exists "$rule_name" || return 1
|
||||
|
||||
if [[ -z "$peer_name" ]]; then
|
||||
peer_name=$(peers::find_by_ip "$client_ip")
|
||||
fi
|
||||
|
||||
log::debug "rule::apply: peer_name=$peer_name ip=$client_ip"
|
||||
|
||||
# Check if already applied
|
||||
if rule::is_applied "$rule_name" "$client_ip"; then
|
||||
log::wg "Rule '${rule_name}' already applied to: ${client_ip}"
|
||||
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
|
||||
log::debug "Rule '${rule_name}' already applied to: ${client_ip}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Process block_ips
|
||||
while IFS= read -r block_ip; do
|
||||
[[ -z "$block_ip" ]] && continue
|
||||
fw::block_ip "$client_ip" "$block_ip"
|
||||
done < <(rule::get "$rule_name" "block_ips")
|
||||
|
||||
# Process block_ports
|
||||
while IFS= read -r entry; do
|
||||
[[ -z "$entry" ]] && continue
|
||||
local target port proto
|
||||
|
|
@ -134,13 +122,11 @@ function rule::apply() {
|
|||
fw::block_port "$client_ip" "$target" "$port" "$proto"
|
||||
done < <(rule::get "$rule_name" "block_ports")
|
||||
|
||||
# Process allow_ips (inserted before blocks)
|
||||
while IFS= read -r allow_ip; do
|
||||
[[ -z "$allow_ip" ]] && continue
|
||||
fw::allow_ip "$client_ip" "$allow_ip"
|
||||
done < <(rule::get "$rule_name" "allow_ips")
|
||||
|
||||
# Process allow_ports (highest priority)
|
||||
while IFS= read -r entry; do
|
||||
[[ -z "$entry" ]] && continue
|
||||
local target port proto
|
||||
|
|
@ -149,28 +135,46 @@ function rule::apply() {
|
|||
fw::allow_port "$client_ip" "$target" "$port" "$proto"
|
||||
done < <(rule::get "$rule_name" "allow_ports")
|
||||
|
||||
# Persist rule assignment
|
||||
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
|
||||
|
||||
# DNS redirect
|
||||
local dns_redirect
|
||||
dns_redirect=$(rule::get "$rule_name" "dns_redirect")
|
||||
if [[ "$dns_redirect" == "true" ]]; then
|
||||
local peer_subnet
|
||||
local peer_name peer_subnet
|
||||
peer_name=$(peers::find_by_ip "$client_ip")
|
||||
peer_subnet=$(peers::get_ip "$peer_name" | cut -d'.' -f1-3)
|
||||
if ! fw::_nat_exists -i wg0 -s "${peer_subnet}.0/24" \
|
||||
-p udp --dport 53 -j DNAT \
|
||||
--to-destination "$(config::dns):53" 2>/dev/null; then
|
||||
rule::apply_dns_redirect "${peer_subnet}.0/24"
|
||||
log::debug "dns_redirect: applied for ${peer_subnet}.0/24"
|
||||
else
|
||||
log::debug "dns_redirect: already applied for ${peer_subnet}.0/24"
|
||||
fi
|
||||
fi
|
||||
|
||||
log::debug "Applied rule '${rule_name}' to: ${client_ip}"
|
||||
}
|
||||
|
||||
function rule::apply_transient() {
|
||||
# Apply rule entries without touching peer meta
|
||||
# Used for identity rules and other transient applications
|
||||
local rule_name="${1:?}" client_ip="${2:?}"
|
||||
log::debug "rule::apply_transient: $rule_name -> $client_ip"
|
||||
rule::_apply_entries "$rule_name" "$client_ip"
|
||||
}
|
||||
|
||||
function rule::apply() {
|
||||
local rule_name="${1:?}" client_ip="${2:?}" peer_name="${3:-}"
|
||||
|
||||
rule::require_exists "$rule_name" || return 1
|
||||
|
||||
log::debug "rule::apply: peer_name=${peer_name:-<lookup>} ip=$client_ip"
|
||||
|
||||
rule::_apply_entries "$rule_name" "$client_ip" || return 1
|
||||
|
||||
# Write to peer meta — only for explicit peer rule assignment
|
||||
if [[ -z "$peer_name" ]]; then
|
||||
peer_name=$(peers::find_by_ip "$client_ip")
|
||||
fi
|
||||
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" "$rule_name"
|
||||
}
|
||||
|
||||
function rule::unapply() {
|
||||
local rule_name="${1:-}" client_ip="${2:-}"
|
||||
|
||||
|
|
@ -221,56 +225,93 @@ function rule::unapply() {
|
|||
local dns_redirect
|
||||
dns_redirect=$(rule::get "$rule_name" "dns_redirect")
|
||||
if [[ "$dns_redirect" == "true" ]]; then
|
||||
local subtype
|
||||
subtype=$(peers::get_meta "$peer_name" "subtype")
|
||||
local subnet
|
||||
if [[ -n "$subtype" ]]; then
|
||||
subnet=$(config::subnet_for "$subtype")
|
||||
else
|
||||
local peer_type
|
||||
peer_type=$(peers::get_type "$peer_name") || true
|
||||
[[ -z "$peer_type" ]] && peer_type="phone"
|
||||
subnet=$(config::subnet_for "$peer_type")
|
||||
fi
|
||||
rule::remove_dns_redirect "${subnet}.0/24"
|
||||
local peer_ip peer_subnet
|
||||
peer_ip=$(peers::get_ip "$peer_name")
|
||||
peer_subnet=$(echo "$peer_ip" | cut -d'.' -f1-3)
|
||||
rule::remove_dns_redirect "${peer_subnet}.0/24"
|
||||
fi
|
||||
|
||||
# Clear rule from meta
|
||||
[[ -n "$peer_name" ]] && peers::set_meta "$peer_name" "rule" ""
|
||||
|
||||
log::debug "Removed rule '${rule_name}' from: ${client_ip}"
|
||||
}
|
||||
|
||||
function rule::reapply_all() {
|
||||
local rule_name="${1:-}"
|
||||
rule::require_exists "$rule_name" || return 1
|
||||
# ============================================
|
||||
# Bulk Operations
|
||||
# ============================================
|
||||
|
||||
local peers=()
|
||||
mapfile -t peers < <(peers::with_rule "$rule_name")
|
||||
[[ ${#peers[@]} -eq 0 ]] && return 0
|
||||
|
||||
local count=0
|
||||
for peer_name in "${peers[@]}"; do
|
||||
local client_ip
|
||||
client_ip=$(peers::get_ip "$peer_name")
|
||||
[[ -z "$client_ip" ]] && continue
|
||||
# FLUSH first to ensure clean ordering
|
||||
function rule::_apply_identity_rule() {
|
||||
local peer_name="${1:-}" client_ip="${2:-}"
|
||||
|
||||
local identity_name
|
||||
identity_name=$(identity::get_name "$peer_name")
|
||||
[[ -z "$identity_name" ]] && return 0
|
||||
|
||||
local rules
|
||||
rules=$(identity::rules "$identity_name")
|
||||
[[ -z "$rules" ]] && return 0
|
||||
|
||||
local strict
|
||||
strict=$(identity::rule_flags "$identity_name" "strict_rule")
|
||||
|
||||
if [[ "$strict" == "true" ]]; then
|
||||
# Strict: flush and apply only identity rules — peer rule ignored
|
||||
fw::flush_peer "$client_ip"
|
||||
rule::apply "$rule_name" "$client_ip" "$peer_name"
|
||||
(( count++ )) || true
|
||||
done
|
||||
while IFS= read -r rule_name; do
|
||||
[[ -z "$rule_name" ]] && continue
|
||||
rule::exists "$rule_name" && rule::apply_transient "$rule_name" "$client_ip" || true
|
||||
done <<< "$rules"
|
||||
else
|
||||
# Additive: apply identity rules on top of peer rule
|
||||
while IFS= read -r rule_name; do
|
||||
[[ -z "$rule_name" ]] && continue
|
||||
rule::exists "$rule_name" && rule::apply_transient "$rule_name" "$client_ip" || true
|
||||
done <<< "$rules"
|
||||
fi
|
||||
}
|
||||
|
||||
log::wg_success "Rule '${rule_name}' re-applied to ${count} peers"
|
||||
# rule::full_restore_peer <peer_name> <client_ip>
|
||||
# Flush and fully restore all fw rules for a peer — rule rules + block rules.
|
||||
# Use this instead of calling rule::apply + block::restore_rules_for separately
|
||||
# to ensure block rules are never left missing after a flush.
|
||||
function rule::full_restore_peer() {
|
||||
local peer_name="${1:-}" client_ip="${2:-}"
|
||||
[[ -z "$peer_name" || -z "$client_ip" ]] && return 1
|
||||
|
||||
fw::flush_peer "$client_ip"
|
||||
|
||||
local peer_rule
|
||||
peer_rule=$(peers::get_meta "$peer_name" "rule")
|
||||
|
||||
local strict
|
||||
strict=$(rule::_get_identity_strict "$peer_name")
|
||||
|
||||
if [[ "$strict" == "true" ]]; then
|
||||
# Strict mode: only identity rules apply
|
||||
rule::_apply_identity_rule "$peer_name" "$client_ip"
|
||||
else
|
||||
# Normal mode: peer rule + identity rules (additive)
|
||||
[[ -n "$peer_rule" ]] && rule::apply "$peer_rule" "$client_ip" "$peer_name"
|
||||
rule::_apply_identity_rule "$peer_name" "$client_ip"
|
||||
fi
|
||||
|
||||
block::restore_rules_for "$peer_name" "$client_ip"
|
||||
}
|
||||
|
||||
function rule::_get_identity_strict() {
|
||||
local peer_name="${1:-}"
|
||||
local identity_name
|
||||
identity_name=$(identity::get_name "$peer_name")
|
||||
[[ -z "$identity_name" ]] && echo "false" && return 0
|
||||
identity::rule_flags "$identity_name" "strict_rule"
|
||||
}
|
||||
|
||||
function rule::restore_all() {
|
||||
while IFS= read -r peer_name; do
|
||||
# Skip blocked peers - no fw rules needed when blocked
|
||||
block::is_blocked "$peer_name" && continue
|
||||
|
||||
|
||||
local rule_name
|
||||
rule_name=$(peers::get_meta "$peer_name" "rule")
|
||||
|
||||
[[ -z "$rule_name" ]] && continue
|
||||
|
||||
if ! rule::exists "$rule_name"; then
|
||||
|
|
@ -282,7 +323,8 @@ function rule::restore_all() {
|
|||
client_ip=$(peers::get_ip "$peer_name")
|
||||
[[ -z "$client_ip" ]] && continue
|
||||
|
||||
rule::apply "$rule_name" "$client_ip"
|
||||
# full_restore_peer ensures block rules are restored alongside rule rules
|
||||
rule::full_restore_peer "$peer_name" "$client_ip"
|
||||
done < <(peers::all)
|
||||
log::wg "Rules restored for all peers"
|
||||
}
|
||||
|
|
@ -292,136 +334,19 @@ function rule::restore_all() {
|
|||
# ============================================
|
||||
|
||||
function rule::render_flat() {
|
||||
local rule_name="${1:-}"
|
||||
|
||||
local allow_ports allow_ips block_ips block_ports dns
|
||||
allow_ports=$(rule::get "$rule_name" "allow_ports")
|
||||
allow_ips=$(rule::get "$rule_name" "allow_ips")
|
||||
block_ips=$(rule::get "$rule_name" "block_ips")
|
||||
block_ports=$(rule::get "$rule_name" "block_ports")
|
||||
dns=$(rule::get_own "$rule_name" "dns_redirect")
|
||||
|
||||
local has_content=false
|
||||
[[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && \
|
||||
has_content=true
|
||||
|
||||
if ! $has_content; then
|
||||
printf "\n full access (no restrictions)\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
|
||||
printf "\n"
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "+" "$e" 2
|
||||
done <<< "$allow_ports"$'\n'"$allow_ips"
|
||||
fi
|
||||
|
||||
if [[ -n "$block_ips" || -n "$block_ports" ]]; then
|
||||
printf "\n"
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "-" "$e" 2
|
||||
done <<< "$block_ips"$'\n'"$block_ports"
|
||||
fi
|
||||
|
||||
[[ "${dns,,}" == "true" ]] && \
|
||||
net::print_dns_redirect "$(config::dns)" 6 "DNS"
|
||||
|
||||
return 0
|
||||
ui::rule::flat "$1"
|
||||
}
|
||||
|
||||
|
||||
function rule::render_entries() {
|
||||
# Renders allow/block entries for a rule name with annotations and DNS
|
||||
# Usage: rule::render_entries <rule_name> <indent>
|
||||
# indent: 4 for rule show, 4 for inspect (same)
|
||||
local rule_name="${1:-}" indent="${2:-4}"
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule_name")"
|
||||
|
||||
local allow_ports allow_ips block_ips block_ports dns
|
||||
allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true)
|
||||
allow_ips=$(rule::get "$rule_name" "allow_ips" 2>/dev/null || true)
|
||||
block_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true)
|
||||
block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true)
|
||||
dns=$(rule::get_own "$rule_name" "dns_redirect")
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "+" "$e"
|
||||
done <<< "$allow_ports"$'\n'"$allow_ips"
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "-" "$e"
|
||||
done <<< "$block_ips"$'\n'"$block_ports"
|
||||
|
||||
[[ "${dns,,}" == "true" ]] && \
|
||||
net::print_dns_redirect "$(config::dns)" 6 "DNS"
|
||||
ui::rule::entries "$1"
|
||||
}
|
||||
|
||||
|
||||
function rule::render_own_entries() {
|
||||
# Renders own (non-inherited) entries for a rule
|
||||
local rule_name="${1:-}"
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule_name")"
|
||||
|
||||
local allow_ports allow_ips block_ips block_ports dns
|
||||
allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true)
|
||||
allow_ips=$(json::get "$rule_file" "allow_ips" 2>/dev/null || true)
|
||||
block_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true)
|
||||
block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true)
|
||||
dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true)
|
||||
|
||||
local has_own=false
|
||||
local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}"
|
||||
[[ -n "${combined//[$'\n']/}" ]] && has_own=true
|
||||
|
||||
$has_own || return 0
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "+" "$e"
|
||||
done <<< "$allow_ports"$'\n'"$allow_ips"
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "-" "$e"
|
||||
done <<< "$block_ips"$'\n'"$block_ports"
|
||||
|
||||
[[ "${dns,,}" == "true" ]] && \
|
||||
net::print_dns_redirect "$(config::dns)" 6 "DNS"
|
||||
|
||||
return 0
|
||||
ui::rule::own_entries "$1"
|
||||
}
|
||||
|
||||
|
||||
function rule::render_extends_tree() {
|
||||
# Renders full inheritance tree for a rule
|
||||
local rule_name="${1:-}"
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule_name")"
|
||||
|
||||
local extends_raw=()
|
||||
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true)
|
||||
|
||||
[[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]] && return 1
|
||||
|
||||
for base_name in "${extends_raw[@]}"; do
|
||||
[[ -z "$base_name" ]] && continue
|
||||
printf "\n \033[0;37m↳ %s\033[0m\n" "$base_name"
|
||||
rule::render_entries "$base_name"
|
||||
done
|
||||
|
||||
# Own rules after inherited
|
||||
local own_output
|
||||
own_output=$(rule::render_own_entries "$rule_name")
|
||||
if [[ -n "$own_output" ]]; then
|
||||
printf "\n \033[0;37mOwn:\033[0m\n"
|
||||
printf "%s\n" "$own_output"
|
||||
fi
|
||||
|
||||
return 0
|
||||
ui::rule::tree "$1"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
|
|
@ -436,4 +361,4 @@ function rule::apply_dns_redirect() {
|
|||
function rule::remove_dns_redirect() {
|
||||
local client_subnet="${1:-}"
|
||||
fw::nat_remove_dns_redirect "$client_subnet" "$(config::dns)" "$(config::interface)"
|
||||
}
|
||||
}
|
||||
362
modules/subnet.module.sh
Normal file
362
modules/subnet.module.sh
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
#!/usr/bin/env bash
|
||||
# subnet.module.sh — subnet map lookups, resolution, validation, and CIDR utilities
|
||||
# All subnet data lives in subnets.json; this module wraps json:: calls
|
||||
# and provides hardcoded fallbacks for safety.
|
||||
|
||||
# ======================================================
|
||||
# CIDR Utilities
|
||||
# Pure functions — no external dependencies, no side effects.
|
||||
# Suitable for unit testing.
|
||||
# ======================================================
|
||||
|
||||
# subnet::prefix <cidr>
|
||||
# Returns the first three octets of a CIDR (the allocation prefix).
|
||||
# Example: 10.1.3.0/24 -> 10.1.3
|
||||
function subnet::prefix() {
|
||||
echo "${1%%/*}" | cut -d'.' -f1-3
|
||||
}
|
||||
|
||||
# subnet::base_ip <cidr>
|
||||
# Returns the network address without the mask.
|
||||
# Example: 10.1.3.0/24 -> 10.1.3.0
|
||||
function subnet::base_ip() {
|
||||
echo "${1%%/*}"
|
||||
}
|
||||
|
||||
# subnet::mask <cidr>
|
||||
# Returns the prefix length.
|
||||
# Example: 10.1.3.0/24 -> 24
|
||||
function subnet::mask() {
|
||||
echo "${1##*/}"
|
||||
}
|
||||
|
||||
# subnet::host_range <cidr>
|
||||
# Returns the iterable host range for a subnet.
|
||||
# Currently supports /24 (1-254). Mask-aware implementation deferred.
|
||||
function subnet::host_range() {
|
||||
local cidr="${1:-}"
|
||||
local mask
|
||||
mask=$(subnet::mask "$cidr")
|
||||
case "$mask" in
|
||||
24) seq 1 254 ;;
|
||||
*)
|
||||
# Fallback for non-/24 — still seq 1 254, but noted for future upgrade
|
||||
seq 1 254
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# subnet::contains <cidr> <ip>
|
||||
# Returns 0 if the IP falls within the CIDR, 1 otherwise.
|
||||
# Example: subnet::contains 10.1.3.0/24 10.1.3.5 -> 0 (true)
|
||||
function subnet::contains() {
|
||||
local cidr="${1:-}" ip="${2:-}"
|
||||
[[ -z "$cidr" || -z "$ip" ]] && return 1
|
||||
|
||||
local prefix mask
|
||||
prefix=$(subnet::prefix "$cidr")
|
||||
mask=$(subnet::mask "$cidr")
|
||||
|
||||
# For /24: check that the first three octets match
|
||||
case "$mask" in
|
||||
24)
|
||||
local ip_prefix
|
||||
ip_prefix=$(echo "$ip" | cut -d'.' -f1-3)
|
||||
[[ "$ip_prefix" == "$prefix" ]]
|
||||
;;
|
||||
*)
|
||||
# Delegate to Python for non-/24 subnets
|
||||
python3 -c "
|
||||
import ipaddress, sys
|
||||
try:
|
||||
net = ipaddress.ip_network('${cidr}', strict=False)
|
||||
addr = ipaddress.ip_address('${ip}')
|
||||
sys.exit(0 if addr in net else 1)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# subnet::is_valid_cidr <cidr>
|
||||
# Returns 0 if the string is a valid CIDR notation.
|
||||
function subnet::is_valid_cidr() {
|
||||
local cidr="${1:-}"
|
||||
[[ "$cidr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]] || return 1
|
||||
# Also validate each octet is 0-255
|
||||
local ip
|
||||
ip=$(subnet::base_ip "$cidr")
|
||||
local IFS='.'
|
||||
read -ra octets <<< "$ip"
|
||||
for octet in "${octets[@]}"; do
|
||||
(( octet >= 0 && octet <= 255 )) || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# subnet::ip_valid_for <cidr> <ip>
|
||||
# Returns 0 if the IP is a valid host address within the given CIDR.
|
||||
# Excludes network address (.0) and broadcast address (.255) for /24.
|
||||
function subnet::ip_valid_for() {
|
||||
local cidr="${1:-}" ip="${2:-}"
|
||||
[[ -z "$cidr" || -z "$ip" ]] && return 1
|
||||
|
||||
# Must be a valid IP first
|
||||
ip::is_valid "$ip" || return 1
|
||||
|
||||
# Must be within the subnet
|
||||
subnet::contains "$cidr" "$ip" || return 1
|
||||
|
||||
# Must not be network or broadcast address (for /24)
|
||||
local mask
|
||||
mask=$(subnet::mask "$cidr")
|
||||
if [[ "$mask" == "24" ]]; then
|
||||
local last_octet
|
||||
last_octet=$(echo "$ip" | cut -d'.' -f4)
|
||||
[[ "$last_octet" == "0" || "$last_octet" == "255" ]] && return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# subnet::require_valid_cidr <cidr>
|
||||
# Errors and exits if the CIDR is not valid.
|
||||
function subnet::require_valid_cidr() {
|
||||
local cidr="${1:-}"
|
||||
if ! subnet::is_valid_cidr "$cidr"; then
|
||||
log::error "Invalid CIDR notation: '${cidr}'"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# subnet::require_ip_valid_for <cidr> <ip>
|
||||
# Errors and exits if the IP is not a valid host for the subnet.
|
||||
function subnet::require_ip_valid_for() {
|
||||
local cidr="${1:-}" ip="${2:-}"
|
||||
if ! subnet::ip_valid_for "$cidr" "$ip"; then
|
||||
log::error "IP '${ip}' is not a valid host address in subnet '${cidr}'"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Hardcoded Fallbacks
|
||||
# Mirror of production subnets.json.
|
||||
# Used only when subnets.json lookup fails.
|
||||
# ======================================================
|
||||
|
||||
function subnet::_hardcoded_cidr() {
|
||||
local type="${1:-}" subnet_name="${2:-}"
|
||||
if [[ -n "$subnet_name" ]]; then
|
||||
case "$subnet_name" in
|
||||
guests) echo "10.1.100.0/24" ;;
|
||||
servers) echo "10.1.200.0/24" ;;
|
||||
iot) echo "10.1.210.0/24" ;;
|
||||
*) echo "10.1.0.0/24" ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
case "$type" in
|
||||
desktop) echo "10.1.1.0/24" ;;
|
||||
laptop) echo "10.1.2.0/24" ;;
|
||||
phone) echo "10.1.3.0/24" ;;
|
||||
tablet) echo "10.1.4.0/24" ;;
|
||||
guest) echo "10.1.100.0/24" ;;
|
||||
guest-desktop) echo "10.1.101.0/24" ;;
|
||||
guest-laptop) echo "10.1.102.0/24" ;;
|
||||
guest-phone) echo "10.1.103.0/24" ;;
|
||||
guest-tablet) echo "10.1.104.0/24" ;;
|
||||
server) echo "10.1.200.0/24" ;;
|
||||
iot) echo "10.1.210.0/24" ;;
|
||||
*) echo "10.1.0.0/24" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
function subnet::_hardcoded_type() {
|
||||
local ip="${1:-}"
|
||||
case "$ip" in
|
||||
10.1.1.*) echo "desktop" ;;
|
||||
10.1.2.*) echo "laptop" ;;
|
||||
10.1.3.*) echo "phone" ;;
|
||||
10.1.4.*) echo "tablet" ;;
|
||||
10.1.100.*) echo "none" ;;
|
||||
10.1.101.*) echo "desktop" ;;
|
||||
10.1.102.*) echo "laptop" ;;
|
||||
10.1.103.*) echo "phone" ;;
|
||||
10.1.104.*) echo "tablet" ;;
|
||||
10.1.200.*) echo "server" ;;
|
||||
10.1.210.*) echo "iot" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
function subnet::_hardcoded_tunnel_mode() {
|
||||
echo "split"
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Core Resolution
|
||||
# ======================================================
|
||||
|
||||
function subnet::policy() {
|
||||
local subnet_name="${1:-}" type_key="${2:-}"
|
||||
policy::for_subnet "$subnet_name" "$type_key"
|
||||
}
|
||||
|
||||
# subnet::lookup <subnet_name> [type_key]
|
||||
# Returns the CIDR for a given subnet name and optional type.
|
||||
# Falls back to hardcoded map on failure.
|
||||
function subnet::lookup() {
|
||||
local subnet_name="${1:-}" type_key="${2:-}"
|
||||
local result
|
||||
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true
|
||||
if [[ -n "$result" ]]; then
|
||||
echo "$result"
|
||||
return 0
|
||||
fi
|
||||
subnet::_hardcoded_cidr "" "$subnet_name"
|
||||
}
|
||||
|
||||
# subnet::resolve_for_add <type> [subnet_name]
|
||||
# Main entry point for wgctl add — returns the CIDR to allocate from.
|
||||
function subnet::resolve_for_add() {
|
||||
local peer_type="${1:-}" subnet_name="${2:-}"
|
||||
local result
|
||||
|
||||
if [[ -n "$subnet_name" ]]; then
|
||||
# Group entry: try type-specific child first, then "none" slot
|
||||
if [[ -n "$peer_type" ]]; then
|
||||
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" "$peer_type" 2>/dev/null) || true
|
||||
[[ -n "$result" ]] && { echo "$result"; return 0; }
|
||||
fi
|
||||
result=$(json::subnet_lookup "$(ctx::subnets)" "$subnet_name" 2>/dev/null) || true
|
||||
[[ -n "$result" ]] && { echo "$result"; return 0; }
|
||||
subnet::_hardcoded_cidr "" "$subnet_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No subnet_name — resolve from type (native allocation)
|
||||
if [[ -n "$peer_type" ]]; then
|
||||
result=$(json::subnet_lookup "$(ctx::subnets)" "$peer_type" 2>/dev/null) || true
|
||||
[[ -n "$result" ]] && { echo "$result"; return 0; }
|
||||
fi
|
||||
|
||||
subnet::_hardcoded_cidr "$peer_type"
|
||||
}
|
||||
|
||||
# subnet::type_for_add <type_flag> [subnet_name]
|
||||
# Returns the canonical type string to store in meta.
|
||||
function subnet::type_for_add() {
|
||||
local type_flag="${1:-}" subnet_name="${2:-}"
|
||||
local result
|
||||
|
||||
if [[ -n "$subnet_name" ]]; then
|
||||
result=$(json::subnet_type "$(ctx::subnets)" "$subnet_name" "$type_flag" 2>/dev/null) || true
|
||||
[[ -n "$result" ]] && { echo "$result"; return 0; }
|
||||
fi
|
||||
|
||||
echo "${type_flag:-none}"
|
||||
}
|
||||
|
||||
# subnet::tunnel_mode <subnet_name> [type_key]
|
||||
# Returns "split" or "full" for the given subnet.
|
||||
function subnet::tunnel_mode() {
|
||||
local subnet_name="${1:-}" type_key="${2:-}"
|
||||
local policy_name
|
||||
policy_name=$(policy::for_subnet "$subnet_name" "$type_key")
|
||||
policy::tunnel_mode "$policy_name"
|
||||
}
|
||||
|
||||
function subnet::type_from_ip() {
|
||||
local ip="${1:-}"
|
||||
[[ -z "$ip" ]] && echo "unknown" && return 0
|
||||
|
||||
# Fast path: hardcoded map covers all production subnets — pure bash, no subshell
|
||||
local type
|
||||
type=$(subnet::_hardcoded_type "$ip")
|
||||
if [[ "$type" != "unknown" ]]; then
|
||||
echo "$type"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Slow path: Python lookup for dynamically-added subnets not in hardcoded map
|
||||
local result
|
||||
result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true
|
||||
if [[ -n "$result" ]]; then
|
||||
echo "${result##*|}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "unknown"
|
||||
}
|
||||
|
||||
# subnet::name_from_ip <ip>
|
||||
# Returns the subnet name (e.g. "guests", "desktop") for an IP.
|
||||
function subnet::name_from_ip() {
|
||||
local ip="${1:-}"
|
||||
local result
|
||||
result=$(json::subnet_for_ip "$(ctx::subnets)" "$ip" 2>/dev/null) || true
|
||||
[[ -n "$result" ]] && { echo "${result%%|*}"; return 0; }
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Returns the default rule for a subnet, or empty string if none configured.
|
||||
# Called by add.command.sh to resolve the rule before falling back to "user".
|
||||
function subnet::default_rule() {
|
||||
local subnet_name="${1:-}" type_key="${2:-}"
|
||||
[[ -z "$subnet_name" ]] && return 0
|
||||
local policy_name
|
||||
policy_name=$(policy::for_subnet "$subnet_name" "$type_key")
|
||||
policy::default_rule "$policy_name"
|
||||
}
|
||||
|
||||
# subnet::list_names
|
||||
# Returns all top-level subnet names, one per line.
|
||||
# Used by commands to dynamically register --<subnet> flags.
|
||||
function subnet::list_names() {
|
||||
json::subnet_list_names "$(ctx::subnets)" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Validation
|
||||
# ======================================================
|
||||
|
||||
function subnet::exists() {
|
||||
local name="${1:-}"
|
||||
json::subnet_exists "$(ctx::subnets)" "$name" 2>/dev/null
|
||||
}
|
||||
|
||||
function subnet::require_exists() {
|
||||
local name="${1:-}"
|
||||
if ! subnet::exists "$name"; then
|
||||
log::error "Subnet '${name}' not found. Use 'wgctl subnet list' to see available subnets."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function subnet::peers_using() {
|
||||
local subnet_name="${1:-}"
|
||||
local peers
|
||||
peers=$(json::subnet_peers \
|
||||
"$(ctx::meta)" \
|
||||
"$(ctx::clients)" \
|
||||
"$subnet_name" \
|
||||
"$(ctx::subnets)" \
|
||||
2>/dev/null) || true
|
||||
echo "$peers" | tr '\n' ',' | sed 's/,$//'
|
||||
}
|
||||
|
||||
|
||||
# ======================================================
|
||||
# Display Data
|
||||
# ======================================================
|
||||
|
||||
function subnet::list_data() {
|
||||
json::subnet_list "$(ctx::subnets)" 2>/dev/null || true
|
||||
}
|
||||
|
||||
function subnet::show_data() {
|
||||
local name="${1:-}"
|
||||
json::subnet_show "$(ctx::subnets)" "$name"
|
||||
}
|
||||
12
modules/ui.module.sh
Normal file
12
modules/ui.module.sh
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
# ui.module.sh — wgctl rendering layer
|
||||
# Loads all submodules from ui/ and provides any truly shared
|
||||
# rendering primitives used across multiple submodules.
|
||||
|
||||
# ============================================
|
||||
# Lifecycle
|
||||
# ============================================
|
||||
|
||||
function ui::on_load() {
|
||||
load_module "ui/*"
|
||||
}
|
||||
81
modules/ui/identity.module.sh
Normal file
81
modules/ui/identity.module.sh
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env bash
|
||||
# ui/identity.module.sh — rendering for identity data
|
||||
# All functions pure rendering — no writes, no state changes.
|
||||
|
||||
function ui::identity::header() {
|
||||
printf " %-20s %-7s %s\n" "IDENTITY" "PEERS" "DEVICE TYPES"
|
||||
ui::divider 54
|
||||
}
|
||||
|
||||
function ui::identity::row() {
|
||||
local name="${1:-}" peer_count="${2:-}" types="${3:-}"
|
||||
local types_display="${types//,/, }"
|
||||
[[ -z "$types_display" ]] && types_display="—"
|
||||
printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display"
|
||||
}
|
||||
|
||||
function ui::identity::detail_name() {
|
||||
local name="${1:-}" peer_count="${2:-}"
|
||||
echo ""
|
||||
ui::row "Identity" "$name"
|
||||
ui::row "Peers" "$peer_count"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function ui::identity::device_row() {
|
||||
local peer_name="${1:-}" dev_type="${2:-}" \
|
||||
dev_index="${3:-1}" status="${4:-}"
|
||||
local suffix=""
|
||||
[[ "$dev_index" -gt 1 ]] && suffix=" (#${dev_index})"
|
||||
printf " · %-24s %-10s%s%s\n" \
|
||||
"$peer_name" "$dev_type" "$suffix" "$status"
|
||||
}
|
||||
|
||||
function ui::identity::migrate_create() {
|
||||
local peer_name="${1:-}" identity_name="${2:-}" \
|
||||
peer_type="${3:-}" index="${4:-}"
|
||||
printf " %s %-22s → identity %-14s %-10s #%s\n" \
|
||||
"$(color::green "+")" "$peer_name" "$identity_name" "$peer_type" "$index"
|
||||
}
|
||||
|
||||
function ui::identity::migrate_skip() {
|
||||
local peer_name="${1:-}"
|
||||
printf " %s %-22s (no convention match)\n" \
|
||||
"$(color::dim "·")" "$peer_name"
|
||||
}
|
||||
|
||||
function ui::identity::migrate_summary() {
|
||||
local created="${1:-0}" skipped="${2:-0}" dry_run="${3:-false}"
|
||||
echo ""
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
log::info "Would create entries for ${created} peers (${skipped} skipped)"
|
||||
else
|
||||
log::ok "Created identity entries for ${created} peers (${skipped} skipped)"
|
||||
fi
|
||||
}
|
||||
|
||||
function ui::identity::list_row_compact() {
|
||||
local name="${1:-}" peer_count="${2:-}" rules_list="${3:-}" policy="${4:-}"
|
||||
local peer_word="peers"
|
||||
[[ "$peer_count" -eq 1 ]] && peer_word="peer"
|
||||
|
||||
local peers_col="${peer_count} ${peer_word}"
|
||||
local rules_val="-"
|
||||
[[ -n "$rules_list" ]] && rules_val="$rules_list"
|
||||
|
||||
# Pad rules_val to fixed width before adding any ANSI
|
||||
local pad=16
|
||||
local rules_padded
|
||||
rules_padded=$(printf "%-${pad}s" "$rules_val")
|
||||
|
||||
printf " \033[1m%-20s\033[0m %-10s \033[2mrules:\033[0m %s \033[2mpolicy:\033[0m %s\n" \
|
||||
"$name" "$peers_col" "$rules_padded" "$policy"
|
||||
}
|
||||
|
||||
|
||||
function ui::identity::list_row_table() {
|
||||
local name="${1:-}" peer_count="${2:-}" types="${3:-}"
|
||||
local types_display="${types//,/, }"
|
||||
[[ -z "$types_display" ]] && types_display="—"
|
||||
printf " %-20s %-7s %s\n" "$name" "$peer_count" "$types_display"
|
||||
}
|
||||
161
modules/ui/peer.module.sh
Normal file
161
modules/ui/peer.module.sh
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
#!/usr/bin/env bash
|
||||
# ui/peer.module.sh — rendering for peer list data
|
||||
# Both compact (tableless) and table layouts kept for future config switching.
|
||||
|
||||
_LIST_STYLE="${LIST_STYLE:-compact}"
|
||||
|
||||
function ui::peer::list_style() {
|
||||
echo "$_LIST_STYLE"
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Compact layout (tableless)
|
||||
# ======================================================
|
||||
|
||||
function ui::peer::list_row_compact() {
|
||||
local w_name="${1:-22}" w_ip="${2:-14}" w_type="${3:-9}" \
|
||||
w_rule="${4:-6}" w_group="${5:-6}"
|
||||
shift 5
|
||||
local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \
|
||||
group="${5:-}" status="${6:-}" last_seen="${7:-}" \
|
||||
is_blocked="${8:-false}" is_restricted="${9:-false}"
|
||||
|
||||
local status_color="\033[0;37m"
|
||||
if [[ "$is_blocked" == "true" ]]; then
|
||||
status_color="\033[1;31m"
|
||||
elif [[ "$is_restricted" == "true" ]]; then
|
||||
status_color="\033[1;33m"
|
||||
elif [[ "$status" == "online" ]]; then
|
||||
status_color="\033[1;32m"
|
||||
fi
|
||||
|
||||
local ls_color="\033[0;37m"
|
||||
[[ "$status" == "online" ]] && ls_color="\033[1;32m"
|
||||
|
||||
local rule_val="${rule:--}"
|
||||
local group_val="${group:--}"
|
||||
|
||||
# Pad name, ip, type — pure ASCII, safe for printf
|
||||
local name_pad ip_pad type_pad status_pad
|
||||
name_pad=$(printf "%-${w_name}s" "$name")
|
||||
ip_pad=$(printf "%-${w_ip}s" "$ip")
|
||||
type_pad=$(printf "%-${w_type}s" "$type")
|
||||
status_pad=$(printf "%-8s" "$status")
|
||||
|
||||
# Padding for label+value fields — compute trailing spaces manually
|
||||
# so ANSI codes in labels don't confuse printf width calculation
|
||||
local rule_pad_n group_pad_n
|
||||
rule_pad_n=$(( w_rule - ${#rule_val} ))
|
||||
group_pad_n=$(( w_group - ${#group_val} ))
|
||||
[[ $rule_pad_n -lt 0 ]] && rule_pad_n=0
|
||||
[[ $group_pad_n -lt 0 ]] && group_pad_n=0
|
||||
|
||||
printf " %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \
|
||||
"$name_pad" "$ip_pad" "$type_pad" \
|
||||
"$rule_val" "$rule_pad_n" "" \
|
||||
"$group_val" "$group_pad_n" "" \
|
||||
"$status_color" "$status_pad" \
|
||||
"$ls_color" "$last_seen"
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Table layout (kept for config switching)
|
||||
# ======================================================
|
||||
|
||||
function ui::peer::list_header_table() {
|
||||
local has_groups="${1:-false}"
|
||||
if $has_groups; then
|
||||
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
|
||||
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
|
||||
printf " %s\n" "$(printf '─%.0s' {1..135})"
|
||||
else
|
||||
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
|
||||
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
|
||||
printf " %s\n" "$(printf '─%.0s' {1..107})"
|
||||
fi
|
||||
}
|
||||
|
||||
function ui::peer::list_footer_table() {
|
||||
local has_groups="${1:-false}"
|
||||
if $has_groups; then
|
||||
printf " %s\n" "$(printf '─%.0s' {1..135})"
|
||||
else
|
||||
printf " %s\n" "$(printf '─%.0s' {1..107})"
|
||||
fi
|
||||
}
|
||||
|
||||
function ui::peer::list_row_table() {
|
||||
local has_groups="${1:-false}"
|
||||
shift
|
||||
local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \
|
||||
group="${5:-}" status="${6:-}" last_seen="${7:-}"
|
||||
|
||||
local padded_status
|
||||
padded_status=$(ui::pad_status "$status" 25)
|
||||
|
||||
if $has_groups; then
|
||||
printf " %-28s %-15s %-13s %-12s %-12s %s %s\n" \
|
||||
"$name" "$ip" "$type" "${rule:-—}" "${group:-—}" \
|
||||
"$padded_status" "$last_seen"
|
||||
else
|
||||
printf " %-28s %-15s %-13s %-12s %s %s\n" \
|
||||
"$name" "$ip" "$type" "${rule:-—}" \
|
||||
"$padded_status" "$last_seen"
|
||||
fi
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Detailed layout (grouped by identity)
|
||||
# ======================================================
|
||||
|
||||
function ui::peer::list_identity_header() {
|
||||
local identity_name="${1:-}"
|
||||
printf "\n \033[1m%s\033[0m\n" "$identity_name"
|
||||
}
|
||||
|
||||
function ui::peer::list_row_detailed() {
|
||||
local w_name="${1:-22}" w_ip="${2:-14}" w_type="${3:-9}" \
|
||||
w_rule="${4:-6}" w_group="${5:-6}" w_subnet="${6:-8}"
|
||||
shift 6
|
||||
local name="${1:-}" ip="${2:-}" type="${3:-}" rule="${4:-}" \
|
||||
group="${5:-}" subnet="${6:-}" status="${7:-}" last_seen="${8:-}" \
|
||||
is_blocked="${9:-false}" is_restricted="${10:-false}"
|
||||
|
||||
local status_color="\033[0;37m"
|
||||
if [[ "$is_blocked" == "true" ]]; then
|
||||
status_color="\033[1;31m"
|
||||
elif [[ "$is_restricted" == "true" ]]; then
|
||||
status_color="\033[1;33m"
|
||||
elif [[ "$status" == "online" ]]; then
|
||||
status_color="\033[1;32m"
|
||||
fi
|
||||
|
||||
local ls_color="\033[0;37m"
|
||||
[[ "$status" == "online" ]] && ls_color="\033[1;32m"
|
||||
|
||||
local rule_val="${rule:-—}"
|
||||
local group_val="${group:-—}"
|
||||
local subnet_val="${subnet:-—}"
|
||||
|
||||
local name_pad ip_pad type_pad status_pad
|
||||
name_pad=$(printf "%-${w_name}s" "$name")
|
||||
ip_pad=$(printf "%-${w_ip}s" "$ip")
|
||||
type_pad=$(printf "%-${w_type}s" "$type")
|
||||
status_pad=$(printf "%-8s" "$status")
|
||||
|
||||
local rule_pad_n group_pad_n subnet_pad_n
|
||||
rule_pad_n=$(( w_rule - ${#rule_val} ))
|
||||
group_pad_n=$(( w_group - ${#group_val} ))
|
||||
subnet_pad_n=$(( w_subnet - ${#subnet_val} ))
|
||||
[[ $rule_pad_n -lt 0 ]] && rule_pad_n=0
|
||||
[[ $group_pad_n -lt 0 ]] && group_pad_n=0
|
||||
[[ $subnet_pad_n -lt 0 ]] && subnet_pad_n=0
|
||||
|
||||
printf " · %s %s %s \033[2mrule:\033[0m %s%*s \033[2mgroup:\033[0m %s%*s \033[2msubnet:\033[0m %s%*s %b%s\033[0m %b%s\033[0m\n" \
|
||||
"$name_pad" "$ip_pad" "$type_pad" \
|
||||
"$rule_val" "$rule_pad_n" "" \
|
||||
"$group_val" "$group_pad_n" "" \
|
||||
"$subnet_val" "$subnet_pad_n" "" \
|
||||
"$status_color" "$status_pad" \
|
||||
"$ls_color" "$last_seen"
|
||||
}
|
||||
257
modules/ui/rule.module.sh
Normal file
257
modules/ui/rule.module.sh
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
#!/usr/bin/env bash
|
||||
# ui/rule.module.sh — rendering for rule data
|
||||
# Replaces rule::render_* functions from rule.module.sh.
|
||||
# All functions pure rendering — no writes, no state changes.
|
||||
|
||||
# ======================================================
|
||||
# Entry Rendering (shared primitives)
|
||||
# ======================================================
|
||||
|
||||
# ui::rule::entries <rule_name> [indent]
|
||||
# Renders the fully resolved entries for a rule (allow + block).
|
||||
function ui::rule::entries() {
|
||||
local rule_name="${1:-}" indent="${2:-4}"
|
||||
|
||||
local allow_ports allow_ips block_ips block_ports dns
|
||||
allow_ports=$(rule::get "$rule_name" "allow_ports" 2>/dev/null || true)
|
||||
allow_ips=$(rule::get "$rule_name" "allow_ips" 2>/dev/null || true)
|
||||
block_ips=$(rule::get "$rule_name" "block_ips" 2>/dev/null || true)
|
||||
block_ports=$(rule::get "$rule_name" "block_ports" 2>/dev/null || true)
|
||||
dns=$(rule::get_own "$rule_name" "dns_redirect")
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "+" "$e" "$indent"
|
||||
done <<< "$allow_ports"$'\n'"$allow_ips"
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "-" "$e" "$indent"
|
||||
done <<< "$block_ips"$'\n'"$block_ports"
|
||||
|
||||
[[ "${dns,,}" == "true" ]] && \
|
||||
net::print_dns_redirect "$(config::dns)" 6 "DNS"
|
||||
}
|
||||
|
||||
# ui::rule::own_entries <rule_name> [indent]
|
||||
# Renders only the rule's own (non-inherited) entries.
|
||||
function ui::rule::own_entries() {
|
||||
local rule_name="${1:-}" indent="${2:-4}"
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule_name")" || return 0
|
||||
[[ -z "$rule_file" ]] && return 0
|
||||
|
||||
local allow_ports allow_ips block_ips block_ports dns
|
||||
allow_ports=$(json::get "$rule_file" "allow_ports" 2>/dev/null || true)
|
||||
allow_ips=$(json::get "$rule_file" "allow_ips" 2>/dev/null || true)
|
||||
block_ips=$(json::get "$rule_file" "block_ips" 2>/dev/null || true)
|
||||
block_ports=$(json::get "$rule_file" "block_ports" 2>/dev/null || true)
|
||||
dns=$(json::get "$rule_file" "dns_redirect" 2>/dev/null || true)
|
||||
|
||||
local combined="${allow_ports}${allow_ips}${block_ips}${block_ports}"
|
||||
[[ -z "${combined//[$'\n']/}" ]] && return 0
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "+" "$e" "$indent"
|
||||
done <<< "$allow_ports"$'\n'"$allow_ips"
|
||||
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "-" "$e" "$indent"
|
||||
done <<< "$block_ips"$'\n'"$block_ports"
|
||||
|
||||
[[ "${dns,,}" == "true" ]] && \
|
||||
net::print_dns_redirect "$(config::dns)" 6 "DNS"
|
||||
}
|
||||
|
||||
# ui::rule::flat <rule_name>
|
||||
# Renders the full resolved entries as a flat list.
|
||||
function ui::rule::flat() {
|
||||
local rule_name="${1:-}"
|
||||
|
||||
local allow_ports allow_ips block_ips block_ports dns
|
||||
allow_ports=$(rule::get "$rule_name" "allow_ports")
|
||||
allow_ips=$(rule::get "$rule_name" "allow_ips")
|
||||
block_ips=$(rule::get "$rule_name" "block_ips")
|
||||
block_ports=$(rule::get "$rule_name" "block_ports")
|
||||
dns=$(rule::get_own "$rule_name" "dns_redirect")
|
||||
|
||||
local has_content=false
|
||||
[[ -n "${allow_ports}${allow_ips}${block_ips}${block_ports}" ]] && has_content=true
|
||||
|
||||
if ! $has_content; then
|
||||
printf "\n full access (no restrictions)\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$allow_ports" || -n "$allow_ips" ]]; then
|
||||
printf "\n"
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "+" "$e" 2
|
||||
done <<< "$allow_ports"$'\n'"$allow_ips"
|
||||
fi
|
||||
|
||||
if [[ -n "$block_ips" || -n "$block_ports" ]]; then
|
||||
printf "\n"
|
||||
while IFS= read -r e; do
|
||||
[[ -z "$e" ]] && continue
|
||||
net::print_entry "-" "$e" 2
|
||||
done <<< "$block_ips"$'\n'"$block_ports"
|
||||
fi
|
||||
|
||||
[[ "${dns,,}" == "true" ]] && \
|
||||
net::print_dns_redirect "$(config::dns)" 6 "DNS"
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Shared Base Renderer
|
||||
# ======================================================
|
||||
|
||||
# ui::rule::_render_bases <array_nameref> [entry_indent] [label_indent]
|
||||
# Renders a list of base rule names with newlines between them.
|
||||
# Used by both ui::rule::tree and ui::rule::_identity_rule_entry.
|
||||
function ui::rule::_render_bases() {
|
||||
local -n _bases="$1"
|
||||
local entry_indent="${2:-6}" label_indent="${3:-4}"
|
||||
local label_pad
|
||||
label_pad=$(printf '%*s' "$label_indent" '')
|
||||
|
||||
local first=true
|
||||
for base_name in "${_bases[@]}"; do
|
||||
[[ -z "$base_name" ]] && continue
|
||||
$first || printf "\n"
|
||||
first=false
|
||||
printf "%s\033[0;37m↳ %s\033[0m\n" "$label_pad" "$base_name"
|
||||
ui::rule::entries "$base_name" "$entry_indent"
|
||||
done
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Tree Rendering
|
||||
# ======================================================
|
||||
|
||||
# ui::rule::tree <rule_name>
|
||||
# Renders a rule's extends tree — one level deep with own entries.
|
||||
# Returns 1 if rule has no extends (caller can fall back to flat).
|
||||
function ui::rule::tree() {
|
||||
local rule_name="${1:-}"
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule_name")" || return 1
|
||||
[[ -z "$rule_file" ]] && return 1
|
||||
|
||||
local extends_raw=()
|
||||
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
|
||||
|
||||
if [[ ${#extends_raw[@]} -eq 0 || -z "${extends_raw[0]:-}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
ui::rule::_render_bases extends_raw 6 4
|
||||
|
||||
local own_output
|
||||
own_output=$(ui::rule::own_entries "$rule_name" 6)
|
||||
if [[ -n "$own_output" ]]; then
|
||||
printf "\n \033[0;37mOwn:\033[0m\n"
|
||||
printf "%s\n" "$own_output"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Identity Rule Block
|
||||
# ======================================================
|
||||
|
||||
# ui::rule::identity_block <identity_name> <strict_rule>
|
||||
# Renders the full identity rule block in inspect.
|
||||
function ui::rule::identity_block() {
|
||||
local identity_name="${1:-}" strict="${2:-false}"
|
||||
|
||||
local rules
|
||||
rules=$(identity::rules "$identity_name")
|
||||
[[ -z "$rules" ]] && return 0
|
||||
|
||||
printf "\n \033[0;37m· identity:%s\033[0m\n" "$identity_name"
|
||||
|
||||
local first=true
|
||||
while IFS= read -r rule_name; do
|
||||
[[ -z "$rule_name" ]] && continue
|
||||
$first || printf "\n"
|
||||
first=false
|
||||
ui::rule::_identity_rule_entry "$rule_name"
|
||||
done <<< "$rules"
|
||||
|
||||
if [[ "$strict" == "true" ]]; then
|
||||
printf "\n \033[2m(strict — peer rule suppressed)\033[0m\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# ui::rule::_identity_rule_entry <rule_name>
|
||||
# Renders one rule within an identity block.
|
||||
function ui::rule::_identity_rule_entry() {
|
||||
local rule_name="${1:-}"
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule_name")" || return 0
|
||||
|
||||
printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
|
||||
|
||||
local extends_raw=()
|
||||
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
|
||||
|
||||
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
|
||||
# Rule has extends — render one level deep using shared helper
|
||||
ui::rule::_render_bases extends_raw 10 8
|
||||
|
||||
local own_output
|
||||
own_output=$(ui::rule::own_entries "$rule_name" 10)
|
||||
if [[ -n "$own_output" ]]; then
|
||||
printf "\n \033[0;37mOwn:\033[0m\n"
|
||||
printf "%s\n" "$own_output"
|
||||
fi
|
||||
else
|
||||
# Leaf rule — show own entries or note full access
|
||||
local own_output
|
||||
own_output=$(ui::rule::own_entries "$rule_name" 8)
|
||||
if [[ -n "$own_output" ]]; then
|
||||
printf "%s\n" "$own_output"
|
||||
else
|
||||
printf " \033[2mfull access (no restrictions)\033[0m\n"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ======================================================
|
||||
# Peer Entry
|
||||
# ======================================================
|
||||
|
||||
function ui::rule::_peer_rule_entry() {
|
||||
local rule_name="${1:-}"
|
||||
local rule_file
|
||||
rule_file="$(rule::path "$rule_name")" || return 0
|
||||
|
||||
printf " \033[0;37m↳ %s\033[0m\n" "$rule_name"
|
||||
|
||||
local extends_raw=()
|
||||
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
|
||||
|
||||
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
|
||||
ui::rule::_render_bases extends_raw 10 8
|
||||
|
||||
local own_output
|
||||
own_output=$(ui::rule::own_entries "$rule_name" 10)
|
||||
if [[ -n "$own_output" ]]; then
|
||||
printf "\n \033[0;37mOwn:\033[0m\n"
|
||||
printf "%s\n" "$own_output"
|
||||
fi
|
||||
else
|
||||
local own_output
|
||||
own_output=$(ui::rule::own_entries "$rule_name" 8)
|
||||
if [[ -n "$own_output" ]]; then
|
||||
printf "%s\n" "$own_output"
|
||||
else
|
||||
printf " \033[2mfull access (no restrictions)\033[0m\n"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
213
modules/ui/subnet.module.sh
Normal file
213
modules/ui/subnet.module.sh
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
#!/usr/bin/env bash
|
||||
# ui/subnet.module.sh — rendering for subnet data
|
||||
# All functions pure rendering — no writes, no state changes.
|
||||
|
||||
function ui::subnet::header() {
|
||||
printf " %-14s %-18s %-10s %-8s %s\n" \
|
||||
"NAME" "SUBNET" "TYPE" "TUNNEL" "DESCRIPTION"
|
||||
ui::divider 70
|
||||
}
|
||||
|
||||
function ui::policy::list_row() {
|
||||
local name="${1:-}" default_rule="${2:-}" strict="${3:-}" auto="${4:-}"
|
||||
|
||||
local rule_val="-"
|
||||
[[ -n "$default_rule" ]] && rule_val="$default_rule"
|
||||
|
||||
local rule_padded
|
||||
rule_padded=$(printf "%-16s" "$rule_val")
|
||||
|
||||
local strict_display
|
||||
[[ "$strict" == "true" ]] && strict_display="yes" || strict_display="no"
|
||||
local strict_padded
|
||||
strict_padded=$(printf "%-4s" "$strict_display")
|
||||
|
||||
local auto_display=""
|
||||
[[ "$auto" == "false" ]] && auto_display=" \033[2mauto:\033[0m no"
|
||||
|
||||
printf " %-14s \033[2mrule:\033[0m %s \033[2mstrict:\033[0m %s%s\n" \
|
||||
"$name" "$rule_padded" "$strict_padded" "$auto_display"
|
||||
}
|
||||
|
||||
function ui::policy::detail_field() {
|
||||
local key="${1:-}" value="${2:-}"
|
||||
ui::row "$key" "$value"
|
||||
}
|
||||
|
||||
|
||||
function ui::subnet::row() {
|
||||
local display_name="${1:-}" subnet="${2:-}" type_key="${3:-}" \
|
||||
tunnel_mode="${4:-}" desc="${5:-}" is_group="${6:-false}"
|
||||
local name_col="$display_name"
|
||||
[[ "$is_group" == "true" ]] && name_col=" ${display_name}"
|
||||
printf " %-14s %-18s %-10s %-8s %s\n" \
|
||||
"$name_col" "$subnet" "$type_key" "$tunnel_mode" "$desc"
|
||||
}
|
||||
|
||||
function ui::subnet::row_scalar() {
|
||||
local name="${1:-}" subnet="${2:-}" tunnel="${3:-split}"
|
||||
local tunnel_val
|
||||
tunnel_val=$(printf "%-8s" "$tunnel")
|
||||
printf " %-14s %-18s \033[2mtunnel:\033[0m %s\n" \
|
||||
"$name" "$subnet" "$tunnel_val"
|
||||
}
|
||||
|
||||
function ui::subnet::row_group_parent() {
|
||||
local name="${1:-}"
|
||||
printf " \033[1m%s\033[0m\n" "$name"
|
||||
}
|
||||
|
||||
function ui::subnet::row_group_child() {
|
||||
local type_key="${1:-}" subnet="${2:-}" tunnel="${3:-split}"
|
||||
local tunnel_val
|
||||
tunnel_val=$(printf "%-8s" "$tunnel")
|
||||
printf " · %-10s %-18s \033[2mtunnel:\033[0m %s\n" \
|
||||
"$type_key" "$subnet" "$tunnel_val"
|
||||
}
|
||||
|
||||
function ui::subnet::group_separator() {
|
||||
echo ""
|
||||
}
|
||||
|
||||
function ui::subnet::detail() {
|
||||
local name="${1:-}" is_group="${2:-false}"
|
||||
ui::row "Name" "$name"
|
||||
ui::row "Type" "$( [[ "$is_group" == "true" ]] && echo "group" || echo "scalar" )"
|
||||
}
|
||||
|
||||
function ui::subnet::detail_field() {
|
||||
local key="${1:-}" value="${2:-}"
|
||||
ui::row "$key" "$value"
|
||||
}
|
||||
|
||||
function ui::subnet::child_header() {
|
||||
printf "\n"
|
||||
printf " %-12s %-18s %-8s %s\n" "TYPE" "SUBNET" "TUNNEL" "DESCRIPTION"
|
||||
ui::divider 56
|
||||
}
|
||||
|
||||
function ui::subnet::child_row() {
|
||||
local type_key="${1:-}" subnet="${2:-}" tunnel_mode="${3:-}" desc="${4:-}"
|
||||
printf " %-12s %-18s %-8s %s\n" "$type_key" "$subnet" "$tunnel_mode" "$desc"
|
||||
}
|
||||
|
||||
function ui::subnet::peers_in_use() {
|
||||
local peers_csv="${1:-}"
|
||||
[[ -z "$peers_csv" ]] && return 0
|
||||
echo ""
|
||||
ui::row "Peers using" "${peers_csv//,/, }"
|
||||
}
|
||||
|
||||
function ui::subnet::show_scalar() {
|
||||
local name="${1:-}" subnet="${2:-}" tunnel="${3:-}" desc="${4:-}"
|
||||
echo ""
|
||||
printf " \033[1m%-16s\033[0m %-18s \033[2mtunnel:\033[0m %s\n" \
|
||||
"$name" "$subnet" "$tunnel"
|
||||
echo ""
|
||||
[[ -n "$desc" ]] && printf " \033[2mDescription:\033[0m %s\n" "$desc"
|
||||
}
|
||||
|
||||
function ui::subnet::show_group() {
|
||||
local name="${1:-}"
|
||||
echo ""
|
||||
printf " \033[1m%s\033[0m\n" "$name"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function ui::subnet::show_child_row() {
|
||||
local type_key="${1:-}" subnet="${2:-}" tunnel="${3:-}" desc="${4:-}"
|
||||
local desc_part=""
|
||||
[[ -n "$desc" ]] && desc_part=" $desc"
|
||||
printf " · %-10s %-18s \033[2mtunnel:\033[0m %-8s%s\n" \
|
||||
"$type_key" "$subnet" "$tunnel" "$desc_part"
|
||||
}
|
||||
|
||||
function ui::subnet::show_peers() {
|
||||
local peers_csv="${1:-}"
|
||||
[[ -z "$peers_csv" ]] && return 0
|
||||
|
||||
echo ""
|
||||
local peers_arr=()
|
||||
IFS=',' read -ra peers_arr <<< "$peers_csv"
|
||||
local count="${#peers_arr[@]}"
|
||||
|
||||
printf " Peers (%s):\n" "$count"
|
||||
|
||||
# Group peers by identity, then by subnet child (for groups)
|
||||
# Build: identity -> list of peer names
|
||||
declare -A identity_peers_map=()
|
||||
local no_identity=()
|
||||
|
||||
for peer in "${peers_arr[@]}"; do
|
||||
peer="${peer// /}"
|
||||
[[ -z "$peer" ]] && continue
|
||||
local id_name
|
||||
id_name=$(identity::get_name "$peer")
|
||||
if [[ -n "$id_name" ]]; then
|
||||
identity_peers_map["$id_name"]+="${peer},"
|
||||
else
|
||||
no_identity+=("$peer")
|
||||
fi
|
||||
done
|
||||
|
||||
# Print identity groups on same line
|
||||
for id_name in "${!identity_peers_map[@]}"; do
|
||||
local peer_list="${identity_peers_map[$id_name]%,}"
|
||||
printf " · %s\n" "${peer_list//,/, }"
|
||||
done
|
||||
|
||||
# Print peers without identity one per line
|
||||
for peer in "${no_identity[@]}"; do
|
||||
printf " · %s\n" "$peer"
|
||||
done
|
||||
}
|
||||
|
||||
function ui::subnet::show_peers_annotated() {
|
||||
# For group subnets — peers annotated with their subnet child
|
||||
local peers_csv="${1:-}" subnets_file="${2:-}"
|
||||
[[ -z "$peers_csv" ]] && return 0
|
||||
|
||||
local peers_arr=()
|
||||
IFS=',' read -ra peers_arr <<< "$peers_csv"
|
||||
local count="${#peers_arr[@]}"
|
||||
|
||||
echo ""
|
||||
printf " Peers (%s):\n" "$count"
|
||||
|
||||
declare -A identity_peers_map=() # identity -> "peer→child,peer→child"
|
||||
local no_identity=()
|
||||
|
||||
for peer in "${peers_arr[@]}"; do
|
||||
peer="${peer// /}"
|
||||
[[ -z "$peer" ]] && continue
|
||||
|
||||
# Find which child subnet this peer's IP belongs to
|
||||
local peer_ip child_name=""
|
||||
peer_ip=$(peers::get_ip "$peer" 2>/dev/null) || peer_ip=""
|
||||
if [[ -n "$peer_ip" ]]; then
|
||||
local result
|
||||
result=$(json::subnet_for_ip "$subnets_file" "$peer_ip" 2>/dev/null) || true
|
||||
[[ -n "$result" ]] && child_name="${result##*|}"
|
||||
fi
|
||||
|
||||
local annotated="${peer}"
|
||||
[[ -n "$child_name" ]] && annotated="${peer} \033[2m→ ${child_name}\033[0m"
|
||||
|
||||
local id_name
|
||||
id_name=$(identity::get_name "$peer")
|
||||
if [[ -n "$id_name" ]]; then
|
||||
identity_peers_map["$id_name"]+="${annotated},"
|
||||
else
|
||||
no_identity+=("$annotated")
|
||||
fi
|
||||
done
|
||||
|
||||
for id_name in "${!identity_peers_map[@]}"; do
|
||||
local peer_list="${identity_peers_map[$id_name]%,}"
|
||||
printf " · %b\n" "${peer_list//,/, }"
|
||||
done
|
||||
|
||||
for peer in "${no_identity[@]}"; do
|
||||
printf " · %b\n" "$peer"
|
||||
done
|
||||
}
|
||||
8
wgctl
8
wgctl
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
set -uo pipefail
|
||||
|
||||
source "$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/core.sh"
|
||||
|
||||
|
|
@ -9,9 +9,9 @@ LOG_LEVEL=DEBUG
|
|||
# Modules
|
||||
# ============================================
|
||||
|
||||
load_module log
|
||||
load_module config
|
||||
load_module ip
|
||||
load_module ui
|
||||
load_module config
|
||||
load_module keys
|
||||
load_module peers
|
||||
load_module firewall
|
||||
|
|
@ -20,6 +20,8 @@ load_module rule
|
|||
load_module block
|
||||
load_module net
|
||||
load_module group
|
||||
load_module subnet
|
||||
load_module identity
|
||||
|
||||
# ============================================
|
||||
# Alias Map
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue