最後活躍 1 month ago

nuno 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 2189 insertions

gistfile1.txt(檔案已創建)

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