diff --git a/kartograf/rpki_diff.py b/kartograf/rpki_diff.py new file mode 100644 index 0000000..ea347be --- /dev/null +++ b/kartograf/rpki_diff.py @@ -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 --epoch 1775145600 + + # Compare rpki_raw.json files + python3 rpki_diff.py compare-roas --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()