Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions kartograf/rpki_diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
RPKI Cache Diff Tool for ASmap collaborative launches.

Compares RPKI data between participants at two levels:
1. Cache-level: compare CCR hashes generated by rpki-client
2. ROA-level: compare rpki_raw.json to find which ROAs differ
and show the actual prefix-level impact

Usage:
# Compare two participants' cache directories using CCR hashes
python3 rpki_diff.py compare <cache_a> <cache_b> --epoch 1775145600

# Compare rpki_raw.json files
python3 rpki_diff.py compare-roas <json_a> <json_b> --names alice bob
"""

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path


# --- CCR hash-based cache comparison ---

def get_ccr_hashes(cache_dir, epoch, tal_paths=None):
"""Run rpki-client in offline mode and extract CCR hashes.

Requires rpki-client >= 9.6 with CCR hash support.
"""
run_args = ["rpki-client", "-n", "-d", str(cache_dir)]
if epoch:
run_args += ["-P", str(epoch)]
if tal_paths:
for tal in tal_paths:
run_args += ["-t", str(tal)]

result = subprocess.run(run_args, capture_output=True, check=False)
stdout = result.stdout.decode() if result.stdout else ""

hashes = {}
for line in stdout.splitlines():
match = re.match(r"^(CCR .+ hash): (.+)$", line)
if match:
hashes[match.group(1)] = match.group(2)

return hashes


def compare_ccr_hashes(cache_a, cache_b, epoch, tal_paths=None,
name_a="A", name_b="B"):
"""Compare CCR hashes between two RPKI cache directories."""
print(f"Computing CCR hashes for {name_a}...")
hashes_a = get_ccr_hashes(cache_a, epoch, tal_paths)
print(f"Computing CCR hashes for {name_b}...")
hashes_b = get_ccr_hashes(cache_b, epoch, tal_paths)

if not hashes_a and not hashes_b:
print("No CCR hashes found. Is rpki-client >= 9.6 installed?")
return 1

all_keys = sorted(set(hashes_a.keys()) | set(hashes_b.keys()))

print(f"\n=== CCR Hash Comparison: {name_a} vs {name_b} ===\n")
print(f"{'Hash type':<35} {'Match':>6}")
print("-" * 45)

matching = 0
differing = 0

for key in all_keys:
ha = hashes_a.get(key, "missing")
hb = hashes_b.get(key, "missing")
match = ha == hb
status = "YES" if match else "NO"
if match:
matching += 1
else:
differing += 1
print(f"{key:<35} {status:>6}")
if not match:
print(f" {name_a}: {ha}")
print(f" {name_b}: {hb}")

print("-" * 45)
print(f"Matching: {matching} Differing: {differing}")

return differing


# --- ROA-level comparison ---

def load_roas(filepath):
"""Load rpki_raw.json and index by hash_id."""
with open(filepath) as f:
data = json.load(f)
by_hash = {}
for roa in data:
hid = roa.get("hash_id", "")
by_hash[hid] = roa
return by_hash, data


def compare_roas(file_a, file_b, name_a="A", name_b="B"):
"""Compare two rpki_raw.json files at the ROA level.

