最終更新 1 month ago

nuno revised this gist 1 month ago. Go to revision

1 file changed, 3073 insertions

gistfile1.txt(file created)

@@ -0,0 +1,3073 @@
1 + #!/usr/bin/env python3
2 + """
3 + wgctl JSON helper — called by shell functions to read/write JSON files.
4 + Usage: json_helper.py <command> <file> [key] [value]
5 + """
6 +
7 + import sys
8 + import json
9 + import os
10 +
11 + DATETIME_FMT = os.environ.get('WGCTL_DATETIME_FMT', '%Y-%m-%d %H:%M')
12 +
13 + def 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 +
29 + def 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 +
46 + def 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 +
57 + def 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 +
73 + def 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 +
85 + def 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 +
93 + def 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 +
101 + def 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 +
113 + def 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 +
130 + def 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 +
157 + def 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 +
332 + def 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 +
486 + def 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 +
526 + def 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 +
546 + def 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 +
565 + def 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 +
661 + def 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 +
670 + def 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 +
705 + def 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 +
722 + def 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 +
737 + def 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 +
748 + def 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 +
806 + def 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 +
832 + def 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 +
841 + def 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 +
857 + def 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 +
871 + def 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 +
885 + def 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 +
895 + def 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 +
903 + def 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 +
918 + def 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 +
945 + def 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 +
1002 + def 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 +
1068 + def _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 +
1115 + def 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 +
1124 + def 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 +
1138 + def 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 +
1198 + def 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 +
1208 + def 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 +
1225 + def 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 +
1233 + def _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 +
1242 + def _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 +
1268 + def _block_write(file, data):
1269 + """Write block file"""
1270 + with open(file, 'w') as f:
1271 + json.dump(data, f, indent=2)
1272 +
1273 + def 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 +
1279 + def 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 +
1289 + def 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 +
1303 + def 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 +
1317 + def 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 +
1335 + def 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 +
1362 + def 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 +
1376 + def 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 +
1385 + def block_get_groups(file):
1386 + data = _block_read(file)
1387 + if not data:
1388 + return
1389 + print(','.join(data.get('blocked_by_groups', [])))
1390 +
1391 + def 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 +
1402 + def _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 +
1415 + def _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 +
1421 + def 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 +
1431 + def 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 +
1448 + def 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 +
1462 + def 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 +
1475 + def 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 +
1489 + def 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 +
1512 + def 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 +
1538 + def 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 +
1556 + def 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 +
1569 + def 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 +
1582 + def _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 +
1595 + def _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 +
1601 + def _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 +
1605 + def 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 +
1625 + def 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 +
1645 + def 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 +
1659 + def 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 +
1692 + def 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 +
1711 + def 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 +
1732 + def 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 +
1758 + def 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 +
1789 + def 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 +
1809 + def 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 +
1883 + def 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 +
1898 + def 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 +
1914 + def 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 +
1935 + def 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 +
1952 + def 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 +
1961 + def 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 +
1971 + def _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 +
1984 + def _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 +
1990 + def _identity_init(name):
1991 + """Return empty identity structure"""
1992 + return {
1993 + 'name': name,
1994 + 'peers': [],
1995 + 'devices': {}
1996 + }
1997 +
1998 + def _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 +
2028 + def 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 +
2053 + def 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 +
2064 + def 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 +
2075 + def 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 +
2084 + def 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 +
2092 + def 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 +
2117 + def 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 +
2132 + def 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 +
2161 + def 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 +
2171 + def 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 +
2183 + def 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 +
2236 + def 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 +
2254 + def 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 +
2306 + def _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 +
2323 + def _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 +
2329 + def _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 +
2338 + def 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 +
2365 + def 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 +
2380 + def 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 +
2385 + def 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 +
2397 + def 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 +
2409 + def 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 +
2425 + def 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 +
2442 + def 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 +
2469 + def 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 +
2510 + def 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 +
2582 + def 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 +
2777 + def _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 +
2791 + def _hosts_write(file, data):
2792 + with open(file, 'w') as f:
2793 + json.dump(data, f, indent=2)
2794 +
2795 + def 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 +
2832 + def 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 +
2850 + def 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 +
2867 + def 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 +
2878 + def 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 +
2885 + def 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 +
2901 + commands = {
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 +
3061 + if __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)
Newer Older