diff --git a/commands/identity.command.sh b/commands/identity.command.sh index 58f6487..8cf4132 100644 --- a/commands/identity.command.sh +++ b/commands/identity.command.sh @@ -30,6 +30,7 @@ function cmd::identity::on_load() { flag::register --unset-auto-apply flag::register --field flag::register --value + flag::register --migrate } # ============================================ @@ -358,11 +359,12 @@ function cmd::identity::_rule() { } function cmd::identity::_rule_assign() { - local name="" rule="" + local name="" rule="" migrate=false while [[ $# -gt 0 ]]; do case "$1" in - --name) name="$2"; shift 2 ;; - --rule) rule="$2"; shift 2 ;; + --name) name="$2"; shift 2 ;; + --rule) rule="$2"; shift 2 ;; + --migrate) migrate=true; shift ;; *) log::error "Unknown flag: $1"; return 1 ;; esac done @@ -372,6 +374,29 @@ function cmd::identity::_rule_assign() { identity::require_exists "$name" || return 1 rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } + local conflicts=() + while IFS= read -r peer_name; do + [[ -z "$peer_name" ]] && continue + local peer_rule + peer_rule=$(peers::get_meta "$peer_name" "rule" 2>/dev/null) + [[ "$peer_rule" == "$rule" ]] && conflicts+=("$peer_name") + done < <(identity::peers "$name") + + if [[ ${#conflicts[@]} -gt 0 ]]; then + if ! $migrate; then + log::error "The following peers have '${rule}' as a direct rule: ${conflicts[*]}" + log::error "Use --migrate to remove direct rules and let the identity rule take over." + return 1 + fi + # Migrate — remove direct rules from conflicting peers + for peer_name in "${conflicts[@]}"; do + local ip + ip=$(peers::get_ip "$peer_name") + rule::unapply "$rule" "$ip" + log::wg "Migrated '${rule}' from peer '${peer_name}' to identity '${name}'" + done + fi + local exit_code identity::add_rule "$name" "$rule" || exit_code=$? diff --git a/commands/list.command.sh b/commands/list.command.sh index c21ed06..42698a3 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -419,13 +419,10 @@ function cmd::list::_render_detailed() { 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) - if [[ -z "$subnet" ]]; then - local peer_type="${p_types[$name]:-}" - [[ -n "$peer_type" ]] && subnet="$peer_type" - fi - [[ -z "$subnet" ]] && subnet="-" + + local peer_type="${p_types[$name]:-}" + subnet=$(peers::get_display_subnet "$name" "$peer_type") + ui::peer::list_row_detailed \ "$w_name" "$w_ip" "$w_type" "$w_rule" "$w_group" "$w_subnet" \ "$name" "$ip" "$type" "$rule" "$group" "$subnet" \ diff --git a/commands/peer.command.sh b/commands/peer.command.sh new file mode 100644 index 0000000..106ae6c --- /dev/null +++ b/commands/peer.command.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# peer.command.sh — peer management operations + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::peer::on_load() { + flag::register --name + flag::register --type + flag::register --all + flag::register --mode + flag::register --dns + flag::register --fallback-dns + flag::register --force +} + +# ============================================ +# Help +# ============================================ + +function cmd::peer::help() { + cat < [options] + +Manage peer configuration and settings. + +Subcommands: + update-dns Update DNS settings in client config(s) + update-tunnel Update tunnel mode (split/full) in client config(s) + +Options for update-dns: + --name Target peer + --all Apply to all peers + --type Filter by device type + --dns Primary DNS (default: from config) + --fallback-dns Fallback DNS servers (comma-separated) + Default: from WG_DNS_FALLBACK in wgctl.conf + +Options for update-tunnel: + --name Target peer + --all Apply to all peers + --type Filter by device type + --mode Tunnel mode: split | full + --force Skip confirmation for --all + +Examples: + wgctl peer update-dns --all + wgctl peer update-dns --name phone-nuno + wgctl peer update-dns --name phone-nuno --fallback-dns 9.9.9.9,1.1.1.1 + wgctl peer update-tunnel --all --mode split + wgctl peer update-tunnel --name phone-nuno --mode full +EOF +} + +# ============================================ +# Run +# ============================================ + +function cmd::peer::run() { + local subcmd="${1:-help}" + shift || true + case "$subcmd" in + update-dns) cmd::peer::update_dns "$@" ;; + update-tunnel) cmd::peer::update_tunnel "$@" ;; + help) cmd::peer::help ;; + *) + log::error "Unknown subcommand: '${subcmd}'" + cmd::peer::help + return 1 + ;; + esac +} + +# ============================================ +# Update DNS +# ============================================ + +function cmd::peer::update_dns() { + local name="" type="" all=false + local dns="" fallback_dns="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --all) all=true; shift ;; + --dns) dns="$2"; shift 2 ;; + --fallback-dns) fallback_dns="$2"; shift 2 ;; + --help) cmd::peer::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" && "$all" == "false" ]] && \ + log::error "Specify --name or --all" && return 1 + + # Resolve DNS string + local primary="${dns:-$(config::dns)}" + local fallback="${fallback_dns:-$(config::dns_fallback)}" + local dns_string + if [[ -n "$fallback" ]]; then + dns_string="${primary}, ${fallback}" + else + dns_string="$primary" + fi + + # Collect target peers + local peers=() + if $all; then + while IFS= read -r conf; do + peers+=("$(basename "$conf" .conf)") + done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null) + else + name=$(peers::resolve_and_require "$name" "$type") || return 1 + peers=("$name") + fi + + local updated=0 + for peer_name in "${peers[@]}"; do + local conf + conf="$(ctx::clients)/${peer_name}.conf" + [[ ! -f "$conf" ]] && continue + + # Replace DNS line in-place + if grep -q "^DNS" "$conf"; then + sed -i "s|^DNS = .*|DNS = ${dns_string}|" "$conf" + else + # Add DNS line after Address line + sed -i "/^Address/a DNS = ${dns_string}" "$conf" + fi + (( updated++ )) || true + log::debug "Updated DNS for: ${peer_name}" + done + + log::wg_success "Updated DNS to '${dns_string}' for ${updated} peer(s)" +} + +# ============================================ +# Update Tunnel +# ============================================ + +function cmd::peer::update_tunnel() { + local name="" type="" all=false mode="" force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --all) all=true; shift ;; + --mode) mode="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::peer::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" && "$all" == "false" ]] && \ + log::error "Specify --name or --all" && return 1 + [[ -z "$mode" ]] && \ + log::error "Missing required flag: --mode (split|full)" && return 1 + [[ "$mode" != "split" && "$mode" != "full" ]] && \ + log::error "Invalid mode: ${mode} (must be split or full)" && return 1 + + local allowed_ips + allowed_ips=$(config::allowed_ips_for "$mode") + + # Collect target peers + local peers=() + if $all; then + if ! $force; then + read -r -p "Update tunnel mode to '${mode}' for ALL peers? [y/N] " confirm + case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac + fi + while IFS= read -r conf; do + peers+=("$(basename "$conf" .conf)") + done < <(find "$(ctx::clients)" -name "*.conf" 2>/dev/null) + else + name=$(peers::resolve_and_require "$name" "$type") || return 1 + peers=("$name") + fi + + local updated=0 + for peer_name in "${peers[@]}"; do + local conf + conf="$(ctx::clients)/${peer_name}.conf" + [[ ! -f "$conf" ]] && continue + + # Replace AllowedIPs line in-place + sed -i "s|^AllowedIPs = .*|AllowedIPs = ${allowed_ips}|" "$conf" + (( updated++ )) || true + log::debug "Updated tunnel for: ${peer_name}" + done + + log::wg_success "Updated tunnel to '${mode}' (${allowed_ips}) for ${updated} peer(s)" + log::wg "Peers must reconnect to apply the new tunnel mode" +} \ No newline at end of file diff --git a/commands/rule.command.sh b/commands/rule.command.sh index cf1e5d4..18d34bd 100644 --- a/commands/rule.command.sh +++ b/commands/rule.command.sh @@ -551,6 +551,19 @@ function cmd::rule::assign() { peer=$(peers::resolve_and_require "$peer" "$type") || return 1 + # Identity rule check + local peer_identity + + peer_identity=$(peers::get_meta "$peer" "identity") + if [[ -n "$peer_identity" ]]; then + local identity_rules + identity_rules=$(identity::rules "$peer_identity" 2>/dev/null) + if echo "$identity_rules" | grep -qx "$name"; then + log::error "Rule '${name}' is already applied to '${peer}' via identity '${peer_identity}' — cannot assign directly" + return 1 + fi + fi + local existing_rule ip existing_rule=$(peers::get_meta "$peer" "rule") ip=$(peers::get_ip "$peer") diff --git a/modules/config.module.sh b/modules/config.module.sh index 3e39a28..0c24a9b 100644 --- a/modules/config.module.sh +++ b/modules/config.module.sh @@ -28,6 +28,7 @@ declare -g _ACTIVITY_CURRENT_HIGH_BYTES="${ACTIVITY_CURRENT_HIGH_BYTES:-10000000 function config::_init_defaults() { _WG_INTERFACE="${WG_INTERFACE:-wg0}" _WG_DNS="${WG_DNS:-10.0.0.103}" + _WG_DNS_FALLBACK="${WG_DNS_FALLBACK:-}" _WG_LAN="${WG_LAN:-10.0.0.0/24}" _WG_SUBNET="${WG_SUBNET:-10.1.0.0/16}" _WG_PORT="${WG_PORT:-51820}" @@ -127,6 +128,7 @@ function config::load() { WG_INTERFACE) _WG_INTERFACE="$value" ;; WG_ENDPOINT) _WG_ENDPOINT="$value" ;; WG_DNS) _WG_DNS="$value" ;; + WG_DNS_FALLBACK) _WG_DNS_FALLBACK="$value" ;; WG_PORT) _WG_PORT="$value" ;; WG_SUBNET) _WG_SUBNET="$value" ;; WG_LAN) _WG_LAN="$value" ;; @@ -154,6 +156,7 @@ function config::interface() { echo "$_WG_INTERFACE"; } function config::config_file() { echo "$_WG_CONFIG"; } function config::endpoint() { echo "$_WG_ENDPOINT"; } function config::dns() { echo "$_WG_DNS"; } +function config::dns_fallback() { echo "${_WG_DNS_FALLBACK:-}"; } function config::port() { echo "$_WG_PORT"; } function config::subnet() { echo "$_WG_SUBNET"; } function config::lan() { echo "$_WG_LAN"; } @@ -178,4 +181,15 @@ function config::allowed_ips_for() { return 1 ;; esac -} \ No newline at end of file +} + +function config::dns_string() { + local fallback + fallback=$(config::dns_fallback) + if [[ -n "$fallback" ]]; then + echo "$(config::dns), ${fallback}" + else + echo "$(config::dns)" + fi +} + \ No newline at end of file diff --git a/modules/peers.module.sh b/modules/peers.module.sh index 53bccab..7f9a860 100644 --- a/modules/peers.module.sh +++ b/modules/peers.module.sh @@ -28,7 +28,7 @@ function peers::create_client_config() { [Interface] PrivateKey = ${private_key} Address = ${ip}/32 -DNS = $(config::dns) +DNS = $(config::dns_string) [Peer] PublicKey = ${server_public_key} @@ -271,6 +271,18 @@ function peers::set_main_group() { peers::set_meta "$name" "main_group" "$group" } +function peers::get_display_subnet() { + local peer_name="${1:-}" peer_type="${2:-}" + local subnet + subnet=$(peers::get_meta "$peer_name" "subnet" 2>/dev/null) + if [[ -z "$subnet" ]]; then + subnet=$(subnet::type_from_ip "$(peers::get_ip "$peer_name")" 2>/dev/null) + [[ -n "$peer_type" ]] && subnet="$peer_type" + fi + [[ -z "$subnet" ]] && subnet="-" + echo "$subnet" +} + # ============================================ # Name + Type Parsing # ============================================