最後活躍 1 month ago

rule.command.sh 原始檔案
1#!/usr/bin/env bash
2
3# ============================================
4# Lifecycle
5# ============================================
6
7function cmd::rule::on_load() {
8 flag::register --name
9 flag::register --desc
10 flag::register --group
11 flag::register --extends
12 flag::register --remove-extends
13 flag::register --block-ip
14 flag::register --allow-ip
15 flag::register --block-service
16 flag::register --allow-service
17 flag::register --block-port
18 flag::register --allow-port
19 flag::register --remove-block-ip
20 flag::register --remove-allow-ip
21 flag::register --remove-block-port
22 flag::register --remove-allow-port
23 flag::register --peer
24 flag::register --peers
25 flag::register --dns-redirect
26 flag::register --base
27 flag::register --no-base
28 flag::register --tree
29 flag::register --detailed
30 flag::register --resolved
31 flag::register --force
32 flag::register --type
33 flag::register --all
34
35 command::mixin json_output
36}
37
38# ============================================
39# Help
40# ============================================
41
42function cmd::rule::help() {
43 cat <<EOF
44Usage: wgctl rule <subcommand> [options]
45
46Manage firewall rules with inheritance support.
47Rules can extend base rules to compose reusable access policies.
48Service names from 'wgctl net' can be used instead of raw IPs/ports.
49
50Subcommands:
51 list, ls List all rules
52 list --detailed Show inheritance tree
53 show, inspect --name <r> Show rule details and inheritance
54 add, new, create --name <r> Create a new rule
55 update, edit --name <r> Update a rule and re-apply to peers
56 remove, rm, del --name <r> Remove a rule
57 assign --name <r> Assign a rule to a peer
58 unassign --name <r> --peer <p> Remove rule from a peer
59 reapply Re-apply rule to all assigned peers
60 migrate Apply default rules to unassigned peers
61
62Options for list:
63 --base Show only base rules
64 --no-base Hide base rules section
65 --group <name> Filter by group (case insensitive)
66 --detailed Show rule entries inline
67
68Options for add:
69 --name <name> Rule name
70 --desc <description> Description
71 --group <group> Display group (e.g. VM Rules, Users)
72 --extends <rule,...> Inherit from base rules (comma-separated)
73 --base Create as base rule (not directly assignable)
74 --allow-ip <ip/cidr> Allow IP or subnet (repeatable)
75 --allow-port <ip:port:proto> Allow specific port (repeatable)
76 --block-ip <ip/cidr> Block IP or subnet (repeatable)
77 --block-port <ip:port:proto> Block specific port (repeatable)
78 --block-service <name> Block named service (repeatable)
79 --allow-service <name> Allow named service (repeatable)
80 --dns-redirect Force DNS through Pi-hole
81
82Options for update:
83 (same as add, plus:)
84 --add-extends <rule,...> Add inherited base rules
85 --remove-extends <rule,...> Remove inherited base rules
86 --remove-allow-ip <ip> Remove allow IP entry
87 --remove-allow-port <entry> Remove allow port entry
88 --remove-block-ip <ip> Remove block IP entry
89 --remove-block-port <entry> Remove block port entry
90
91Options for show:
92 --name <name> Rule name
93 --resolved Show resolved/merged entries
94 --no-peers Hide assigned peers
95
96Options for reapply:
97 --name <name> Rule name
98 --all Reapply all rules
99
100Examples:
101 wgctl rule list
102 wgctl rule list --detailed
103 wgctl rule list --group "VM Rules"
104 wgctl rule show --name guest
105 wgctl rule show --name moonlight-02 --resolved
106 wgctl rule add --name no-proxmox --base --block-service proxmox
107 wgctl rule add --name dev-01 --desc "Dev access" --extends no-lan
108 wgctl rule assign --name dev-01 --peer laptop-nuno
109 wgctl rule reapply --all
110EOF
111}
112
113# ============================================
114# Run
115# ============================================
116
117function cmd::rule::run() {
118 local subcmd="${1:-help}"
119 shift || true
120
121 if command::json; then
122 cmd::rule::_output_json
123 return 0
124 fi
125
126 case "$subcmd" in
127 list|ls) cmd::rule::list "$@" ;;
128 show|inspect) cmd::rule::show "$@" ;;
129 add|new|create) cmd::rule::add "$@" ;;
130 update|edit) cmd::rule::update "$@" ;;
131 remove|rm|del|delete) cmd::rule::remove "$@" ;;
132 assign) cmd::rule::assign "$@" ;;
133 unassign) cmd::rule::unassign "$@" ;;
134 migrate) cmd::rule::migrate "$@" ;;
135 reapply) cmd::rule::reapply "$@" ;;
136 help) cmd::rule::help ;;
137 *)
138 log::error "Unknown subcommand: '${subcmd}'"
139 cmd::rule::help
140 return 1
141 ;;
142 esac
143}
144
145# ============================================
146# List
147# ============================================
148
149function cmd::rule::list() {
150 local rules_dir
151 rules_dir="$(ctx::rules)"
152
153 local show_base_only=false show_base=true
154 local filter_group="" detailed=false
155
156 while [[ $# -gt 0 ]]; do
157 case "$1" in
158 --base) show_base_only=true; shift ;;
159 --no-base) show_base=false; shift ;;
160 --group) filter_group="${2,,}"; shift 2 ;;
161 --detailed) detailed=true; shift ;;
162 --help) cmd::rule::help; return ;;
163 *)
164 log::error "Unknown flag: $1"
165 return 1 ;;
166 esac
167 done
168
169 local data
170 data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)")
171 [[ -z "$data" ]] && log::wg "No rules configured" && return 0
172
173 # Measure max name width
174 local w_name=12
175 while IFS='|' read -r name rest; do
176 [[ -z "$name" ]] && continue
177 (( ${#name} > w_name )) && w_name=${#name}
178 done <<< "$data"
179 (( w_name += 2 ))
180
181 log::section "Firewall Rules"
182 echo ""
183
184 if display::is_table "rule_list"; then
185 cmd::rule::_render_table "$data"
186 return 0
187 fi
188
189 local current_group="" printing_base=false found_any=false
190
191 while IFS="|" read -r name desc n_allows n_blocks \
192 peer_count extends is_base group; do
193 [[ -z "$name" ]] && continue
194
195 $show_base_only && [[ "$is_base" == "False" ]] && continue
196 ! $show_base && [[ "$is_base" == "True" ]] && continue
197 [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]] && continue
198
199 found_any=true
200
201 # Base rules section header
202 if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then
203 if ! $show_base_only; then
204 ui::rule::list_base_header
205 fi
206 printing_base=true
207 current_group=""
208 fi
209
210 # Group header — non-base rules only
211 if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then
212 if [[ -n "$group" ]]; then
213 ui::rule::list_group_header "$group"
214 elif [[ -n "$current_group" ]]; then
215 echo ""
216 fi
217 current_group="$group"
218 fi
219
220 # Rule row
221 # ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name"
222
223 # Extends
224 # Rule row — pass extends_csv for compact inline display
225 local compact_extends=""
226 if [[ -z "$detailed" ]] || ! $detailed; then
227 compact_extends="$extends"
228 fi
229 ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name" "$compact_extends"
230
231 # Detailed mode — show expanded entries
232 if $detailed && [[ -n "$extends" ]]; then
233 ui::rule::list_extends_detailed "$extends" "$rules_dir"
234 echo ""
235 fi
236
237 done <<< "$data"
238
239 $found_any || {
240 [[ -n "$filter_group" ]] && \
241 log::wg_warning "No rules found in group: ${filter_group}" || \
242 log::wg_warning "No rules found"
243 }
244
245 echo ""
246}
247
248function cmd::rule::_render_table() {
249 local data="${1:-}"
250 [[ -z "$data" ]] && return 0
251
252 ui::rule::list_header_table
253 while IFS='|' read -r name desc n_allows n_blocks peer_count extends is_base group; do
254 [[ -z "$name" ]] && continue
255 ui::rule::list_row_table "$name" "$n_allows" "$n_blocks" "$peer_count" "$extends" "$group"
256 done <<< "$data"
257 printf "\n"
258}
259
260# ============================================
261# Show
262# ============================================
263
264function cmd::rule::show() {
265 local name="" show_peers=true show_resolved=false
266
267 while [[ $# -gt 0 ]]; do
268 case "$1" in
269 --name) name="$2"; shift 2 ;;
270 --no-peers) show_peers=false; shift ;;
271 --resolved) show_resolved=true; shift ;;
272 --help) cmd::rule::help; return ;;
273 *) log::error "Unknown flag: $1"; return 1 ;;
274 esac
275 done
276
277 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
278 rule::require_exists "$name" || return 1
279
280 local rule_file
281 rule_file="$(rule::path "$name")"
282
283 # DNS display
284 local dns_redirect resolved_dns dns_display
285 dns_redirect=$(rule::get_own "$name" "dns_redirect")
286 dns_redirect="${dns_redirect:-false}"
287 resolved_dns=$(rule::get "$name" "dns_redirect")
288 resolved_dns="${resolved_dns:-false}"
289
290 if [[ "${resolved_dns,,}" == "true" && "${dns_redirect,,}" != "true" ]]; then
291 dns_display="true (inherited)"
292 elif [[ "${dns_redirect,,}" == "true" ]]; then
293 dns_display="true"
294 else
295 dns_display="false"
296 fi
297
298 log::section "Rule: ${name}"
299 printf "\n"
300
301 local desc group
302 desc=$(json::get "$rule_file" "desc")
303 group=$(json::get "$rule_file" "group")
304 ui::row "Description" "${desc:-}"
305 ui::row "Group" "${group:-}"
306 ui::row "DNS" "$dns_display"
307
308 printf "\n"
309 if ui::rule::tree "$name"; then
310 :
311 else
312 ui::rule::flat "$name"
313 printf "\n"
314 fi
315
316 # Resolved view
317 if $show_resolved; then
318 ui::rule::section_header "Resolved (applied to peers)"
319 printf "\n"
320 local res_allow_ports res_allow_ips res_block_ips res_block_ports
321 res_allow_ports=$(rule::get "$name" "allow_ports")
322 res_allow_ips=$(rule::get "$name" "allow_ips")
323 res_block_ips=$(rule::get "$name" "block_ips")
324 res_block_ports=$(rule::get "$name" "block_ports")
325 while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "+" "$e"; done \
326 <<< "$res_allow_ports"$'\n'"$res_allow_ips"
327 while IFS= read -r e; do [[ -n "$e" ]] && net::print_entry "-" "$e"; done \
328 <<< "$res_block_ips"$'\n'"$res_block_ports"
329 printf "\n"
330 fi
331
332 # Peers
333 $show_peers || return 0
334
335 local peer_list=()
336 mapfile -t peer_list < <(peers::with_rule "$name") || true
337 local peer_count=${#peer_list[@]}
338 ui::empty "$peer_count" && return 0
339
340 [[ "$peer_count" -eq 1 ]]
341 printf "\n \033[0;37m── Peers (%s) \033[0m%s\n\n" \
342 "$peer_count" "$(printf '\033[0;37m─%.0s' {1..30})"
343
344 for peer_name in "${peer_list[@]}"; do
345 local ip
346 ip=$(peers::get_ip "$peer_name")
347 printf " %-28s %s\n" "$peer_name" "$ip"
348 done
349 printf "\n"
350 return 0
351}
352
353# ============================================
354# Add
355# ============================================
356
357function cmd::rule::add() {
358 local name="" desc="" group=""
359 local extends=()
360 local allow_ips=() block_ips=() block_ports=() allow_ports=()
361 local block_services=() allow_services=()
362 local dns_redirect=false is_base=false
363
364 while [[ $# -gt 0 ]]; do
365 case "$1" in
366 --name) name="$2"; shift 2 ;;
367 --desc) desc="$2"; shift 2 ;;
368 --group) group="$2"; shift 2 ;;
369 --extends)
370 IFS=',' read -ra ext <<< "$2"
371 extends+=("${ext[@]}")
372 shift 2 ;;
373 --base) is_base=true; shift ;;
374 --allow-ip) allow_ips+=("$2"); shift 2 ;;
375 --allow-port) allow_ports+=("$2"); shift 2 ;;
376 --block-ip) block_ips+=("$2"); shift 2 ;;
377 --block-port) block_ports+=("$2"); shift 2 ;;
378 --block-service) block_services+=("$2"); shift 2 ;;
379 --allow-service) allow_services+=("$2"); shift 2 ;;
380 --dns-redirect) dns_redirect=true; shift ;;
381 --help) cmd::rule::help; return ;;
382 *)
383 log::error "Unknown flag: $1"
384 return 1 ;;
385 esac
386 done
387
388 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
389
390 if rule::exists "$name"; then
391 log::error "Rule already exists: ${name}"
392 return 1
393 fi
394
395 for ext in "${extends[@]}"; do
396 rule::require_exists "$ext" || return 1
397 done
398
399 local rule_dir
400 if $is_base; then
401 rule_dir="$(ctx::rules)/base"
402 mkdir -p "$rule_dir"
403 else
404 rule_dir="$(ctx::rules)"
405 fi
406
407 for svc in "${block_services[@]}"; do
408 while IFS= read -r resolved; do
409 if [[ "$resolved" == *:*:* ]]; then
410 block_ports+=("${resolved}")
411 else
412 block_ips+=("${resolved}/32")
413 fi
414 done < <(net::resolve "$svc")
415 done
416
417 for svc in "${allow_services[@]}"; do
418 while IFS= read -r resolved; do
419 if [[ "$resolved" == *:*:* ]]; then
420 allow_ports+=("${resolved}")
421 else
422 allow_ips+=("${resolved}/32")
423 fi
424 done < <(net::resolve "$svc")
425 done
426
427 local rule_file="${rule_dir}/${name}.rule"
428 local allow_str block_str port_str allow_port_str extends_str
429 allow_str=$(IFS=','; echo "${allow_ips[*]}")
430 block_str=$(IFS=','; echo "${block_ips[*]}")
431 port_str=$(IFS=','; echo "${block_ports[*]}")
432 allow_port_str=$(IFS=','; echo "${allow_ports[*]}")
433 extends_str=$(IFS=','; echo "${extends[*]}")
434
435 json::create_rule "$rule_file" "$name" "$desc" \
436 "$($dns_redirect && echo true || echo false)" \
437 "$allow_str" "$block_str" "$port_str" \
438 "$allow_port_str" "$extends_str" "$group" || return 1
439
440 local base_label=""
441 $is_base && base_label=" (base)"
442 log::wg_success "Rule created: ${name}${base_label}"
443}
444
445# ============================================
446# Update
447# ============================================
448
449function cmd::rule::update() {
450 local name="" desc="" group=""
451 local add_extends=() rm_extends=()
452 local allow_ips=() block_ips=() block_ports=() allow_ports=()
453 local rm_allow_ips=() rm_block_ips=() rm_block_ports=() rm_allow_ports=()
454 local dns_redirect=""
455
456 while [[ $# -gt 0 ]]; do
457 case "$1" in
458 --name) name="$2"; shift 2 ;;
459 --desc) desc="$2"; shift 2 ;;
460 --group) group="$2"; shift 2 ;;
461 --add-extends)
462 IFS=',' read -ra ext <<< "$2"
463 add_extends+=("${ext[@]}")
464 shift 2 ;;
465 --remove-extends)
466 IFS=',' read -ra ext <<< "$2"
467 rm_extends+=("${ext[@]}")
468 shift 2 ;;
469 --allow-ip) allow_ips+=("$2"); shift 2 ;;
470 --allow-port) allow_ports+=("$2"); shift 2 ;;
471 --block-ip) block_ips+=("$2"); shift 2 ;;
472 --block-port) block_ports+=("$2"); shift 2 ;;
473 --remove-allow-ip) rm_allow_ips+=("$2"); shift 2 ;;
474 --remove-block-ip) rm_block_ips+=("$2"); shift 2 ;;
475 --remove-block-port) rm_block_ports+=("$2"); shift 2 ;;
476 --remove-allow-port) rm_allow_ports+=("$2"); shift 2 ;;
477 --dns-redirect) dns_redirect=true; shift ;;
478 --help) cmd::rule::help; return ;;
479 *)
480 log::error "Unknown flag: $1"
481 return 1 ;;
482 esac
483 done
484
485 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
486 rule::require_exists "$name" || return 1
487
488 local rule_file
489 rule_file="$(rule::path "$name")"
490
491 [[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\""
492 [[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\""
493 [[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true"
494
495 for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done
496 for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done
497 for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done
498 for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done
499
500 for ext in "${add_extends[@]}"; do
501 rule::require_exists "$ext" || return 1
502 json::append "$rule_file" "extends" "$ext"
503 done
504 for ext in "${rm_extends[@]}"; do
505 json::remove "$rule_file" "extends" "$ext"
506 done
507
508 for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done
509 for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done
510 for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done
511 for p in "${rm_allow_ports[@]}"; do json::remove "$rule_file" "allow_ports" "$p"; done
512
513 log::wg_success "Rule updated: ${name}"
514 rule::reapply_all "$name"
515}
516
517# ============================================
518# Remove
519# ============================================
520
521function cmd::rule::remove() {
522 local name="" force=false
523
524 while [[ $# -gt 0 ]]; do
525 case "$1" in
526 --name) name="$2"; shift 2 ;;
527 --force) force=true; shift ;;
528 --help) cmd::rule::help; return ;;
529 *) log::error "Unknown flag: $1"; return 1 ;;
530 esac
531 done
532
533 [[ -z "$name" ]] && log::error "Missing required flag: --name" && return 1
534 rule::require_exists "$name" || return 1
535
536 local peer_list=()
537 mapfile -t peer_list < <(peers::with_rule "$name") || true
538 local peer_count=${#peer_list[@]}
539
540 if [[ "$peer_count" -gt 0 ]]; then
541 log::error "Rule '${name}' is assigned to ${peer_count} peer(s) — unassign first or use --force"
542 $force || return 1
543 for peer in "${peer_list[@]}"; do
544 local ip
545 ip=$(peers::get_ip "$peer")
546 rule::unapply "$name" "$ip"
547 done
548 fi
549
550 rm -f "$(rule::path "$name")"
551 log::wg_success "Rule removed: ${name}"
552}
553
554# ============================================
555# Assign / Unassign
556# ============================================
557
558function cmd::rule::assign() {
559 local name="" peer="" type=""
560 while [[ $# -gt 0 ]]; do
561 case "$1" in
562 --name) name="$2"; shift 2 ;;
563 --peer) peer="$2"; shift 2 ;;
564 --type) type="$2"; shift 2 ;;
565 --help) cmd::rule::help; return ;;
566 *) log::error "Unknown flag: $1"; return 1 ;;
567 esac
568 done
569
570 [[ -z "$name" || -z "$peer" ]] && \
571 log::error "Missing required flags: --name and --peer" && return 1
572
573 rule::require_exists "$name" || return 1
574 rule::require_assignable "$name" || return 1
575
576 peer=$(peers::resolve_and_require "$peer" "$type") || return 1
577
578 # Identity rule check
579 local peer_identity
580
581 peer_identity=$(peers::get_identity "$peer")
582 if [[ -n "$peer_identity" ]]; then
583 local identity_rules
584 identity_rules=$(identity::rules "$peer_identity" 2>/dev/null)
585 if echo "$identity_rules" | grep -qx "$name"; then
586 log::error "Rule '${name}' is already applied to '${peer}' via identity '${peer_identity}' — cannot assign directly"
587 return 1
588 fi
589 fi
590
591 local existing_rule ip
592 existing_rule=$(peers::get_meta "$peer" "rule")
593 ip=$(peers::get_ip "$peer")
594 [[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1
595
596 if [[ -n "$existing_rule" && "$existing_rule" != "$name" ]]; then
597 rule::unapply "$existing_rule" "$ip"
598 log::wg "Removed existing rule '${existing_rule}' from: ${peer}"
599 fi
600
601 rule::apply "$name" "$ip"
602 log::wg_success "Assigned rule '${name}' to: ${peer}"
603}
604
605function cmd::rule::unassign() {
606 local peer="" type=""
607 while [[ $# -gt 0 ]]; do
608 case "$1" in
609 --peer) peer="$2"; shift 2 ;;
610 --type) type="$2"; shift 2 ;;
611 --help) cmd::rule::help; return ;;
612 *) log::error "Unknown flag: $1"; return 1 ;;
613 esac
614 done
615
616 [[ -z "$peer" ]] && log::error "Missing required flag: --peer" && return 1
617 peer=$(peers::resolve_and_require "$peer" "$type") || return 1
618
619 local existing_rule
620 existing_rule=$(peers::get_meta "$peer" "rule")
621
622 if [[ -z "$existing_rule" ]]; then
623 log::wg_warning "Peer '${peer}' has no assigned rule"
624 return 0
625 fi
626
627 local ip
628 ip=$(peers::get_ip "$peer")
629 rule::unapply "$existing_rule" "$ip"
630 log::wg_success "Unassigned rule from: ${peer}"
631}
632
633# ============================================
634# Migrate
635# ============================================
636
637function cmd::rule::migrate() {
638 log::section "Migrating peers to default rules"
639 local count=0
640
641 while IFS= read -r peer_name; do
642 local existing
643 existing=$(peers::get_meta "$peer_name" "rule")
644 [[ -n "$existing" ]] && continue
645
646 # Try to get default rule from subnet policy
647 local peer_type subnet_name default_rule
648 peer_type=$(peers::get_meta "$peer_name" "type")
649 subnet_name=$(peers::get_meta "$peer_name" "subnet")
650 default_rule=$(subnet::default_rule "$subnet_name" "$peer_type")
651 [[ -z "$default_rule" ]] && continue
652
653 rule::exists "$default_rule" || continue
654
655 local ip
656 ip=$(peers::get_ip "$peer_name")
657 rule::apply "$default_rule" "$ip" "$peer_name"
658 (( count++ )) || true
659 done < <(peers::all)
660
661 log::wg_success "Migrated ${count} peers"
662}
663
664# ============================================
665# Reapply
666# ============================================
667
668function cmd::rule::reapply() {
669 local name="" all=false
670 while [[ $# -gt 0 ]]; do
671 case "$1" in
672 --name) name="$2"; shift 2 ;;
673 --all) all=true; shift ;;
674 *) log::error "Unknown flag: $1"; return 1 ;;
675 esac
676 done
677
678 if $all; then
679 log::section "Reapplying all rules"
680 local count=0
681 while IFS= read -r rule_file; do
682 local rname
683 rname=$(basename "$rule_file" .rule)
684 local peer_list=()
685 mapfile -t peer_list < <(peers::with_rule "$rname") || true
686 [[ ${#peer_list[@]} -eq 0 ]] && continue
687 rule::reapply_all "$rname"
688 (( count++ )) || true
689 done < <(find "$(ctx::rules)" -maxdepth 1 -name "*.rule")
690 log::wg_success "Reapplied ${count} assignable rules"
691 return 0
692 fi
693
694 [[ -z "$name" ]] && log::error "Missing --name or --all" && return 1
695 rule::require_exists "$name" || return 1
696 rule::reapply_all "$name"
697 log::wg_success "Rule '${name}' reapplied"
698}
699
700function cmd::rule::_output_json() {
701 local rules_dir
702 rules_dir="$(ctx::rules)"
703 local data
704 data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)" 2>/dev/null)
705
706 local -a rules=()
707 while IFS='|' read -r name desc n_allows n_blocks peer_count extends is_base group; do
708 [[ -z "$name" ]] && continue
709
710 # Build extends array
711 local extends_json="[]"
712 if [[ -n "$extends" ]]; then
713 local ext_array
714 ext_array=$(echo "$extends" | tr ',' '\n' | \
715 while IFS= read -r e; do [[ -n "$e" ]] && printf '"%s",' "$e"; done | sed 's/,$//')
716 extends_json="[${ext_array}]"
717 fi
718
719 # Convert Python bool to JSON bool
720 local is_base_json="false"
721 [[ "$is_base" == "True" ]] && is_base_json="true"
722
723 rules+=("$(printf '{"name":"%s","desc":"%s","allows":%s,"blocks":%s,"peer_count":%s,"extends":%s,"is_base":%s,"group":"%s"}' \
724 "$name" "$desc" "$n_allows" "$n_blocks" "$peer_count" \
725 "$extends_json" "$is_base_json" "$group")")
726 done <<< "$data"
727
728 local count=${#rules[@]}
729 local array
730 array=$(printf '%s\n' "${rules[@]:-}" | paste -sd ',' -)
731 printf '{"rules":[%s]}' "${array:-}" | json::envelope "rule list" "$count"
732}