Emphasizes the actual prefix-level impact of any differences.
"""
roas_a, data_a = load_roas(file_a)
roas_b, data_b = load_roas(file_b)

ids_a = set(roas_a.keys())
ids_b = set(roas_b.keys())

only_a = ids_a - ids_b
only_b = ids_b - ids_a
common = ids_a & ids_b

# Check for content differences in common ROAs
content_diff = 0
for hid in common:
if roas_a[hid] != roas_b[hid]:
content_diff += 1

# Analyze VRP / prefix impact of differences
prefixes_only_a = set()
prefixes_only_b = set()
asns_only_a = set()
asns_only_b = set()

for hid in only_a:
roa = roas_a[hid]
for vrp in roa.get("vrps", []):
prefixes_only_a.add(vrp.get("prefix", ""))
asns_only_a.add(vrp.get("asid", 0))

for hid in only_b:
roa = roas_b[hid]
for vrp in roa.get("vrps", []):
prefixes_only_b.add(vrp.get("prefix", ""))
asns_only_b.add(vrp.get("asid", 0))

# Prefixes that actually diverge (in one but not the other)
divergent_prefixes = prefixes_only_a.symmetric_difference(prefixes_only_b)

print(f"=== ROA Comparison: {name_a} vs {name_b} ===\n")

# Lead with the actual impact
print(f"--- Prefix Impact ---")
print(f"Divergent prefixes: {len(divergent_prefixes):,}")
if divergent_prefixes:
for pfx in sorted(divergent_prefixes)[:20]:
side = name_a if pfx in prefixes_only_a else name_b
print(f" {pfx} (only in {side})")
if len(divergent_prefixes) > 20:
print(f" ... and {len(divergent_prefixes) - 20:,} more")

print(f"\n--- ROA Details ---")
print(f"Total ROAs in {name_a}: {len(data_a):,}")
print(f"Total ROAs in {name_b}: {len(data_b):,}")
print(f"Common (by hash_id): {len(common):,}")
print(f"Only in {name_a}: {len(only_a):,}")
print(f"Only in {name_b}: {len(only_b):,}")
print(f"Content differs: {content_diff:,}")

if only_a or only_b:
print(f"\nUnique prefixes only in {name_a}: {len(prefixes_only_a):,}")
print(f"Unique prefixes only in {name_b}: {len(prefixes_only_b):,}")
print(f"Unique ASNs only in {name_a}: {len(asns_only_a):,}")
print(f"Unique ASNs only in {name_b}: {len(asns_only_b):,}")

# Show sample differing ROAs
if only_a:
print(f"\nSample ROAs only in {name_a} (first 5):")
for hid in list(only_a)[:5]:
roa = roas_a[hid]
vrps = roa.get("vrps", [])
print(f" hash_id: {hid[:16]}... "
f"vrps: {len(vrps)} "
f"valid_until: {roa.get('valid_until', '?')}")

if only_b:
print(f"\nSample ROAs only in {name_b} (first 5):")
for hid in list(only_b)[:5]:
roa = roas_b[hid]
vrps = roa.get("vrps", [])
print(f" hash_id: {hid[:16]}... "
f"vrps: {len(vrps)} "
f"valid_until: {roa.get('valid_until', '?')}")

return {
"divergent_prefixes": len(divergent_prefixes),
"only_a": len(only_a),
"only_b": len(only_b),
"content_diff": content_diff,
}


def find_tals(data_dir):
"""Look for TAL files in standard Kartograf data directory layout."""
tals = sorted(Path(data_dir).rglob("*.tal"))
return [str(t) for t in tals] if tals else None


def main():
parser = argparse.ArgumentParser(
description="RPKI Cache Diff Tool for ASmap collaborative launches"
)
subparsers = parser.add_subparsers(dest="command", required=True)

# compare: CCR hash comparison of two cache directories
p_compare = subparsers.add_parser("compare",
help="Compare two RPKI cache directories using CCR hashes")
p_compare.add_argument("cache_a",
help="Path to first participant's RPKI cache directory")
p_compare.add_argument("cache_b",
help="Path to second participant's RPKI cache directory")
p_compare.add_argument("--epoch", required=True,
help="Epoch timestamp for CCR hash computation")
p_compare.add_argument("--tals",
help="Directory containing TAL files (auto-detected if omitted)")
p_compare.add_argument("--names", nargs=2, default=["A", "B"],
help="Names for the two participants")

# compare-roas: compare rpki_raw.json files
p_roas = subparsers.add_parser("compare-roas",
help="Compare two rpki_raw.json files")
p_roas.add_argument("json_a", help="First rpki_raw.json")
p_roas.add_argument("json_b", help="Second rpki_raw.json")
p_roas.add_argument("--names", nargs=2, default=["A", "B"])

args = parser.parse_args()

if args.command == "compare":
tal_paths = None
if args.tals:
tal_paths = [str(p) for p in sorted(Path(args.tals).glob("*.tal"))]
else:
# Try to find TALs near the cache directories
for parent in [Path(args.cache_a).parent,
Path(args.cache_a).parent.parent]:
found = find_tals(parent)
if found:
tal_paths = found
break

diff = compare_ccr_hashes(args.cache_a, args.cache_b,
args.epoch, tal_paths,
*args.names)
sys.exit(1 if diff else 0)

elif args.command == "compare-roas":
result = compare_roas(args.json_a, args.json_b, *args.names)
sys.exit(1 if result["divergent_prefixes"] else 0)


if __name__ == "__main__":
main()