398 lines
13 KiB
Bash
Executable File
398 lines
13 KiB
Bash
Executable File
#!/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-<timestamp>.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
|