Utoljára aktív 1 month ago

Revízió b075da91d3560bea94e5f3f8c5ebb77148f266e4

gistfile1.txt Eredeti
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, net_file, limit, collapse='1'):
158 """
159 Format firewall drop events with dedup, counts, and service annotation.
160 collapse='1' (default): hourly aggregation
161 collapse='0': show all deduplicated events (--detailed mode)
162 Output per line: ts|client|dest_ip|dest_port|proto|service_name|count
163 """
164 import glob
165 from datetime import datetime
166 from collections import defaultdict
167 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
168 do_collapse = str(collapse) != '0'
169
170 # Build ip->name map
171 ip_to_name = {}
172 for conf in glob.glob(f"{clients_dir}/*.conf"):
173 name = os.path.basename(conf).replace('.conf', '')
174 try:
175 with open(conf) as f:
176 for line in f:
177 if line.startswith('Address'):
178 ip = line.split('=')[1].strip().split('/')[0]
179 ip_to_name[ip] = name
180 except Exception:
181 pass
182
183 # Load net services for reverse lookup
184 net_data = {}
185 if net_file and os.path.exists(net_file):
186 try:
187 with open(net_file) as f:
188 net_data = json.load(f)
189 except Exception:
190 pass
191
192 def reverse_lookup(dest_ip, dest_port, proto):
193 for svc_name, svc in net_data.items():
194 if not isinstance(svc, dict):
195 continue
196 if svc.get('ip', '') != dest_ip:
197 continue
198 ports = svc.get('ports', {})
199 if dest_port:
200 for port_name, port_def in ports.items():
201 if not isinstance(port_def, dict):
202 continue
203 if (str(port_def.get('port', '')) == str(dest_port) and
204 port_def.get('proto', 'tcp') == proto):
205 return f"{svc_name}:{port_name}"
206 return svc_name
207 return svc_name
208 return ''
209
210 # Parse and first-pass dedup (within time window per key)
211 events = []
212 last_seen = {}
213
214 try:
215 with open(file) as f:
216 for line in f:
217 try:
218 e = json.loads(line.strip())
219 src = e.get('src_ip', '')
220 if not src:
221 continue
222 if filter_ip and src != filter_ip:
223 continue
224
225 proto_num = int(e.get('ip.protocol', 0))
226 proto = proto_map.get(proto_num, str(proto_num))
227 dst = e.get('dest_ip', '')
228 port = str(e.get('dest_port', ''))
229 key = (src, dst, port, proto_num)
230
231 ts_str = e.get('timestamp', '')
232 try:
233 ts = datetime.fromisoformat(ts_str).timestamp()
234 except Exception:
235 ts = 0
236
237 windows = {1: 5, 6: 30, 17: 10}
238 window = windows.get(proto_num, 10)
239 if key in last_seen and (ts - last_seen[key]) < window:
240 continue
241 last_seen[key] = ts
242 events.append(e)
243 except Exception:
244 continue
245 except Exception:
246 pass
247
248 limit = int(limit) if limit else 50
249
250 if do_collapse:
251 # Hourly aggregation: group by (client, dst, port, proto, date, hour)
252 hourly = defaultdict(int)
253 hourly_ts = {} # store first ts per hour bucket for output ordering
254
255 for e in events:
256 src = e.get('src_ip', '')
257 dst = e.get('dest_ip', '')
258 port = str(e.get('dest_port', ''))
259 proto_num = int(e.get('ip.protocol', 0))
260 proto = proto_map.get(proto_num, str(proto_num))
261 ts_str = e.get('timestamp', '')
262 client = ip_to_name.get(src, src)
263 svc_name = reverse_lookup(dst, port, proto)
264
265 try:
266 dt = datetime.fromisoformat(ts_str)
267 hour_key = (client, dst, port, proto, svc_name,
268 dt.strftime('%Y-%m-%d %H'))
269 hourly[hour_key] += 1
270 if hour_key not in hourly_ts:
271 hourly_ts[hour_key] = dt
272 except Exception:
273 continue
274
275 # Sort by timestamp and emit last N
276 sorted_buckets = sorted(hourly_ts.items(), key=lambda x: x[1])
277 for hour_key, dt in sorted_buckets[-limit:]:
278 client, dst, port, proto, svc_name, _ = hour_key
279 count = hourly[hour_key]
280 ts_fmt = dt.strftime(DATETIME_FMT.replace('%M', '00'))
281 print(f"{ts_fmt}|{client}|{dst}|{port}|{proto}|{svc_name}|{count}")
282 else:
283 # Detailed mode — consecutive dedup only
284 deduped = []
285 counts = []
286 for e in events:
287 ts_str = e.get('timestamp', '')
288 src = e.get('src_ip', '')
289 dst = e.get('dest_ip', '')
290 port = str(e.get('dest_port', ''))
291 proto_num = int(e.get('ip.protocol', 0))
292 key = (src, dst, port, proto_num)
293
294 if deduped:
295 prev = deduped[-1]
296 try:
297 prev_ts = datetime.fromisoformat(
298 prev.get('timestamp', '')).timestamp()
299 cur_ts = datetime.fromisoformat(ts_str).timestamp()
300 except Exception:
301 prev_ts = cur_ts = 0
302 prev_key = (
303 prev.get('src_ip', ''),
304 prev.get('dest_ip', ''),
305 str(prev.get('dest_port', '')),
306 int(prev.get('ip.protocol', 0))
307 )
308 if key == prev_key and (cur_ts - prev_ts) < 300:
309 counts[-1] += 1
310 continue
311
312 deduped.append(e)
313 counts.append(1)
314
315 for e, count in list(zip(deduped, counts))[-limit:]:
316 ts_str = e.get('timestamp', '')
317 src = e.get('src_ip', '')
318 dst = e.get('dest_ip', '')
319 port = str(e.get('dest_port', ''))
320 proto_num = int(e.get('ip.protocol', 0))
321 proto = proto_map.get(proto_num, str(proto_num))
322 client = ip_to_name.get(src, src)
323 svc_name = reverse_lookup(dst, port, proto)
324 try:
325 dt = datetime.fromisoformat(ts_str)
326 ts_fmt = dt.strftime(DATETIME_FMT)
327 except Exception:
328 ts_fmt = ts_str
329 print(f"{ts_fmt}|{client}|{dst}|{port}|{proto}|{svc_name}|{count}")
330
331
332def wg_events(file, filter_client, filter_type, limit, collapse='1'):
333 """
334 Format WireGuard events with dedup and counts.
335 collapse='1' (default): hourly aggregation for attempt events
336 collapse='0': show all deduplicated events (--detailed mode)
337 Output per line: ts|client|endpoint|event|count
338 """
339 from datetime import datetime
340 from collections import defaultdict
341 do_collapse = str(collapse) != '0'
342
343 events = []
344 try:
345 with open(file) as f:
346 for line in f:
347 try:
348 e = json.loads(line.strip())
349 client = e.get('client', '')
350 if not client:
351 continue
352 if filter_client and client != filter_client:
353 continue
354 if filter_type and not client.startswith(filter_type + '-'):
355 continue
356 events.append(e)
357 except Exception:
358 pass
359 except Exception:
360 pass
361
362 limit = int(limit) if limit else 50
363
364 if do_collapse:
365 # Hourly aggregation for attempts; individual for handshakes
366 hourly_attempts = defaultdict(int)
367 hourly_ts = {}
368 handshakes = []
369 handshake_counts = []
370
371 for e in events:
372 ts_str = e.get('timestamp', '')
373 client = e.get('client', '')
374 endpoint = e.get('endpoint', '')
375 event = e.get('event', '')
376
377 try:
378 dt = datetime.fromisoformat(ts_str)
379 ts = dt.timestamp()
380 except Exception:
381 dt = None
382 ts = 0
383
384 if event == 'attempt':
385 if dt:
386 hour_key = (client, endpoint, event,
387 dt.strftime('%Y-%m-%d %H'))
388 hourly_attempts[hour_key] += 1
389 if hour_key not in hourly_ts:
390 hourly_ts[hour_key] = dt
391 else:
392 # Handshakes — consecutive dedup only
393 key = (client, event, endpoint[:15])
394 if handshakes:
395 prev = handshakes[-1]
396 try:
397 prev_ts = datetime.fromisoformat(
398 prev.get('timestamp', '')).timestamp()
399 except Exception:
400 prev_ts = 0
401 prev_key = (
402 prev.get('client', ''),
403 prev.get('event', ''),
404 prev.get('endpoint', '')[:15]
405 )
406 if key == prev_key and (ts - prev_ts) < 300:
407 handshake_counts[-1] += 1
408 continue
409 handshakes.append(e)
410 handshake_counts.append(1)
411
412 # Build output list: attempts (hourly) + handshakes, sorted by ts
413 output = []
414
415 for hour_key, dt in hourly_ts.items():
416 client, endpoint, event, _ = hour_key
417 count = hourly_attempts[hour_key]
418 ts_fmt = dt.strftime(DATETIME_FMT.replace('%M', '00'))
419 output.append((dt.timestamp(), f"{ts_fmt}|{client}|{endpoint}|{event}|{count}"))
420
421 for e, count in zip(handshakes, handshake_counts):
422 ts_str = e.get('timestamp', '')
423 client = e.get('client', '')
424 endpoint = e.get('endpoint', '')
425 event = e.get('event', '')
426 try:
427 dt = datetime.fromisoformat(ts_str)
428 ts_fmt = dt.strftime(DATETIME_FMT)
429 ts = dt.timestamp()
430 except Exception:
431 ts_fmt = ts_str
432 ts = 0
433 output.append((ts, f"{ts_fmt}|{client}|{endpoint}|{event}|{count}"))
434
435 output.sort(key=lambda x: x[0])
436 for _, line in output[-limit:]:
437 print(line)
438
439 else:
440 # Detailed mode — consecutive dedup only
441 deduped = []
442 counts = []
443 for e in events:
444 ts_str = e.get('timestamp', '')
445 client = e.get('client', '')
446 event = e.get('event', '')
447 endpoint = e.get('endpoint', '')
448 key = (client, event, endpoint[:15])
449
450 try:
451 ts = datetime.fromisoformat(ts_str).timestamp()
452 except Exception:
453 ts = 0
454
455 if deduped:
456 prev = deduped[-1]
457 try:
458 prev_ts = datetime.fromisoformat(
459 prev.get('timestamp', '')).timestamp()
460 except Exception:
461 prev_ts = 0
462 prev_key = (
463 prev.get('client', ''),
464 prev.get('event', ''),
465 prev.get('endpoint', '')[:15]
466 )
467 if key == prev_key and (ts - prev_ts) < 300:
468 counts[-1] += 1
469 continue
470
471 deduped.append(e)
472 counts.append(1)
473
474 for e, count in list(zip(deduped, counts))[-limit:]:
475 ts_str = e.get('timestamp', '')
476 client = e.get('client', '')
477 endpoint = e.get('endpoint', '')
478 event = e.get('event', '')
479 try:
480 dt = datetime.fromisoformat(ts_str)
481 ts_fmt = dt.strftime(DATETIME_FMT)
482 except Exception:
483 ts_fmt = ts_str
484 print(f"{ts_fmt}|{client}|{endpoint}|{event}|{count}")
485
486def format_fw_event(line, clients_dir):
487 """Format a single fw_event line"""
488 import glob
489 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
490
491 # Build ip->name map
492 ip_to_name = {}
493 for conf in glob.glob(f"{clients_dir}/*.conf"):
494 name = os.path.basename(conf).replace('.conf', '')
495 try:
496 with open(conf) as f:
497 for l in f:
498 if l.startswith('Address'):
499 ip = l.split('=')[1].strip().split('/')[0]
500 ip_to_name[ip] = name
501 except:
502 pass
503
504 try:
505 e = json.loads(line.strip())
506 src = e.get('src_ip', '')
507 if not src:
508 return None
509 ts = e.get('timestamp', '')
510 try:
511 from datetime import datetime
512 dt = datetime.fromisoformat(ts)
513 ts = dt.strftime(DATETIME_FMT)
514 except:
515 pass
516 dst = e.get('dest_ip', '—')
517 port = e.get('dest_port', '')
518 proto_num = e.get('ip.protocol', 0)
519 proto = proto_map.get(proto_num, str(proto_num))
520 dst_str = f"{dst}:{port}" if port else dst
521 client = ip_to_name.get(src, src)
522 return f"{ts}|{client}|{dst_str}|{proto}"
523 except:
524 return None
525
526def format_wg_event(line):
527 """Format a single wg_event line"""
528 try:
529 e = json.loads(line.strip())
530 client = e.get('client', '')
531 if not client:
532 return None
533 ts = e.get('timestamp', '')
534 try:
535 from datetime import datetime
536 dt = datetime.fromisoformat(ts)
537 ts = dt.strftime(DATETIME_FMT)
538 except:
539 pass
540 endpoint = e.get('endpoint', '—')
541 event = e.get('event', '—')
542 return f"{ts}|{client}|{endpoint}|{event}|wg"
543 except:
544 return None
545
546def remove_events(file, identifier):
547 """Remove all events for a client/ip from a JSONL file"""
548 try:
549 lines = []
550 with open(file) as f:
551 for line in f:
552 try:
553 e = json.loads(line.strip())
554 if e.get('client') == identifier or e.get('src_ip') == identifier:
555 continue
556 lines.append(line)
557 except:
558 lines.append(line)
559 with open(file, 'w') as f:
560 f.writelines(lines)
561 except Exception as e:
562 print(f"Error: {e}", file=sys.stderr)
563 sys.exit(1)
564
565def follow_logs(fw_file, wg_file, filter_ip, filter_type, clients_dir, filter_peers=""):
566 """Follow both log files and output formatted events"""
567 import glob, time, select
568
569 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
570 peer_filter = set(filter_peers.split(',')) if filter_peers else set()
571
572 # Build ip->name map
573 ip_to_name = {}
574 for conf in glob.glob(f"{clients_dir}/*.conf"):
575 name = os.path.basename(conf).replace('.conf', '')
576 try:
577 with open(conf) as f:
578 for l in f:
579 if l.startswith('Address'):
580 ip = l.split('=')[1].strip().split('/')[0]
581 ip_to_name[ip] = name
582 except:
583 pass
584
585 # Open files and seek to end
586 files = {}
587 for label, path in [('fw', fw_file), ('wg', wg_file)]:
588 if path and os.path.exists(path):
589 f = open(path)
590 f.seek(0, 2) # seek to end
591 files[label] = f
592
593 dedup = {}
594
595 try:
596 while True:
597 for label, f in files.items():
598 line = f.readline()
599 if not line:
600 continue
601 try:
602 e = json.loads(line.strip())
603 except:
604 continue
605
606 if label == 'fw':
607 src = e.get('src_ip', '')
608 if not src:
609 continue
610 if filter_ip and src != filter_ip:
611 continue
612
613 # Filter by peer names if specified
614 if peer_filter:
615 client_name = ip_to_name.get(src, '')
616 if client_name not in peer_filter:
617 continue
618 dst = e.get('dest_ip', '—')
619 port = e.get('dest_port', '')
620 proto_num = e.get('ip.protocol', 0)
621 proto = proto_map.get(proto_num, str(proto_num))
622
623 # Dedup
624 key = (src, dst, port, proto_num)
625 windows = {1: 5, 6: 30, 17: 10}
626 window = windows.get(proto_num, 10)
627 now = time.time()
628 if key in dedup and (now - dedup[key]) < window:
629 continue
630 dedup[key] = now
631
632 client = ip_to_name.get(src, src)
633 if filter_type and not client.startswith(filter_type + '-'):
634 continue
635 dst_str = f"{dst}:{port}" if port else dst
636 ts = e.get('timestamp', '')[:16].replace('T', ' ')
637 print(f"fw|{ts}|{client}|{dst_str}|{proto}", flush=True)
638
639 elif label == 'wg':
640 client = e.get('client', '')
641 if not client:
642 continue
643 if filter_ip:
644 ip = ip_to_name.get(filter_ip, '')
645 if client != ip and client != filter_ip:
646 continue
647
648 if peer_filter and client not in peer_filter:
649 continue
650 if filter_type and not client.startswith(filter_type + '-'):
651 continue
652 ts = e.get('timestamp', '')[:16].replace('T', ' ')
653 endpoint = e.get('endpoint', '—')
654 event = e.get('event', '—')
655 print(f"wg|{ts}|{client}|{endpoint}|{event}", flush=True)
656
657 time.sleep(0.1)
658 except KeyboardInterrupt:
659 pass
660
661def count(file, key):
662 try:
663 with open(file) as f:
664 data = json.load(f)
665 val = data.get(key, [])
666 print(len(val) if isinstance(val, list) else 0)
667 except:
668 print(0)
669
670def audit_fw_counts(clients_dir):
671 """Return peer_name:fw_count pairs from iptables"""
672 import glob, subprocess, re
673
674 try:
675 result = subprocess.run(
676 ['iptables', '-L', 'FORWARD', '-n', '-v'],
677 capture_output=True, text=True
678 )
679 fw_lines = result.stdout.splitlines()
680 except Exception:
681 fw_lines = []
682
683 # Filter to only data lines (skip headers and blanks)
684 # In -v output, source IP is in column 8 (0-indexed)
685 # Format: pkts bytes target prot opt in out source destination [options]
686 rule_lines = [l for l in fw_lines if l.strip() and not l.startswith('Chain') and not l.startswith(' pkts')]
687
688 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
689 name = os.path.basename(conf).replace('.conf', '')
690 try:
691 with open(conf) as f:
692 ip = ''
693 for line in f:
694 if line.startswith('Address'):
695 ip = line.split('=')[1].strip().split('/')[0]
696 break
697 if not ip:
698 continue
699 # Count lines where source column exactly matches the peer IP
700 count = sum(1 for l in rule_lines if re.search(r'\s' + re.escape(ip) + r'\s', l))
701 print(f"{name}:{count}")
702 except Exception:
703 pass
704
705def peer_group_map(groups_dir):
706 """Return peer:group pairs for all groups"""
707 import glob
708 try:
709 for group_file in glob.glob(f"{groups_dir}/*.group"):
710 try:
711 with open(group_file) as f:
712 g = json.load(f)
713 name = g.get('name', '')
714 for peer in g.get('peers', []):
715 if peer:
716 print(f"{peer}:{name}")
717 except:
718 pass
719 except:
720 pass
721
722def peer_groups(groups_dir, peer_name):
723 """Find all groups containing a peer"""
724 import glob
725 try:
726 for group_file in glob.glob(f"{groups_dir}/*.group"):
727 try:
728 with open(group_file) as f:
729 g = json.load(f)
730 if peer_name in g.get('peers', []):
731 print(g.get('name', ''))
732 except:
733 pass
734 except:
735 pass
736
737def iso_to_ts(iso_str):
738 """Convert ISO timestamp to unix timestamp"""
739 try:
740 from datetime import datetime, timezone
741 dt = datetime.fromisoformat(iso_str)
742 if dt.tzinfo is None:
743 dt = dt.replace(tzinfo=timezone.utc)
744 print(int(dt.timestamp()))
745 except:
746 print(0)
747
748def rule_list_data(rules_dir, meta_dir):
749 """Return all rule data including base rules and extends"""
750 import glob
751
752 rule_peer_counts = {}
753 for f in glob.glob(f"{meta_dir}/*.meta"):
754 try:
755 with open(f) as mf:
756 meta = json.load(mf)
757 rule = meta.get('rule', '')
758 if rule:
759 rule_peer_counts[rule] = rule_peer_counts.get(rule, 0) + 1
760 except:
761 pass
762
763 rule_files = (
764 sorted(glob.glob(f"{rules_dir}/*.rule")) +
765 sorted(glob.glob(f"{rules_dir}/base/*.rule"))
766 )
767
768 # Collect all data first
769 rules_data = []
770 for rule_file in rule_files:
771 is_base = '/base/' in rule_file
772 try:
773 with open(rule_file) as f:
774 r = json.load(f)
775 name = r.get('name', '')
776 desc = r.get('desc', '')
777 group = r.get('group', '')
778 extends = ','.join(r.get('extends', []))
779 resolved = _rule_resolve_internal(rules_dir, name)
780 n_allows = len(resolved.get('allow_ips', [])) + \
781 len(resolved.get('allow_ports', []))
782 n_blocks = len(resolved.get('block_ips', [])) + \
783 len(resolved.get('block_ports', []))
784 peer_count = rule_peer_counts.get(name, 0)
785 rules_data.append({
786 'name': name, 'desc': desc, 'n_allows': n_allows,
787 'n_blocks': n_blocks, 'peer_count': peer_count,
788 'extends': extends, 'is_base': is_base, 'group': group
789 })
790 except:
791 pass
792
793 # Sort: non-base first, then by group (empty group last within non-base),
794 # then by name within group
795 rules_data.sort(key=lambda x: (
796 x['is_base'],
797 x['group'] == '' and not x['is_base'],
798 x['group'],
799 x['name']
800 ))
801
802 for r in rules_data:
803 print(f"{r['name']}|{r['desc']}|{r['n_allows']}|{r['n_blocks']}|"
804 f"{r['peer_count']}|{r['extends']}|{r['is_base']}|{r['group']}")
805
806def group_list_data(groups_dir, blocks_dir, clients_dir):
807 """Return group summary data in one call"""
808 import glob
809
810 # Get all block files
811 blocked_peers = set()
812 for f in glob.glob(f"{blocks_dir}/*.block"):
813 name = os.path.basename(f).replace('.block', '')
814 blocked_peers.add(name)
815
816 for group_file in sorted(glob.glob(f"{groups_dir}/*.group")):
817 try:
818 with open(group_file) as f:
819 g = json.load(f)
820 name = g.get('name', '')
821 desc = g.get('desc', '')
822 peers = [p for p in g.get('peers', []) if p]
823 valid_peers = [p for p in peers
824 if os.path.exists(os.path.join(clients_dir, f"{p}.conf"))]
825
826 total = len(valid_peers)
827 blocked = sum(1 for p in peers if p in blocked_peers)
828 print(f"{name}|{desc}|{total}|{blocked}")
829 except:
830 pass
831
832def fmt_datetime(iso_str, fmt):
833 """Format ISO timestamp with given strftime format"""
834 try:
835 from datetime import datetime
836 dt = datetime.fromisoformat(iso_str)
837 print(dt.strftime(fmt))
838 except:
839 print(iso_str)
840
841def create_rule(file, name, desc, dns_redirect, allow_ips, block_ips,
842 block_ports, allow_ports='', extends='', group=''):
843 rule = {
844 'name': name,
845 'desc': desc,
846 'group': group,
847 'dns_redirect': dns_redirect == 'true',
848 'extends': [x for x in extends.split(',') if x] if extends else [],
849 'allow_ips': [x for x in allow_ips.split(',') if x] if allow_ips else [],
850 'allow_ports': [x for x in allow_ports.split(',') if x] if allow_ports else [],
851 'block_ips': [x for x in block_ips.split(',') if x] if block_ips else [],
852 'block_ports': [x for x in block_ports.split(',') if x] if block_ports else [],
853 }
854 with open(file, 'w') as f:
855 json.dump(rule, f, indent=2)
856
857def cleanup_config(config_file):
858 """Normalize blank lines in WireGuard config"""
859 import re
860 try:
861 with open(config_file) as f:
862 config = f.read()
863 config = re.sub(r'\n{3,}', '\n\n', config)
864 config = config.rstrip('\n') + '\n'
865 with open(config_file, 'w') as f:
866 f.write(config)
867 except Exception as e:
868 print(f"Error: {e}", file=sys.stderr)
869 sys.exit(1)
870
871def remove_peer_block(config_file, name):
872 """Remove a peer block from WireGuard config by name"""
873 import re
874 try:
875 with open(config_file) as f:
876 config = f.read()
877 pattern = r'\n\[Peer\]\n# ' + re.escape(name) + r'\n[^\n]+\n[^\n]+\n'
878 result = re.sub(pattern, '\n', config)
879 with open(config_file, 'w') as f:
880 f.write(result)
881 except Exception as e:
882 print(f"Error: {e}", file=sys.stderr)
883 sys.exit(1)
884
885def create_group(file, name, desc):
886 """Create a new group JSON file"""
887 try:
888 group = {'name': name, 'desc': desc, 'peers': []}
889 with open(file, 'w') as f:
890 json.dump(group, f, indent=2)
891 except Exception as e:
892 print(f"Error: {e}", file=sys.stderr)
893 sys.exit(1)
894
895def parse_event(line):
896 """Parse a single JSON event line"""
897 try:
898 e = json.loads(line)
899 print(f"{e.get('timestamp','')}|{e.get('client','')}|{e.get('endpoint','')}|{e.get('event','')}")
900 except:
901 pass
902
903def parse_fw_event(line):
904 """Parse a single fw_events.log JSON line"""
905 try:
906 e = json.loads(line)
907 ts = e.get('timestamp', '')
908 src = e.get('src_ip', '')
909 dst = e.get('dest_ip', '')
910 port = e.get('dest_port', '')
911 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
912 proto_num = e.get('ip.protocol', 0)
913 proto = proto_map.get(proto_num, str(proto_num))
914 print(f"{ts}|{src}|{dst}|{port}|{proto}")
915 except:
916 pass
917
918def peer_transfer(wg_interface):
919 """Get total transfer bytes per peer"""
920 import subprocess
921 low = int(os.environ.get('ACTIVITY_TOTAL_LOW_BYTES', '1000000'))
922 med = int(os.environ.get('ACTIVITY_TOTAL_MED_BYTES', '10000000'))
923 high = int(os.environ.get('ACTIVITY_TOTAL_HIGH_BYTES', '100000000'))
924 try:
925 result = subprocess.run(
926 ['wg', 'show', wg_interface, 'transfer'],
927 capture_output=True, text=True
928 )
929 for line in result.stdout.strip().split('\n'):
930 if not line:
931 continue
932 parts = line.split('\t')
933 if len(parts) == 3:
934 pubkey, rx, tx = parts
935 total = int(rx) + int(tx)
936 if total == 0: level = 'none'
937 elif total < low: level = 'low'
938 elif total < med: level = 'medium'
939 elif total < high: level = 'high'
940 else: level = 'very high'
941 print(f"{pubkey}|{rx}|{tx}|{level}")
942 except:
943 pass
944
945def peer_transfer_delta(wg_interface, cache_file):
946 """Calculate current transfer rate using delta from previous sample"""
947 import subprocess, time
948
949 low = int(os.environ.get('ACTIVITY_CURRENT_LOW_BYTES', '10000')) # 10KB/s
950 med = int(os.environ.get('ACTIVITY_CURRENT_MED_BYTES', '100000')) # 100KB/s
951 high = int(os.environ.get('ACTIVITY_CURRENT_HIGH_BYTES', '1000000')) # 1MB/s
952
953 current = {}
954 now = time.time()
955 try:
956 result = subprocess.run(
957 ['wg', 'show', wg_interface, 'transfer'],
958 capture_output=True, text=True
959 )
960 for line in result.stdout.strip().split('\n'):
961 if not line:
962 continue
963 parts = line.split('\t')
964 if len(parts) == 3:
965 pubkey, rx, tx = parts
966 current[pubkey] = {'rx': int(rx), 'tx': int(tx), 'ts': now}
967 except:
968 pass
969
970 prev = {}
971 if os.path.exists(cache_file):
972 try:
973 with open(cache_file) as f:
974 prev = json.load(f)
975 except:
976 pass
977
978 try:
979 with open(cache_file, 'w') as f:
980 json.dump(current, f)
981 except:
982 pass
983
984 for pubkey, data in current.items():
985 if pubkey in prev:
986 dt = data['ts'] - prev[pubkey].get('ts', data['ts'])
987 if dt > 0:
988 rx_rate = max(0, (data['rx'] - prev[pubkey]['rx']) / dt)
989 tx_rate = max(0, (data['tx'] - prev[pubkey]['tx']) / dt)
990 total = rx_rate + tx_rate
991 if total <= 0: level = 'idle'
992 elif total < low: level = 'low'
993 elif total < med: level = 'medium'
994 elif total < high: level = 'high'
995 else: level = 'very high'
996 print(f"{pubkey}|{int(rx_rate)}|{int(tx_rate)}|{level}")
997 else:
998 print(f"{pubkey}|0|0|idle")
999 else:
1000 print(f"{pubkey}|0|0|unknown")
1001
1002def remove_events_filtered(wg_file, fw_file, filter_name, filter_ip,
1003 filter_fw, filter_wg, before_days):
1004 """Remove events with filters: by name/ip, source, or age"""
1005 import time
1006 from datetime import datetime, timezone
1007
1008 cutoff_ts = None
1009 if before_days:
1010 cutoff_ts = time.time() - (float(before_days) * 86400)
1011
1012 def should_remove_wg(e):
1013 if filter_name and e.get('client') != filter_name:
1014 return False
1015 if cutoff_ts:
1016 try:
1017 ts = datetime.fromisoformat(e.get('timestamp','')).timestamp()
1018 return ts < cutoff_ts
1019 except:
1020 return False
1021 return True
1022
1023 def should_remove_fw(e):
1024 if filter_ip and e.get('src_ip') != filter_ip:
1025 return False
1026 if cutoff_ts:
1027 try:
1028 ts = datetime.fromisoformat(e.get('timestamp','')).timestamp()
1029 return ts < cutoff_ts
1030 except:
1031 return False
1032 return True
1033
1034 removed_wg = removed_fw = 0
1035
1036 if not filter_fw and os.path.exists(wg_file):
1037 lines = []
1038 with open(wg_file) as f:
1039 for line in f:
1040 try:
1041 e = json.loads(line.strip())
1042 if should_remove_wg(e):
1043 removed_wg += 1
1044 continue
1045 except:
1046 pass
1047 lines.append(line)
1048 with open(wg_file, 'w') as f:
1049 f.writelines(lines)
1050
1051 if not filter_wg and os.path.exists(fw_file):
1052 lines = []
1053 with open(fw_file) as f:
1054 for line in f:
1055 try:
1056 e = json.loads(line.strip())
1057 if should_remove_fw(e):
1058 removed_fw += 1
1059 continue
1060 except:
1061 pass
1062 lines.append(line)
1063 with open(fw_file, 'w') as f:
1064 f.writelines(lines)
1065
1066 print(f"{removed_wg}|{removed_fw}")
1067
1068def _rule_resolve_internal(rules_dir, rule_name, visited=None):
1069 """Internal recursive resolver — returns dict, does not print"""
1070 if visited is None:
1071 visited = set()
1072 if rule_name in visited:
1073 raise ValueError(f"Circular dependency detected: {rule_name}")
1074 visited.add(rule_name)
1075
1076 rule_file = find_rule_file(rules_dir, rule_name)
1077 with open(rule_file) as f:
1078 rule = json.load(f)
1079
1080 merged = {
1081 'allow_ips': [],
1082 'allow_ports': [],
1083 'block_ips': [],
1084 'block_ports': [],
1085 'dns_redirect': False
1086 }
1087
1088 for base_name in rule.get('extends', []):
1089 base = _rule_resolve_internal(rules_dir, base_name, visited.copy())
1090 merged['allow_ips'] += base.get('allow_ips', [])
1091 merged['allow_ports'] += base.get('allow_ports', [])
1092 merged['block_ips'] += base.get('block_ips', [])
1093 merged['block_ports'] += base.get('block_ports', [])
1094 if base.get('dns_redirect'):
1095 merged['dns_redirect'] = True
1096
1097 # Merge own fields — use .get() with defaults for all fields
1098 merged['allow_ips'] = list(dict.fromkeys(
1099 merged['allow_ips'] + rule.get('allow_ips', [])))
1100 merged['allow_ports'] = list(dict.fromkeys(
1101 merged['allow_ports'] + rule.get('allow_ports', [])))
1102 merged['block_ips'] = list(dict.fromkeys(
1103 merged['block_ips'] + rule.get('block_ips', [])))
1104 merged['block_ports'] = list(dict.fromkeys(
1105 merged['block_ports'] + rule.get('block_ports', [])))
1106 if rule.get('dns_redirect', False):
1107 merged['dns_redirect'] = True
1108
1109 merged['name'] = rule.get('name', rule_name)
1110 merged['desc'] = rule.get('desc', '')
1111 merged['group'] = rule.get('group', '')
1112 merged['extends'] = rule.get('extends', [])
1113 return merged
1114
1115def rule_resolve(rules_dir, rule_name):
1116 """Resolve a rule with inheritance — prints JSON"""
1117 try:
1118 resolved = _rule_resolve_internal(rules_dir, rule_name)
1119 print(json.dumps(resolved))
1120 except Exception as e:
1121 print(f"Error: {e}", file=sys.stderr)
1122 sys.exit(1)
1123
1124def rule_resolve_field(rules_dir, rule_name, field):
1125 """Get a single field from resolved rule — prints values one per line"""
1126 try:
1127 resolved = _rule_resolve_internal(rules_dir, rule_name)
1128 val = resolved.get(field, [])
1129 if isinstance(val, list):
1130 for v in val:
1131 print(v)
1132 else:
1133 print(val)
1134 except Exception as e:
1135 print(f"Error: {e}", file=sys.stderr)
1136 sys.exit(1)
1137
1138def rule_inspect(rules_dir, rule_name):
1139 """Show inheritance tree for a rule"""
1140 try:
1141 rule_file = find_rule_file(rules_dir, rule_name)
1142 with open(rule_file) as f:
1143 rule = json.load(f)
1144 resolved = _rule_resolve_internal(rules_dir, rule_name)
1145 has_extends = bool(rule.get('extends', []))
1146
1147 # Own rules
1148 for ip in rule.get('allow_ips', []):
1149 print(f"own|allow_ip|{ip}")
1150 for p in rule.get('allow_ports', []):
1151 print(f"own|allow_port|{p}")
1152 for ip in rule.get('block_ips', []):
1153 print(f"own|block_ip|{ip}")
1154 for p in rule.get('block_ports', []):
1155 print(f"own|block_port|{p}")
1156
1157 # DNS redirect — separate section
1158 if rule.get('dns_redirect'):
1159 print(f"dns|dns_redirect|true")
1160
1161 if has_extends:
1162 # Inherited rules per base
1163 for base_name in rule.get('extends', []):
1164 base = _rule_resolve_internal(rules_dir, base_name)
1165 for ip in base.get('allow_ips', []):
1166 print(f"inherited:{base_name}|allow_ip|{ip}")
1167 for p in base.get('allow_ports', []):
1168 print(f"inherited:{base_name}|allow_port|{p}")
1169 for ip in base.get('block_ips', []):
1170 print(f"inherited:{base_name}|block_ip|{ip}")
1171 for p in base.get('block_ports', []):
1172 print(f"inherited:{base_name}|block_port|{p}")
1173 if base.get('dns_redirect'):
1174 print(f"inherited:{base_name}|dns_redirect|true")
1175
1176 # Resolved summary only when inheritance exists
1177 has_resolved = (
1178 resolved.get('allow_ips') or resolved.get('allow_ports') or
1179 resolved.get('block_ips') or resolved.get('block_ports') or
1180 resolved.get('dns_redirect')
1181 )
1182 if has_resolved:
1183 for ip in resolved.get('allow_ips', []):
1184 print(f"resolved|allow_ip|{ip}")
1185 for p in resolved.get('allow_ports', []):
1186 print(f"resolved|allow_port|{p}")
1187 for ip in resolved.get('block_ips', []):
1188 print(f"resolved|block_ip|{ip}")
1189 for p in resolved.get('block_ports', []):
1190 print(f"resolved|block_port|{p}")
1191 if resolved.get('dns_redirect'):
1192 print(f"resolved|dns_redirect|true")
1193
1194 except Exception as e:
1195 print(f"Error: {e}", file=sys.stderr)
1196 sys.exit(1)
1197
1198def find_rule_file(rules_dir, rule_name):
1199 """Find rule file in rules/ or rules/base/"""
1200 for path in [
1201 os.path.join(rules_dir, f"{rule_name}.rule"),
1202 os.path.join(rules_dir, "base", f"{rule_name}.rule"),
1203 ]:
1204 if os.path.exists(path):
1205 return path
1206 return ""
1207
1208def get_raw(file, key):
1209 try:
1210 with open(file) as f:
1211 data = json.load(f)
1212 val = data.get(key) # returns None if missing
1213 if val is None:
1214 pass # print nothing
1215 elif isinstance(val, bool):
1216 print(str(val).lower())
1217 elif isinstance(val, list):
1218 for v in val:
1219 print(v)
1220 else:
1221 print(val)
1222 except:
1223 pass
1224
1225def count_resolved(rules_dir, rule_name, key):
1226 """Count entries in resolved rule field"""
1227 try:
1228 resolved = _rule_resolve_internal(rules_dir, rule_name)
1229 print(len(resolved.get(key, [])))
1230 except:
1231 print(0)
1232
1233def _block_init(peer_ip):
1234 """Return empty block structure"""
1235 return {
1236 "peer_ip": peer_ip,
1237 "blocked_direct": False,
1238 "blocked_by_groups": [],
1239 "rules": []
1240 }
1241
1242def _block_read(file):
1243 try:
1244 with open(file) as f:
1245 content = f.read().strip()
1246 if not content:
1247 return None # empty file = no block data
1248 try:
1249 return json.loads(content)
1250 except json.JSONDecodeError:
1251 # Old format — migrate
1252 lines = content.split('\n')
1253 peer_ip = lines[0].split()[0] if lines else ''
1254 new_data = {
1255 "peer_ip": peer_ip,
1256 "blocked_direct": True,
1257 "blocked_by_groups": [],
1258 "rules": [{"name": "full block", "type": "full"}]
1259 }
1260 with open(file, 'w') as f:
1261 json.dump(new_data, f, indent=2)
1262 return new_data
1263 except FileNotFoundError:
1264 return None
1265 except Exception:
1266 return None
1267
1268def _block_write(file, data):
1269 """Write block file"""
1270 with open(file, 'w') as f:
1271 json.dump(data, f, indent=2)
1272
1273def block_get(file):
1274 """Read and print block file as JSON"""
1275 data = _block_read(file)
1276 if data:
1277 print(json.dumps(data))
1278
1279def block_is_blocked(file):
1280 """Return true if peer is effectively blocked"""
1281 data = _block_read(file)
1282 if not data:
1283 print("false")
1284 return
1285 blocked = data.get("blocked_direct", False) or \
1286 bool(data.get("blocked_by_groups", []))
1287 print("true" if blocked else "false")
1288
1289def block_set_direct(file, peer_ip, value):
1290 """Set blocked_direct"""
1291 try:
1292 data = _block_read(file) or _block_init(peer_ip)
1293 data["blocked_direct"] = value.lower() == "true"
1294 data["peer_ip"] = peer_ip
1295 _block_write(file, data)
1296 remaining = data["blocked_direct"] or bool(data.get("blocked_by_groups", []))
1297 pass
1298 # print("true" if remaining else "false")
1299 except Exception as e:
1300 print(f"Error: {e}", file=sys.stderr)
1301 sys.exit(1)
1302
1303def block_add_group(file, peer_ip, group):
1304 """Add group to blocked_by_groups"""
1305 try:
1306 data = _block_read(file) or _block_init(peer_ip)
1307 data["peer_ip"] = peer_ip
1308 groups = data.get("blocked_by_groups", [])
1309 if group not in groups:
1310 groups.append(group)
1311 data["blocked_by_groups"] = groups
1312 _block_write(file, data)
1313 except Exception as e:
1314 print(f"Error: {e}", file=sys.stderr)
1315 sys.exit(1)
1316
1317def block_remove_group(file, peer_ip, group):
1318 """Remove group from blocked_by_groups, return whether still blocked"""
1319 try:
1320 data = _block_read(file) or _block_init(peer_ip)
1321 groups = data.get("blocked_by_groups", [])
1322 if group in groups:
1323 groups.remove(group)
1324 data["blocked_by_groups"] = groups
1325 _block_write(file, data)
1326 except Exception as e:
1327 print(f"Error: {e}", file=sys.stderr)
1328 sys.exit(1)
1329 # print("true" if remaining else "false")
1330 pass
1331 except Exception as e:
1332 print(f"Error: {e}", file=sys.stderr)
1333 sys.exit(1)
1334
1335def block_add_rule(file, peer_ip, rule_type, name="", target="",
1336 port="", proto=""):
1337 """Add a block rule entry"""
1338 try:
1339 data = _block_read(file) or _block_init(peer_ip)
1340 data["peer_ip"] = peer_ip
1341 rule = {"type": rule_type}
1342 if name: rule["name"] = name
1343 if target: rule["target"] = target
1344 if port: rule["port"] = port
1345 if proto: rule["proto"] = proto
1346
1347 rules = data.get("rules", [])
1348 for existing in rules:
1349 if existing.get("type") == rule_type and \
1350 existing.get("target","") == target and \
1351 existing.get("port","") == port and \
1352 existing.get("proto","") == proto:
1353 return # already exists, skip
1354
1355 rules.append(rule)
1356 data["rules"] = rules
1357 _block_write(file, data)
1358 except Exception as e:
1359 print(f"Error: {e}", file=sys.stderr)
1360 sys.exit(1)
1361
1362def block_remove_rule(file, rule_type, target="", port="", proto=""):
1363 data = _block_read(file)
1364 if not data:
1365 return
1366 rules = data.get("rules", [])
1367 filtered = [r for r in rules if not (
1368 r.get("type") == rule_type and
1369 r.get("target", "") == target and
1370 r.get("port", "") == port and
1371 r.get("proto", "") == proto
1372 )]
1373 data["rules"] = filtered
1374 _block_write(file, data)
1375
1376def block_get_rules(file):
1377 """Print rules as pipe-separated lines: name|type|target|port|proto"""
1378 data = _block_read(file)
1379 if not data:
1380 return
1381 for r in data.get("rules", []):
1382 print(f"{r.get('name','')}|{r.get('type','')}|"
1383 f"{r.get('target','')}|{r.get('port','')}|{r.get('proto','')}")
1384
1385def block_get_groups(file):
1386 data = _block_read(file)
1387 if not data:
1388 return
1389 print(','.join(data.get('blocked_by_groups', [])))
1390
1391def block_get_direct(file):
1392 data = _block_read(file)
1393 if not data:
1394 print('false')
1395 return
1396 print('true' if data.get('blocked_direct', False) else 'false')
1397
1398# ============================================
1399# Net / Services
1400# ============================================
1401
1402def _net_read(file):
1403 """Read services.json, return dict or empty dict"""
1404 try:
1405 if not os.path.exists(file):
1406 return {}
1407 with open(file) as f:
1408 content = f.read().strip()
1409 if not content:
1410 return {}
1411 return json.loads(content)
1412 except Exception:
1413 return {}
1414
1415def _net_write(file, data):
1416 """Write services.json"""
1417 os.makedirs(os.path.dirname(file), exist_ok=True)
1418 with open(file, 'w') as f:
1419 json.dump(data, f, indent=2)
1420
1421def net_list(file):
1422 """List all service names with IP and port count"""
1423 data = _net_read(file)
1424 for name, svc in sorted(data.items()):
1425 ip = svc.get('ip', '')
1426 desc = svc.get('desc', '')
1427 tags = ','.join(svc.get('tags', []))
1428 ports = len(svc.get('ports', {}))
1429 print(f"{name}|{ip}|{desc}|{tags}|{ports}")
1430
1431def net_show(file, name):
1432 """Show full service details"""
1433 data = _net_read(file)
1434 if name not in data:
1435 print(f"Error: Service not found: {name}", file=sys.stderr)
1436 sys.exit(1)
1437 svc = data[name]
1438 print(f"name|{name}")
1439 print(f"ip|{svc.get('ip','')}")
1440 print(f"desc|{svc.get('desc','')}")
1441 print(f"tags|{','.join(svc.get('tags',[]))}")
1442 for port_name, port_def in svc.get('ports', {}).items():
1443 port = port_def.get('port', '')
1444 proto = port_def.get('proto', 'tcp')
1445 desc = port_def.get('desc', '')
1446 print(f"port|{port_name}|{port}|{proto}|{desc}")
1447
1448def net_exists(file, name):
1449 """Check if service exists"""
1450 data = _net_read(file)
1451 # Handle service:port syntax
1452 if ':' in name:
1453 svc_name, port_name = name.split(':', 1)
1454 if port_name == 'ports':
1455 print('true' if svc_name in data else 'false')
1456 else:
1457 svc = data.get(svc_name, {})
1458 print('true' if port_name in svc.get('ports', {}) else 'false')
1459 else:
1460 print('true' if name in data else 'false')
1461
1462def net_add_service(file, name, ip, desc='', tags=''):
1463 """Add or update a service"""
1464 data = _net_read(file)
1465 if name not in data:
1466 data[name] = {'ip': ip, 'ports': {}}
1467 else:
1468 data[name]['ip'] = ip
1469 if desc:
1470 data[name]['desc'] = desc
1471 if tags:
1472 data[name]['tags'] = [t.strip() for t in tags.split(',') if t.strip()]
1473 _net_write(file, data)
1474
1475def net_add_port(file, service, port_name, port, proto='tcp', desc=''):
1476 """Add or update a port on a service"""
1477 data = _net_read(file)
1478 if service not in data:
1479 print(f"Error: Service not found: {service}", file=sys.stderr)
1480 sys.exit(1)
1481 if 'ports' not in data[service]:
1482 data[service]['ports'] = {}
1483 entry = {'port': int(port), 'proto': proto}
1484 if desc:
1485 entry['desc'] = desc
1486 data[service]['ports'][port_name] = entry
1487 _net_write(file, data)
1488
1489def net_remove(file, name):
1490 """Remove service or port"""
1491 data = _net_read(file)
1492 if ':' in name:
1493 svc_name, port_name = name.split(':', 1)
1494 if svc_name not in data:
1495 print(f"Error: Service not found: {svc_name}", file=sys.stderr)
1496 sys.exit(1)
1497 if port_name == 'ports':
1498 # Remove all ports
1499 data[svc_name]['ports'] = {}
1500 else:
1501 if port_name not in data[svc_name].get('ports', {}):
1502 print(f"Error: Port not found: {port_name}", file=sys.stderr)
1503 sys.exit(1)
1504 del data[svc_name]['ports'][port_name]
1505 else:
1506 if name not in data:
1507 print(f"Error: Service not found: {name}", file=sys.stderr)
1508 sys.exit(1)
1509 del data[name]
1510 _net_write(file, data)
1511
1512def net_resolve(file, name):
1513 """Resolve service name to ip or ip:port:proto lines"""
1514 data = _net_read(file)
1515 if ':' in name:
1516 svc_name, port_name = name.split(':', 1)
1517 if svc_name not in data:
1518 print(f"Error: Service not found: {svc_name}", file=sys.stderr)
1519 sys.exit(1)
1520 svc = data[svc_name]
1521 ip = svc.get('ip', '')
1522 if port_name == 'ports':
1523 # All ports
1524 for pname, pdef in svc.get('ports', {}).items():
1525 print(f"{ip}:{pdef['port']}:{pdef.get('proto','tcp')}")
1526 else:
1527 if port_name not in svc.get('ports', {}):
1528 print(f"Error: Port not found: {port_name}", file=sys.stderr)
1529 sys.exit(1)
1530 pdef = svc['ports'][port_name]
1531 print(f"{ip}:{pdef['port']}:{pdef.get('proto','tcp')}")
1532 else:
1533 if name not in data:
1534 print(f"Error: Service not found: {name}", file=sys.stderr)
1535 sys.exit(1)
1536 print(data[name].get('ip', ''))
1537
1538def net_reverse_lookup(file, ip, port='', proto=''):
1539 """Reverse lookup IP/port to service name"""
1540 data = _net_read(file)
1541 for svc_name, svc in data.items():
1542 if svc.get('ip') != ip:
1543 continue
1544 if not port:
1545 print(svc_name)
1546 return
1547 for port_name, port_def in svc.get('ports', {}).items():
1548 if (str(port_def.get('port','')) == str(port) and
1549 port_def.get('proto','tcp') == proto):
1550 print(f"{svc_name}:{port_name}")
1551 return
1552 # IP matched but no port match — return service name
1553 print(svc_name)
1554 return
1555
1556def block_is_empty(file):
1557 data = _block_read(file)
1558 if not data:
1559 print("true")
1560 return
1561 empty = (
1562 not data.get("blocked_direct", False) and
1563 not data.get("blocked_by_groups", []) and
1564 not data.get("rules", []) and
1565 not data.get("services", [])
1566 )
1567 print("true" if empty else "false")
1568
1569def group_has_peer(file, peer_name):
1570 try:
1571 with open(file) as f:
1572 data = json.load(f)
1573 peers = data.get('peers', [])
1574 print('true' if peer_name in peers else 'false')
1575 except Exception:
1576 print('false')
1577
1578# ============================================
1579# Subnet Map
1580# ============================================
1581
1582def _subnet_read(file):
1583 """Read subnets.json, return dict or empty dict"""
1584 try:
1585 if not os.path.exists(file):
1586 return {}
1587 with open(file) as f:
1588 content = f.read().strip()
1589 if not content:
1590 return {}
1591 return json.loads(content)
1592 except Exception:
1593 return {}
1594
1595def _subnet_write(file, data):
1596 """Write subnets.json"""
1597 os.makedirs(os.path.dirname(file), exist_ok=True)
1598 with open(file, 'w') as f:
1599 json.dump(data, f, indent=2)
1600
1601def _subnet_is_group(entry):
1602 """Return True if a subnet entry is a nested group (like 'guests')"""
1603 return isinstance(entry, dict) and 'subnet' not in entry
1604
1605def subnet_lookup(file, name, type_key=''):
1606 """
1607 Resolve a subnet name (and optional type) to a CIDR string.
1608 For scalar entries: subnet_lookup(file, "desktop") -> "10.1.1.0/24"
1609 For group entries: subnet_lookup(file, "guests", "phone") -> "10.1.103.0/24"
1610 For group with no type: subnet_lookup(file, "guests") -> "10.1.100.0/24" (none slot)
1611 Prints the CIDR on success, nothing and exits 1 on failure.
1612 """
1613 data = _subnet_read(file)
1614 if name not in data:
1615 sys.exit(1)
1616 entry = data[name]
1617 if _subnet_is_group(entry):
1618 key = type_key if type_key else 'none'
1619 if key not in entry:
1620 sys.exit(1)
1621 print(entry[key]['subnet'])
1622 else:
1623 print(entry['subnet'])
1624
1625def subnet_type(file, name, type_key=''):
1626 """
1627 Return the type string for a subnet entry.
1628 For scalar: subnet_type(file, "desktop") -> "desktop"
1629 For group: subnet_type(file, "guests", "phone") -> "phone"
1630 subnet_type(file, "guests") -> "none"
1631 """
1632 data = _subnet_read(file)
1633 if name not in data:
1634 sys.exit(1)
1635 entry = data[name]
1636 if _subnet_is_group(entry):
1637 key = type_key if type_key else 'none'
1638 if key not in entry:
1639 sys.exit(1)
1640 # For group entries the type IS the child key
1641 print(key if key != 'none' else 'none')
1642 else:
1643 print(entry.get('type', name))
1644
1645def subnet_tunnel_mode(file, name, type_key=''):
1646 """Return tunnel_mode for a subnet entry"""
1647 data = _subnet_read(file)
1648 if name not in data:
1649 print('split') # safe default
1650 return
1651 entry = data[name]
1652 if _subnet_is_group(entry):
1653 key = type_key if type_key else 'none'
1654 child = entry.get(key, {})
1655 print(child.get('tunnel_mode', 'split'))
1656 else:
1657 print(entry.get('tunnel_mode', 'split'))
1658
1659def subnet_for_ip(file, ip):
1660 """
1661 Reverse lookup: given a peer IP, find which subnet name (and type) it belongs to.
1662 Output: name|type (e.g. "guests|phone" or "desktop|desktop")
1663 Returns nothing (exit 0) if not found — caller falls back to hardcoded map.
1664 """
1665 import ipaddress
1666 try:
1667 peer_addr = ipaddress.ip_address(ip)
1668 except ValueError:
1669 return
1670
1671 data = _subnet_read(file)
1672 for name, entry in data.items():
1673 if _subnet_is_group(entry):
1674 for type_key, child in entry.items():
1675 try:
1676 network = ipaddress.ip_network(child['subnet'], strict=False)
1677 if peer_addr in network:
1678 print(f"{name}|{type_key}")
1679 return
1680 except Exception:
1681 continue
1682 else:
1683 try:
1684 network = ipaddress.ip_network(entry['subnet'], strict=False)
1685 if peer_addr in network:
1686 peer_type = entry.get('type', name)
1687 print(f"{name}|{peer_type}")
1688 return
1689 except Exception:
1690 continue
1691
1692def subnet_list(file):
1693 """
1694 List all subnets for display.
1695 Output per line: name|subnet|type|tunnel_mode|desc|is_group
1696 For group entries, outputs one line per child: name.type|subnet|type|tunnel_mode|desc|true
1697 """
1698 data = _subnet_read(file)
1699 for name, entry in data.items():
1700 if _subnet_is_group(entry):
1701 for type_key, child in entry.items():
1702 display_name = f"{name}.{type_key}" if type_key != 'none' else name
1703 print(f"{display_name}|{child.get('subnet','')}|"
1704 f"{type_key}|{child.get('tunnel_mode','split')}|"
1705 f"{child.get('desc','')}|true|{name}")
1706 else:
1707 print(f"{name}|{entry.get('subnet','')}|"
1708 f"{entry.get('type',name)}|{entry.get('tunnel_mode','split')}|"
1709 f"{entry.get('desc','')}|false|{name}")
1710
1711def subnet_show(file, name):
1712 """Show a single subnet entry (scalar or group) in detail"""
1713 data = _subnet_read(file)
1714 if name not in data:
1715 print(f"Error: Subnet '{name}' not found", file=sys.stderr)
1716 sys.exit(1)
1717 entry = data[name]
1718 if _subnet_is_group(entry):
1719 print(f"name|{name}")
1720 print(f"is_group|true")
1721 for type_key, child in entry.items():
1722 print(f"child|{type_key}|{child.get('subnet','')}|"
1723 f"{child.get('tunnel_mode','split')}|{child.get('desc','')}")
1724 else:
1725 print(f"name|{name}")
1726 print(f"is_group|false")
1727 print(f"subnet|{entry.get('subnet','')}")
1728 print(f"type|{entry.get('type',name)}")
1729 print(f"tunnel_mode|{entry.get('tunnel_mode','split')}")
1730 print(f"desc|{entry.get('desc','')}")
1731
1732def subnet_add(file, name, subnet, type_key, tunnel_mode, desc, group_parent=''):
1733 """
1734 Add a new subnet entry.
1735 If group_parent is set, adds as a child under that group key.
1736 Otherwise adds as a scalar entry.
1737 """
1738 data = _subnet_read(file)
1739 entry = {
1740 'subnet': subnet,
1741 'tunnel_mode': tunnel_mode or 'split',
1742 'desc': desc or ''
1743 }
1744 if group_parent:
1745 # Adding a child to an existing group, or creating a new group
1746 if group_parent not in data:
1747 data[group_parent] = {}
1748 elif not _subnet_is_group(data[group_parent]):
1749 print(f"Error: '{group_parent}' exists but is not a group", file=sys.stderr)
1750 sys.exit(1)
1751 data[group_parent][type_key or 'none'] = entry
1752 else:
1753 # Scalar entry — type stored explicitly
1754 entry['type'] = type_key or name
1755 data[name] = entry
1756 _subnet_write(file, data)
1757
1758def subnet_remove(file, name, peers_using):
1759 """
1760 Remove a subnet entry. peers_using is a comma-separated list of peer names
1761 currently using this subnet (passed in from bash after meta scan).
1762 If non-empty, refuses with error.
1763 """
1764 if peers_using:
1765 peers = [p for p in peers_using.split(',') if p]
1766 if peers:
1767 print(f"Error: Subnet '{name}' is in use by: {', '.join(peers)}", file=sys.stderr)
1768 sys.exit(1)
1769 data = _subnet_read(file)
1770 if '.' in name:
1771 # Removing a group child: e.g. "guests.phone"
1772 parent, child_key = name.split('.', 1)
1773 if parent not in data or not _subnet_is_group(data[parent]):
1774 print(f"Error: Group '{parent}' not found", file=sys.stderr)
1775 sys.exit(1)
1776 if child_key not in data[parent]:
1777 print(f"Error: '{child_key}' not found in group '{parent}'", file=sys.stderr)
1778 sys.exit(1)
1779 del data[parent][child_key]
1780 if not data[parent]:
1781 del data[parent] # remove empty group
1782 else:
1783 if name not in data:
1784 print(f"Error: Subnet '{name}' not found", file=sys.stderr)
1785 sys.exit(1)
1786 del data[name]
1787 _subnet_write(file, data)
1788
1789def subnet_rename(file, old_name, new_name, peers_using):
1790 """
1791 Rename a subnet entry. Hard refusal if any peers reference it.
1792 peers_using: comma-separated peer names from bash meta scan.
1793 """
1794 if peers_using:
1795 peers = [p for p in peers_using.split(',') if p]
1796 if peers:
1797 print(f"Error: Cannot rename subnet '{old_name}' — in use by: {', '.join(peers)}", file=sys.stderr)
1798 sys.exit(1)
1799 data = _subnet_read(file)
1800 if old_name not in data:
1801 print(f"Error: Subnet '{old_name}' not found", file=sys.stderr)
1802 sys.exit(1)
1803 if new_name in data:
1804 print(f"Error: Subnet '{new_name}' already exists", file=sys.stderr)
1805 sys.exit(1)
1806 data[new_name] = data.pop(old_name)
1807 _subnet_write(file, data)
1808
1809def subnet_peers(meta_dir, clients_dir, subnet_name, subnets_file):
1810 """
1811 Find all peers using a subnet.
1812 Two-pass check:
1813 1. Meta field: peer has "subnet": subnet_name in their .meta file
1814 2. IP fallback: peer's IP falls within the subnet's CIDR(s)
1815 (catches peers added before meta stored subnet explicitly)
1816 Output: one peer name per line.
1817 """
1818 import glob
1819 import ipaddress
1820
1821 # Resolve all CIDRs covered by this subnet name
1822 data = _subnet_read(subnets_file)
1823 cidrs = []
1824 if subnet_name in data:
1825 entry = data[subnet_name]
1826 if _subnet_is_group(entry):
1827 for child in entry.values():
1828 try:
1829 cidrs.append(ipaddress.ip_network(child['subnet'], strict=False))
1830 except Exception:
1831 pass
1832 else:
1833 try:
1834 cidrs.append(ipaddress.ip_network(entry['subnet'], strict=False))
1835 except Exception:
1836 pass
1837
1838 printed = set()
1839
1840 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
1841 peer_name = os.path.basename(conf).replace('.conf', '')
1842
1843 # Pass 1: check meta field
1844 meta_file = os.path.join(meta_dir, f"{peer_name}.meta")
1845 try:
1846 with open(meta_file) as f:
1847 meta = json.load(f)
1848 if meta.get('subnet', '') == subnet_name:
1849 if peer_name not in printed:
1850 print(peer_name)
1851 printed.add(peer_name)
1852 continue
1853 except Exception:
1854 pass
1855
1856 # Pass 2: IP reverse lookup against subnet CIDRs
1857 if not cidrs:
1858 continue
1859
1860 peer_ip = ''
1861 try:
1862 with open(conf) as f:
1863 for line in f:
1864 if line.startswith('Address'):
1865 peer_ip = line.split('=')[1].strip().split('/')[0]
1866 break
1867 except Exception:
1868 continue
1869
1870 if not peer_ip:
1871 continue
1872
1873 try:
1874 addr = ipaddress.ip_address(peer_ip)
1875 if any(addr in cidr for cidr in cidrs):
1876 if peer_name not in printed:
1877 print(peer_name)
1878 printed.add(peer_name)
1879 except Exception:
1880 continue
1881
1882
1883def subnet_exists(file, name):
1884 """Check if a subnet name exists (scalar or group). Exits 0/1."""
1885 data = _subnet_read(file)
1886 if '.' in name:
1887 parent, child_key = name.split('.', 1)
1888 exists = parent in data and _subnet_is_group(data[parent]) and child_key in data[parent]
1889 else:
1890 exists = name in data
1891 sys.exit(0 if exists else 1)
1892
1893
1894# ============================================
1895# Identity System
1896# ============================================
1897
1898def identity_rules(file):
1899 """
1900 Return all rules assigned to an identity, one per line.
1901 Reads from 'rules' array (1:N). Falls back to 'rule' scalar for migration.
1902 """
1903 data = _identity_read(file)
1904 if not data:
1905 return
1906 # Support legacy scalar 'rule' field
1907 rules = data.get('rules', [])
1908 if not rules and data.get('rule'):
1909 rules = [data['rule']]
1910 for r in rules:
1911 if r:
1912 print(r)
1913
1914def identity_add_rule(file, identity_name, rule_name):
1915 """
1916 Add a rule to an identity's rules array.
1917 Warns if already present (prints warning to stderr, exits 2).
1918 Creates identity file if it doesn't exist.
1919 """
1920 data = _identity_read(file) or _identity_init(identity_name)
1921 rules = data.get('rules', [])
1922 # Migrate legacy scalar field
1923 if 'rule' in data and data['rule']:
1924 if data['rule'] not in rules:
1925 rules.append(data['rule'])
1926 del data['rule']
1927 if rule_name in rules:
1928 print(f"Warning: Rule '{rule_name}' is already assigned to identity '{identity_name}'",
1929 file=sys.stderr)
1930 sys.exit(2)
1931 rules.append(rule_name)
1932 data['rules'] = rules
1933 _identity_write(file, data)
1934
1935def identity_remove_rule(file, rule_name):
1936 """
1937 Remove a specific rule from an identity's rules array.
1938 Exits 1 if rule not found.
1939 """
1940 data = _identity_read(file)
1941 if not data:
1942 print(f"Error: Identity not found", file=sys.stderr)
1943 sys.exit(1)
1944 rules = data.get('rules', [])
1945 if rule_name not in rules:
1946 print(f"Error: Rule '{rule_name}' not assigned to this identity", file=sys.stderr)
1947 sys.exit(1)
1948 rules.remove(rule_name)
1949 data['rules'] = rules
1950 _identity_write(file, data)
1951
1952def identity_clear_rules(file):
1953 """Remove all rules from an identity."""
1954 data = _identity_read(file)
1955 if not data:
1956 return
1957 data['rules'] = []
1958 data.pop('rule', None) # remove legacy scalar too
1959 _identity_write(file, data)
1960
1961def identity_has_rule(file, rule_name):
1962 """Exit 0 if identity has this rule, 1 otherwise."""
1963 data = _identity_read(file)
1964 if not data:
1965 sys.exit(1)
1966 rules = data.get('rules', [])
1967 if not rules and data.get('rule'):
1968 rules = [data['rule']]
1969 sys.exit(0 if rule_name in rules else 1)
1970
1971def _identity_read(file):
1972 """Read an identity file, return dict or None"""
1973 try:
1974 if not os.path.exists(file):
1975 return None
1976 with open(file) as f:
1977 content = f.read().strip()
1978 if not content:
1979 return None
1980 return json.loads(content)
1981 except Exception:
1982 return None
1983
1984def _identity_write(file, data):
1985 """Write an identity file"""
1986 os.makedirs(os.path.dirname(file), exist_ok=True)
1987 with open(file, 'w') as f:
1988 json.dump(data, f, indent=2)
1989
1990def _identity_init(name):
1991 """Return empty identity structure"""
1992 return {
1993 'name': name,
1994 'peers': [],
1995 'devices': {}
1996 }
1997
1998def _parse_peer_name(peer_name):
1999 """
2000 Parse a peer name into (type, identity, index).
2001 phone-nuno -> ('phone', 'nuno', 1)
2002 phone-nuno-2 -> ('phone', 'nuno', 2)
2003 desktop-zephyr -> ('desktop', 'zephyr', 1)
2004 laptop-nuno -> ('laptop', 'nuno', 1)
2005 Returns None if name doesn't match convention.
2006
2007 Convention: {type}-{identity}[-{index}]
2008 Known types: desktop, laptop, phone, tablet, server, iot, none
2009 """
2010 known_types = {'desktop', 'laptop', 'phone', 'tablet', 'server', 'iot', 'none'}
2011 parts = peer_name.split('-')
2012 if len(parts) < 2:
2013 return None
2014 peer_type = parts[0]
2015 if peer_type not in known_types:
2016 return None
2017 # Check if last part is a numeric index
2018 if len(parts) >= 3 and parts[-1].isdigit():
2019 index = int(parts[-1])
2020 identity = '-'.join(parts[1:-1])
2021 else:
2022 index = 1
2023 identity = '-'.join(parts[1:])
2024 if not identity:
2025 return None
2026 return (peer_type, identity, index)
2027
2028def identity_list(identities_dir):
2029 """
2030 List all identities with peer count, rules and policy.
2031 Output per line: name|peer_count|types|rules|policy
2032 """
2033 import glob
2034 for id_file in sorted(glob.glob(f"{identities_dir}/*.identity")):
2035 try:
2036 with open(id_file) as f:
2037 data = json.load(f)
2038 name = data.get('name', '')
2039 peers = data.get('peers', [])
2040 devices = data.get('devices', {})
2041 rules = data.get('rules', [])
2042 # Migrate legacy scalar rule field
2043 if not rules and data.get('rule'):
2044 rules = [data['rule']]
2045 policy = data.get('policy', 'default')
2046 types = sorted(set(
2047 d.get('type', '') for d in devices.values() if d.get('type')
2048 ))
2049 print(f"{name}|{len(peers)}|{','.join(types)}|{','.join(rules)}|{policy}")
2050 except Exception:
2051 continue
2052
2053def identity_show(file):
2054 """Show identity details"""
2055 data = _identity_read(file)
2056 if not data:
2057 print("Error: Identity not found", file=sys.stderr)
2058 sys.exit(1)
2059 print(f"name|{data.get('name','')}")
2060 print(f"peer_count|{len(data.get('peers',[]))}")
2061 for peer_name, dev in data.get('devices', {}).items():
2062 print(f"device|{peer_name}|{dev.get('type','')}|{dev.get('index',1)}")
2063
2064def identity_add_peer(file, identity_name, peer_name, peer_type, index):
2065 """Add a peer to an identity file, creating it if needed"""
2066 data = _identity_read(file) or _identity_init(identity_name)
2067 if peer_name not in data['peers']:
2068 data['peers'].append(peer_name)
2069 data['devices'][peer_name] = {
2070 'type': peer_type,
2071 'index': int(index)
2072 }
2073 _identity_write(file, data)
2074
2075def identity_remove_peer(file, peer_name):
2076 """Remove a peer from an identity file"""
2077 data = _identity_read(file)
2078 if not data:
2079 return
2080 data['peers'] = [p for p in data['peers'] if p != peer_name]
2081 data['devices'].pop(peer_name, None)
2082 _identity_write(file, data)
2083
2084def identity_remove(file):
2085 """Delete an identity file — existence check done in bash"""
2086 try:
2087 os.remove(file)
2088 except Exception as e:
2089 print(f"Error: {e}", file=sys.stderr)
2090 sys.exit(1)
2091
2092def identity_next_index(file, peer_type):
2093 """
2094 Return the next available index for a given type within an identity.
2095 phone-nuno exists (index 1) -> returns 2
2096 No phones exist -> returns 1
2097 """
2098 data = _identity_read(file)
2099 if not data:
2100 print(1)
2101 return
2102 existing = [
2103 d.get('index', 1)
2104 for d in data.get('devices', {}).values()
2105 if d.get('type') == peer_type
2106 ]
2107 if not existing:
2108 print(1)
2109 return
2110 # Find lowest unused index starting from 1
2111 used = set(existing)
2112 i = 1
2113 while i in used:
2114 i += 1
2115 print(i)
2116
2117def identity_peers(file, filter_type=''):
2118 """
2119 List peers belonging to an identity, optionally filtered by type.
2120 Output: one peer name per line.
2121 """
2122 data = _identity_read(file)
2123 if not data:
2124 return
2125 for peer_name in data.get('peers', []):
2126 if filter_type:
2127 dev = data.get('devices', {}).get(peer_name, {})
2128 if dev.get('type') != filter_type:
2129 continue
2130 print(peer_name)
2131
2132def identity_migrate(identities_dir, clients_dir, meta_dir, dry_run):
2133 """
2134 Scan all peer configs and auto-create identity files from name convention.
2135 dry_run: 'true' -> print what would be done, no writes.
2136 Output per action: action|identity|peer|type|index
2137 """
2138 import glob
2139
2140 is_dry = dry_run == 'true'
2141 grouped = {} # identity_name -> [(peer_name, type, index)]
2142
2143 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
2144 peer_name = os.path.basename(conf).replace('.conf', '')
2145 parsed = _parse_peer_name(peer_name)
2146 if not parsed:
2147 print(f"skip|{peer_name}")
2148 continue
2149 peer_type, identity_name, index = parsed
2150 if identity_name not in grouped:
2151 grouped[identity_name] = []
2152 grouped[identity_name].append((peer_name, peer_type, index))
2153
2154 for identity_name, peers in sorted(grouped.items()):
2155 id_file = os.path.join(identities_dir, f"{identity_name}.identity")
2156 for peer_name, peer_type, index in peers:
2157 print(f"create|{identity_name}|{peer_name}|{peer_type}|{index}")
2158 if not is_dry:
2159 identity_add_peer(id_file, identity_name, peer_name, peer_type, index)
2160
2161def identity_infer(peer_name):
2162 """
2163 Parse a peer name and print identity|type|index, or nothing if no match.
2164 Used by add.command.sh to auto-attach on vanilla wgctl add.
2165 """
2166 parsed = _parse_peer_name(peer_name)
2167 if parsed:
2168 peer_type, identity_name, index = parsed
2169 print(f"{identity_name}|{peer_type}|{index}")
2170
2171def identity_exists(file):
2172 """Exit 0 if identity file exists and is valid, else exit 1"""
2173 data = _identity_read(file)
2174 sys.exit(0 if data is not None else 1)
2175
2176# ============================================
2177# peer_data update — adds type field from meta
2178# ============================================
2179# NOTE: This replaces the existing peer_data function.
2180# The new version reads 'type' from meta directly.
2181# Output format: name|ip|rule|type|last_ts|last_evt|main_group
2182
2183def peer_data(clients_dir, meta_dir, events_log):
2184 """
2185 Updated peer_data that reads 'type' from meta.
2186 Output: name|ip|rule|type|last_ts|last_evt|main_group
2187 """
2188 import glob
2189
2190 meta = {}
2191 for f in glob.glob(f"{meta_dir}/*.meta"):
2192 name = os.path.basename(f).replace('.meta', '')
2193 try:
2194 with open(f) as mf:
2195 meta[name] = json.load(mf)
2196 except Exception:
2197 meta[name] = {}
2198
2199 last_events = {}
2200 try:
2201 with open(events_log) as f:
2202 for line in f:
2203 try:
2204 e = json.loads(line.strip())
2205 client = e.get('client', '')
2206 if client:
2207 last_events[client] = e
2208 except Exception:
2209 pass
2210 except Exception:
2211 pass
2212
2213 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
2214 name = os.path.basename(conf).replace('.conf', '')
2215 ip = ''
2216 try:
2217 with open(conf) as f:
2218 for line in f:
2219 if line.startswith('Address'):
2220 ip = line.split('=')[1].strip().split('/')[0]
2221 break
2222 except Exception:
2223 pass
2224
2225 m = meta.get(name, {})
2226 rule = m.get('rule', '')
2227 peer_type = m.get('type', '')
2228 main_group = m.get('main_group', '')
2229
2230 last_event = last_events.get(name, {})
2231 last_ts = last_event.get('timestamp', '')
2232 last_evt = last_event.get('event', '')
2233
2234 print(f"{name}|{ip}|{rule}|{peer_type}|{last_ts}|{last_evt}|{main_group}")
2235
2236def subnet_default_rule(file, name, type_key=''):
2237 """
2238 Return the default_rule for a subnet entry, or empty string if none set.
2239 For scalar: subnet_default_rule(file, "desktop") -> ""
2240 For group: subnet_default_rule(file, "guests", "phone") -> "guest"
2241 """
2242 data = _subnet_read(file)
2243 if name not in data:
2244 print('')
2245 return
2246 entry = data[name]
2247 if _subnet_is_group(entry):
2248 key = type_key if type_key else 'none'
2249 child = entry.get(key, {})
2250 print(child.get('default_rule', ''))
2251 else:
2252 print(entry.get('default_rule', ''))
2253
2254def subnet_list_names(file):
2255 """
2256 List all top-level subnet names, one per line.
2257 Used for dynamic flag registration in commands.
2258 Output: one name per line (e.g. desktop, laptop, guests, servers, iot)
2259 """
2260 data = _subnet_read(file)
2261 for name in data.keys():
2262 print(name)
2263
2264 # ============================================
2265# Policy System
2266# ============================================
2267
2268_POLICY_DEFAULTS = {
2269 "default": {
2270 "tunnel_mode": "split",
2271 "default_rule": None,
2272 "strict_rule": False,
2273 "auto_apply": True,
2274 "desc": "Default policy"
2275 },
2276 "guest": {
2277 "tunnel_mode": "split",
2278 "default_rule": "guest",
2279 "strict_rule": True,
2280 "auto_apply": True,
2281 "desc": "Guest access policy"
2282 },
2283 "trusted": {
2284 "tunnel_mode": "split",
2285 "default_rule": None,
2286 "strict_rule": False,
2287 "auto_apply": True,
2288 "desc": "Trusted device policy"
2289 },
2290 "server": {
2291 "tunnel_mode": "split",
2292 "default_rule": None,
2293 "strict_rule": False,
2294 "auto_apply": True,
2295 "desc": "Server policy"
2296 },
2297 "iot": {
2298 "tunnel_mode": "split",
2299 "default_rule": None,
2300 "strict_rule": False,
2301 "auto_apply": True,
2302 "desc": "IoT device policy"
2303 }
2304}
2305
2306def _policy_read(file):
2307 """Read policies.json, fall back to hardcoded defaults if missing."""
2308 try:
2309 if not os.path.exists(file):
2310 return dict(_POLICY_DEFAULTS)
2311 with open(file) as f:
2312 content = f.read().strip()
2313 if not content:
2314 return dict(_POLICY_DEFAULTS)
2315 data = json.loads(content)
2316 # Merge with defaults so hardcoded policies always exist
2317 merged = dict(_POLICY_DEFAULTS)
2318 merged.update(data)
2319 return merged
2320 except Exception:
2321 return dict(_POLICY_DEFAULTS)
2322
2323def _policy_write(file, data):
2324 """Write policies.json."""
2325 os.makedirs(os.path.dirname(file), exist_ok=True)
2326 with open(file, 'w') as f:
2327 json.dump(data, f, indent=2)
2328
2329def _policy_get_entry(data, name):
2330 """Get a policy entry, falling back to 'default' policy values for missing fields."""
2331 entry = data.get(name, {})
2332 default = data.get('default', _POLICY_DEFAULTS.get('default', {}))
2333 # Merge: entry fields override default
2334 resolved = dict(default)
2335 resolved.update(entry)
2336 return resolved
2337
2338def policy_get(file, name, field=''):
2339 """
2340 Get a policy entry or a specific field from it.
2341 policy_get(file, "guest") -> prints all fields as key|value lines
2342 policy_get(file, "guest", "tunnel_mode") -> prints "split"
2343 policy_get(file, "guest", "strict_rule") -> prints "true" or "false"
2344 """
2345 data = _policy_read(file)
2346 entry = _policy_get_entry(data, name)
2347
2348 if field:
2349 val = entry.get(field)
2350 if val is None:
2351 print('')
2352 elif isinstance(val, bool):
2353 print('true' if val else 'false')
2354 else:
2355 print(val)
2356 else:
2357 for k, v in entry.items():
2358 if isinstance(v, bool):
2359 print(f"{k}|{'true' if v else 'false'}")
2360 elif v is None:
2361 print(f"{k}|")
2362 else:
2363 print(f"{k}|{v}")
2364
2365def policy_list(file):
2366 """
2367 List all policies.
2368 Output per line: name|tunnel_mode|default_rule|strict_rule|auto_apply|desc
2369 """
2370 data = _policy_read(file)
2371 for name, raw_entry in data.items():
2372 entry = _policy_get_entry(data, name)
2373 tunnel_mode = entry.get('tunnel_mode', 'split')
2374 default_rule = entry.get('default_rule') or ''
2375 strict_rule = 'true' if entry.get('strict_rule', False) else 'false'
2376 auto_apply = 'true' if entry.get('auto_apply', True) else 'false'
2377 desc = entry.get('desc', '')
2378 print(f"{name}|{tunnel_mode}|{default_rule}|{strict_rule}|{auto_apply}|{desc}")
2379
2380def policy_exists(file, name):
2381 """Exit 0 if policy exists, 1 otherwise."""
2382 data = _policy_read(file)
2383 sys.exit(0 if name in data else 1)
2384
2385def policy_add(file, name, tunnel_mode, default_rule, strict_rule, auto_apply, desc):
2386 """Add or update a policy entry."""
2387 data = _policy_read(file)
2388 data[name] = {
2389 'tunnel_mode': tunnel_mode or 'split',
2390 'default_rule': default_rule if default_rule else None,
2391 'strict_rule': strict_rule == 'true',
2392 'auto_apply': auto_apply != 'false',
2393 'desc': desc or ''
2394 }
2395 _policy_write(file, data)
2396
2397def policy_remove(file, name):
2398 """Remove a policy. Refuses to remove hardcoded defaults."""
2399 if name in _POLICY_DEFAULTS:
2400 print(f"Error: Cannot remove built-in policy '{name}'", file=sys.stderr)
2401 sys.exit(1)
2402 data = _policy_read(file)
2403 if name not in data:
2404 print(f"Error: Policy '{name}' not found", file=sys.stderr)
2405 sys.exit(1)
2406 del data[name]
2407 _policy_write(file, data)
2408
2409def policy_set_field(file, name, field, value):
2410 """Set a single field on an existing policy."""
2411 data = _policy_read(file)
2412 if name not in data:
2413 print(f"Error: Policy '{name}' not found", file=sys.stderr)
2414 sys.exit(1)
2415 entry = data[name]
2416 if field in ('strict_rule', 'auto_apply'):
2417 entry[field] = value == 'true'
2418 elif field == 'default_rule':
2419 entry[field] = value if value else None
2420 else:
2421 entry[field] = value
2422 data[name] = entry
2423 _policy_write(file, data)
2424
2425def subnet_policy(subnets_file, subnet_name, type_key=''):
2426 """
2427 Get the policy name for a subnet entry.
2428 Falls back to 'default' if no policy set.
2429 """
2430 data = _subnet_read(subnets_file)
2431 if subnet_name not in data:
2432 print('default')
2433 return
2434 entry = data[subnet_name]
2435 if _subnet_is_group(entry):
2436 key = type_key if type_key else 'none'
2437 child = entry.get(key, {})
2438 print(child.get('policy', 'default'))
2439 else:
2440 print(entry.get('policy', 'default'))
2441
2442def json_get_nested(file, *keys):
2443 """
2444 Get a nested field from a JSON file.
2445 json_get_nested(file, "rule_flags", "strict_rule")
2446 Output: the value as a string, or empty string if not found.
2447 """
2448 try:
2449 with open(file) as f:
2450 data = json.load(f)
2451 val = data
2452 for key in keys:
2453 if not isinstance(val, dict):
2454 print('')
2455 return
2456 val = val.get(key)
2457 if val is None:
2458 print('')
2459 return
2460 if isinstance(val, bool):
2461 print('true' if val else 'false')
2462 elif val is None:
2463 print('')
2464 else:
2465 print(val)
2466 except Exception:
2467 print('')
2468
2469def json_set_nested(file, *args):
2470 """
2471 Set a nested field in a JSON file.
2472 Args: file, key1, key2, ..., value
2473 json_set_nested(file, "rule_flags", "strict_rule", "true")
2474 Creates intermediate dicts as needed.
2475 """
2476 if len(args) < 2:
2477 return
2478 keys = args[:-1]
2479 value = args[-1]
2480
2481 try:
2482 if os.path.exists(file):
2483 with open(file) as f:
2484 data = json.load(f)
2485 else:
2486 data = {}
2487
2488 # Navigate/create nested structure
2489 target = data
2490 for key in keys[:-1]:
2491 if key not in target or not isinstance(target[key], dict):
2492 target[key] = {}
2493 target = target[key]
2494
2495 # Coerce value types
2496 final_key = keys[-1]
2497 if value == 'true':
2498 target[final_key] = True
2499 elif value == 'false':
2500 target[final_key] = False
2501 else:
2502 target[final_key] = value
2503
2504 with open(file, 'w') as f:
2505 json.dump(data, f, indent=2)
2506 except Exception as e:
2507 print(f"Error: {e}", file=sys.stderr)
2508 sys.exit(1)
2509
2510def activity_aggregate(fw_file, wg_file, wg_interface, net_file,
2511 clients_dir, meta_dir, hours, filter_peer, filter_service_ip):
2512 """
2513 Aggregate activity data for wgctl activity.
2514 Output:
2515 peer|name|rx_bytes|tx_bytes|drop_count
2516 service|peer_name|dest_display|drop_count
2517 """
2518 import glob
2519 import subprocess
2520 from datetime import datetime, timezone, timedelta
2521 from collections import defaultdict
2522
2523 hours = int(hours) if hours else 24
2524 cutoff = None
2525 if hours > 0:
2526 cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
2527
2528 # Build ip -> peer name map
2529 ip_to_peer = {}
2530 for conf in sorted(glob.glob(f"{clients_dir}/*.conf")):
2531 name = os.path.basename(conf).replace('.conf', '')
2532 try:
2533 with open(conf) as f:
2534 for line in f:
2535 if line.startswith('Address'):
2536 ip = line.split('=')[1].strip().split('/')[0]
2537 ip_to_peer[ip] = name
2538 break
2539 except Exception:
2540 continue
2541
2542 # Build pubkey -> peer name map
2543 pubkey_to_peer = {}
2544 for kf in glob.glob(f"{clients_dir}/*_public.key"):
2545 name = os.path.basename(kf).replace('_public.key', '')
2546 try:
2547 with open(kf) as f:
2548 key = f.read().strip()
2549 if key:
2550 pubkey_to_peer[key] = name
2551 except Exception:
2552 continue
2553
2554 # Get WireGuard transfer totals
2555 peer_rx = defaultdict(int)
2556 peer_tx = defaultdict(int)
2557 try:
2558 result = subprocess.run(
2559 ['wg', 'show', wg_interface, 'transfer'],
2560 capture_output=True, text=True
2561 )
2562 for line in result.stdout.strip().splitlines():
2563 parts = line.split()
2564 if len(parts) >= 3:
2565 pubkey, rx, tx = parts[0], int(parts[1]), int(parts[2])
2566 peer = pubkey_to_peer.get(pubkey)
2567 if peer:
2568 peer_rx[peer] += rx
2569 peer_tx[peer] += tx
2570 except Exception:
2571 pass
2572
2573 # Load net services for reverse lookup
2574 net_data = {}
2575 if os.path.exists(net_file):
2576 try:
2577 with open(net_file) as f:
2578 net_data = json.load(f)
2579 except Exception:
2580 pass
2581
2582def reverse_lookup(dest_ip, dest_port, proto):
2583 for svc_name, svc in net_data.items():
2584 if not isinstance(svc, dict):
2585 continue
2586 if svc.get('ip', '') != dest_ip:
2587 continue
2588 ports = svc.get('ports', {})
2589 if dest_port:
2590 for port_name, port_def in ports.items():
2591 if not isinstance(port_def, dict):
2592 continue
2593 if (str(port_def.get('port', '')) == str(dest_port) and
2594 port_def.get('proto', 'tcp') == proto):
2595 return f"{svc_name}:{port_name}"
2596 return svc_name
2597 return svc_name
2598 return ''
2599
2600 # Parse and first-pass dedup (within time window per key)
2601 events = []
2602 last_seen = {}
2603
2604 try:
2605 with open(file) as f:
2606 for line in f:
2607 try:
2608 e = json.loads(line.strip())
2609 src = e.get('src_ip', '')
2610 if not src:
2611 continue
2612 if filter_ip and src != filter_ip:
2613 continue
2614
2615 proto_num = int(e.get('ip.protocol', 0))
2616 proto = proto_map.get(proto_num, str(proto_num))
2617 dst = e.get('dest_ip', '')
2618 port = str(e.get('dest_port', ''))
2619 key = (src, dst, port, proto_num)
2620
2621 ts_str = e.get('timestamp', '')
2622 try:
2623 ts = datetime.fromisoformat(ts_str).timestamp()
2624 except Exception:
2625 ts = 0
2626
2627 windows = {1: 5, 6: 30, 17: 10}
2628 window = windows.get(proto_num, 10)
2629 if key in last_seen and (ts - last_seen[key]) < window:
2630 continue
2631 last_seen[key] = ts
2632 events.append(e)
2633 except Exception:
2634 pass
2635 except Exception:
2636 pass
2637
2638 # Second-pass dedup consecutive same events with count
2639 deduped = []
2640 counts = []
2641 for e in events:
2642 ts_str = e.get('timestamp', '')
2643 try:
2644 ts = datetime.fromisoformat(ts_str).timestamp()
2645 except Exception:
2646 ts = 0
2647
2648 src = e.get('src_ip', '')
2649 dst = e.get('dest_ip', '')
2650 port = str(e.get('dest_port', ''))
2651 proto_num = int(e.get('ip.protocol', 0))
2652 key = (src, dst, port, proto_num)
2653
2654 if deduped:
2655 prev = deduped[-1]
2656 try:
2657 prev_ts = datetime.fromisoformat(prev.get('timestamp', '')).timestamp()
2658 except Exception:
2659 prev_ts = 0
2660 prev_key = (
2661 prev.get('src_ip', ''),
2662 prev.get('dest_ip', ''),
2663 str(prev.get('dest_port', '')),
2664 int(prev.get('ip.protocol', 0))
2665 )
2666 if key == prev_key and (ts - prev_ts) < 300:
2667 counts[-1] += 1
2668 continue
2669
2670 deduped.append(e)
2671 counts.append(1)
2672
2673 limit = int(limit) if limit else 50
2674 for e, count in list(zip(deduped, counts))[-limit:]:
2675 ts_str = e.get('timestamp', '')
2676 src = e.get('src_ip', '')
2677 dst = e.get('dest_ip', '')
2678 port = str(e.get('dest_port', ''))
2679 proto_num = int(e.get('ip.protocol', 0))
2680 proto = proto_map.get(proto_num, str(proto_num))
2681 client = ip_to_name.get(src, src)
2682 svc_name = reverse_lookup(dst, port, proto)
2683
2684 try:
2685 dt = datetime.fromisoformat(ts_str)
2686 ts_fmt = dt.strftime(DATETIME_FMT)
2687 except Exception:
2688 ts_fmt = ts_str
2689
2690 print(f"{ts_fmt}|{client}|{dst}|{port}|{proto}|{svc_name}|{count}")
2691
2692 def make_dest_display(dest_ip, dest_port, proto, svc_name):
2693 if svc_name:
2694 return svc_name
2695 if dest_port:
2696 display = f"{dest_ip}:{dest_port}"
2697 else:
2698 display = dest_ip
2699 if proto and proto not in ('tcp', '6'):
2700 display += f" ({proto})"
2701 return display
2702
2703 proto_map = {1: 'icmp', 6: 'tcp', 17: 'udp'}
2704
2705 # Parse fw_events for drops
2706 peer_drops = defaultdict(int)
2707 service_drops = defaultdict(lambda: defaultdict(int))
2708
2709 if os.path.exists(fw_file):
2710 try:
2711 with open(fw_file) as f:
2712 for line in f:
2713 line = line.strip()
2714 if not line:
2715 continue
2716 try:
2717 ev = json.loads(line)
2718 if cutoff:
2719 ts_str = ev.get('timestamp', '')
2720 try:
2721 ts = datetime.fromisoformat(ts_str)
2722 if ts.tzinfo is None:
2723 ts = ts.replace(tzinfo=timezone.utc)
2724 if ts < cutoff:
2725 continue
2726 except Exception:
2727 pass
2728
2729 src_ip = ev.get('src_ip', '')
2730 if not src_ip:
2731 continue
2732
2733 dest_ip = ev.get('dest_ip', '')
2734 dest_port = str(ev.get('dest_port', ''))
2735 proto_num = ev.get('ip.protocol', 0)
2736 proto = proto_map.get(int(proto_num), str(proto_num))
2737
2738 peer = ip_to_peer.get(src_ip)
2739 if not peer:
2740 continue
2741
2742 if filter_peer and peer != filter_peer:
2743 continue
2744 if filter_service_ip and dest_ip != filter_service_ip:
2745 continue
2746
2747 svc_name = reverse_lookup(dest_ip, dest_port, proto)
2748 dest_display = make_dest_display(dest_ip, dest_port, proto, svc_name)
2749
2750 peer_drops[peer] += 1
2751 service_drops[peer][dest_display] += 1
2752
2753 except Exception:
2754 continue
2755 except Exception:
2756 pass
2757
2758 # Collect peers with any activity
2759 all_peers = set()
2760 all_peers.update(k for k in peer_rx if peer_rx[k] > 0)
2761 all_peers.update(k for k in peer_tx if peer_tx[k] > 0)
2762 all_peers.update(peer_drops.keys())
2763 if filter_peer:
2764 all_peers = {p for p in all_peers if p == filter_peer}
2765
2766 for peer in sorted(all_peers):
2767 rx = peer_rx.get(peer, 0)
2768 tx = peer_tx.get(peer, 0)
2769 drops = peer_drops.get(peer, 0)
2770
2771 print(f"peer|{peer}|{rx}|{tx}|{drops}")
2772
2773 svc_map = service_drops.get(peer, {})
2774 for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]):
2775 print(f"service|{peer}|{dest_display}|{count}")
2776
2777def _hosts_read(file):
2778 if not os.path.exists(file):
2779 return {"hosts": {}, "subnets": {}, "ports": {}}
2780 try:
2781 with open(file) as f:
2782 data = json.load(f)
2783 # Ensure all sections exist
2784 data.setdefault("hosts", {})
2785 data.setdefault("subnets", {})
2786 data.setdefault("ports", {})
2787 return data
2788 except Exception:
2789 return {"hosts": {}, "subnets": {}, "ports": {}}
2790
2791def _hosts_write(file, data):
2792 with open(file, 'w') as f:
2793 json.dump(data, f, indent=2)
2794
2795def hosts_list(file):
2796 """
2797 List all host entries.
2798 Output per line: type|key|name|desc|tags
2799 """
2800 data = _hosts_read(file)
2801 for ip, entry in sorted(data["hosts"].items()):
2802 if isinstance(entry, dict):
2803 name = entry.get("name", "")
2804 desc = entry.get("desc", "")
2805 tags = ",".join(entry.get("tags", []))
2806 else:
2807 name = str(entry)
2808 desc = ""
2809 tags = ""
2810 print(f"host|{ip}|{name}|{desc}|{tags}")
2811 for subnet, entry in sorted(data["subnets"].items()):
2812 if isinstance(entry, dict):
2813 name = entry.get("name", "")
2814 desc = entry.get("desc", "")
2815 tags = ",".join(entry.get("tags", []))
2816 else:
2817 name = str(entry)
2818 desc = ""
2819 tags = ""
2820 print(f"subnet|{subnet}|{name}|{desc}|{tags}")
2821 for port, entry in sorted(data["ports"].items()):
2822 if isinstance(entry, dict):
2823 name = entry.get("name", "")
2824 desc = entry.get("desc", "")
2825 tags = ",".join(entry.get("tags", []))
2826 else:
2827 name = str(entry)
2828 desc = ""
2829 tags = ""
2830 print(f"port|{port}|{name}|{desc}|{tags}")
2831
2832def hosts_show(file, key, entry_type):
2833 """Show a single host entry. type: host|subnet|port"""
2834 data = _hosts_read(file)
2835 section_map = {"host": "hosts", "subnet": "subnets", "port": "ports"}
2836 section = section_map.get(entry_type, "hosts")
2837 entry = data[section].get(key)
2838 if not entry:
2839 print(f"Error: not found: {key}", file=sys.stderr)
2840 sys.exit(1)
2841 if isinstance(entry, dict):
2842 print(f"name|{entry.get('name', '')}")
2843 print(f"desc|{entry.get('desc', '')}")
2844 print(f"tags|{','.join(entry.get('tags', []))}")
2845 else:
2846 print(f"name|{entry}")
2847 print(f"desc|")
2848 print(f"tags|")
2849
2850def hosts_add(file, entry_type, key, name, desc, tags):
2851 """
2852 Add a host entry.
2853 entry_type: host|subnet|port
2854 key: IP, subnet CIDR, or port number
2855 """
2856 data = _hosts_read(file)
2857 section_map = {"host": "hosts", "subnet": "subnets", "port": "ports"}
2858 section = section_map.get(entry_type, "hosts")
2859 tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
2860 data[section][key] = {
2861 "name": name,
2862 "desc": desc,
2863 "tags": tag_list
2864 }
2865 _hosts_write(file, data)
2866
2867def hosts_remove(file, entry_type, key):
2868 """Remove a host entry."""
2869 data = _hosts_read(file)
2870 section_map = {"host": "hosts", "subnet": "subnets", "port": "ports"}
2871 section = section_map.get(entry_type, "hosts")
2872 if key not in data[section]:
2873 print(f"Error: not found: {key}", file=sys.stderr)
2874 sys.exit(1)
2875 del data[section][key]
2876 _hosts_write(file, data)
2877
2878def hosts_exists(file, entry_type, key):
2879 """Check if a host entry exists."""
2880 data = _hosts_read(file)
2881 section_map = {"host": "hosts", "subnet": "subnets", "port": "ports"}
2882 section = section_map.get(entry_type, "hosts")
2883 print("true" if key in data[section] else "false")
2884
2885def hosts_lookup(file, ip):
2886 """
2887 Resolve an IP to a display name.
2888 Checks hosts section only (exact match).
2889 Returns name or empty string.
2890 """
2891 data = _hosts_read(file)
2892 entry = data["hosts"].get(ip)
2893 if not entry:
2894 print("")
2895 return
2896 if isinstance(entry, dict):
2897 print(entry.get("name", ip))
2898 else:
2899 print(str(entry))
2900
2901commands = {
2902 'get': lambda args: get(args[0], args[1]),
2903 'set': lambda args: set_key(args[0], args[1], args[2]),
2904 'delete': lambda args: delete_key(args[0], args[1]),
2905 'append': lambda args: append(args[0], args[1], args[2]),
2906 'remove': lambda args: remove_value(args[0], args[1], args[2]),
2907 'cat': lambda args: cat(args[0]),
2908 'has_key': lambda args: has_key(args[0], args[1]),
2909 'filter_values': lambda args: filter_values(args[0], args[1], args[2]),
2910 'last_event': lambda args: last_event(args[0], args[1], args[2], args[3]),
2911 'events_for': lambda args: events_for(args[0], args[1], args[2]),
2912 'fw_events': lambda args: fw_events(
2913 args[0], args[1], args[2], args[3], args[4],
2914 args[5] if len(args) > 5 else '50',
2915 args[6] if len(args) > 6 else '1'),
2916 'wg_events': lambda args: wg_events(
2917 args[0], args[1], args[2],
2918 args[3] if len(args) > 3 else '50',
2919 args[4] if len(args) > 4 else '1'),
2920 'format_fw_event': lambda args: format_fw_event(sys.stdin.read(), args[0]),
2921 'format_wg_event': lambda args: format_wg_event(sys.stdin.read()),
2922 'remove_events': lambda args: remove_events(args[0], args[1]),
2923 'follow_logs': lambda args: follow_logs(args[0], args[1], args[2], args[3], args[4], args[5]),
2924 'count': lambda args: count(args[0], args[1]),
2925 'audit_fw_counts': lambda args: audit_fw_counts(args[0]),
2926 'peer_group_map': lambda args: peer_group_map(args[0]),
2927 'peer_groups': lambda args: peer_groups(args[0], args[1]),
2928 'peer_data': lambda args: peer_data(args[0], args[1], args[2]),
2929 'iso_to_ts': lambda args: iso_to_ts(args[0]),
2930 'rule_list_data': lambda args: rule_list_data(args[0], args[1]),
2931 'group_list_data': lambda args: group_list_data(args[0], args[1], args[2]),
2932 'fmt_datetime': lambda args: fmt_datetime(args[0], args[1]),
2933 'create_rule': lambda args: create_rule(
2934 args[0], args[1], args[2], args[3], args[4], args[5], args[6],
2935 args[7] if len(args) > 7 else '',
2936 args[8] if len(args) > 8 else '',
2937 args[9] if len(args) > 9 else ''
2938 ),
2939 'cleanup_config': lambda args: cleanup_config(args[0]),
2940 'remove_peer_block': lambda args: remove_peer_block(args[0], args[1]),
2941 'create_group': lambda args: create_group(args[0], args[1], args[2]),
2942 'parse_event': lambda args: parse_event(args[0]),
2943 'parse_fw_event': lambda args: parse_fw_event(args[0]),
2944 'remove_events_filtered': lambda args: remove_events_filtered(
2945 args[0], args[1], args[2], args[3], args[4]=='true', args[5]=='true', args[6] if len(args)>6 else ''),
2946 'peer_transfer': lambda args: peer_transfer(args[0]),
2947 'peer_transfer_delta': lambda args: peer_transfer_delta(args[0], args[1]),
2948 'rule_resolve': lambda args: rule_resolve(args[0], args[1]),
2949 'rule_resolve_field': lambda args: rule_resolve_field(args[0], args[1], args[2]),
2950 'rule_inspect': lambda args: rule_inspect(args[0], args[1]),
2951 'find_rule_file': lambda args: print(find_rule_file(args[0], args[1])),
2952 'get_raw': lambda args: print(get_raw(args[0], args[1])),
2953 'count_resolved': lambda args: count_resolved(args[0], args[1], args[2]),
2954 'block_get': lambda args: block_get(args[0]),
2955 'block_is_blocked': lambda args: block_is_blocked(args[0]),
2956 'block_set_direct': lambda args: block_set_direct(args[0], args[1], args[2]),
2957 'block_add_group': lambda args: block_add_group(args[0], args[1], args[2]),
2958 'block_remove_group': lambda args: block_remove_group(args[0], args[1], args[2]),
2959 'block_add_rule': lambda args: block_add_rule(
2960 args[0], args[1], args[2],
2961 args[3] if len(args) > 3 else '',
2962 args[4] if len(args) > 4 else '',
2963 args[5] if len(args) > 5 else '',
2964 args[6] if len(args) > 6 else ''
2965 ),
2966 'block_remove_rule': lambda args: block_remove_rule(
2967 args[0], args[1],
2968 args[2] if len(args) > 2 else '',
2969 args[3] if len(args) > 3 else '',
2970 args[4] if len(args) > 4 else ''
2971 ),
2972 'block_get_rules': lambda args: block_get_rules(args[0]),
2973 'block_get_groups': lambda args: block_get_groups(args[0]),
2974 'block_get_direct': lambda args: block_get_direct(args[0]),
2975 'net_list': lambda args: net_list(args[0]),
2976 'net_show': lambda args: net_show(args[0], args[1]),
2977 'net_exists': lambda args: net_exists(args[0], args[1]),
2978 'net_add_service': lambda args: net_add_service(
2979 args[0], args[1], args[2],
2980 args[3] if len(args) > 3 else '',
2981 args[4] if len(args) > 4 else ''
2982 ),
2983 'net_add_port': lambda args: net_add_port(
2984 args[0], args[1], args[2], args[3],
2985 args[4] if len(args) > 4 else 'tcp',
2986 args[5] if len(args) > 5 else ''
2987 ),
2988 'net_remove': lambda args: net_remove(args[0], args[1]),
2989 'net_resolve': lambda args: net_resolve(args[0], args[1]),
2990 'net_reverse_lookup': lambda args: net_reverse_lookup(
2991 args[0], args[1],
2992 args[2] if len(args) > 2 else '',
2993 args[3] if len(args) > 3 else ''
2994 ),
2995 'block_is_empty': lambda args: block_is_empty(args[0]),
2996 'group_has_peer': lambda args: group_has_peer(args[0], args[1]),
2997
2998 # Subnet commands:
2999 'subnet_lookup': lambda args: subnet_lookup(args[0], args[1], args[2] if len(args) > 2 else ''),
3000 'subnet_type': lambda args: subnet_type(args[0], args[1], args[2] if len(args) > 2 else ''),
3001 'subnet_tunnel_mode': lambda args: subnet_tunnel_mode(args[0], args[1], args[2] if len(args) > 2 else ''),
3002 'subnet_for_ip': lambda args: subnet_for_ip(args[0], args[1]),
3003 'subnet_list': lambda args: subnet_list(args[0]),
3004 'subnet_show': lambda args: subnet_show(args[0], args[1]),
3005 'subnet_add': lambda args: subnet_add(
3006 args[0], args[1], args[2], args[3],
3007 args[4] if len(args) > 4 else 'split',
3008 args[5] if len(args) > 5 else '',
3009 args[6] if len(args) > 6 else ''
3010 ),
3011 'subnet_remove': lambda args: subnet_remove(args[0], args[1], args[2] if len(args) > 2 else ''),
3012 'subnet_rename': lambda args: subnet_rename(args[0], args[1], args[2], args[3] if len(args) > 3 else ''),
3013 'subnet_peers': lambda args: subnet_peers(args[0], args[1], args[2], args[3]),
3014 'subnet_exists': lambda args: subnet_exists(args[0], args[1]),
3015
3016 # Identity commands:
3017 'identity_list': lambda args: identity_list(args[0]),
3018 'identity_show': lambda args: identity_show(args[0]),
3019 'identity_add_peer': lambda args: identity_add_peer(args[0], args[1], args[2], args[3], args[4]),
3020 'identity_remove_peer':lambda args: identity_remove_peer(args[0], args[1]),
3021 'identity_remove': lambda args: identity_remove(args[0]),
3022 'identity_next_index': lambda args: identity_next_index(args[0], args[1]),
3023 'identity_peers': lambda args: identity_peers(args[0], args[1] if len(args) > 1 else ''),
3024 'identity_migrate': lambda args: identity_migrate(args[0], args[1], args[2], args[3]),
3025 'identity_infer': lambda args: identity_infer(args[0]),
3026 'identity_exists': lambda args: identity_exists(args[0]),
3027 'subnet_default_rule': lambda args: subnet_default_rule(args[0], args[1], args[2] if len(args) > 2 else ''),
3028 'subnet_list_names': lambda args: subnet_list_names(args[0]),
3029
3030 # Policy commands:
3031 'policy_get': lambda args: policy_get(args[0], args[1], args[2] if len(args) > 2 else ''),
3032 'policy_list': lambda args: policy_list(args[0]),
3033 'policy_exists': lambda args: policy_exists(args[0], args[1]),
3034 'policy_add': lambda args: policy_add(args[0], args[1], args[2], args[3], args[4], args[5], args[6] if len(args) > 6 else ''),
3035 'policy_remove': lambda args: policy_remove(args[0], args[1]),
3036 'policy_set_field': lambda args: policy_set_field(args[0], args[1], args[2], args[3]),
3037 'subnet_policy': lambda args: subnet_policy(args[0], args[1], args[2] if len(args) > 2 else ''),
3038 'get_nested': lambda args: json_get_nested(args[0], *args[1:]),
3039 'set_nested': lambda args: json_set_nested(args[0], *args[1:]),
3040 'identity_rules': lambda args: identity_rules(args[0]),
3041 'identity_add_rule': lambda args: identity_add_rule(args[0], args[1], args[2]),
3042 'identity_remove_rule': lambda args: identity_remove_rule(args[0], args[1]),
3043 'identity_clear_rules': lambda args: identity_clear_rules(args[0]),
3044 'identity_has_rule': lambda args: identity_has_rule(args[0], args[1]),
3045 'activity_aggregate': lambda args: activity_aggregate(
3046 args[0], args[1], args[2], args[3], args[4], args[5],
3047 args[6] if len(args) > 6 else '24',
3048 args[7] if len(args) > 7 else '',
3049 args[8] if len(args) > 8 else ''
3050 ),
3051 'hosts_list': lambda args: hosts_list(args[0]),
3052 'hosts_show': lambda args: hosts_show(args[0], args[1], args[2]),
3053 'hosts_add': lambda args: hosts_add(args[0], args[1], args[2], args[3],
3054 args[4] if len(args) > 4 else '',
3055 args[5] if len(args) > 5 else ''),
3056 'hosts_remove': lambda args: hosts_remove(args[0], args[1], args[2]),
3057 'hosts_exists': lambda args: hosts_exists(args[0], args[1], args[2]),
3058 'hosts_lookup': lambda args: hosts_lookup(args[0], args[1]),
3059}
3060
3061if __name__ == '__main__':
3062 if len(sys.argv) < 2:
3063 print("Usage: json_helper.py <command> <file> [key] [value]", file=sys.stderr)
3064 sys.exit(1)
3065
3066 cmd = sys.argv[1]
3067 args = sys.argv[2:]
3068
3069 if cmd not in commands:
3070 print(f"Unknown command: {cmd}", file=sys.stderr)
3071 sys.exit(1)
3072
3073 commands[cmd](args)