gistfile1.txt
· 14 KiB · Text
Surowy
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::list::on_load() {
flag::register --type
flag::register --online
flag::register --offline
flag::register --restricted
flag::register --blocked
flag::register --allowed
flag::register --detailed
flag::register --name
}
# ============================================
# Help
# ============================================
function cmd::list::help() {
cat <<EOF
Usage: wgctl list [options]
List all WireGuard clients.
Options:
--type <type> Filter by device type
--online Show only connected clients
--offline Show only disconnected clients
--allowed Show only fully allowed clients
--restricted Show only restricted clients
--blocked Show only blocked clients
--detailed Show full detail cards for all clients
--name <name> Show detail card for a single client
Examples:
wgctl list
wgctl ls --type phone
wgctl list --online
wgctl list --blocked
wgctl list --allowed
wgctl list --restricted
wgctl list --detailed
wgctl list --name phone-nuno
EOF
}
# ============================================
# Status Helpers
# ============================================
function cmd::list::last_handshake_ts() {
local public_key="$1"
wg show "$(config::interface)" latest-handshakes 2>/dev/null \
| grep "^${public_key}" \
| awk '{print $2}'
}
function cmd::list::last_dropped_ts() {
local client_ip="$1"
journalctl -k --grep "wgctl-dropped: " 2>/dev/null \
| grep "SRC=${client_ip}" \
| tail -1 \
| awk '{print $1, $2, $3}'
}
function cmd::list::is_connected() {
local public_key="$1"
local ts
ts=$(cmd::list::last_handshake_ts "$public_key")
[[ -z "$ts" || "$ts" == "0" ]] && return 1
local now diff
now=$(date +%s)
diff=$(( now - ts ))
(( diff < 180 ))
}
function cmd::list::is_attempting() {
local name="$1"
local ts
ts=$(monitor::last_attempt "$name")
[[ -z "$ts" ]] && return 1
local now attempt_ts diff
now=$(date +%s)
attempt_ts=$(python3 -c "
from datetime import datetime, timezone
dt = datetime.fromisoformat('${ts}')
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
" 2>/dev/null || echo 0)
diff=$(( now - attempt_ts ))
(( diff < 180 ))
}
function cmd::list::is_blocked() {
local name="$1"
peers::is_blocked "$name"
}
function cmd::list::is_restricted() {
local name="$1"
[[ -f "$(ctx::block::path "${name}.block")" ]]
}
function cmd::list::format_last_seen() {
local name="$1"
local public_key="$2"
local ip="$3"
if cmd::list::is_blocked "$name"; then
local ts
ts=$(monitor::last_attempt "$name")
if [[ -n "$ts" ]]; then
# Format ISO timestamp
local formatted
formatted=$(python3 -c "
from datetime import datetime, timezone
dt = datetime.fromisoformat('${ts}')
print(dt.strftime('%Y-%m-%d %H:%M'))
" 2>/dev/null || echo "$ts")
echo "${formatted} (dropped)"
else
echo "—"
fi
else
local ts
ts=$(cmd::list::last_handshake_ts "$public_key")
if [[ -z "$ts" || "$ts" == "0" ]]; then
echo "—"
else
local formatted
formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts")
echo "${formatted} (handshake)"
fi
fi
}
function cmd::list::format_status() {
local name="$1"
local public_key="$2"
local ip="$3" # new
local connected=false
local blocked=false
local restricted=false
cmd::list::is_blocked "$name" && blocked=true
cmd::list::is_restricted "$name" && restricted=true
if $blocked; then
cmd::list::is_attempting "$name" && connected=true
modifier=" (blocked)"
elif $restricted; then
cmd::list::is_connected "$public_key" && connected=true
modifier=" (restricted)"
else
cmd::list::is_connected "$public_key" && connected=true
modifier=""
fi
local conn_str
$connected && conn_str="online" || conn_str="offline"
local status="${conn_str}${modifier}"
local color
if $blocked; then
color="\033[1;31m"
elif $restricted; then
color="\033[1;33m"
elif $connected; then
color="\033[1;32m"
else
color="\033[0;37m"
fi
echo -e "${color}${status}\033[0m"
}
# ============================================
# Display Type
# ============================================
function cmd::list::display_type() {
local name="$1"
local type="$2"
# log::debug "$(config::is_guest_type "$type")"
if config::is_guest_type "$type"; then
local subtype
subtype=$(peers::get_meta "$name" "subtype")
# log::debug "$subtype"
if [[ -n "$subtype" ]]; then
echo "guest/${subtype}"
else
echo "guest"
fi
else
echo "$type"
fi
}
# ============================================
# Detail Card
# ============================================
function cmd::list::show_client() {
local name="$1"
local dir
dir="$(ctx::clients)"
local conf="${dir}/${name}.conf"
if [[ ! -f "$conf" ]]; then
log::error "Client not found: ${name}"
return 1
fi
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local allowed_ips
allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}')
local public_key
public_key=$(keys::public "$name" 2>/dev/null || echo "unknown")
# Get endpoint
local endpoint="—"
if cmd::list::is_blocked "$name"; then
local ep
ep=$(monitor::last_endpoint "$name")
[[ -n "$ep" ]] && endpoint="$ep"
else
local ep
ep=$(monitor::endpoint_for_key "$public_key")
[[ -n "$ep" ]] && endpoint="$ep"
fi
# Determine type
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet."; then
type="$t"
break
fi
done
local status
status=$(cmd::list::format_status "$name" "$public_key" "$ip")
local last_seen
last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip")
# Block rules
local block_file
block_file="$(ctx::block::path "${name}.block")"
local blocks=""
if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then
while IFS=" " read -r client_ip target port proto; do
if [[ -z "$target" ]]; then
blocks+=" all traffic blocked\n"
else
local rule=" ${target}"
[[ -n "$port" ]] && rule+=":${port}/${proto}"
blocks+="${rule}\n"
fi
done < "$block_file"
fi
local sep
sep="$(printf '─%.0s' {1..50})"
echo ""
echo " ${sep}"
printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name"
echo " ${sep}"
printf " %-20s %s\n" "IP:" "$ip"
printf " %-20s %s\n" "Type:" "$type"
printf " %-20s %b\n" "Status:" "$status"
printf " %-20s %s\n" "Endpoint:" "$endpoint"
printf " %-20s %s\n" "Last seen:" "$last_seen"
printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips"
printf " %-20s %s\n" "Public key:" "$public_key"
if [[ -z "$blocks" ]]; then
printf " %-20s %s\n" "Blocks:" "none"
elif [[ "$blocks" == *"all traffic blocked"* ]]; then
printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:"
else
printf " %-20s\n" "Blocks:"
echo -e "$blocks"
fi
echo " ${sep}"
echo ""
}
# ============================================
# Run
# ============================================
function cmd::list::run() {
local filter_type=""
local online_only=false
local offline_only=false
local restricted_only=false
local blocked_only=false
local allowed_only=false
local detailed=false
local single_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--type) filter_type="$2"; shift 2 ;;
--online) online_only=true; shift ;;
--offline) offline_only=true; shift ;;
--restricted) restricted_only=true; shift ;;
--blocked) blocked_only=true; shift ;;
--allowed) allowed_only=true; shift ;;
--detailed) detailed=true; shift ;;
--name) single_name="$2"; shift 2 ;;
--help) cmd::list::help; return ;;
*)
log::error "Unknown flag: $1"
cmd::list::help
return 1
;;
esac
done
# Single client detail card
if [[ -n "$single_name" ]]; then
cmd::list::show_client "$single_name"
return
fi
local dir
dir="$(ctx::clients)"
local confs=("${dir}"/*.conf)
if [[ ! -f "${confs[0]}" ]]; then
log::wg_list "No clients configured"
return 0
fi
# START - GROUP SECTION
# Check if any groups exist
local has_groups=false
local groups_dir
groups_dir="$(ctx::groups)"
local group_files=("${groups_dir}"/*.group)
[[ -f "${group_files[0]}" ]] && has_groups=true
# Precompute peer->group map
declare -A peer_group_map
if $has_groups; then
while IFS=":" read -r peer_name group_name; do
[[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name"
done < <(json::peer_group_map "$groups_dir")
fi
# END - GROUP SECTION
# Detailed mode — cards only, no table
if $detailed; then
log::section "WireGuard Clients"
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
# Apply type filter
if [[ -n "$filter_type" ]]; then
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet."; then
type="$t"
break
fi
done
[[ "$type" != "$filter_type" ]] && continue
fi
cmd::list::show_client "$client_name"
done
return
fi
# Normal table view
log::section "WireGuard Clients"
if $has_groups; then
printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \
"NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN"
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
for conf in "${dir}"/*.conf; do
[[ -f "$conf" ]] || continue
local client_name
client_name=$(basename "$conf" .conf)
local ip
ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1)
# Determine type
local type="unknown"
for t in $(config::device_types); do
local subnet
subnet=$(config::subnet_for "$t")
if string::starts_with "$ip" "$subnet."; then
type="$t"
break
fi
done
# Apply type filter
if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then
continue
fi
local public_key
public_key=$(keys::public "$client_name" 2>/dev/null || echo "")
# Apply filters
if $online_only && ! cmd::list::is_connected "$public_key"; then
continue
fi
if $offline_only && cmd::list::is_connected "$public_key"; then
continue
fi
if $restricted_only && ! cmd::list::is_restricted "$client_name"; then
continue
fi
if $blocked_only && ! cmd::list::is_blocked "$client_name"; then
continue
fi
if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then
continue
fi
local status
status=$(cmd::list::format_status "$client_name" "$public_key" "$ip")
local last_seen
last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip")
local display_type
display_type=$(cmd::list::display_type "$client_name" "$type")
# log::debug "display_type called with name=$client_name type=$type"
local rule
rule=$(peers::effective_rule "$client_name")
rule="${rule:-—}"
local padded_status
padded_status=$(cmd::list::pad_status "$status" 25)
local group_display="—"
if $has_groups; then
group_display="${peer_group_map[$client_name]:-—}"
fi
local rule_col_width=12
[[ "$rule" == "—" ]] && rule_col_width=14
local group_col_width=12
[[ "$group_display" == "—" ]] && group_col_width=14
if $has_groups; then
printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$group_display" "$padded_status" "$last_seen"
else
printf " %-28s %-15s %-13s %-12s %s %s\n" \
"$client_name" "$ip" "$display_type" "$rule" \
"$padded_status" "$last_seen"
fi
done
if $has_groups; then
printf " %s\n" "$(printf '─%.0s' {1..135})"
else
printf " %s\n" "$(printf '─%.0s' {1..107})"
fi
local group_summary=""
if $has_groups; then
declare -A group_counts
for peer in "${!peer_group_map[@]}"; do
local g="${peer_group_map[$peer]}"
group_counts["$g"]=$(( ${group_counts["$g"]:-0} + 1 )) || true
done
for g in "${!group_counts[@]}"; do
group_summary+="${group_counts[$g]} in ${g}, "
done
group_summary="${group_summary%, }"
fi
cmd::list::_render_summary "$group_summary"
printf "\n"
}
function cmd::list::_render_summary() {
local group_summary="${1:-}"
# Summary line
local total online_count
total=$(peers::all | wc -l)
# Count by rule
declare -A rule_summary
while IFS= read -r peer_name; do
local r
r=$(peers::effective_rule "$peer_name")
rule_summary["$r"]=$(( ${rule_summary["$r"]:-0} + 1 ))
done < <(peers::all)
local summary=""
for r in "${!rule_summary[@]}"; do
summary+="${rule_summary[$r]} ${r}, "
done
summary="${summary%, }" # remove trailing comma
if [[ -n "$group_summary" ]]; then
printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary"
else
printf "\n Showing %s peers [%s]\n\n" "$total" "$summary"
fi
}
# Strip ANSI codes to measure visible length, then pad manually
function cmd::list::pad_status() {
local status="$1"
local width="${2:-20}"
local visible
visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g')
local pad=$(( width - ${#visible} ))
printf "%b%${pad}s" "$status" ""
}
| 1 | #!/usr/bin/env bash |
| 2 | |
| 3 | # ============================================ |
| 4 | # Lifecycle |
| 5 | # ============================================ |
| 6 | |
| 7 | function cmd::list::on_load() { |
| 8 | flag::register --type |
| 9 | flag::register --online |
| 10 | flag::register --offline |
| 11 | flag::register --restricted |
| 12 | flag::register --blocked |
| 13 | flag::register --allowed |
| 14 | flag::register --detailed |
| 15 | flag::register --name |
| 16 | } |
| 17 | |
| 18 | # ============================================ |
| 19 | # Help |
| 20 | # ============================================ |
| 21 | |
| 22 | function cmd::list::help() { |
| 23 | cat <<EOF |
| 24 | Usage: wgctl list [options] |
| 25 | |
| 26 | List all WireGuard clients. |
| 27 | |
| 28 | Options: |
| 29 | --type <type> Filter by device type |
| 30 | --online Show only connected clients |
| 31 | --offline Show only disconnected clients |
| 32 | --allowed Show only fully allowed clients |
| 33 | --restricted Show only restricted clients |
| 34 | --blocked Show only blocked clients |
| 35 | --detailed Show full detail cards for all clients |
| 36 | --name <name> Show detail card for a single client |
| 37 | |
| 38 | Examples: |
| 39 | wgctl list |
| 40 | wgctl ls --type phone |
| 41 | wgctl list --online |
| 42 | wgctl list --blocked |
| 43 | wgctl list --allowed |
| 44 | wgctl list --restricted |
| 45 | wgctl list --detailed |
| 46 | wgctl list --name phone-nuno |
| 47 | EOF |
| 48 | } |
| 49 | |
| 50 | # ============================================ |
| 51 | # Status Helpers |
| 52 | # ============================================ |
| 53 | |
| 54 | function cmd::list::last_handshake_ts() { |
| 55 | local public_key="$1" |
| 56 | wg show "$(config::interface)" latest-handshakes 2>/dev/null \ |
| 57 | | grep "^${public_key}" \ |
| 58 | | awk '{print $2}' |
| 59 | } |
| 60 | |
| 61 | function cmd::list::last_dropped_ts() { |
| 62 | local client_ip="$1" |
| 63 | journalctl -k --grep "wgctl-dropped: " 2>/dev/null \ |
| 64 | | grep "SRC=${client_ip}" \ |
| 65 | | tail -1 \ |
| 66 | | awk '{print $1, $2, $3}' |
| 67 | } |
| 68 | |
| 69 | function cmd::list::is_connected() { |
| 70 | local public_key="$1" |
| 71 | local ts |
| 72 | ts=$(cmd::list::last_handshake_ts "$public_key") |
| 73 | [[ -z "$ts" || "$ts" == "0" ]] && return 1 |
| 74 | local now diff |
| 75 | now=$(date +%s) |
| 76 | diff=$(( now - ts )) |
| 77 | (( diff < 180 )) |
| 78 | } |
| 79 | |
| 80 | function cmd::list::is_attempting() { |
| 81 | local name="$1" |
| 82 | local ts |
| 83 | ts=$(monitor::last_attempt "$name") |
| 84 | [[ -z "$ts" ]] && return 1 |
| 85 | |
| 86 | local now attempt_ts diff |
| 87 | now=$(date +%s) |
| 88 | attempt_ts=$(python3 -c " |
| 89 | from datetime import datetime, timezone |
| 90 | dt = datetime.fromisoformat('${ts}') |
| 91 | if dt.tzinfo is None: |
| 92 | dt = dt.replace(tzinfo=timezone.utc) |
| 93 | print(int(dt.timestamp())) |
| 94 | " 2>/dev/null || echo 0) |
| 95 | |
| 96 | diff=$(( now - attempt_ts )) |
| 97 | (( diff < 180 )) |
| 98 | } |
| 99 | |
| 100 | function cmd::list::is_blocked() { |
| 101 | local name="$1" |
| 102 | peers::is_blocked "$name" |
| 103 | } |
| 104 | |
| 105 | function cmd::list::is_restricted() { |
| 106 | local name="$1" |
| 107 | [[ -f "$(ctx::block::path "${name}.block")" ]] |
| 108 | } |
| 109 | |
| 110 | function cmd::list::format_last_seen() { |
| 111 | local name="$1" |
| 112 | local public_key="$2" |
| 113 | local ip="$3" |
| 114 | |
| 115 | if cmd::list::is_blocked "$name"; then |
| 116 | local ts |
| 117 | ts=$(monitor::last_attempt "$name") |
| 118 | if [[ -n "$ts" ]]; then |
| 119 | # Format ISO timestamp |
| 120 | local formatted |
| 121 | formatted=$(python3 -c " |
| 122 | from datetime import datetime, timezone |
| 123 | dt = datetime.fromisoformat('${ts}') |
| 124 | print(dt.strftime('%Y-%m-%d %H:%M')) |
| 125 | " 2>/dev/null || echo "$ts") |
| 126 | echo "${formatted} (dropped)" |
| 127 | else |
| 128 | echo "—" |
| 129 | fi |
| 130 | else |
| 131 | local ts |
| 132 | ts=$(cmd::list::last_handshake_ts "$public_key") |
| 133 | if [[ -z "$ts" || "$ts" == "0" ]]; then |
| 134 | echo "—" |
| 135 | else |
| 136 | local formatted |
| 137 | formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts") |
| 138 | echo "${formatted} (handshake)" |
| 139 | fi |
| 140 | fi |
| 141 | } |
| 142 | |
| 143 | function cmd::list::format_status() { |
| 144 | local name="$1" |
| 145 | local public_key="$2" |
| 146 | local ip="$3" # new |
| 147 | |
| 148 | local connected=false |
| 149 | local blocked=false |
| 150 | local restricted=false |
| 151 | |
| 152 | cmd::list::is_blocked "$name" && blocked=true |
| 153 | cmd::list::is_restricted "$name" && restricted=true |
| 154 | |
| 155 | if $blocked; then |
| 156 | cmd::list::is_attempting "$name" && connected=true |
| 157 | modifier=" (blocked)" |
| 158 | elif $restricted; then |
| 159 | cmd::list::is_connected "$public_key" && connected=true |
| 160 | modifier=" (restricted)" |
| 161 | else |
| 162 | cmd::list::is_connected "$public_key" && connected=true |
| 163 | modifier="" |
| 164 | fi |
| 165 | |
| 166 | local conn_str |
| 167 | $connected && conn_str="online" || conn_str="offline" |
| 168 | |
| 169 | local status="${conn_str}${modifier}" |
| 170 | |
| 171 | local color |
| 172 | if $blocked; then |
| 173 | color="\033[1;31m" |
| 174 | elif $restricted; then |
| 175 | color="\033[1;33m" |
| 176 | elif $connected; then |
| 177 | color="\033[1;32m" |
| 178 | else |
| 179 | color="\033[0;37m" |
| 180 | fi |
| 181 | |
| 182 | echo -e "${color}${status}\033[0m" |
| 183 | } |
| 184 | |
| 185 | # ============================================ |
| 186 | # Display Type |
| 187 | # ============================================ |
| 188 | |
| 189 | function cmd::list::display_type() { |
| 190 | local name="$1" |
| 191 | local type="$2" |
| 192 | |
| 193 | # log::debug "$(config::is_guest_type "$type")" |
| 194 | if config::is_guest_type "$type"; then |
| 195 | local subtype |
| 196 | subtype=$(peers::get_meta "$name" "subtype") |
| 197 | # log::debug "$subtype" |
| 198 | |
| 199 | if [[ -n "$subtype" ]]; then |
| 200 | echo "guest/${subtype}" |
| 201 | else |
| 202 | echo "guest" |
| 203 | fi |
| 204 | else |
| 205 | echo "$type" |
| 206 | fi |
| 207 | } |
| 208 | |
| 209 | # ============================================ |
| 210 | # Detail Card |
| 211 | # ============================================ |
| 212 | |
| 213 | function cmd::list::show_client() { |
| 214 | local name="$1" |
| 215 | local dir |
| 216 | dir="$(ctx::clients)" |
| 217 | local conf="${dir}/${name}.conf" |
| 218 | |
| 219 | if [[ ! -f "$conf" ]]; then |
| 220 | log::error "Client not found: ${name}" |
| 221 | return 1 |
| 222 | fi |
| 223 | |
| 224 | local ip |
| 225 | ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) |
| 226 | |
| 227 | local allowed_ips |
| 228 | allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}') |
| 229 | |
| 230 | local public_key |
| 231 | public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") |
| 232 | |
| 233 | # Get endpoint |
| 234 | local endpoint="—" |
| 235 | if cmd::list::is_blocked "$name"; then |
| 236 | local ep |
| 237 | ep=$(monitor::last_endpoint "$name") |
| 238 | [[ -n "$ep" ]] && endpoint="$ep" |
| 239 | else |
| 240 | local ep |
| 241 | ep=$(monitor::endpoint_for_key "$public_key") |
| 242 | [[ -n "$ep" ]] && endpoint="$ep" |
| 243 | fi |
| 244 | |
| 245 | # Determine type |
| 246 | local type="unknown" |
| 247 | for t in $(config::device_types); do |
| 248 | local subnet |
| 249 | subnet=$(config::subnet_for "$t") |
| 250 | if string::starts_with "$ip" "$subnet."; then |
| 251 | type="$t" |
| 252 | break |
| 253 | fi |
| 254 | done |
| 255 | |
| 256 | local status |
| 257 | status=$(cmd::list::format_status "$name" "$public_key" "$ip") |
| 258 | |
| 259 | local last_seen |
| 260 | last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip") |
| 261 | |
| 262 | # Block rules |
| 263 | local block_file |
| 264 | block_file="$(ctx::block::path "${name}.block")" |
| 265 | local blocks="" |
| 266 | |
| 267 | if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then |
| 268 | while IFS=" " read -r client_ip target port proto; do |
| 269 | if [[ -z "$target" ]]; then |
| 270 | blocks+=" all traffic blocked\n" |
| 271 | else |
| 272 | local rule=" ${target}" |
| 273 | [[ -n "$port" ]] && rule+=":${port}/${proto}" |
| 274 | blocks+="${rule}\n" |
| 275 | fi |
| 276 | done < "$block_file" |
| 277 | fi |
| 278 | |
| 279 | local sep |
| 280 | sep="$(printf '─%.0s' {1..50})" |
| 281 | |
| 282 | echo "" |
| 283 | echo " ${sep}" |
| 284 | printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name" |
| 285 | echo " ${sep}" |
| 286 | printf " %-20s %s\n" "IP:" "$ip" |
| 287 | printf " %-20s %s\n" "Type:" "$type" |
| 288 | printf " %-20s %b\n" "Status:" "$status" |
| 289 | printf " %-20s %s\n" "Endpoint:" "$endpoint" |
| 290 | printf " %-20s %s\n" "Last seen:" "$last_seen" |
| 291 | printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips" |
| 292 | printf " %-20s %s\n" "Public key:" "$public_key" |
| 293 | |
| 294 | if [[ -z "$blocks" ]]; then |
| 295 | printf " %-20s %s\n" "Blocks:" "none" |
| 296 | elif [[ "$blocks" == *"all traffic blocked"* ]]; then |
| 297 | printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:" |
| 298 | else |
| 299 | printf " %-20s\n" "Blocks:" |
| 300 | echo -e "$blocks" |
| 301 | fi |
| 302 | |
| 303 | echo " ${sep}" |
| 304 | echo "" |
| 305 | } |
| 306 | |
| 307 | # ============================================ |
| 308 | # Run |
| 309 | # ============================================ |
| 310 | |
| 311 | function cmd::list::run() { |
| 312 | local filter_type="" |
| 313 | local online_only=false |
| 314 | local offline_only=false |
| 315 | local restricted_only=false |
| 316 | local blocked_only=false |
| 317 | local allowed_only=false |
| 318 | local detailed=false |
| 319 | local single_name="" |
| 320 | |
| 321 | while [[ $# -gt 0 ]]; do |
| 322 | case "$1" in |
| 323 | --type) filter_type="$2"; shift 2 ;; |
| 324 | --online) online_only=true; shift ;; |
| 325 | --offline) offline_only=true; shift ;; |
| 326 | --restricted) restricted_only=true; shift ;; |
| 327 | --blocked) blocked_only=true; shift ;; |
| 328 | --allowed) allowed_only=true; shift ;; |
| 329 | --detailed) detailed=true; shift ;; |
| 330 | --name) single_name="$2"; shift 2 ;; |
| 331 | --help) cmd::list::help; return ;; |
| 332 | *) |
| 333 | log::error "Unknown flag: $1" |
| 334 | cmd::list::help |
| 335 | return 1 |
| 336 | ;; |
| 337 | esac |
| 338 | done |
| 339 | |
| 340 | # Single client detail card |
| 341 | if [[ -n "$single_name" ]]; then |
| 342 | cmd::list::show_client "$single_name" |
| 343 | return |
| 344 | fi |
| 345 | |
| 346 | local dir |
| 347 | dir="$(ctx::clients)" |
| 348 | local confs=("${dir}"/*.conf) |
| 349 | |
| 350 | if [[ ! -f "${confs[0]}" ]]; then |
| 351 | log::wg_list "No clients configured" |
| 352 | return 0 |
| 353 | fi |
| 354 | |
| 355 | # START - GROUP SECTION |
| 356 | # Check if any groups exist |
| 357 | local has_groups=false |
| 358 | local groups_dir |
| 359 | groups_dir="$(ctx::groups)" |
| 360 | local group_files=("${groups_dir}"/*.group) |
| 361 | [[ -f "${group_files[0]}" ]] && has_groups=true |
| 362 | |
| 363 | # Precompute peer->group map |
| 364 | declare -A peer_group_map |
| 365 | if $has_groups; then |
| 366 | while IFS=":" read -r peer_name group_name; do |
| 367 | [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name" |
| 368 | done < <(json::peer_group_map "$groups_dir") |
| 369 | fi |
| 370 | # END - GROUP SECTION |
| 371 | |
| 372 | # Detailed mode — cards only, no table |
| 373 | if $detailed; then |
| 374 | log::section "WireGuard Clients" |
| 375 | for conf in "${dir}"/*.conf; do |
| 376 | [[ -f "$conf" ]] || continue |
| 377 | local client_name |
| 378 | client_name=$(basename "$conf" .conf) |
| 379 | |
| 380 | # Apply type filter |
| 381 | if [[ -n "$filter_type" ]]; then |
| 382 | local ip |
| 383 | ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) |
| 384 | local type="unknown" |
| 385 | for t in $(config::device_types); do |
| 386 | local subnet |
| 387 | subnet=$(config::subnet_for "$t") |
| 388 | if string::starts_with "$ip" "$subnet."; then |
| 389 | type="$t" |
| 390 | break |
| 391 | fi |
| 392 | done |
| 393 | [[ "$type" != "$filter_type" ]] && continue |
| 394 | fi |
| 395 | |
| 396 | cmd::list::show_client "$client_name" |
| 397 | done |
| 398 | return |
| 399 | fi |
| 400 | |
| 401 | # Normal table view |
| 402 | log::section "WireGuard Clients" |
| 403 | |
| 404 | if $has_groups; then |
| 405 | printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \ |
| 406 | "NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" |
| 407 | printf " %s\n" "$(printf '─%.0s' {1..135})" |
| 408 | else |
| 409 | printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \ |
| 410 | "NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN" |
| 411 | printf " %s\n" "$(printf '─%.0s' {1..107})" |
| 412 | fi |
| 413 | |
| 414 | for conf in "${dir}"/*.conf; do |
| 415 | [[ -f "$conf" ]] || continue |
| 416 | |
| 417 | local client_name |
| 418 | client_name=$(basename "$conf" .conf) |
| 419 | |
| 420 | local ip |
| 421 | ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) |
| 422 | |
| 423 | # Determine type |
| 424 | local type="unknown" |
| 425 | for t in $(config::device_types); do |
| 426 | local subnet |
| 427 | subnet=$(config::subnet_for "$t") |
| 428 | if string::starts_with "$ip" "$subnet."; then |
| 429 | type="$t" |
| 430 | break |
| 431 | fi |
| 432 | done |
| 433 | |
| 434 | # Apply type filter |
| 435 | if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then |
| 436 | continue |
| 437 | fi |
| 438 | |
| 439 | local public_key |
| 440 | public_key=$(keys::public "$client_name" 2>/dev/null || echo "") |
| 441 | |
| 442 | # Apply filters |
| 443 | if $online_only && ! cmd::list::is_connected "$public_key"; then |
| 444 | continue |
| 445 | fi |
| 446 | |
| 447 | if $offline_only && cmd::list::is_connected "$public_key"; then |
| 448 | continue |
| 449 | fi |
| 450 | |
| 451 | if $restricted_only && ! cmd::list::is_restricted "$client_name"; then |
| 452 | continue |
| 453 | fi |
| 454 | |
| 455 | if $blocked_only && ! cmd::list::is_blocked "$client_name"; then |
| 456 | continue |
| 457 | fi |
| 458 | |
| 459 | if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then |
| 460 | continue |
| 461 | fi |
| 462 | |
| 463 | local status |
| 464 | status=$(cmd::list::format_status "$client_name" "$public_key" "$ip") |
| 465 | |
| 466 | local last_seen |
| 467 | last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip") |
| 468 | |
| 469 | local display_type |
| 470 | display_type=$(cmd::list::display_type "$client_name" "$type") |
| 471 | # log::debug "display_type called with name=$client_name type=$type" |
| 472 | |
| 473 | local rule |
| 474 | rule=$(peers::effective_rule "$client_name") |
| 475 | rule="${rule:-—}" |
| 476 | |
| 477 | local padded_status |
| 478 | padded_status=$(cmd::list::pad_status "$status" 25) |
| 479 | local group_display="—" |
| 480 | if $has_groups; then |
| 481 | group_display="${peer_group_map[$client_name]:-—}" |
| 482 | fi |
| 483 | |
| 484 | local rule_col_width=12 |
| 485 | [[ "$rule" == "—" ]] && rule_col_width=14 |
| 486 | |
| 487 | local group_col_width=12 |
| 488 | [[ "$group_display" == "—" ]] && group_col_width=14 |
| 489 | |
| 490 | if $has_groups; then |
| 491 | printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \ |
| 492 | "$client_name" "$ip" "$display_type" "$rule" \ |
| 493 | "$group_display" "$padded_status" "$last_seen" |
| 494 | else |
| 495 | printf " %-28s %-15s %-13s %-12s %s %s\n" \ |
| 496 | "$client_name" "$ip" "$display_type" "$rule" \ |
| 497 | "$padded_status" "$last_seen" |
| 498 | fi |
| 499 | done |
| 500 | |
| 501 | if $has_groups; then |
| 502 | printf " %s\n" "$(printf '─%.0s' {1..135})" |
| 503 | else |
| 504 | printf " %s\n" "$(printf '─%.0s' {1..107})" |
| 505 | fi |
| 506 | |
| 507 | local group_summary="" |
| 508 | if $has_groups; then |
| 509 | declare -A group_counts |
| 510 | for peer in "${!peer_group_map[@]}"; do |
| 511 | local g="${peer_group_map[$peer]}" |
| 512 | group_counts["$g"]=$(( ${group_counts["$g"]:-0} + 1 )) || true |
| 513 | done |
| 514 | for g in "${!group_counts[@]}"; do |
| 515 | group_summary+="${group_counts[$g]} in ${g}, " |
| 516 | done |
| 517 | group_summary="${group_summary%, }" |
| 518 | fi |
| 519 | |
| 520 | cmd::list::_render_summary "$group_summary" |
| 521 | |
| 522 | printf "\n" |
| 523 | } |
| 524 | |
| 525 | function cmd::list::_render_summary() { |
| 526 | local group_summary="${1:-}" |
| 527 | # Summary line |
| 528 | local total online_count |
| 529 | total=$(peers::all | wc -l) |
| 530 | |
| 531 | # Count by rule |
| 532 | declare -A rule_summary |
| 533 | while IFS= read -r peer_name; do |
| 534 | local r |
| 535 | r=$(peers::effective_rule "$peer_name") |
| 536 | rule_summary["$r"]=$(( ${rule_summary["$r"]:-0} + 1 )) |
| 537 | done < <(peers::all) |
| 538 | |
| 539 | local summary="" |
| 540 | for r in "${!rule_summary[@]}"; do |
| 541 | summary+="${rule_summary[$r]} ${r}, " |
| 542 | done |
| 543 | summary="${summary%, }" # remove trailing comma |
| 544 | |
| 545 | if [[ -n "$group_summary" ]]; then |
| 546 | printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary" |
| 547 | else |
| 548 | printf "\n Showing %s peers [%s]\n\n" "$total" "$summary" |
| 549 | fi |
| 550 | } |
| 551 | |
| 552 | # Strip ANSI codes to measure visible length, then pad manually |
| 553 | function cmd::list::pad_status() { |
| 554 | local status="$1" |
| 555 | local width="${2:-20}" |
| 556 | local visible |
| 557 | visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g') |
| 558 | local pad=$(( width - ${#visible} )) |
| 559 | printf "%b%${pad}s" "$status" "" |
| 560 | } |