#!/usr/bin/env bash # incus-mitm -- Capture and analyze Incus API traffic via event stream # # Usage: incus-mitm [ACTION] [OPTIONS] # # Actions: --capture, --analyze, --live, --callers # Requires: incus CLI with cluster remote configured, python3 # # The primary capture method uses `incus monitor` which captures all # API operations without requiring TLS interception. set -euo pipefail # --------------------------------------------------------------------------- # Colors # --------------------------------------------------------------------------- setup_colors() { if [[ "${NO_COLOR:-}" == "1" || "${TERM:-}" == "dumb" ]]; then BOLD="" DIM="" RESET="" CYAN="" GREEN="" YELLOW="" RED="" BLUE="" else BOLD=$'\033[1m' DIM=$'\033[2m' RESET=$'\033[0m' CYAN=$'\033[36m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' BLUE=$'\033[34m' fi } setup_colors # --------------------------------------------------------------------------- # Usage # --------------------------------------------------------------------------- usage() { cat <<'EOF' Usage: incus-mitm [ACTION] [OPTIONS] Capture and analyze Incus REST API traffic using the event stream. No TLS interception needed — uses `incus monitor` to observe all API calls, operations, and lifecycle events in real time. Actions: --capture [SECONDS] Capture events for N seconds (default: 60) --live Stream events in real time (Ctrl+C to stop) --analyze FILE Analyze a previously captured JSON file --callers FILE Show unique API callers by certificate Options: --remote NAME Incus remote name (default: oc-node-01) --output FILE Output file for capture (default: /tmp/incus-events-.json) --filter CALLER Only show events from specific caller (aether, cli, node) --dry-run Show commands that would be executed --help, -h Show this help Examples: incus-mitm --capture 120 # Capture 2 minutes of events incus-mitm --capture 30 --output events.json incus-mitm --live # Stream in real time incus-mitm --analyze /tmp/incus-events.json incus-mitm --callers /tmp/incus-events.json incus-mitm --analyze events.json --filter aether Certificate Fingerprint Reference: Capture events first, then use --callers to identify who is who. Common patterns: CN=maarten@* → CLI client CN=root@oc-* → Aether SDN controller CN=root@oc-node-* → Cluster inter-node traffic EOF exit 0 } # --------------------------------------------------------------------------- # Defaults and argument parsing # --------------------------------------------------------------------------- ACTION="" REMOTE="oc-node-01" OUTPUT="" CAPTURE_SECONDS=60 FILTER="" DRY_RUN=false ANALYZE_FILE="" while [[ $# -gt 0 ]]; do case "$1" in --capture) ACTION="capture" if [[ "${2:-}" =~ ^[0-9]+$ ]]; then CAPTURE_SECONDS="$2" shift fi shift ;; --live) ACTION="live"; shift ;; --analyze) ACTION="analyze" ANALYZE_FILE="${2:?--analyze requires a file}" shift 2 ;; --callers) ACTION="callers" ANALYZE_FILE="${2:?--callers requires a file}" shift 2 ;; --remote) REMOTE="${2:?--remote requires a name}"; shift 2 ;; --output) OUTPUT="${2:?--output requires a path}"; shift 2 ;; --filter) FILTER="${2:?--filter requires a caller type}"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; -h|--help) usage ;; *) echo "Error: unknown option: $1" >&2 echo "Run 'incus-mitm --help' for usage." >&2 exit 1 ;; esac done if [[ -z "$ACTION" ]]; then usage fi # --------------------------------------------------------------------------- # Capture # --------------------------------------------------------------------------- do_capture() { local output="${OUTPUT:-/tmp/incus-events-$(date +%Y%m%d-%H%M%S).json}" if [[ "$DRY_RUN" == true ]]; then echo "${DIM}[dry-run] timeout ${CAPTURE_SECONDS} incus monitor ${REMOTE}: --format=json > ${output}${RESET}" return 0 fi echo "${BOLD}Capturing Incus events for ${CAPTURE_SECONDS}s...${RESET}" echo "${DIM}Output: ${output}${RESET}" echo "${DIM}Trigger operations in Aether or via CLI to generate events.${RESET}" echo timeout "$CAPTURE_SECONDS" incus monitor "${REMOTE}:" --format=json > "$output" 2>&1 || true local count count=$(wc -l < "$output") echo echo "${GREEN}Captured ${count} events → ${output}${RESET}" echo "Run: ${BOLD}incus-mitm --analyze ${output}${RESET}" } # --------------------------------------------------------------------------- # Live stream # --------------------------------------------------------------------------- do_live() { if [[ "$DRY_RUN" == true ]]; then echo "${DIM}[dry-run] incus monitor ${REMOTE}: --format=json | python3 (live parser)${RESET}" return 0 fi echo "${BOLD}Live event stream (Ctrl+C to stop)${RESET}" echo incus monitor "${REMOTE}:" --format=json 2>&1 | python3 -u -c " import sys, json # Known fingerprint prefixes (populated during session) known_certs = {} for line in sys.stdin: line = line.strip() if not line: continue try: e = json.loads(line) except: continue ts = e.get('timestamp', '')[11:19] etype = e.get('type', '') meta = e.get('metadata', {}) # Track cert fingerprints if etype == 'logging' and 'Matched trusted cert' in meta.get('message', ''): ctx = meta.get('context', {}) fp = ctx.get('fingerprint', '') subj = ctx.get('subject', '') if fp and subj: known_certs[fp[:20]] = subj # Show API calls if etype == 'logging' and 'method' in meta.get('context', {}): ctx = meta['context'] fp = ctx.get('username', '')[:20] caller = known_certs.get(fp, fp[:12] + '...') if 'maarten' in caller: caller = 'CLI' elif 'oc-server' in caller: caller = 'Aether' elif 'oc-node' in caller: caller = caller.split('root@')[1].split(',')[0] if 'root@' in caller else 'node' filter_val = '${FILTER}'.lower() if filter_val: if filter_val == 'aether' and 'Aether' not in caller: continue elif filter_val == 'cli' and 'CLI' not in caller: continue elif filter_val == 'node' and 'oc-node' not in caller: continue print(f' \033[2m{ts}\033[0m {ctx[\"method\"]:6s} {ctx[\"url\"]:70s} \033[36m[{caller}]\033[0m', flush=True) # Show lifecycle events elif etype == 'lifecycle': action = meta.get('action', '') source = (meta.get('source') or '').split('/')[-1] print(f' \033[2m{ts}\033[0m \033[33m{action:30s}\033[0m {source}', flush=True) # Show operation status changes (only success/failure) elif etype == 'operation': status = meta.get('status', '') if status in ('Success', 'Failure'): desc = meta.get('description', '') resources = [] for k, v in (meta.get('resources') or {}).items(): for item in v: resources.append(item.split('/')[-1]) res = ', '.join(resources) color = '\033[32m' if status == 'Success' else '\033[31m' print(f' \033[2m{ts}\033[0m {color}[{status:7s}]\033[0m {desc:30s} {res}', flush=True) " } # --------------------------------------------------------------------------- # Analyze # --------------------------------------------------------------------------- do_analyze() { if [[ ! -f "$ANALYZE_FILE" ]]; then echo "Error: file not found: $ANALYZE_FILE" >&2 exit 1 fi python3 -c " import json, sys from collections import defaultdict events = [] with open('${ANALYZE_FILE}') as f: for line in f: line = line.strip() if not line: continue try: events.append(json.loads(line)) except: pass # Build cert map cert_map = {} for e in events: if e.get('type') == 'logging' and 'Matched trusted cert' in e.get('metadata', {}).get('message', ''): ctx = e['metadata']['context'] fp = ctx.get('fingerprint', '') subj = ctx.get('subject', '') if fp and subj: cert_map[fp[:20]] = subj def caller_name(fp): subj = cert_map.get(fp[:20], '') if 'maarten' in subj: return 'CLI' if 'oc-server' in subj: return 'Aether' if 'oc-node' in subj: return subj.split('root@')[1].split(',')[0] if 'root@' in subj else 'node' return fp[:12] + '...' filter_val = '${FILTER}'.lower() print('\033[1m\033[36m=== API Calls ===\033[0m') api_calls = [] for e in events: if e.get('type') == 'logging' and 'method' in e.get('metadata', {}).get('context', {}): ctx = e['metadata']['context'] caller = caller_name(ctx.get('username', '')) if filter_val: if filter_val == 'aether' and caller != 'Aether': continue elif filter_val == 'cli' and caller != 'CLI': continue ts = e['timestamp'][11:19] api_calls.append((ts, ctx['method'], ctx['url'], caller)) print(f\" {ts} {ctx['method']:6s} {ctx['url']:70s} [{caller}]\") print() print('\033[1m\033[36m=== Operations ===\033[0m') seen = set() for e in events: if e.get('type') == 'operation': meta = e.get('metadata', {}) key = f\"{meta.get('id')}_{meta.get('status')}\" if key in seen: continue seen.add(key) status = meta.get('status', '') if status not in ('Success', 'Failure', 'Cancelled'): continue resources = [] for k, v in (meta.get('resources') or {}).items(): for item in v: resources.append(item.split('/')[-1]) ts = e['timestamp'][11:19] color = '\033[32m' if status == 'Success' else '\033[31m' print(f\" {ts} {color}[{status:7s}]\033[0m {meta.get('description'):30s} {', '.join(resources)}\") print() print('\033[1m\033[36m=== Lifecycle Events ===\033[0m') for e in events: if e.get('type') == 'lifecycle': meta = e.get('metadata', {}) ts = e['timestamp'][11:19] source = (meta.get('source') or '').split('/')[-1] print(f\" {ts} {meta.get('action'):30s} {source}\") print() print('\033[1m\033[36m=== Summary ===\033[0m') print(f' Total events: {len(events)}') print(f' API calls: {len(api_calls)}') callers = defaultdict(int) methods = defaultdict(int) for _, method, url, caller in api_calls: callers[caller] += 1 methods[method] += 1 print(f' By caller:') for c, n in sorted(callers.items(), key=lambda x: -x[1]): print(f' {c}: {n}') print(f' By method:') for m, n in sorted(methods.items(), key=lambda x: -x[1]): print(f' {m}: {n}') " } # --------------------------------------------------------------------------- # Callers # --------------------------------------------------------------------------- do_callers() { if [[ ! -f "$ANALYZE_FILE" ]]; then echo "Error: file not found: $ANALYZE_FILE" >&2 exit 1 fi python3 -c " import json from collections import defaultdict events = [] with open('${ANALYZE_FILE}') as f: for line in f: line = line.strip() if not line: continue try: events.append(json.loads(line)) except: pass cert_map = {} call_counts = defaultdict(int) for e in events: if e.get('type') == 'logging': meta = e.get('metadata', {}) ctx = meta.get('context', {}) if 'Matched trusted cert' in meta.get('message', ''): fp = ctx.get('fingerprint', '') subj = ctx.get('subject', '') if fp and subj: cert_map[fp] = subj if 'method' in ctx: call_counts[ctx.get('username', '')] += 1 print('\033[1m\033[36m=== Certificate Fingerprints ===\033[0m') for fp, subj in sorted(cert_map.items(), key=lambda x: x[1]): calls = call_counts.get(fp, 0) if 'maarten' in subj: role = 'CLI client' elif 'oc-server' in subj: role = 'Aether SDN controller' elif 'oc-node' in subj: role = 'Cluster node' else: role = 'Unknown' print(f' {fp[:40]}...') print(f' Subject: {subj}') print(f' Role: {role}') print(f' Calls: {calls}') print() " } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- case "$ACTION" in capture) do_capture ;; live) do_live ;; analyze) do_analyze ;; callers) do_callers ;; esac