Last active 1 month ago

nuno revised this gist 1 month ago. Go to revision

1 file changed, 522 insertions

rule.command.sh(file created)

@@ -0,0 +1,522 @@
1 + #!/usr/bin/env bash
2 +
3 + # ============================================
4 + # Lifecycle
5 + # ============================================
6 +
7 + function cmd::rule::on_load() {
8 + flag::register --name
9 + flag::register --desc
10 + flag::register --block-ip
11 + flag::register --allow-ip
12 + flag::register --block-port
13 + flag::register --allow-port
14 + flag::register --remove-block-ip
15 + flag::register --remove-allow-ip
16 + flag::register --remove-block-port
17 + flag::register --remove-allow-port
18 + flag::register --peer
19 + flag::register --peers
20 + flag::register --dns-redirect
21 + }
22 +
23 + # ============================================
24 + # Help
25 + # ============================================
26 +
27 + function cmd::rule::help() {
28 + cat <<EOF
29 + Usage: wgctl rule <subcommand> [options]
30 +
31 + Manage firewall rules for peers.
32 +
33 + Subcommands:
34 + list, ls List all rules
35 + show Show rule details and assigned peers
36 + add, new, create Create a new rule
37 + update, edit Update a rule and re-apply to all peers
38 + remove, rm, del Remove a rule
39 + assign Assign a rule to a peer
40 + unassign Remove rule from a peer
41 + migrate Apply default rules to all unassigned peers
42 +
43 + Options for add/update:
44 + --name <name> Rule name (e.g. guest, user, dev-01)
45 + --desc <description> Human readable description
46 + --allow-ip <ip/cidr> Allow IP or subnet (repeatable)
47 + --allow-port <ip:port:proto> Allow specific port (repeatable)
48 + --block-ip <ip/cidr> Block IP or subnet (repeatable)
49 + --block-port <ip:port:proto> Block specific port (repeatable)
50 + --dns-redirect Force DNS through Pi-hole
51 + --remove-allow-ip <ip> Remove allow IP entry (update only)
52 + --remove-allow-port <entry> Remove allow port entry (update only)
53 + --remove-block-ip <ip> Remove block IP entry (update only)
54 + --remove-block-port <entry> Remove block port entry (update only)
55 +
56 + Options for assign/unassign:
57 + --name <rule> Rule name
58 + --peer <peer> Peer name (e.g. phone-nuno)
59 + --type <type> Peer device type (optional)
60 +
61 + Examples:
62 + wgctl rule list
63 + wgctl rule show --name guest
64 + wgctl rule add --name dev-01 --desc "Dev VM only" --allow-ip 10.0.0.50 --block-ip 10.0.0.0/24
65 + wgctl rule update --name user --block-port 10.0.0.100:8006:tcp
66 + wgctl rule update --name user --remove-block-ip 10.0.0.99
67 + wgctl rule assign --name dev-01 --peer laptop-nuno
68 + wgctl rule unassign --peer laptop-nuno --type laptop
69 + wgctl rule migrate
70 + EOF
71 + }
72 +
73 + # ============================================
74 + # Run
75 + # ============================================
76 +
77 + function cmd::rule::run() {
78 + local subcmd="${1:-help}"
79 + shift || true
80 +
81 + case "$subcmd" in
82 + list|ls) cmd::rule::list "$@" ;;
83 + show) cmd::rule::show "$@" ;;
84 + add|new|create) cmd::rule::add "$@" ;;
85 + update|edit) cmd::rule::update "$@" ;;
86 + remove|rm|del|delete) cmd::rule::remove "$@" ;;
87 + assign) cmd::rule::assign "$@" ;;
88 + unassign) cmd::rule::unassign "$@" ;;
89 + migrate) cmd::rule::migrate "$@" ;;
90 + help) cmd::rule::help ;;
91 + *)
92 + log::error "Unknown subcommand: '${subcmd}'"
93 + cmd::rule::help
94 + return 1
95 + ;;
96 + esac
97 + }
98 +
99 + # ============================================
100 + # List
101 + # ============================================
102 +
103 + function cmd::rule::list() {
104 + local rules_dir
105 + rules_dir="$(ctx::rules)"
106 +
107 + local rules=("${rules_dir}"/*.rule)
108 + if [[ ! -f "${rules[0]}" ]]; then
109 + log::wg "No rules configured"
110 + return 0
111 + fi
112 +
113 + log::section "Firewall Rules"
114 + printf "\n %-20s %-45s %-8s %-8s %s\n" \
115 + "NAME" "DESCRIPTION" "ALLOWS" "BLOCKS" "PEERS"
116 + printf " %s\n" "$(printf '─%.0s' {1..95})"
117 +
118 + while IFS="|" read -r name desc n_allows n_blocks peer_count; do
119 + [[ -z "$name" ]] && continue
120 + local short_desc="${desc:0:43}"
121 + [[ ${#desc} -gt 43 ]] && short_desc="${short_desc}..."
122 + printf " %-20s %-45s %-8s %-8s %s\n" \
123 + "$name" "${short_desc:-—}" "$n_allows" "$n_blocks" "${peer_count} peers"
124 + done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)")
125 +
126 + printf "\n"
127 + }
128 +
129 + # ============================================
130 + # Show
131 + # ============================================
132 +
133 + function cmd::rule::show() {
134 + local name="" show_peers=false
135 +
136 + while [[ $# -gt 0 ]]; do
137 + case "$1" in
138 + --name) name="$2"; shift 2 ;;
139 + --peers) show_peers=true; shift ;;
140 + --help) cmd::rule::help; return ;;
141 + *) log::error "Unknown flag: $1"; return 1 ;;
142 + esac
143 + done
144 +
145 + if [[ -z "$name" ]]; then
146 + log::error "Missing required flag: --name"
147 + return 1
148 + fi
149 +
150 + rule::require_exists "$name" || return 1
151 +
152 + local rule_file
153 + rule_file="$(ctx::rule::path "${name}.rule")"
154 +
155 + log::section "Rule: ${name}"
156 +
157 + local desc dns_redirect
158 + desc=$(json::get "$rule_file" "desc")
159 + dns_redirect=$(json::get "$rule_file" "dns_redirect")
160 +
161 + printf "\n %-20s %s\n" "Description:" "${desc:-—}"
162 + printf " %-20s %s\n" "DNS Redirect:" "${dns_redirect:-false}"
163 +
164 + # Allow ports
165 + local allow_ports
166 + allow_ports=$(json::get "$rule_file" "allow_ports")
167 + if [[ -n "$allow_ports" ]]; then
168 + printf "\n Allow Ports:\n"
169 + while IFS= read -r entry; do
170 + printf " + %s\n" "$entry"
171 + done <<< "$allow_ports"
172 + fi
173 +
174 + # Allow IPs
175 + local allow_ips
176 + allow_ips=$(json::get "$rule_file" "allow_ips")
177 + if [[ -n "$allow_ips" ]]; then
178 + printf "\n Allow IPs:\n"
179 + while IFS= read -r ip; do
180 + printf " + %s\n" "$ip"
181 + done <<< "$allow_ips"
182 + fi
183 +
184 + # Block IPs
185 + local block_ips
186 + block_ips=$(json::get "$rule_file" "block_ips")
187 + if [[ -n "$block_ips" ]]; then
188 + printf "\n Block IPs:\n"
189 + while IFS= read -r ip; do
190 + printf " - %s\n" "$ip"
191 + done <<< "$block_ips"
192 + fi
193 +
194 + # Block ports
195 + local block_ports
196 + block_ports=$(json::get "$rule_file" "block_ports")
197 + if [[ -n "$block_ports" ]]; then
198 + printf "\n Block Ports:\n"
199 + while IFS= read -r entry; do
200 + printf " - %s\n" "$entry"
201 + done <<< "$block_ports"
202 + fi
203 +
204 + # Precompute peers before any other operations
205 + local peer_list=()
206 + mapfile -t peer_list < <(peers::with_rule "$name")
207 + local peer_count=${#peer_list[@]}
208 +
209 + # Peer count — always shown
210 + printf "\n %-20s %s\n" "Assigned Peers:" "$peer_count"
211 + printf " %s\n" "$(printf '─%.0s' {1..40})"
212 +
213 + # Peer details — only with --peers flag
214 + if $show_peers && [[ $peer_count -gt 0 ]]; then
215 + for peer_name in "${peer_list[@]}"; do
216 + local ip
217 + ip=$(peers::get_ip "$peer_name")
218 + printf " %-28s %s\n" "$peer_name" "$ip"
219 + done
220 + fi
221 +
222 + printf "\n"
223 + }
224 +
225 + # ============================================
226 + # Add
227 + # ============================================
228 +
229 + function cmd::rule::add() {
230 + local name="" desc=""
231 + local allow_ips=() block_ips=() block_ports=()
232 + local dns_redirect=false
233 +
234 + while [[ $# -gt 0 ]]; do
235 + case "$1" in
236 + --name) name="$2"; shift 2 ;;
237 + --desc) desc="$2"; shift 2 ;;
238 + --allow-ip) allow_ips+=("$2"); shift 2 ;;
239 + --block-ip) block_ips+=("$2"); shift 2 ;;
240 + --block-port) block_ports+=("$2"); shift 2 ;;
241 + --dns-redirect) dns_redirect=true; shift ;;
242 + --help) cmd::rule::help; return ;;
243 + *)
244 + log::error "Unknown flag: $1"
245 + return 1
246 + ;;
247 + esac
248 + done
249 +
250 + if [[ -z "$name" ]]; then
251 + log::error "Missing required flag: --name"
252 + return 1
253 + fi
254 +
255 + if rule::exists "$name"; then
256 + log::error "Rule already exists: ${name}"
257 + return 1
258 + fi
259 +
260 + local rule_file
261 + rule_file="$(ctx::rule::path "${name}.rule")"
262 +
263 + # Build JSON using json_helper
264 + python3 -c "
265 + import json
266 + rule = {
267 + 'name': '${name}',
268 + 'desc': '${desc}',
269 + 'dns_redirect': $(${dns_redirect} && echo 'true' || echo 'false'),
270 + 'allow_ips': [$(printf '"%s",' "${allow_ips[@]}" | sed 's/,$//')] ,
271 + 'block_ips': [$(printf '"%s",' "${block_ips[@]}" | sed 's/,$//')],
272 + 'block_ports': [$(printf '"%s",' "${block_ports[@]}" | sed 's/,$//')]
273 + }
274 + with open('${rule_file}', 'w') as f:
275 + json.dump(rule, f, indent=2)
276 + "
277 +
278 + log::wg_success "Rule created: ${name}"
279 + }
280 +
281 + # ============================================
282 + # Update
283 + # ============================================
284 +
285 + function cmd::rule::update() {
286 + local name="" desc=""
287 + local allow_ips=() block_ips=() block_ports=()
288 + local allow_ports=()
289 + local rm_allow_ips=() rm_block_ips=() rm_block_ports=() rm_allow_ports=()
290 + local dns_redirect=""
291 +
292 + while [[ $# -gt 0 ]]; do
293 + case "$1" in
294 + --name) name="$2"; shift 2 ;;
295 + --desc) desc="$2"; shift 2 ;;
296 + --allow-ip) allow_ips+=("$2"); shift 2 ;;
297 + --block-ip) block_ips+=("$2"); shift 2 ;;
298 + --block-port) block_ports+=("$2"); shift 2 ;;
299 + --allow-port) allow_ports+=("$2"); shift 2 ;;
300 + --remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;;
301 + --remove-block-ip) rm_block_ips+=("$2"); shift 2 ;;
302 + --remove-block-port) rm_block_ports+=("$2"); shift 2 ;;
303 + --remove-allow-port) rm_allow_ports+=("$2"); shift 2 ;;
304 + --dns-redirect) dns_redirect=true; shift ;;
305 + --help) cmd::rule::help; return ;;
306 + *)
307 + log::error "Unknown flag: $1"
308 + return 1
309 + ;;
310 + esac
311 + done
312 +
313 + if [[ -z "$name" ]]; then
314 + log::error "Missing required flag: --name"
315 + return 1
316 + fi
317 +
318 + rule::require_exists "$name" || return 1
319 +
320 + local rule_file
321 + rule_file="$(ctx::rule::path "${name}.rule")"
322 +
323 + # Update desc and dns_redirect
324 + [[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\""
325 + [[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true"
326 +
327 + # Add entries
328 + for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done
329 + for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done
330 + for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done
331 + for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done
332 +
333 + # Remove entries
334 + for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done
335 + for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done
336 + for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done
337 + for p in "${rm_allow_ports[@]}"; do json::remove "$rule_file" "allow_ports" "$p"; done
338 +
339 + log::wg_success "Rule updated: ${name}"
340 +
341 + # Re-apply to all assigned peers
342 + rule::reapply_all "$name"
343 + }
344 +
345 + # ============================================
346 + # Remove
347 + # ============================================
348 +
349 + function cmd::rule::remove() {
350 + local name="" force=false
351 +
352 + while [[ $# -gt 0 ]]; do
353 + case "$1" in
354 + --name) name="$2"; shift 2 ;;
355 + --force) force=true; shift ;;
356 + --help) cmd::rule::help; return ;;
357 + *)
358 + log::error "Unknown flag: $1"
359 + return 1
360 + ;;
361 + esac
362 + done
363 +
364 + if [[ -z "$name" ]]; then
365 + log::error "Missing required flag: --name"
366 + return 1
367 + fi
368 +
369 + rule::require_exists "$name" || return 1
370 +
371 + # Check for assigned peers
372 + local peer_count
373 + peer_count=$(peers::with_rule "$name" | grep -c . || echo 0)
374 + if [[ "$peer_count" -gt 0 ]]; then
375 + log::error "Rule '${name}' is assigned to ${peer_count} peer(s) — unassign first or use --force"
376 + if ! $force; then
377 + return 1
378 + fi
379 + # Force: unassign from all peers
380 + while IFS= read -r peer; do
381 + local ip
382 + ip=$(peers::get_ip "$peer")
383 + rule::unapply "$name" "$ip"
384 + done < <(peers::with_rule "$name")
385 + fi
386 +
387 + rm -f "$(ctx::rule::path "${name}.rule")"
388 + log::wg_success "Rule removed: ${name}"
389 + }
390 +
391 + # ============================================
392 + # Assign
393 + # ============================================
394 +
395 + function cmd::rule::assign() {
396 + local name="" peer="" type=""
397 +
398 + while [[ $# -gt 0 ]]; do
399 + case "$1" in
400 + --name) name="$2"; shift 2 ;;
401 + --peer) peer="$2"; shift 2 ;;
402 + --type) type="$2"; shift 2 ;;
403 + --help) cmd::rule::help; return ;;
404 + *)
405 + log::error "Unknown flag: $1"
406 + return 1
407 + ;;
408 + esac
409 + done
410 +
411 + if [[ -z "$name" || -z "$peer" ]]; then
412 + log::error "Missing required flags: --name and --peer"
413 + return 1
414 + fi
415 +
416 + rule::require_exists "$name" || return 1
417 +
418 + # Support --type for peer resolution
419 + peer=$(peers::resolve_and_require "$peer" "$type") || return 1
420 +
421 + # Unapply existing rule first if any
422 + local existing_rule
423 + existing_rule=$(peers::get_meta "$peer" "rule")
424 + if [[ -n "$existing_rule" ]]; then
425 + local ip
426 + ip=$(peers::get_ip "$peer")
427 + rule::unapply "$existing_rule" "$ip"
428 + log::wg "Removed existing rule '${existing_rule}' from: ${peer}"
429 + fi
430 +
431 + local ip
432 + ip=$(peers::get_ip "$peer")
433 + rule::apply "$name" "$ip"
434 +
435 + log::wg_success "Assigned rule '${name}' to: ${peer}"
436 + }
437 +
438 + # ============================================
439 + # Unassign
440 + # ============================================
441 +
442 + function cmd::rule::unassign() {
443 + local peer="" type=""
444 +
445 + while [[ $# -gt 0 ]]; do
446 + case "$1" in
447 + --peer) peer="$2"; shift 2 ;;
448 + --type) type="$2"; shift 2 ;;
449 + --help) cmd::rule::help; return ;;
450 + *)
451 + log::error "Unknown flag: $1"
452 + return 1
453 + ;;
454 + esac
455 + done
456 +
457 + if [[ -z "$peer" ]]; then
458 + log::error "Missing required flag: --peer"
459 + return 1
460 + fi
461 +
462 + peer=$(peers::resolve_and_require "$peer" "$type") || return 1
463 +
464 + local existing_rule
465 + existing_rule=$(peers::get_meta "$peer" "rule")
466 +
467 + if [[ -z "$existing_rule" ]]; then
468 + log::wg_warning "Peer '${peer}' has no assigned rule"
469 + return 0
470 + fi
471 +
472 + local ip
473 + ip=$(peers::get_ip "$peer")
474 + rule::unapply "$existing_rule" "$ip"
475 +
476 + log::wg_success "Unassigned rule from: ${peer}"
477 + }
478 +
479 + # ============================================
480 + # Migrate Rules
481 + # ============================================
482 +
483 + function cmd::rule::migrate() {
484 + log::section "Migrating peers to default rules"
485 +
486 + # Write migration plan to temp file to avoid fd conflicts
487 + local tmp
488 + tmp=$(mktemp)
489 +
490 + while IFS= read -r peer_name; do
491 + local existing
492 + existing=$(peers::get_meta "$peer_name" "rule")
493 + [[ -n "$existing" ]] && continue
494 + local default_rule
495 + default_rule=$(peers::default_rule "$peer_name")
496 + rule::exists "$default_rule" || continue
497 + local ip
498 + ip=$(peers::get_ip "$peer_name")
499 + echo "${peer_name} ${default_rule} ${ip}" >> "$tmp"
500 + done < <(peers::all)
501 +
502 + local count=0
503 + local lines
504 + mapfile -t lines < "$tmp"
505 + echo "DEBUG: lines count=${#lines[@]}"
506 + for line in "${lines[@]}"; do
507 + echo "DEBUG: processing line=$line"
508 + IFS=" " read -r peer_name default_rule ip <<< "$line"
509 + rule::apply "$default_rule" "$ip" "$peer_name" </dev/null
510 + echo "DEBUG: after apply, count=$count"
511 + (( count++ )) || true
512 + echo "DEBUG: incremented count=$count"
513 + done
514 + echo "DEBUG: loop done, count=$count"
515 +
516 + echo "DEBUG: final count=$count"
517 + echo "DEBUG: tmp contents:"
518 + cat "$tmp"
519 +
520 + rm -f "$tmp"
521 + log::wg_success "Migrated ${count} peers"
522 + }
Newer Older