最終更新 1 month ago

修正履歴 5d7f305193d4a2edd88a9948d13587e59d4f9cd7

gistfile1.txt Raw
1#!/usr/bin/env python3
2"""
3wgctl JSON helper — called by shell functions to read/write JSON files.
4Usage: json_helper.py <command> <file> [key] [value]
5"""
6
7import sys
8import json
9import os
10
11DATETIME_FMT = os.environ.get('WGCTL_DATETIME_FMT', '%Y-%m-%d %H:%M')
12
13def get(file, key):
14 try:
15 with open(file) as f:
16 data = json.load(f)
17 val = data.get(key, [])
18 if isinstance(val, bool):
19 print(str(val).lower()) # true/false not True/False
20 elif isinstance(val, list):
21 if val:
22 print('\n'.join(str(v) for v in val))
23 else:
24 if val:
25 print(val)
26 except:
27 sys.exit(0)
28
29def set_key(file, key, value):
30 try:
31 data = {}
32 if os.path.exists(file):
33 with open(file) as f:
34 data = json.load(f)
35 # Try to parse as JSON value first (for arrays/bools)
36 try:
37 data[key] = json.loads(value)
38 except:
39 data[key] = value
40 with open(file, 'w') as f:
41 json.dump(data, f, indent=2)
42 except Exception as e:
43 print(f"Error: {e}", file=sys.stderr)
44 sys.exit(1)
45
46def delete_key(file, key):
47 try:
48 with open(file) as f:
49 data = json.load(f)
50 data.pop(key, None)
51 with open(file, 'w') as f:
52 json.dump(data, f, indent=2)
53 except Exception as e:
54 print(f"Error: {e}", file=sys.stderr)
55 sys.exit(1)
56
57def append(file, key, value):
58 try:
59 data = {}
60 if os.path.exists(file):
61 with open(file) as f:
62 data = json.load(f)
63 if key not in data:
64 data[key] = []
65 if value not in data[key]:
66 data[key].append(value)
67 with open(file, 'w') as f:
68 json.dump(data, f, indent=2)
69 except Exception as e:
70 print(f"Error: {e}", file=sys.stderr)
71 sys.exit(1)
72
73def remove_value(file, key, value):
74 try:
75 with open(file) as f:
76 data = json.load(f)
77 if key in data and value in data[key]:
78 data[key].remove(value)
79 with open(file, 'w') as f:
80 json.dump(data, f, indent=2)
81 except Exception as e:
82 print(f"Error: {e}", file=sys.stderr)
83 sys.exit(1)
84
85def cat(file):
86 try:
87 with open(file) as f:
88 data = json.load(f)
89 print(json.dumps(data, indent=2))
90 except Exception as e:
91 sys.exit(1)
92
93def has_key(file, key):
94 try:
95 with open(file) as f:
96 data = json.load(f)
97 sys.exit(0 if key in data else 1)
98 except:
99 sys.exit(1)
100
101def filter_values(file, key, value):
102 """Remove all entries where value matches"""
103 try:
104 with open(file) as f:
105 data = json.load(f)
106 data = {k: v for k, v in data.items() if v != value}
107 with open(file, 'w') as f:
108 json.dump(data, f, indent=2)
109 except Exception as e:
110 print(f"Error: {e}", file=sys.stderr)
111 sys.exit(1)
112
113def last_event(file, key, field, client):
114 """Get last event field for a client"""
115 try:
116 last = None
117 with open(file) as f:
118 for line in f:
119 try:
120 e = json.loads(line.strip())
121 if e.get(key) == client:
122 last = e
123 except:
124 pass
125 if last:
126 print(last.get(field, ''))
127 except:
128 pass
129
130def events_for(file, ip, limit):
131 """Format events for a given IP"""
132 try:
133 from datetime import datetime
134 events = []
135 with open(file) as f:
136 for line in f:
137 try:
138 e = json.loads(line.strip())
139 if e.get('ip') == ip:
140 events.append(e)
141 except:
142 pass
143 for e in events[-int(limit):]:
144 ts = e.get('timestamp', '')
145 try:
146 dt = datetime.fromisoformat(ts)
147 ts = dt.strftime(DATETIME_FMT)
148 except:
149 pass
150 endpoint = e.get('endpoint', '—')
151 client = e.get('client', '—')
152 event = e.get('event', '—')
153 print(f' {ts} {client:<20} {endpoint:<20} {event}')
154 except:
155 pass
156
157def fw_events(file, filter_ip, filter_type, clients_dir, limit):
158 """Format firewall drop events with dedup and counts"""
159 import glob
160 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
161
162 ip_to_name = {}
163 for conf in glob.glob(f"{clients_dir}/*.conf"):
164 name = os.path.basename(conf).replace('.conf', '')
165 try:
166 with open(conf) as f:
167 for line in f:
168 if line.startswith('Address'):
169 ip = line.split('=')[1].strip().split('/')[0]
170 ip_to_name[ip] = name
171 except:
172 pass
173
174 events = []
175 last_seen = {}
176
177 try:
178 with open(file) as f:
179 for line in f:
180 try:
181 e = json.loads(line.strip())
182 src = e.get('src_ip', '')
183 if not src:
184 continue
185 if filter_ip and src != filter_ip:
186 continue
187 dst = e.get('dest_ip', '')
188 port = e.get('dest_port', '')
189 proto = e.get('ip.protocol', 0)
190 key = (src, dst, port, proto)
191 ts_str = e.get('timestamp', '')
192 try:
193 from datetime import datetime
194 ts = datetime.fromisoformat(ts_str).timestamp()
195 except:
196 ts = 0
197 windows = {1: 5, 6: 30, 17: 10}
198 window = windows.get(proto, 10)
199 if key in last_seen and (ts - last_seen[key]) < window:
200 continue
201 last_seen[key] = ts
202 events.append(e)
203 except:
204 pass
205 except:
206 pass
207
208 # Dedup consecutive same src+dst+port within 60s with count
209 deduped = []
210 counts = []
211 for e in events:
212 ts_str = e.get('timestamp', '')
213 try:
214 from datetime import datetime
215 ts = datetime.fromisoformat(ts_str).timestamp()
216 except:
217 ts = 0
218 src = e.get('src_ip', '')
219 dst = e.get('dest_ip', '')
220 port = e.get('dest_port', '')
221 proto = e.get('ip.protocol', 0)
222 key = (src, dst, port, proto)
223
224 if deduped and counts:
225 prev = deduped[-1]
226 try:
227 prev_ts = datetime.fromisoformat(prev.get('timestamp','')).timestamp()
228 except:
229 prev_ts = 0
230 prev_key = (prev.get('src_ip',''), prev.get('dest_ip',''),
231 prev.get('dest_port',''), prev.get('ip.protocol',0))
232 if key == prev_key and (ts - prev_ts) < 60:
233 counts[-1] += 1
234 continue
235
236 deduped.append(e)
237 counts.append(1)
238
239 grouped = []
240 group_counts = []
241 for e in deduped:
242 ts_str = e.get('timestamp', '')
243 try:
244 from datetime import datetime
245 dt = datetime.fromisoformat(ts_str)
246 # Truncate to minute for grouping
247 minute_key = dt.strftime('%Y-%m-%d %H:%M')
248 except:
249 minute_key = ts_str[:16]
250
251 src = e.get('src_ip', '')
252 dst = e.get('dest_ip', '')
253 port = e.get('dest_port', '')
254 proto = e.get('ip.protocol', 0)
255 key = (minute_key, src, dst, proto) # group within same minute
256
257 if grouped and group_counts:
258 prev = grouped[-1]
259 try:
260 prev_dt = datetime.fromisoformat(prev.get('timestamp',''))
261 prev_minute = prev_dt.strftime('%Y-%m-%d %H:%M')
262 except:
263 prev_minute = ''
264 prev_key = (prev_minute, prev.get('src_ip',''),
265 prev.get('dest_ip',''), prev.get('ip.protocol',0))
266 if key == prev_key:
267 group_counts[-1] += 1
268 continue
269
270 grouped.append(e)
271 group_counts.append(1)
272
273 for e, count in zip(deduped[-int(limit):], counts[-int(limit):]):
274 ts = e.get('timestamp', '')
275 try:
276 from datetime import datetime
277 dt = datetime.fromisoformat(ts)
278 ts = dt.strftime(DATETIME_FMT)
279 except:
280 pass
281 src = e.get('src_ip', '—')
282 dst = e.get('dest_ip', '—')
283 port = e.get('dest_port', '')
284 proto_num = e.get('ip.protocol', 0)
285 proto = proto_map.get(proto_num, str(proto_num))
286 dst_str = f"{dst}:{port}" if port else dst
287 client = ip_to_name.get(src, src)
288 if filter_type and not client.startswith(filter_type + '-'):
289 continue
290 count_str = f" (x{count})" if count > 1 else ""
291 print(f"{ts}|{client}|{dst_str}|{proto}{count_str}")
292
293def wg_events(file, filter_client, filter_type, limit):
294 """Format WireGuard events from events.log with dedup"""
295 events = []
296 try:
297 with open(file) as f:
298 for line in f:
299 try:
300 e = json.loads(line.strip())
301 client = e.get('client', '')
302 if not client:
303 continue
304 if filter_client and client != filter_client:
305 continue
306 if filter_type and not client.startswith(filter_type + '-'):
307 continue
308 events.append(e)
309 except:
310 pass
311 except:
312 pass
313
314 # Dedup consecutive same client+event+endpoint within 60s
315 deduped = []
316 counts = []
317 for e in events:
318 ts_str = e.get('timestamp', '')
319 try:
320 from datetime import datetime
321 ts = datetime.fromisoformat(ts_str).timestamp()
322 except:
323 ts = 0
324 client = e.get('client', '')
325 event = e.get('event', '')
326 endpoint = e.get('endpoint', '')
327 key = (client, event, endpoint[:15])
328
329 if deduped and counts:
330 prev = deduped[-1]
331 prev_ts_str = prev.get('timestamp', '')
332 try:
333 prev_ts = datetime.fromisoformat(prev_ts_str).timestamp()
334 except:
335 prev_ts = 0
336 prev_key = (prev.get('client',''), prev.get('event',''), prev.get('endpoint','')[:15])
337 if key == prev_key and (ts - prev_ts) < 60:
338 counts[-1] += 1
339 continue
340
341 deduped.append(e)
342 counts.append(1)
343
344 for e, count in zip(deduped[-int(limit):], counts[-int(limit):]):
345 ts = e.get('timestamp', '')
346 try:
347 from datetime import datetime
348 dt = datetime.fromisoformat(ts)
349 ts = dt.strftime(DATETIME_FMT)
350 except:
351 pass
352 client = e.get('client', '—')
353 endpoint = e.get('endpoint', '—')
354 event = e.get('event', '—')
355 count_str = f" (x{count})" if count > 1 else ""
356 print(f"{ts}|{client}|{endpoint}|{event}{count_str}")
357
358def format_fw_event(line, clients_dir):
359 """Format a single fw_event line"""
360 import glob
361 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
362
363 # Build ip->name map
364 ip_to_name = {}
365 for conf in glob.glob(f"{clients_dir}/*.conf"):
366 name = os.path.basename(conf).replace('.conf', '')
367 try:
368 with open(conf) as f:
369 for l in f:
370 if l.startswith('Address'):
371 ip = l.split('=')[1].strip().split('/')[0]
372 ip_to_name[ip] = name
373 except:
374 pass
375
376 try:
377 e = json.loads(line.strip())
378 src = e.get('src_ip', '')
379 if not src:
380 return None
381 ts = e.get('timestamp', '')
382 try:
383 from datetime import datetime
384 dt = datetime.fromisoformat(ts)
385 ts = dt.strftime(DATETIME_FMT)
386 except:
387 pass
388 dst = e.get('dest_ip', '—')
389 port = e.get('dest_port', '')
390 proto_num = e.get('ip.protocol', 0)
391 proto = proto_map.get(proto_num, str(proto_num))
392 dst_str = f"{dst}:{port}" if port else dst
393 client = ip_to_name.get(src, src)
394 return f"{ts}|{client}|{dst_str}|{proto}"
395 except:
396 return None
397
398def format_wg_event(line):
399 """Format a single wg_event line"""
400 try:
401 e = json.loads(line.strip())
402 client = e.get('client', '')
403 if not client:
404 return None
405 ts = e.get('timestamp', '')
406 try:
407 from datetime import datetime
408 dt = datetime.fromisoformat(ts)
409 ts = dt.strftime(DATETIME_FMT)
410 except:
411 pass
412 endpoint = e.get('endpoint', '—')
413 event = e.get('event', '—')
414 return f"{ts}|{client}|{endpoint}|{event}|wg"
415 except:
416 return None
417
418def remove_events(file, identifier):
419 """Remove all events for a client/ip from a JSONL file"""
420 try:
421 lines = []
422 with open(file) as f:
423 for line in f:
424 try:
425 e = json.loads(line.strip())
426 if e.get('client') == identifier or e.get('src_ip') == identifier:
427 continue
428 lines.append(line)
429 except:
430 lines.append(line)
431 with open(file, 'w') as f:
432 f.writelines(lines)
433 except Exception as e:
434 print(f"Error: {e}", file=sys.stderr)
435 sys.exit(1)
436
437def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir, filter_peers=""):
438 """Follow both log files and output formatted events"""
439 import glob, time, select
440
441 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
442 peer_filter = set(filter_peers.split(',')) if filter_peers else set()
443
444 # Build ip->name map
445 ip_to_name = {}
446 for conf in glob.glob(f"{clients_dir}/*.conf"):
447 name = os.path.basename(conf).replace('.conf', '')
448 try:
449 with open(conf) as f:
450 for l in f:
451 if l.startswith('Address'):
452 ip = l.split('=')[1].strip().split('/')[0]
453 ip_to_name[ip] = name
454 except:
455 pass
456
457 # Open files and seek to end
458 files = {}
459 for label, path in [('fw', fw_file), ('wg', wg_file)]:
460 if path and os.path.exists(path):
461 f = open(path)
462 f.seek(0, 2) # seek to end
463 files[label] = f
464
465 dedup = {}
466
467 try:
468 while True:
469 for label, f in files.items():
470 line = f.readline()
471 if not line:
472 continue
473 try:
474 e = json.loads(line.strip())
475 except:
476 continue
477
478 if label == 'fw':
479 src = e.get('src_ip', '')
480 if not src:
481 continue
482 if filter_ip and src != filter_ip:
483 continue
484
485 # Filter by peer names if specified
486 if peer_filter:
487 client_name = ip_to_name.get(src, '')
488 if client_name not in peer_filter:
489 continue
490 dst = e.get('dest_ip', '—')
491 port = e.get('dest_port', '')
492 proto_num = e.get('ip.protocol', 0)
493 proto = proto_map.get(proto_num, str(proto_num))
494
495 # Dedup
496 key = (src, dst, port, proto_num)
497 windows = {1: 5, 6: 30, 17: 10}
498 window = windows.get(proto_num, 10)
499 now = time.time()
500 if key in dedup and (now - dedup[key]) < window:
501 continue
502 dedup[key] = now
503
504 client = ip_to_name.get(src, src)
505 if filter_type and not client.startswith(filter_type + '-'):
506 continue
507 dst_str = f"{dst}:{port}" if port else dst
508 ts = e.get('timestamp', '')[:16].replace('T', ' ')
509 print(f"fw|{ts}|{client}|{dst_str}|{proto}", flush=True)
510
511 elif label == 'wg':
512 client = e.get('client', '')
513 if not client:
514 continue
515 if filter_ip:
516 ip = ip_to_name.get(filter_ip, '')
517 if client != ip and client != filter_ip:
518 continue
519
520 if peer_filter and client not in peer_filter:
521 continue
522 if filter_type and not client.startswith(filter_type + '-'):
523 continue
524 ts = e.get('timestamp', '')[:16].replace('T', ' ')
525 endpoint = e.get('endpoint', '—')
526 event = e.get('event', '—')
527 print(f"wg|{ts}|{client}|{endpoint}|{event}", flush=True)
528
529 time.sleep(0.1)
530 except KeyboardInterrupt:
531 pass
532
533def count(file, key):
534 try:
535 with open(file) as f:
536 data = json.load(f)
537 val = data.get(key, [])
538 print(len(val) if isinstance(val, list) else 0)
539 except:
540 print(0)
541
542def audit_fw_counts(clients_dir):
543 """Return peer_name:fw_count pairs from iptables"""
544 import glob, subprocess, re
545
546 try:
547 result = subprocess.run(
548 ['iptables', '-L', 'FORWARD', '-n', '-v'],
549 capture_output=True, text=True
550 )
551 fw_lines = result.stdout.splitlines()
552 except Exception:
553 fw_lines = []
554
555 # Filter to only data lines (skip headers and blanks)
556 # In -v output, source IP is in column 8 (0-indexed)
557 # Format: pkts bytes target prot opt in out source destination [options]
558 rule_lines = [l for l in fw_lines if l.strip() and not l.startswith('Chain') and not l.startswith(' pkts')]
559
560 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
561 name = os.path.basename(conf).replace('.conf', '')
562 try:
563 with open(conf) as f:
564 ip = ''
565 for line in f:
566 if line.startswith('Address'):
567 ip = line.split('=')[1].strip().split('/')[0]
568 break
569 if not ip:
570 continue
571 # Count lines where source column exactly matches the peer IP
572 count = sum(1 for l in rule_lines if re.search(r'\s' + re.escape(ip) + r'\s', l))
573 print(f"{name}:{count}")
574 except Exception:
575 pass
576
577def peer_group_map(groups_dir):
578 """Return peer:group pairs for all groups"""
579 import glob
580 try:
581 for group_file in glob.glob(f"{groups_dir}/*.group"):
582 try:
583 with open(group_file) as f:
584 g = json.load(f)
585 name = g.get('name', '')
586 for peer in g.get('peers', []):
587 if peer:
588 print(f"{peer}:{name}")
589 except:
590 pass
591 except:
592 pass
593
594def peer_groups(groups_dir, peer_name):
595 """Find all groups containing a peer"""
596 import glob
597 try:
598 for group_file in glob.glob(f"{groups_dir}/*.group"):
599 try:
600 with open(group_file) as f:
601 g = json.load(f)
602 if peer_name in g.get('peers', []):
603 print(g.get('name', ''))
604 except:
605 pass
606 except:
607 pass
608
609def iso_to_ts(iso_str):
610 """Convert ISO timestamp to unix timestamp"""
611 try:
612 from datetime import datetime, timezone
613 dt = datetime.fromisoformat(iso_str)
614 if dt.tzinfo is None:
615 dt = dt.replace(tzinfo=timezone.utc)
616 print(int(dt.timestamp()))
617 except:
618 print(0)
619
620def rule_list_data(rules_dir, meta_dir):
621 """Return all rule data including base rules and extends"""
622 import glob
623
624 rule_peer_counts = {}
625 for f in glob.glob(f"{meta_dir}/*.meta"):
626 try:
627 with open(f) as mf:
628 meta = json.load(mf)
629 rule = meta.get('rule', '')
630 if rule:
631 rule_peer_counts[rule] = rule_peer_counts.get(rule, 0) + 1
632 except:
633 pass
634
635 rule_files = (
636 sorted(glob.glob(f"{rules_dir}/*.rule")) +
637 sorted(glob.glob(f"{rules_dir}/base/*.rule"))
638 )
639
640 # Collect all data first
641 rules_data = []
642 for rule_file in rule_files:
643 is_base = '/base/' in rule_file
644 try:
645 with open(rule_file) as f:
646 r = json.load(f)
647 name = r.get('name', '')
648 desc = r.get('desc', '')
649 group = r.get('group', '')
650 extends = ','.join(r.get('extends', []))
651 resolved = _rule_resolve_internal(rules_dir, name)
652 n_allows = len(resolved.get('allow_ips', [])) + \
653 len(resolved.get('allow_ports', []))
654 n_blocks = len(resolved.get('block_ips', [])) + \
655 len(resolved.get('block_ports', []))
656 peer_count = rule_peer_counts.get(name, 0)
657 rules_data.append({
658 'name': name, 'desc': desc, 'n_allows': n_allows,
659 'n_blocks': n_blocks, 'peer_count': peer_count,
660 'extends': extends, 'is_base': is_base, 'group': group
661 })
662 except:
663 pass
664
665 # Sort: non-base first, then by group (empty group last within non-base),
666 # then by name within group
667 rules_data.sort(key=lambda x: (
668 x['is_base'],
669 x['group'] == '' and not x['is_base'],
670 x['group'],
671 x['name']
672 ))
673
674 for r in rules_data:
675 print(f"{r['name']}|{r['desc']}|{r['n_allows']}|{r['n_blocks']}|"
676 f"{r['peer_count']}|{r['extends']}|{r['is_base']}|{r['group']}")
677
678def group_list_data(groups_dir, blocks_dir):
679 """Return group summary data in one call"""
680 import glob
681
682 # Get all block files
683 blocked_peers = set()
684 for f in glob.glob(f"{blocks_dir}/*.block"):
685 name = os.path.basename(f).replace('.block', '')
686 blocked_peers.add(name)
687
688 for group_file in sorted(glob.glob(f"{groups_dir}/*.group")):
689 try:
690 with open(group_file) as f:
691 g = json.load(f)
692 name = g.get('name', '')
693 desc = g.get('desc', '')
694 peers = [p for p in g.get('peers', []) if p]
695 total = len(peers)
696 blocked = sum(1 for p in peers if p in blocked_peers)
697 print(f"{name}|{desc}|{total}|{blocked}")
698 except:
699 pass
700
701def fmt_datetime(iso_str, fmt):
702 """Format ISO timestamp with given strftime format"""
703 try:
704 from datetime import datetime
705 dt = datetime.fromisoformat(iso_str)
706 print(dt.strftime(fmt))
707 except:
708 print(iso_str)
709
710def create_rule(file, name, desc, dns_redirect, allow_ips, block_ips,
711 block_ports, allow_ports='', extends='', group=''):
712 rule = {
713 'name': name,
714 'desc': desc,
715 'group': group,
716 'dns_redirect': dns_redirect == 'true',
717 'extends': [x for x in extends.split(',') if x] if extends else [],
718 'allow_ips': [x for x in allow_ips.split(',') if x] if allow_ips else [],
719 'allow_ports': [x for x in allow_ports.split(',') if x] if allow_ports else [],
720 'block_ips': [x for x in block_ips.split(',') if x] if block_ips else [],
721 'block_ports': [x for x in block_ports.split(',') if x] if block_ports else [],
722 }
723 with open(file, 'w') as f:
724 json.dump(rule, f, indent=2)
725
726def cleanup_config(config_file):
727 """Normalize blank lines in WireGuard config"""
728 import re
729 try:
730 with open(config_file) as f:
731 config = f.read()
732 config = re.sub(r'\n{3,}', '\n\n', config)
733 config = config.rstrip('\n') + '\n'
734 with open(config_file, 'w') as f:
735 f.write(config)
736 except Exception as e:
737 print(f"Error: {e}", file=sys.stderr)
738 sys.exit(1)
739
740def remove_peer_block(config_file, name):
741 """Remove a peer block from WireGuard config by name"""
742 import re
743 try:
744 with open(config_file) as f:
745 config = f.read()
746 pattern = r'\n\[Peer\]\n# ' + re.escape(name) + r'\n[^\n]+\n[^\n]+\n'
747 result = re.sub(pattern, '\n', config)
748 with open(config_file, 'w') as f:
749 f.write(result)
750 except Exception as e:
751 print(f"Error: {e}", file=sys.stderr)
752 sys.exit(1)
753
754def create_group(file, name, desc):
755 """Create a new group JSON file"""
756 try:
757 group = {'name': name, 'desc': desc, 'peers': []}
758 with open(file, 'w') as f:
759 json.dump(group, f, indent=2)
760 except Exception as e:
761 print(f"Error: {e}", file=sys.stderr)
762 sys.exit(1)
763
764def parse_event(line):
765 """Parse a single JSON event line"""
766 try:
767 e = json.loads(line)
768 print(f"{e.get('timestamp','')}|{e.get('client','')}|{e.get('endpoint','')}|{e.get('event','')}")
769 except:
770 pass
771
772def parse_fw_event(line):
773 """Parse a single fw_events.log JSON line"""
774 try:
775 e = json.loads(line)
776 ts = e.get('timestamp', '')
777 src = e.get('src_ip', '')
778 dst = e.get('dest_ip', '')
779 port = e.get('dest_port', '')
780 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
781 proto_num = e.get('ip.protocol', 0)
782 proto = proto_map.get(proto_num, str(proto_num))
783 print(f"{ts}|{src}|{dst}|{port}|{proto}")
784 except:
785 pass
786
787def peer_transfer(wg_interface):
788 """Get total transfer bytes per peer"""
789 import subprocess
790 low = int(os.environ.get('ACTIVITY_TOTAL_LOW_BYTES', '1000000'))
791 med = int(os.environ.get('ACTIVITY_TOTAL_MED_BYTES', '10000000'))
792 high = int(os.environ.get('ACTIVITY_TOTAL_HIGH_BYTES', '100000000'))
793 try:
794 result = subprocess.run(
795 ['wg', 'show', wg_interface, 'transfer'],
796 capture_output=True, text=True
797 )
798 for line in result.stdout.strip().split('\n'):
799 if not line:
800 continue
801 parts = line.split('\t')
802 if len(parts) == 3:
803 pubkey, rx, tx = parts
804 total = int(rx) + int(tx)
805 if total == 0: level = 'none'
806 elif total < low: level = 'low'
807 elif total < med: level = 'medium'
808 elif total < high: level = 'high'
809 else: level = 'very high'
810 print(f"{pubkey}|{rx}|{tx}|{level}")
811 except:
812 pass
813
814def peer_transfer_delta(wg_interface, cache_file):
815 """Calculate current transfer rate using delta from previous sample"""
816 import subprocess, time
817
818 low = int(os.environ.get('ACTIVITY_CURRENT_LOW_BYTES', '10000')) # 10KB/s
819 med = int(os.environ.get('ACTIVITY_CURRENT_MED_BYTES', '100000')) # 100KB/s
820 high = int(os.environ.get('ACTIVITY_CURRENT_HIGH_BYTES', '1000000')) # 1MB/s
821
822 current = {}
823 now = time.time()
824 try:
825 result = subprocess.run(
826 ['wg', 'show', wg_interface, 'transfer'],
827 capture_output=True, text=True
828 )
829 for line in result.stdout.strip().split('\n'):
830 if not line:
831 continue
832 parts = line.split('\t')
833 if len(parts) == 3:
834 pubkey, rx, tx = parts
835 current[pubkey] = {'rx': int(rx), 'tx': int(tx), 'ts': now}
836 except:
837 pass
838
839 prev = {}
840 if os.path.exists(cache_file):
841 try:
842 with open(cache_file) as f:
843 prev = json.load(f)
844 except:
845 pass
846
847 try:
848 with open(cache_file, 'w') as f:
849 json.dump(current, f)
850 except:
851 pass
852
853 for pubkey, data in current.items():
854 if pubkey in prev:
855 dt = data['ts'] - prev[pubkey].get('ts', data['ts'])
856 if dt > 0:
857 rx_rate = max(0, (data['rx'] - prev[pubkey]['rx']) / dt)
858 tx_rate = max(0, (data['tx'] - prev[pubkey]['tx']) / dt)
859 total = rx_rate + tx_rate
860 if total <= 0: level = 'idle'
861 elif total < low: level = 'low'
862 elif total < med: level = 'medium'
863 elif total < high: level = 'high'
864 else: level = 'very high'
865 print(f"{pubkey}|{int(rx_rate)}|{int(tx_rate)}|{level}")
866 else:
867 print(f"{pubkey}|0|0|idle")
868 else:
869 print(f"{pubkey}|0|0|unknown")
870
871def remove_events_filtered(wg_file, fw_file, filter_name, filter_ip,
872 filter_fw, filter_wg, before_days):
873 """Remove events with filters: by name/ip, source, or age"""
874 import time
875 from datetime import datetime, timezone
876
877 cutoff_ts = None
878 if before_days:
879 cutoff_ts = time.time() - (float(before_days) * 86400)
880
881 def should_remove_wg(e):
882 if filter_name and e.get('client') != filter_name:
883 return False
884 if cutoff_ts:
885 try:
886 ts = datetime.fromisoformat(e.get('timestamp','')).timestamp()
887 return ts < cutoff_ts
888 except:
889 return False
890 return True
891
892 def should_remove_fw(e):
893 if filter_ip and e.get('src_ip') != filter_ip:
894 return False
895 if cutoff_ts:
896 try:
897 ts = datetime.fromisoformat(e.get('timestamp','')).timestamp()
898 return ts < cutoff_ts
899 except:
900 return False
901 return True
902
903 removed_wg = removed_fw = 0
904
905 if not filter_fw and os.path.exists(wg_file):
906 lines = []
907 with open(wg_file) as f:
908 for line in f:
909 try:
910 e = json.loads(line.strip())
911 if should_remove_wg(e):
912 removed_wg += 1
913 continue
914 except:
915 pass
916 lines.append(line)
917 with open(wg_file, 'w') as f:
918 f.writelines(lines)
919
920 if not filter_wg and os.path.exists(fw_file):
921 lines = []
922 with open(fw_file) as f:
923 for line in f:
924 try:
925 e = json.loads(line.strip())
926 if should_remove_fw(e):
927 removed_fw += 1
928 continue
929 except:
930 pass
931 lines.append(line)
932 with open(fw_file, 'w') as f:
933 f.writelines(lines)
934
935 print(f"{removed_wg}|{removed_fw}")
936
937def _rule_resolve_internal(rules_dir, rule_name, visited=None):
938 """Internal recursive resolver — returns dict, does not print"""
939 if visited is None:
940 visited = set()
941 if rule_name in visited:
942 raise ValueError(f"Circular dependency detected: {rule_name}")
943 visited.add(rule_name)
944
945 rule_file = find_rule_file(rules_dir, rule_name)
946 with open(rule_file) as f:
947 rule = json.load(f)
948
949 merged = {
950 'allow_ips': [],
951 'allow_ports': [],
952 'block_ips': [],
953 'block_ports': [],
954 'dns_redirect': False
955 }
956
957 for base_name in rule.get('extends', []):
958 base = _rule_resolve_internal(rules_dir, base_name, visited.copy())
959 merged['allow_ips'] += base.get('allow_ips', [])
960 merged['allow_ports'] += base.get('allow_ports', [])
961 merged['block_ips'] += base.get('block_ips', [])
962 merged['block_ports'] += base.get('block_ports', [])
963 if base.get('dns_redirect'):
964 merged['dns_redirect'] = True
965
966 # Merge own fields — use .get() with defaults for all fields
967 merged['allow_ips'] = list(dict.fromkeys(
968 merged['allow_ips'] + rule.get('allow_ips', [])))
969 merged['allow_ports'] = list(dict.fromkeys(
970 merged['allow_ports'] + rule.get('allow_ports', [])))
971 merged['block_ips'] = list(dict.fromkeys(
972 merged['block_ips'] + rule.get('block_ips', [])))
973 merged['block_ports'] = list(dict.fromkeys(
974 merged['block_ports'] + rule.get('block_ports', [])))
975 if rule.get('dns_redirect', False):
976 merged['dns_redirect'] = True
977
978 merged['name'] = rule.get('name', rule_name)
979 merged['desc'] = rule.get('desc', '')
980 merged['group'] = rule.get('group', '')
981 merged['extends'] = rule.get('extends', [])
982 return merged
983
984def rule_resolve(rules_dir, rule_name):
985 """Resolve a rule with inheritance — prints JSON"""
986 try:
987 resolved = _rule_resolve_internal(rules_dir, rule_name)
988 print(json.dumps(resolved))
989 except Exception as e:
990 print(f"Error: {e}", file=sys.stderr)
991 sys.exit(1)
992
993def rule_resolve_field(rules_dir, rule_name, field):
994 """Get a single field from resolved rule — prints values one per line"""
995 try:
996 resolved = _rule_resolve_internal(rules_dir, rule_name)
997 val = resolved.get(field, [])
998 if isinstance(val, list):
999 for v in val:
1000 print(v)
1001 else:
1002 print(val)
1003 except Exception as e:
1004 print(f"Error: {e}", file=sys.stderr)
1005 sys.exit(1)
1006
1007def rule_inspect(rules_dir, rule_name):
1008 """Show inheritance tree for a rule"""
1009 try:
1010 rule_file = find_rule_file(rules_dir, rule_name)
1011 with open(rule_file) as f:
1012 rule = json.load(f)
1013 resolved = _rule_resolve_internal(rules_dir, rule_name)
1014 has_extends = bool(rule.get('extends', []))
1015
1016 # Own rules
1017 for ip in rule.get('allow_ips', []):
1018 print(f"own|allow_ip|{ip}")
1019 for p in rule.get('allow_ports', []):
1020 print(f"own|allow_port|{p}")
1021 for ip in rule.get('block_ips', []):
1022 print(f"own|block_ip|{ip}")
1023 for p in rule.get('block_ports', []):
1024 print(f"own|block_port|{p}")
1025
1026 # DNS redirect — separate section
1027 if rule.get('dns_redirect'):
1028 print(f"dns|dns_redirect|true")
1029
1030 if has_extends:
1031 # Inherited rules per base
1032 for base_name in rule.get('extends', []):
1033 base = _rule_resolve_internal(rules_dir, base_name)
1034 for ip in base.get('allow_ips', []):
1035 print(f"inherited:{base_name}|allow_ip|{ip}")
1036 for p in base.get('allow_ports', []):
1037 print(f"inherited:{base_name}|allow_port|{p}")
1038 for ip in base.get('block_ips', []):
1039 print(f"inherited:{base_name}|block_ip|{ip}")
1040 for p in base.get('block_ports', []):
1041 print(f"inherited:{base_name}|block_port|{p}")
1042 if base.get('dns_redirect'):
1043 print(f"inherited:{base_name}|dns_redirect|true")
1044
1045 # Resolved summary only when inheritance exists
1046 has_resolved = (
1047 resolved.get('allow_ips') or resolved.get('allow_ports') or
1048 resolved.get('block_ips') or resolved.get('block_ports') or
1049 resolved.get('dns_redirect')
1050 )
1051 if has_resolved:
1052 for ip in resolved.get('allow_ips', []):
1053 print(f"resolved|allow_ip|{ip}")
1054 for p in resolved.get('allow_ports', []):
1055 print(f"resolved|allow_port|{p}")
1056 for ip in resolved.get('block_ips', []):
1057 print(f"resolved|block_ip|{ip}")
1058 for p in resolved.get('block_ports', []):
1059 print(f"resolved|block_port|{p}")
1060 if resolved.get('dns_redirect'):
1061 print(f"resolved|dns_redirect|true")
1062
1063 except Exception as e:
1064 print(f"Error: {e}", file=sys.stderr)
1065 sys.exit(1)
1066
1067def find_rule_file(rules_dir, rule_name):
1068 """Find rule file in rules/ or rules/base/"""
1069 for path in [
1070 os.path.join(rules_dir, f"{rule_name}.rule"),
1071 os.path.join(rules_dir, "base", f"{rule_name}.rule"),
1072 ]:
1073 if os.path.exists(path):
1074 return path
1075 return ""
1076
1077def get_raw(file, key):
1078 try:
1079 with open(file) as f:
1080 data = json.load(f)
1081 val = data.get(key) # returns None if missing
1082 if val is None:
1083 pass # print nothing
1084 elif isinstance(val, bool):
1085 print(str(val).lower())
1086 elif isinstance(val, list):
1087 for v in val:
1088 print(v)
1089 else:
1090 print(val)
1091 except:
1092 pass
1093
1094def count_resolved(rules_dir, rule_name, key):
1095 """Count entries in resolved rule field"""
1096 try:
1097 resolved = _rule_resolve_internal(rules_dir, rule_name)
1098 print(len(resolved.get(key, [])))
1099 except:
1100 print(0)
1101
1102def _block_init(peer_ip):
1103 """Return empty block structure"""
1104 return {
1105 "peer_ip": peer_ip,
1106 "blocked_direct": False,
1107 "blocked_by_groups": [],
1108 "rules": []
1109 }
1110
1111def _block_read(file):
1112 try:
1113 with open(file) as f:
1114 content = f.read().strip()
1115 if not content:
1116 return None # empty file = no block data
1117 try:
1118 return json.loads(content)
1119 except json.JSONDecodeError:
1120 # Old format — migrate
1121 lines = content.split('\n')
1122 peer_ip = lines[0].split()[0] if lines else ''
1123 new_data = {
1124 "peer_ip": peer_ip,
1125 "blocked_direct": True,
1126 "blocked_by_groups": [],
1127 "rules": [{"name": "full block", "type": "full"}]
1128 }
1129 with open(file, 'w') as f:
1130 json.dump(new_data, f, indent=2)
1131 return new_data
1132 except FileNotFoundError:
1133 return None
1134 except Exception:
1135 return None
1136
1137def _block_write(file, data):
1138 """Write block file"""
1139 with open(file, 'w') as f:
1140 json.dump(data, f, indent=2)
1141
1142def block_get(file):
1143 """Read and print block file as JSON"""
1144 data = _block_read(file)
1145 if data:
1146 print(json.dumps(data))
1147
1148def block_is_blocked(file):
1149 """Return true if peer is effectively blocked"""
1150 data = _block_read(file)
1151 if not data:
1152 print("false")
1153 return
1154 blocked = data.get("blocked_direct", False) or \
1155 bool(data.get("blocked_by_groups", []))
1156 print("true" if blocked else "false")
1157
1158def block_set_direct(file, peer_ip, value):
1159 """Set blocked_direct"""
1160 try:
1161 data = _block_read(file) or _block_init(peer_ip)
1162 data["blocked_direct"] = value.lower() == "true"
1163 data["peer_ip"] = peer_ip
1164 _block_write(file, data)
1165 remaining = data["blocked_direct"] or bool(data.get("blocked_by_groups", []))
1166 pass
1167 # print("true" if remaining else "false")
1168 except Exception as e:
1169 print(f"Error: {e}", file=sys.stderr)
1170 sys.exit(1)
1171
1172def block_add_group(file, peer_ip, group):
1173 """Add group to blocked_by_groups"""
1174 try:
1175 data = _block_read(file) or _block_init(peer_ip)
1176 data["peer_ip"] = peer_ip
1177 groups = data.get("blocked_by_groups", [])
1178 if group not in groups:
1179 groups.append(group)
1180 data["blocked_by_groups"] = groups
1181 _block_write(file, data)
1182 except Exception as e:
1183 print(f"Error: {e}", file=sys.stderr)
1184 sys.exit(1)
1185
1186def block_remove_group(file, peer_ip, group):
1187 """Remove group from blocked_by_groups, return whether still blocked"""
1188 try:
1189 data = _block_read(file) or _block_init(peer_ip)
1190 groups = data.get("blocked_by_groups", [])
1191 if group in groups:
1192 groups.remove(group)
1193 data["blocked_by_groups"] = groups
1194 _block_write(file, data)
1195 except Exception as e:
1196 print(f"Error: {e}", file=sys.stderr)
1197 sys.exit(1)
1198 # print("true" if remaining else "false")
1199 pass
1200 except Exception as e:
1201 print(f"Error: {e}", file=sys.stderr)
1202 sys.exit(1)
1203
1204def block_add_rule(file, peer_ip, rule_type, name="", target="",
1205 port="", proto=""):
1206 """Add a block rule entry"""
1207 try:
1208 data = _block_read(file) or _block_init(peer_ip)
1209 data["peer_ip"] = peer_ip
1210 rule = {"type": rule_type}
1211 if name: rule["name"] = name
1212 if target: rule["target"] = target
1213 if port: rule["port"] = port
1214 if proto: rule["proto"] = proto
1215
1216 rules = data.get("rules", [])
1217 for existing in rules:
1218 if existing.get("type") == rule_type and \
1219 existing.get("target","") == target and \
1220 existing.get("port","") == port and \
1221 existing.get("proto","") == proto:
1222 return # already exists, skip
1223
1224 rules.append(rule)
1225 data["rules"] = rules
1226 _block_write(file, data)
1227 except Exception as e:
1228 print(f"Error: {e}", file=sys.stderr)
1229 sys.exit(1)
1230
1231def block_remove_rule(file, rule_type, target="", port="", proto=""):
1232 data = _block_read(file)
1233 if not data:
1234 return
1235 rules = data.get("rules", [])
1236 filtered = [r for r in rules if not (
1237 r.get("type") == rule_type and
1238 r.get("target", "") == target and
1239 r.get("port", "") == port and
1240 r.get("proto", "") == proto
1241 )]
1242 data["rules"] = filtered
1243 _block_write(file, data)
1244
1245def block_get_rules(file):
1246 """Print rules as pipe-separated lines: name|type|target|port|proto"""
1247 data = _block_read(file)
1248 if not data:
1249 return
1250 for r in data.get("rules", []):
1251 print(f"{r.get('name','')}|{r.get('type','')}|"
1252 f"{r.get('target','')}|{r.get('port','')}|{r.get('proto','')}")
1253
1254def block_get_groups(file):
1255 data = _block_read(file)
1256 if not data:
1257 return
1258 print(','.join(data.get('blocked_by_groups', [])))
1259
1260def block_get_direct(file):
1261 data = _block_read(file)
1262 if not data:
1263 print('false')
1264 return
1265 print('true' if data.get('blocked_direct', False) else 'false')
1266
1267# ============================================
1268# Net / Services
1269# ============================================
1270
1271def _net_read(file):
1272 """Read services.json, return dict or empty dict"""
1273 try:
1274 if not os.path.exists(file):
1275 return {}
1276 with open(file) as f:
1277 content = f.read().strip()
1278 if not content:
1279 return {}
1280 return json.loads(content)
1281 except Exception:
1282 return {}
1283
1284def _net_write(file, data):
1285 """Write services.json"""
1286 os.makedirs(os.path.dirname(file), exist_ok=True)
1287 with open(file, 'w') as f:
1288 json.dump(data, f, indent=2)
1289
1290def net_list(file):
1291 """List all service names with IP and port count"""
1292 data = _net_read(file)
1293 for name, svc in sorted(data.items()):
1294 ip = svc.get('ip', '')
1295 desc = svc.get('desc', '')
1296 tags = ','.join(svc.get('tags', []))
1297 ports = len(svc.get('ports', {}))
1298 print(f"{name}|{ip}|{desc}|{tags}|{ports}")
1299
1300def net_show(file, name):
1301 """Show full service details"""
1302 data = _net_read(file)
1303 if name not in data:
1304 print(f"Error: Service not found: {name}", file=sys.stderr)
1305 sys.exit(1)
1306 svc = data[name]
1307 print(f"name|{name}")
1308 print(f"ip|{svc.get('ip','')}")
1309 print(f"desc|{svc.get('desc','')}")
1310 print(f"tags|{','.join(svc.get('tags',[]))}")
1311 for port_name, port_def in svc.get('ports', {}).items():
1312 port = port_def.get('port', '')
1313 proto = port_def.get('proto', 'tcp')
1314 desc = port_def.get('desc', '')
1315 print(f"port|{port_name}|{port}|{proto}|{desc}")
1316
1317def net_exists(file, name):
1318 """Check if service exists"""
1319 data = _net_read(file)
1320 # Handle service:port syntax
1321 if ':' in name:
1322 svc_name, port_name = name.split(':', 1)
1323 if port_name == 'ports':
1324 print('true' if svc_name in data else 'false')
1325 else:
1326 svc = data.get(svc_name, {})
1327 print('true' if port_name in svc.get('ports', {}) else 'false')
1328 else:
1329 print('true' if name in data else 'false')
1330
1331def net_add_service(file, name, ip, desc='', tags=''):
1332 """Add or update a service"""
1333 data = _net_read(file)
1334 if name not in data:
1335 data[name] = {'ip': ip, 'ports': {}}
1336 else:
1337 data[name]['ip'] = ip
1338 if desc:
1339 data[name]['desc'] = desc
1340 if tags:
1341 data[name]['tags'] = [t.strip() for t in tags.split(',') if t.strip()]
1342 _net_write(file, data)
1343
1344def net_add_port(file, service, port_name, port, proto='tcp', desc=''):
1345 """Add or update a port on a service"""
1346 data = _net_read(file)
1347 if service not in data:
1348 print(f"Error: Service not found: {service}", file=sys.stderr)
1349 sys.exit(1)
1350 if 'ports' not in data[service]:
1351 data[service]['ports'] = {}
1352 entry = {'port': int(port), 'proto': proto}
1353 if desc:
1354 entry['desc'] = desc
1355 data[service]['ports'][port_name] = entry
1356 _net_write(file, data)
1357
1358def net_remove(file, name):
1359 """Remove service or port"""
1360 data = _net_read(file)
1361 if ':' in name:
1362 svc_name, port_name = name.split(':', 1)
1363 if svc_name not in data:
1364 print(f"Error: Service not found: {svc_name}", file=sys.stderr)
1365 sys.exit(1)
1366 if port_name == 'ports':
1367 # Remove all ports
1368 data[svc_name]['ports'] = {}
1369 else:
1370 if port_name not in data[svc_name].get('ports', {}):
1371 print(f"Error: Port not found: {port_name}", file=sys.stderr)
1372 sys.exit(1)
1373 del data[svc_name]['ports'][port_name]
1374 else:
1375 if name not in data:
1376 print(f"Error: Service not found: {name}", file=sys.stderr)
1377 sys.exit(1)
1378 del data[name]
1379 _net_write(file, data)
1380
1381def net_resolve(file, name):
1382 """Resolve service name to ip or ip:port:proto lines"""
1383 data = _net_read(file)
1384 if ':' in name:
1385 svc_name, port_name = name.split(':', 1)
1386 if svc_name not in data:
1387 print(f"Error: Service not found: {svc_name}", file=sys.stderr)
1388 sys.exit(1)
1389 svc = data[svc_name]
1390 ip = svc.get('ip', '')
1391 if port_name == 'ports':
1392 # All ports
1393 for pname, pdef in svc.get('ports', {}).items():
1394 print(f"{ip}:{pdef['port']}:{pdef.get('proto','tcp')}")
1395 else:
1396 if port_name not in svc.get('ports', {}):
1397 print(f"Error: Port not found: {port_name}", file=sys.stderr)
1398 sys.exit(1)
1399 pdef = svc['ports'][port_name]
1400 print(f"{ip}:{pdef['port']}:{pdef.get('proto','tcp')}")
1401 else:
1402 if name not in data:
1403 print(f"Error: Service not found: {name}", file=sys.stderr)
1404 sys.exit(1)
1405 print(data[name].get('ip', ''))
1406
1407def net_reverse_lookup(file, ip, port='', proto=''):
1408 """Reverse lookup IP/port to service name"""
1409 data = _net_read(file)
1410 for svc_name, svc in data.items():
1411 if svc.get('ip') != ip:
1412 continue
1413 if not port:
1414 print(svc_name)
1415 return
1416 for port_name, port_def in svc.get('ports', {}).items():
1417 if (str(port_def.get('port','')) == str(port) and
1418 port_def.get('proto','tcp') == proto):
1419 print(f"{svc_name}:{port_name}")
1420 return
1421 # IP matched but no port match — return service name
1422 print(svc_name)
1423 return
1424
1425def block_is_empty(file):
1426 data = _block_read(file)
1427 if not data:
1428 print("true")
1429 return
1430 empty = (
1431 not data.get("blocked_direct", False) and
1432 not data.get("blocked_by_groups", []) and
1433 not data.get("rules", []) and
1434 not data.get("services", [])
1435 )
1436 print("true" if empty else "false")
1437
1438def group_has_peer(file, peer_name):
1439 try:
1440 with open(file) as f:
1441 data = json.load(f)
1442 peers = data.get('peers', [])
1443 print('true' if peer_name in peers else 'false')
1444 except Exception:
1445 print('false')
1446
1447# ============================================
1448# Subnet Map
1449# ============================================
1450
1451def _subnet_read(file):
1452 """Read subnets.json, return dict or empty dict"""
1453 try:
1454 if not os.path.exists(file):
1455 return {}
1456 with open(file) as f:
1457 content = f.read().strip()
1458 if not content:
1459 return {}
1460 return json.loads(content)
1461 except Exception:
1462 return {}
1463
1464def _subnet_write(file, data):
1465 """Write subnets.json"""
1466 os.makedirs(os.path.dirname(file), exist_ok=True)
1467 with open(file, 'w') as f:
1468 json.dump(data, f, indent=2)
1469
1470def _subnet_is_group(entry):
1471 """Return True if a subnet entry is a nested group (like 'guests')"""
1472 return isinstance(entry, dict) and 'subnet' not in entry
1473
1474def subnet_lookup(file, name, type_key=''):
1475 """
1476 Resolve a subnet name (and optional type) to a CIDR string.
1477 For scalar entries: subnet_lookup(file, "desktop") -> "10.1.1.0/24"
1478 For group entries: subnet_lookup(file, "guests", "phone") -> "10.1.103.0/24"
1479 For group with no type: subnet_lookup(file, "guests") -> "10.1.100.0/24" (none slot)
1480 Prints the CIDR on success, nothing and exits 1 on failure.
1481 """
1482 data = _subnet_read(file)
1483 if name not in data:
1484 sys.exit(1)
1485 entry = data[name]
1486 if _subnet_is_group(entry):
1487 key = type_key if type_key else 'none'
1488 if key not in entry:
1489 sys.exit(1)
1490 print(entry[key]['subnet'])
1491 else:
1492 print(entry['subnet'])
1493
1494def subnet_type(file, name, type_key=''):
1495 """
1496 Return the type string for a subnet entry.
1497 For scalar: subnet_type(file, "desktop") -> "desktop"
1498 For group: subnet_type(file, "guests", "phone") -> "phone"
1499 subnet_type(file, "guests") -> "none"
1500 """
1501 data = _subnet_read(file)
1502 if name not in data:
1503 sys.exit(1)
1504 entry = data[name]
1505 if _subnet_is_group(entry):
1506 key = type_key if type_key else 'none'
1507 if key not in entry:
1508 sys.exit(1)
1509 # For group entries the type IS the child key
1510 print(key if key != 'none' else 'none')
1511 else:
1512 print(entry.get('type', name))
1513
1514def subnet_tunnel_mode(file, name, type_key=''):
1515 """Return tunnel_mode for a subnet entry"""
1516 data = _subnet_read(file)
1517 if name not in data:
1518 print('split') # safe default
1519 return
1520 entry = data[name]
1521 if _subnet_is_group(entry):
1522 key = type_key if type_key else 'none'
1523 child = entry.get(key, {})
1524 print(child.get('tunnel_mode', 'split'))
1525 else:
1526 print(entry.get('tunnel_mode', 'split'))
1527
1528def subnet_for_ip(file, ip):
1529 """
1530 Reverse lookup: given a peer IP, find which subnet name (and type) it belongs to.
1531 Output: name|type (e.g. "guests|phone" or "desktop|desktop")
1532 Returns nothing (exit 0) if not found — caller falls back to hardcoded map.
1533 """
1534 import ipaddress
1535 try:
1536 peer_addr = ipaddress.ip_address(ip)
1537 except ValueError:
1538 return
1539
1540 data = _subnet_read(file)
1541 for name, entry in data.items():
1542 if _subnet_is_group(entry):
1543 for type_key, child in entry.items():
1544 try:
1545 network = ipaddress.ip_network(child['subnet'], strict=False)
1546 if peer_addr in network:
1547 print(f"{name}|{type_key}")
1548 return
1549 except Exception:
1550 continue
1551 else:
1552 try:
1553 network = ipaddress.ip_network(entry['subnet'], strict=False)
1554 if peer_addr in network:
1555 peer_type = entry.get('type', name)
1556 print(f"{name}|{peer_type}")
1557 return
1558 except Exception:
1559 continue
1560
1561def subnet_list(file):
1562 """
1563 List all subnets for display.
1564 Output per line: name|subnet|type|tunnel_mode|desc|is_group
1565 For group entries, outputs one line per child: name.type|subnet|type|tunnel_mode|desc|true
1566 """
1567 data = _subnet_read(file)
1568 for name, entry in data.items():
1569 if _subnet_is_group(entry):
1570 for type_key, child in entry.items():
1571 display_name = f"{name}.{type_key}" if type_key != 'none' else name
1572 print(f"{display_name}|{child.get('subnet','')}|"
1573 f"{type_key}|{child.get('tunnel_mode','split')}|"
1574 f"{child.get('desc','')}|true|{name}")
1575 else:
1576 print(f"{name}|{entry.get('subnet','')}|"
1577 f"{entry.get('type',name)}|{entry.get('tunnel_mode','split')}|"
1578 f"{entry.get('desc','')}|false|{name}")
1579
1580def subnet_show(file, name):
1581 """Show a single subnet entry (scalar or group) in detail"""
1582 data = _subnet_read(file)
1583 if name not in data:
1584 print(f"Error: Subnet '{name}' not found", file=sys.stderr)
1585 sys.exit(1)
1586 entry = data[name]
1587 if _subnet_is_group(entry):
1588 print(f"name|{name}")
1589 print(f"is_group|true")
1590 for type_key, child in entry.items():
1591 print(f"child|{type_key}|{child.get('subnet','')}|"
1592 f"{child.get('tunnel_mode','split')}|{child.get('desc','')}")
1593 else:
1594 print(f"name|{name}")
1595 print(f"is_group|false")
1596 print(f"subnet|{entry.get('subnet','')}")
1597 print(f"type|{entry.get('type',name)}")
1598 print(f"tunnel_mode|{entry.get('tunnel_mode','split')}")
1599 print(f"desc|{entry.get('desc','')}")
1600
1601def subnet_add(file, name, subnet, type_key, tunnel_mode, desc, group_parent=''):
1602 """
1603 Add a new subnet entry.
1604 If group_parent is set, adds as a child under that group key.
1605 Otherwise adds as a scalar entry.
1606 """
1607 data = _subnet_read(file)
1608 entry = {
1609 'subnet': subnet,
1610 'tunnel_mode': tunnel_mode or 'split',
1611 'desc': desc or ''
1612 }
1613 if group_parent:
1614 # Adding a child to an existing group, or creating a new group
1615 if group_parent not in data:
1616 data[group_parent] = {}
1617 elif not _subnet_is_group(data[group_parent]):
1618 print(f"Error: '{group_parent}' exists but is not a group", file=sys.stderr)
1619 sys.exit(1)
1620 data[group_parent][type_key or 'none'] = entry
1621 else:
1622 # Scalar entry — type stored explicitly
1623 entry['type'] = type_key or name
1624 data[name] = entry
1625 _subnet_write(file, data)
1626
1627def subnet_remove(file, name, peers_using):
1628 """
1629 Remove a subnet entry. peers_using is a comma-separated list of peer names
1630 currently using this subnet (passed in from bash after meta scan).
1631 If non-empty, refuses with error.
1632 """
1633 if peers_using:
1634 peers = [p for p in peers_using.split(',') if p]
1635 if peers:
1636 print(f"Error: Subnet '{name}' is in use by: {', '.join(peers)}", file=sys.stderr)
1637 sys.exit(1)
1638 data = _subnet_read(file)
1639 if '.' in name:
1640 # Removing a group child: e.g. "guests.phone"
1641 parent, child_key = name.split('.', 1)
1642 if parent not in data or not _subnet_is_group(data[parent]):
1643 print(f"Error: Group '{parent}' not found", file=sys.stderr)
1644 sys.exit(1)
1645 if child_key not in data[parent]:
1646 print(f"Error: '{child_key}' not found in group '{parent}'", file=sys.stderr)
1647 sys.exit(1)
1648 del data[parent][child_key]
1649 if not data[parent]:
1650 del data[parent] # remove empty group
1651 else:
1652 if name not in data:
1653 print(f"Error: Subnet '{name}' not found", file=sys.stderr)
1654 sys.exit(1)
1655 del data[name]
1656 _subnet_write(file, data)
1657
1658def subnet_rename(file, old_name, new_name, peers_using):
1659 """
1660 Rename a subnet entry. Hard refusal if any peers reference it.
1661 peers_using: comma-separated peer names from bash meta scan.
1662 """
1663 if peers_using:
1664 peers = [p for p in peers_using.split(',') if p]
1665 if peers:
1666 print(f"Error: Cannot rename subnet '{old_name}' — in use by: {', '.join(peers)}", file=sys.stderr)
1667 sys.exit(1)
1668 data = _subnet_read(file)
1669 if old_name not in data:
1670 print(f"Error: Subnet '{old_name}' not found", file=sys.stderr)
1671 sys.exit(1)
1672 if new_name in data:
1673 print(f"Error: Subnet '{new_name}' already exists", file=sys.stderr)
1674 sys.exit(1)
1675 data[new_name] = data.pop(old_name)
1676 _subnet_write(file, data)
1677
1678def subnet_peers(meta_dir, clients_dir, subnet_name, subnets_file):
1679 """
1680 Find all peers using a subnet.
1681 Two-pass check:
1682 1. Meta field: peer has "subnet": subnet_name in their .meta file
1683 2. IP fallback: peer's IP falls within the subnet's CIDR(s)
1684 (catches peers added before meta stored subnet explicitly)
1685 Output: one peer name per line.
1686 """
1687 import glob
1688 import ipaddress
1689
1690 # Resolve all CIDRs covered by this subnet name
1691 data = _subnet_read(subnets_file)
1692 cidrs = []
1693 if subnet_name in data:
1694 entry = data[subnet_name]
1695 if _subnet_is_group(entry):
1696 for child in entry.values():
1697 try:
1698 cidrs.append(ipaddress.ip_network(child['subnet'], strict=False))
1699 except Exception:
1700 pass
1701 else:
1702 try:
1703 cidrs.append(ipaddress.ip_network(entry['subnet'], strict=False))
1704 except Exception:
1705 pass
1706
1707 printed = set()
1708
1709 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
1710 peer_name = os.path.basename(conf).replace('.conf', '')
1711
1712 # Pass 1: check meta field
1713 meta_file = os.path.join(meta_dir, f"{peer_name}.meta")
1714 try:
1715 with open(meta_file) as f:
1716 meta = json.load(f)
1717 if meta.get('subnet', '') == subnet_name:
1718 if peer_name not in printed:
1719 print(peer_name)
1720 printed.add(peer_name)
1721 continue
1722 except Exception:
1723 pass
1724
1725 # Pass 2: IP reverse lookup against subnet CIDRs
1726 if not cidrs:
1727 continue
1728
1729 peer_ip = ''
1730 try:
1731 with open(conf) as f:
1732 for line in f:
1733 if line.startswith('Address'):
1734 peer_ip = line.split('=')[1].strip().split('/')[0]
1735 break
1736 except Exception:
1737 continue
1738
1739 if not peer_ip:
1740 continue
1741
1742 try:
1743 addr = ipaddress.ip_address(peer_ip)
1744 if any(addr in cidr for cidr in cidrs):
1745 if peer_name not in printed:
1746 print(peer_name)
1747 printed.add(peer_name)
1748 except Exception:
1749 continue
1750
1751
1752def subnet_exists(file, name):
1753 """Check if a subnet name exists (scalar or group). Exits 0/1."""
1754 data = _subnet_read(file)
1755 if '.' in name:
1756 parent, child_key = name.split('.', 1)
1757 exists = parent in data and _subnet_is_group(data[parent]) and child_key in data[parent]
1758 else:
1759 exists = name in data
1760 sys.exit(0 if exists else 1)
1761
1762
1763# ============================================
1764# Identity System
1765# ============================================
1766
1767def _identity_read(file):
1768 """Read an identity file, return dict or None"""
1769 try:
1770 if not os.path.exists(file):
1771 return None
1772 with open(file) as f:
1773 content = f.read().strip()
1774 if not content:
1775 return None
1776 return json.loads(content)
1777 except Exception:
1778 return None
1779
1780def _identity_write(file, data):
1781 """Write an identity file"""
1782 os.makedirs(os.path.dirname(file), exist_ok=True)
1783 with open(file, 'w') as f:
1784 json.dump(data, f, indent=2)
1785
1786def _identity_init(name):
1787 """Return empty identity structure"""
1788 return {
1789 'name': name,
1790 'peers': [],
1791 'devices': {}
1792 }
1793
1794def _parse_peer_name(peer_name):
1795 """
1796 Parse a peer name into (type, identity, index).
1797 phone-nuno -> ('phone', 'nuno', 1)
1798 phone-nuno-2 -> ('phone', 'nuno', 2)
1799 desktop-zephyr -> ('desktop', 'zephyr', 1)
1800 laptop-nuno -> ('laptop', 'nuno', 1)
1801 Returns None if name doesn't match convention.
1802
1803 Convention: {type}-{identity}[-{index}]
1804 Known types: desktop, laptop, phone, tablet, server, iot, none
1805 """
1806 known_types = {'desktop', 'laptop', 'phone', 'tablet', 'server', 'iot', 'none'}
1807 parts = peer_name.split('-')
1808 if len(parts) < 2:
1809 return None
1810 peer_type = parts[0]
1811 if peer_type not in known_types:
1812 return None
1813 # Check if last part is a numeric index
1814 if len(parts) >= 3 and parts[-1].isdigit():
1815 index = int(parts[-1])
1816 identity = '-'.join(parts[1:-1])
1817 else:
1818 index = 1
1819 identity = '-'.join(parts[1:])
1820 if not identity:
1821 return None
1822 return (peer_type, identity, index)
1823
1824def identity_list(identities_dir):
1825 """List all identities with peer count"""
1826 import glob
1827 for id_file in sorted(glob.glob(f"{identities_dir}/*.identity")):
1828 try:
1829 with open(id_file) as f:
1830 data = json.load(f)
1831 name = data.get('name', '')
1832 peers = data.get('peers', [])
1833 devices = data.get('devices', {})
1834 types = sorted(set(d.get('type', '') for d in devices.values() if d.get('type')))
1835 print(f"{name}|{len(peers)}|{','.join(types)}")
1836 except Exception:
1837 continue
1838
1839def identity_show(file):
1840 """Show identity details"""
1841 data = _identity_read(file)
1842 if not data:
1843 print("Error: Identity not found", file=sys.stderr)
1844 sys.exit(1)
1845 print(f"name|{data.get('name','')}")
1846 print(f"peer_count|{len(data.get('peers',[]))}")
1847 for peer_name, dev in data.get('devices', {}).items():
1848 print(f"device|{peer_name}|{dev.get('type','')}|{dev.get('index',1)}")
1849
1850def identity_add_peer(file, identity_name, peer_name, peer_type, index):
1851 """Add a peer to an identity file, creating it if needed"""
1852 data = _identity_read(file) or _identity_init(identity_name)
1853 if peer_name not in data['peers']:
1854 data['peers'].append(peer_name)
1855 data['devices'][peer_name] = {
1856 'type': peer_type,
1857 'index': int(index)
1858 }
1859 _identity_write(file, data)
1860
1861def identity_remove_peer(file, peer_name):
1862 """Remove a peer from an identity file"""
1863 data = _identity_read(file)
1864 if not data:
1865 return
1866 data['peers'] = [p for p in data['peers'] if p != peer_name]
1867 data['devices'].pop(peer_name, None)
1868 _identity_write(file, data)
1869
1870def identity_remove(file):
1871 """Delete an identity file — existence check done in bash"""
1872 try:
1873 os.remove(file)
1874 except Exception as e:
1875 print(f"Error: {e}", file=sys.stderr)
1876 sys.exit(1)
1877
1878def identity_next_index(file, peer_type):
1879 """
1880 Return the next available index for a given type within an identity.
1881 phone-nuno exists (index 1) -> returns 2
1882 No phones exist -> returns 1
1883 """
1884 data = _identity_read(file)
1885 if not data:
1886 print(1)
1887 return
1888 existing = [
1889 d.get('index', 1)
1890 for d in data.get('devices', {}).values()
1891 if d.get('type') == peer_type
1892 ]
1893 if not existing:
1894 print(1)
1895 return
1896 # Find lowest unused index starting from 1
1897 used = set(existing)
1898 i = 1
1899 while i in used:
1900 i += 1
1901 print(i)
1902
1903def identity_peers(file, filter_type=''):
1904 """
1905 List peers belonging to an identity, optionally filtered by type.
1906 Output: one peer name per line.
1907 """
1908 data = _identity_read(file)
1909 if not data:
1910 return
1911 for peer_name in data.get('peers', []):
1912 if filter_type:
1913 dev = data.get('devices', {}).get(peer_name, {})
1914 if dev.get('type') != filter_type:
1915 continue
1916 print(peer_name)
1917
1918def identity_migrate(identities_dir, clients_dir, meta_dir, dry_run):
1919 """
1920 Scan all peer configs and auto-create identity files from name convention.
1921 dry_run: 'true' -> print what would be done, no writes.
1922 Output per action: action|identity|peer|type|index
1923 """
1924 import glob
1925
1926 is_dry = dry_run == 'true'
1927 grouped = {} # identity_name -> [(peer_name, type, index)]
1928
1929 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
1930 peer_name = os.path.basename(conf).replace('.conf', '')
1931 parsed = _parse_peer_name(peer_name)
1932 if not parsed:
1933 print(f"skip|{peer_name}")
1934 continue
1935 peer_type, identity_name, index = parsed
1936 if identity_name not in grouped:
1937 grouped[identity_name] = []
1938 grouped[identity_name].append((peer_name, peer_type, index))
1939
1940 for identity_name, peers in sorted(grouped.items()):
1941 id_file = os.path.join(identities_dir, f"{identity_name}.identity")
1942 for peer_name, peer_type, index in peers:
1943 print(f"create|{identity_name}|{peer_name}|{peer_type}|{index}")
1944 if not is_dry:
1945 identity_add_peer(id_file, identity_name, peer_name, peer_type, index)
1946
1947def identity_infer(peer_name):
1948 """
1949 Parse a peer name and print identity|type|index, or nothing if no match.
1950 Used by add.command.sh to auto-attach on vanilla wgctl add.
1951 """
1952 parsed = _parse_peer_name(peer_name)
1953 if parsed:
1954 peer_type, identity_name, index = parsed
1955 print(f"{identity_name}|{peer_type}|{index}")
1956
1957def identity_exists(file):
1958 """Exit 0 if identity file exists and is valid, else exit 1"""
1959 data = _identity_read(file)
1960 sys.exit(0 if data is not None else 1)
1961
1962# ============================================
1963# peer_data update — adds type field from meta
1964# ============================================
1965# NOTE: This replaces the existing peer_data function.
1966# The new version reads 'type' from meta directly instead of inferring from subtype.
1967# Output format: name|ip|rule|type|last_ts|last_evt|main_group
1968# (field 4 is now 'type' instead of 'subtype')
1969
1970def peer_data(clients_dir, meta_dir, events_log):
1971 """
1972 Updated peer_data that reads 'type' from meta instead of 'subtype'.
1973 Output: name|ip|rule|type|last_ts|last_evt|main_group
1974 """
1975 import glob
1976
1977 meta = {}
1978 for f in glob.glob(f"{meta_dir}/*.meta"):
1979 name = os.path.basename(f).replace('.meta', '')
1980 try:
1981 with open(f) as mf:
1982 meta[name] = json.load(mf)
1983 except Exception:
1984 meta[name] = {}
1985
1986 last_events = {}
1987 try:
1988 with open(events_log) as f:
1989 for line in f:
1990 try:
1991 e = json.loads(line.strip())
1992 client = e.get('client', '')
1993 if client:
1994 last_events[client] = e
1995 except Exception:
1996 pass
1997 except Exception:
1998 pass
1999
2000 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
2001 name = os.path.basename(conf).replace('.conf', '')
2002 ip = ''
2003 try:
2004 with open(conf) as f:
2005 for line in f:
2006 if line.startswith('Address'):
2007 ip = line.split('=')[1].strip().split('/')[0]
2008 break
2009 except Exception:
2010 pass
2011
2012 m = meta.get(name, {})
2013 rule = m.get('rule', '')
2014 peer_type = m.get('type', '')
2015 main_group = m.get('main_group', '')
2016
2017 last_event = last_events.get(name, {})
2018 last_ts = last_event.get('timestamp', '')
2019 last_evt = last_event.get('event', '')
2020
2021 print(f"{name}|{ip}|{rule}|{peer_type}|{last_ts}|{last_evt}|{main_group}")
2022
2023def subnet_default_rule(file, name, type_key=''):
2024 """
2025 Return the default_rule for a subnet entry, or empty string if none set.
2026 For scalar: subnet_default_rule(file, "desktop") -> ""
2027 For group: subnet_default_rule(file, "guests", "phone") -> "guest"
2028 """
2029 data = _subnet_read(file)
2030 if name not in data:
2031 print('')
2032 return
2033 entry = data[name]
2034 if _subnet_is_group(entry):
2035 key = type_key if type_key else 'none'
2036 child = entry.get(key, {})
2037 print(child.get('default_rule', ''))
2038 else:
2039 print(entry.get('default_rule', ''))
2040
2041def subnet_list_names(file):
2042 """
2043 List all top-level subnet names, one per line.
2044 Used for dynamic flag registration in commands.
2045 Output: one name per line (e.g. desktop, laptop, guests, servers, iot)
2046 """
2047 data = _subnet_read(file)
2048 for name in data.keys():
2049 print(name)
2050
2051
2052commands = {
2053 'get': lambda args: get(args[0], args[1]),
2054 'set': lambda args: set_key(args[0], args[1], args[2]),
2055 'delete': lambda args: delete_key(args[0], args[1]),
2056 'append': lambda args: append(args[0], args[1], args[2]),
2057 'remove': lambda args: remove_value(args[0], args[1], args[2]),
2058 'cat': lambda args: cat(args[0]),
2059 'has_key': lambda args: has_key(args[0], args[1]),
2060 'filter_values': lambda args: filter_values(args[0], args[1], args[2]),
2061 'last_event': lambda args: last_event(args[0], args[1], args[2], args[3]),
2062 'events_for': lambda args: events_for(args[0], args[1], args[2]),
2063 'fw_events': lambda args: fw_events(args[0], args[1], args[2], args[3], args[4]),
2064 'wg_events': lambda args: wg_events(args[0], args[1], args[2], args[3]),
2065 'format_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]),
2066 'format_wg_event': lambda args: format_wg_event(sys.stdin.read()),
2067 'remove_events': lambda args: remove_events(args[0], args[1]),
2068 'follow_logs': lambda args: follow_logs(args[0], args[1], args[2], args[3], args[4], args[5]),
2069 'count': lambda args: count(args[0], args[1]),
2070 'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
2071 'peer_group_map': lambda args: peer_group_map(args[0]),
2072 'peer_groups': lambda args: peer_groups(args[0], args[1]),
2073 'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
2074 'iso_to_ts': lambda args: iso_to_ts(args[0]),
2075 'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
2076 'group_list_data': lambda args: group_list_data(args[0], args[1]),
2077 'fmt_datetime': lambda args: fmt_datetime(args[0], args[1]),
2078 'create_rule': lambda args: create_rule(
2079 args[0], args[1], args[2], args[3], args[4], args[5], args[6],
2080 args[7] if len(args) > 7 else '',
2081 args[8] if len(args) > 8 else '',
2082 args[9] if len(args) > 9 else ''
2083 ),
2084 'cleanup_config': lambda args: cleanup_config(args[0]),
2085 'remove_peer_block': lambda args: remove_peer_block(args[0], args[1]),
2086 'create_group': lambda args: create_group(args[0], args[1], args[2]),
2087 'parse_event': lambda args: parse_event(args[0]),
2088 'parse_fw_event': lambda args: parse_fw_event(args[0]),
2089 'peer_transfer': lambda args: peer_transfer(args[0]),
2090 'remove_events_filtered': lambda args: remove_events_filtered(
2091 args[0], args[1], args[2], args[3], args[4]=='true', args[5]=='true', args[6] if len(args)>6 else ''),
2092 'peer_transfer': lambda args: peer_transfer(args[0]),
2093 'peer_transfer_delta': lambda args: peer_transfer_delta(args[0], args[1]),
2094 'rule_resolve': lambda args: rule_resolve(args[0], args[1]),
2095 'rule_resolve_field': lambda args: rule_resolve_field(args[0], args[1], args[2]),
2096 'rule_inspect': lambda args: rule_inspect(args[0], args[1]),
2097 'find_rule_file': lambda args: print(find_rule_file(args[0], args[1])),
2098 'get_raw': lambda args: print(get_raw(args[0], args[1])),
2099 'count_resolved': lambda args: count_resolved(args[0], args[1], args[2]),
2100 'block_get': lambda args: block_get(args[0]),
2101 'block_is_blocked': lambda args: block_is_blocked(args[0]),
2102 'block_set_direct': lambda args: block_set_direct(args[0], args[1], args[2]),
2103 'block_add_group': lambda args: block_add_group(args[0], args[1], args[2]),
2104 'block_remove_group': lambda args: block_remove_group(args[0], args[1], args[2]),
2105 'block_add_rule': lambda args: block_add_rule(
2106 args[0], args[1], args[2],
2107 args[3] if len(args) > 3 else '',
2108 args[4] if len(args) > 4 else '',
2109 args[5] if len(args) > 5 else '',
2110 args[6] if len(args) > 6 else ''
2111 ),
2112 'block_remove_rule': lambda args: block_remove_rule(
2113 args[0], args[1],
2114 args[2] if len(args) > 2 else '',
2115 args[3] if len(args) > 3 else '',
2116 args[4] if len(args) > 4 else ''
2117 ),
2118 'block_get_rules': lambda args: block_get_rules(args[0]),
2119 'block_get_groups': lambda args: block_get_groups(args[0]),
2120 'block_get_direct': lambda args: block_get_direct(args[0]),
2121 'net_list': lambda args: net_list(args[0]),
2122 'net_show': lambda args: net_show(args[0], args[1]),
2123 'net_exists': lambda args: net_exists(args[0], args[1]),
2124 'net_add_service': lambda args: net_add_service(
2125 args[0], args[1], args[2],
2126 args[3] if len(args) > 3 else '',
2127 args[4] if len(args) > 4 else ''
2128 ),
2129 'net_add_port': lambda args: net_add_port(
2130 args[0], args[1], args[2], args[3],
2131 args[4] if len(args) > 4 else 'tcp',
2132 args[5] if len(args) > 5 else ''
2133 ),
2134 'net_remove': lambda args: net_remove(args[0], args[1]),
2135 'net_resolve': lambda args: net_resolve(args[0], args[1]),
2136 'net_reverse_lookup': lambda args: net_reverse_lookup(
2137 args[0], args[1],
2138 args[2] if len(args) > 2 else '',
2139 args[3] if len(args) > 3 else ''
2140 ),
2141 'block_is_empty': lambda args: block_is_empty(args[0]),
2142 'group_has_peer': lambda args: group_has_peer(args[0], args[1]),
2143
2144 # Subnet commands:
2145 'subnet_lookup': lambda args: subnet_lookup(args[0], args[1], args[2] if len(args) > 2 else ''),
2146 'subnet_type': lambda args: subnet_type(args[0], args[1], args[2] if len(args) > 2 else ''),
2147 'subnet_tunnel_mode': lambda args: subnet_tunnel_mode(args[0], args[1], args[2] if len(args) > 2 else ''),
2148 'subnet_for_ip': lambda args: subnet_for_ip(args[0], args[1]),
2149 'subnet_list': lambda args: subnet_list(args[0]),
2150 'subnet_show': lambda args: subnet_show(args[0], args[1]),
2151 'subnet_add': lambda args: subnet_add(
2152 args[0], args[1], args[2], args[3],
2153 args[4] if len(args) > 4 else 'split',
2154 args[5] if len(args) > 5 else '',
2155 args[6] if len(args) > 6 else ''
2156 ),
2157 'subnet_remove': lambda args: subnet_remove(args[0], args[1], args[2] if len(args) > 2 else ''),
2158 'subnet_rename': lambda args: subnet_rename(args[0], args[1], args[2], args[3] if len(args) > 3 else ''),
2159 'subnet_peers': lambda args: subnet_peers(args[0], args[1], args[2], args[3]),
2160 'subnet_exists': lambda args: subnet_exists(args[0], args[1]),
2161
2162 # Identity commands:
2163 'identity_list': lambda args: identity_list(args[0]),
2164 'identity_show': lambda args: identity_show(args[0]),
2165 'identity_add_peer': lambda args: identity_add_peer(args[0], args[1], args[2], args[3], args[4]),
2166 'identity_remove_peer':lambda args: identity_remove_peer(args[0], args[1]),
2167 'identity_remove': lambda args: identity_remove(args[0]),
2168 'identity_next_index': lambda args: identity_next_index(args[0], args[1]),
2169 'identity_peers': lambda args: identity_peers(args[0], args[1] if len(args) > 1 else ''),
2170 'identity_migrate': lambda args: identity_migrate(args[0], args[1], args[2], args[3]),
2171 'identity_infer': lambda args: identity_infer(args[0]),
2172 'identity_exists': lambda args: identity_exists(args[0]),
2173 'subnet_default_rule': lambda args: subnet_default_rule(args[0], args[1], args[2] if len(args) > 2 else ''),
2174 'subnet_list_names': lambda args: subnet_list_names(args[0]),
2175}
2176
2177if __name__ == '__main__':
2178 if len(sys.argv) < 2:
2179 print("Usage: json_helper.py <command> <file> [key] [value]", file=sys.stderr)
2180 sys.exit(1)
2181
2182 cmd = sys.argv[1]
2183 args = sys.argv[2:]
2184
2185 if cmd not in commands:
2186 print(f"Unknown command: {cmd}", file=sys.stderr)
2187 sys.exit(1)
2188
2189 commands[cmd](args)