diff --git a/exabgp-ui/src/App.vue b/exabgp-ui/src/App.vue index c031bec..4458128 100644 --- a/exabgp-ui/src/App.vue +++ b/exabgp-ui/src/App.vue @@ -41,6 +41,7 @@ + @@ -63,6 +64,7 @@ import RouteTable from './components/RouteTable.vue' import AnnounceForm from './components/AnnounceForm.vue' import PeerStatus from './components/PeerStatus.vue' import ChurnControl from './components/ChurnControl.vue' +import FullTable from './components/FullTable.vue' const health = ref(null) const routes = ref([]) @@ -75,6 +77,7 @@ const tabs = [ { id: 'inject', label: 'Inject' }, { id: 'peers', label: 'Peers' }, { id: 'churn', label: 'Churn' }, + { id: 'full-table', label: 'Full Table' }, ] async function fetchHealth() { diff --git a/exabgp-ui/src/components/FullTable.vue b/exabgp-ui/src/components/FullTable.vue new file mode 100644 index 0000000..d7c0e53 --- /dev/null +++ b/exabgp-ui/src/components/FullTable.vue @@ -0,0 +1,477 @@ + + + Full Table Injection + + Inject a realistic IPv4 routing table into ExaBGP for stress testing. + Routes are generated with varied AS paths, prefix lengths, and communities matching real DFZ distribution. + + + + + + Table Size + + + {{ level.label }} + {{ level.desc }} + + + + + + + Custom Count + + + + + + + ▶ Inject {{ formatNum(selectedCount) }} Routes + + + ■ Stop Injection + + + {{ withdrawing ? 'Withdrawing...' : 'Withdraw All' }} + + + + + + + + + {{ statusMsg || 'Idle' }} + + + + + + {{ formatNum(state.injected) }} / {{ formatNum(state.total) }} + {{ state.progress_pct || 0 }}% + + + + + + + + + + Rate + {{ formatNum(state.rate_pps || 0) }}/s + + + Elapsed + {{ state.elapsed_sec || 0 }}s + + + Active Routes + {{ formatNum(state.active_routes || 0) }} + + + + + {{ state.error }} + + + + + + + diff --git a/exabgp/api/server.py b/exabgp/api/server.py index de56802..dc001c2 100644 --- a/exabgp/api/server.py +++ b/exabgp/api/server.py @@ -48,12 +48,16 @@ peer_states = {} # ExaBGP command helpers # --------------------------------------------------------------------------- +_quiet_mode = False + + def _send(cmd: str): """Write a command to ExaBGP via stdout.""" with _stdout_lock: sys.stdout.write(cmd + '\n') sys.stdout.flush() - log.info('→ ExaBGP: %s', cmd) + if not _quiet_mode: + log.info('→ ExaBGP: %s', cmd) def _build_announce(prefix, next_hop='self', as_path=None, communities=None, med=None, local_pref=None): @@ -162,7 +166,22 @@ def api_withdraw_all(): # --------------------------------------------------------------------------- sys.path.insert(0, '/exabgp') -from scenarios import SCENARIOS +from scenarios import SCENARIOS, generate_full_internet + +# --------------------------------------------------------------------------- +# Full-table background injection +# --------------------------------------------------------------------------- + +_injection_state = { + 'active': False, + 'total': 0, + 'injected': 0, + 'elapsed_sec': 0, + 'rate_pps': 0, + 'error': None, + 'stop_requested': False, +} +_injection_lock = threading.Lock() @app.route('/scenarios', methods=['GET']) @@ -223,6 +242,131 @@ def get_peers(): return jsonify({'peers': peer_states}) +# --------------------------------------------------------------------------- +# Full-table injection endpoints +# --------------------------------------------------------------------------- + +def _injection_worker(count, batch_size): + """Background thread: generate and inject full internet table.""" + global _quiet_mode + try: + _quiet_mode = True # suppress per-route logging + log.info('Generating %d full-table prefixes...', count) + routes = generate_full_internet(count) + with _injection_lock: + _injection_state['total'] = len(routes) + log.info('Generated %d routes, starting injection at batch_size=%d', len(routes), batch_size) + + start_time = time.time() + for i, route in enumerate(routes): + with _injection_lock: + if _injection_state['stop_requested']: + log.info('Injection stopped by user at %d/%d', i, len(routes)) + break + + prefix = route['prefix'] + announce_route( + prefix, + next_hop=route.get('next_hop', 'self'), + as_path=route.get('as_path', []), + communities=route.get('communities', []), + med=route.get('med'), + local_pref=route.get('local_pref'), + ) + + # Update progress periodically (every batch_size routes) + if (i + 1) % batch_size == 0: + elapsed = time.time() - start_time + with _injection_lock: + _injection_state['injected'] = i + 1 + _injection_state['elapsed_sec'] = round(elapsed, 1) + _injection_state['rate_pps'] = round((i + 1) / elapsed, 1) if elapsed > 0 else 0 + log.info('Injection progress: %d/%d (%.0f/s)', + i + 1, len(routes), (i + 1) / elapsed if elapsed > 0 else 0) + + elapsed = time.time() - start_time + with _injection_lock: + _injection_state['injected'] = min(i + 1, len(routes)) + _injection_state['elapsed_sec'] = round(elapsed, 1) + _injection_state['rate_pps'] = round(_injection_state['injected'] / elapsed, 1) if elapsed > 0 else 0 + _injection_state['active'] = False + log.info('Injection complete: %d routes in %.1fs (%.0f/s)', + _injection_state['injected'], elapsed, + _injection_state['injected'] / elapsed if elapsed > 0 else 0) + + except Exception as e: + log.error('Injection error: %s', e) + with _injection_lock: + _injection_state['error'] = str(e) + _injection_state['active'] = False + finally: + _quiet_mode = False + + +@app.route('/full-table/start', methods=['POST']) +def start_full_table(): + """Start background injection of a full IPv4 routing table. + + POST body (all optional): + count: Number of prefixes (default 900000) + batch_size: Progress update interval (default 1000) + """ + with _injection_lock: + if _injection_state['active']: + return jsonify({ + 'error': 'Injection already in progress', + 'state': dict(_injection_state), + }), 409 + + data = request.get_json(force=True) if request.data else {} + count = int(data.get('count', 900000)) + batch_size = int(data.get('batch_size', 1000)) + + with _injection_lock: + _injection_state.update({ + 'active': True, + 'total': count, + 'injected': 0, + 'elapsed_sec': 0, + 'rate_pps': 0, + 'error': None, + 'stop_requested': False, + }) + + t = threading.Thread(target=_injection_worker, args=(count, batch_size), daemon=True) + t.start() + + log.info('Started full-table injection: %d prefixes', count) + return jsonify({ + 'status': 'started', + 'count': count, + 'message': f'Generating and injecting {count} prefixes in background. GET /full-table/status to track progress.', + }) + + +@app.route('/full-table/status', methods=['GET']) +def full_table_status(): + """Get current full-table injection progress.""" + with _injection_lock: + state = dict(_injection_state) + if state['total'] > 0: + state['progress_pct'] = round(state['injected'] / state['total'] * 100, 1) + else: + state['progress_pct'] = 0 + state['active_routes'] = len(active_routes) + return jsonify(state) + + +@app.route('/full-table/stop', methods=['POST']) +def stop_full_table(): + """Stop an in-progress full-table injection.""" + with _injection_lock: + if not _injection_state['active']: + return jsonify({'error': 'No injection in progress'}), 400 + _injection_state['stop_requested'] = True + return jsonify({'status': 'stop_requested', 'injected_so_far': _injection_state['injected']}) + + # --------------------------------------------------------------------------- # ExaBGP event loop (main thread) # --------------------------------------------------------------------------- diff --git a/exabgp/inject.py b/exabgp/inject.py index 370e20b..f956d26 100644 --- a/exabgp/inject.py +++ b/exabgp/inject.py @@ -12,6 +12,9 @@ Usage: inject.py withdraw-all inject.py scenario inject.py withdraw-scenario + inject.py full-table [--count N] [--follow] # inject full IPv4 table (background) + inject.py full-table-status # show injection progress + inject.py full-table-stop # stop injection inject.py churn [--count N] [--interval SEC] # cycle announce/withdraw for ip_rib_log population inject.py monitor # live-refresh terminal view @@ -29,8 +32,8 @@ import requests API = os.environ.get('EXABGP_API', 'http://localhost:5050') -def _post(path, data=None): - r = requests.post(f'{API}{path}', json=data or {}, timeout=10) +def _post(path, data=None, timeout=10): + r = requests.post(f'{API}{path}', json=data or {}, timeout=timeout) r.raise_for_status() return r.json() @@ -174,6 +177,101 @@ def cmd_withdraw_scenario(args): print(f"Withdrew scenario '{args.name}': {data['count']} routes withdrawn") +def cmd_full_table(args): + """Inject a full IPv4 routing table for stress testing.""" + count = args.count + print(f"Starting full-table injection: {count} prefixes") + print("This generates routes in background. Use 'inject.py full-table-status' to track.\n") + + data = _post('/full-table/start', {'count': count, 'batch_size': args.batch_size}, timeout=120) + print(data.get('message', 'Started')) + + if args.follow: + print() + _follow_injection() + + +def cmd_full_table_status(args): + """Show full-table injection progress.""" + data = _get('/full-table/status') + active = data.get('active', False) + total = data.get('total', 0) + injected = data.get('injected', 0) + pct = data.get('progress_pct', 0) + rate = data.get('rate_pps', 0) + elapsed = data.get('elapsed_sec', 0) + error = data.get('error') + active_routes = data.get('active_routes', 0) + + if error: + print(f"ERROR: {error}") + elif active: + bar_len = 40 + filled = int(bar_len * pct / 100) + bar = '#' * filled + '-' * (bar_len - filled) + print(f"[{bar}] {pct:.1f}%") + print(f" Injected: {injected:,} / {total:,} ({rate:.0f} routes/s)") + print(f" Elapsed: {elapsed:.0f}s") + print(f" Active routes in ExaBGP: {active_routes:,}") + elif total > 0: + print(f"Injection complete: {injected:,} / {total:,} routes in {elapsed:.0f}s ({rate:.0f}/s)") + print(f"Active routes in ExaBGP: {active_routes:,}") + else: + print("No injection running or completed.") + print(f"Active routes: {active_routes:,}") + + +def cmd_full_table_stop(args): + """Stop an in-progress full-table injection.""" + try: + data = _post('/full-table/stop') + print(f"Stop requested. Injected so far: {data.get('injected_so_far', '?'):,}") + except requests.exceptions.HTTPError as e: + if e.response.status_code == 400: + print("No injection in progress.") + else: + raise + + +def _follow_injection(): + """Poll injection status until complete.""" + import shutil + lines_printed = 0 + try: + while True: + data = _get('/full-table/status') + active = data.get('active', False) + total = data.get('total', 0) + injected = data.get('injected', 0) + pct = data.get('progress_pct', 0) + rate = data.get('rate_pps', 0) + elapsed = data.get('elapsed_sec', 0) + active_routes = data.get('active_routes', 0) + + # Move cursor up to overwrite + if lines_printed > 0: + print(f"\033[{lines_printed}A", end='') + + bar_len = 40 + filled = int(bar_len * pct / 100) + bar = '#' * filled + '-' * (bar_len - filled) + output_lines = [ + f" [{bar}] {pct:.1f}%", + f" Injected: {injected:,} / {total:,} ({rate:.0f} routes/s) elapsed: {elapsed:.0f}s", + f" Active routes: {active_routes:,}", + ] + print('\n'.join(output_lines)) + lines_printed = len(output_lines) + + if not active: + print(f"\nDone! {injected:,} routes injected in {elapsed:.0f}s") + break + + time.sleep(2) + except KeyboardInterrupt: + print("\n\nFollowing stopped (injection continues in background).") + + def cmd_churn(args): """ Cycle announce/withdraw on the 'churn' scenario to generate ip_rib_log @@ -236,6 +334,17 @@ def main(): p = sub.add_parser('withdraw-scenario', help='Withdraw a named scenario') p.add_argument('name') + p = sub.add_parser('full-table', help='Inject full IPv4 routing table (background)') + p.add_argument('--count', type=int, default=900000, metavar='N', + help='Number of prefixes to inject (default: 900000)') + p.add_argument('--batch-size', type=int, default=1000, metavar='N', + help='Progress update interval (default: 1000)') + p.add_argument('--follow', '-f', action='store_true', + help='Follow progress until complete') + + sub.add_parser('full-table-status', help='Show full-table injection progress') + sub.add_parser('full-table-stop', help='Stop full-table injection') + p = sub.add_parser('churn', help='Cycle announce/withdraw to populate ip_rib_log') p.add_argument('--count', type=int, default=0, metavar='N', help='Number of cycles (0 = infinite)') @@ -255,6 +364,9 @@ def main(): 'withdraw-all': cmd_withdraw_all, 'scenario': cmd_scenario, 'withdraw-scenario': cmd_withdraw_scenario, + 'full-table': cmd_full_table, + 'full-table-status': cmd_full_table_status, + 'full-table-stop': cmd_full_table_stop, 'churn': cmd_churn, } diff --git a/exabgp/scenarios/__init__.py b/exabgp/scenarios/__init__.py index 3a7ed3a..0af571d 100644 --- a/exabgp/scenarios/__init__.py +++ b/exabgp/scenarios/__init__.py @@ -441,6 +441,100 @@ _PATH_DIVERSITY_ROUTES = [ # Registry # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Full Internet Table Generator +# Generates realistic-looking IPv4 prefixes across the routable address space +# with varied AS paths, prefix lengths, origins, and communities. +# Configurable count: 10K (quick test) to 900K+ (full table stress test). +# --------------------------------------------------------------------------- + +# Well-known transit ASNs for realistic path construction +_TRANSIT_ASNS = [174, 701, 1299, 2914, 3257, 3356, 6461, 6762, 7018, 3491, 5400, 1239] + +# Realistic origin ASNs (mix of large providers and small networks) +_ORIGIN_POOL = [ + 13335, 15169, 16509, 8075, 20940, 32934, 714, 54113, 13414, 7922, + 36459, 46489, 14618, 16276, 24940, 47541, 35916, 49981, 9808, 4134, + 4837, 9121, 12322, 3320, 6830, 5511, 1273, 6939, 4766, 9318, + 23693, 38001, 45102, 58453, 10026, 18881, 28573, 7738, 26599, 8151, + 11888, 17676, 4713, 7545, 9299, 50304, 51167, 60068, 41095, 34984, +] + +# IANA-allocated first octets for routable IPv4 (subset for realism) +_ROUTABLE_FIRST_OCTETS = list(range(1, 56)) + list(range(57, 127)) + list(range(128, 224)) + + +def generate_full_internet(count=900000): + """Generate a realistic full IPv4 routing table. + + Distributes prefixes across the IPv4 address space with realistic + prefix lengths (/8 through /24) and varied AS paths. + + Args: + count: Number of prefixes to generate (default 900K). + + Returns: + List of route dicts. + """ + import random + rng = random.Random(42) # deterministic for reproducibility + + routes = [] + generated = set() + + # Prefix length distribution (approximates real DFZ): + # /24: ~55%, /23: ~8%, /22: ~7%, /21: ~5%, /20: ~5%, + # /19: ~4%, /18: ~3%, /17: ~2%, /16: ~5%, /15-/8: ~6% + prefix_len_weights = { + 24: 55, 23: 8, 22: 7, 21: 5, 20: 5, + 19: 4, 18: 3, 17: 2, 16: 5, 15: 2, + 14: 1, 13: 1, 12: 1, 11: 0.5, 10: 0.3, + 9: 0.1, 8: 0.1, + } + plen_choices = list(prefix_len_weights.keys()) + plen_weights = list(prefix_len_weights.values()) + + # AS path length distribution: 1-hop: 5%, 2-hop: 30%, 3-hop: 40%, 4-hop: 20%, 5-hop: 5% + path_len_weights = [5, 30, 40, 20, 5] + + while len(routes) < count: + # Pick a routable first octet weighted by allocation density + first = rng.choice(_ROUTABLE_FIRST_OCTETS) + plen = rng.choices(plen_choices, weights=plen_weights, k=1)[0] + + # Generate random prefix within this /8 + if plen <= 8: + prefix = f'{first}.0.0.0/{plen}' + elif plen <= 16: + second = rng.randint(0, 255) & (0xFF << (16 - plen)) + prefix = f'{first}.{second}.0.0/{plen}' + elif plen <= 24: + second = rng.randint(0, 255) + third = rng.randint(0, 255) & (0xFF << (24 - plen)) + prefix = f'{first}.{second}.{third}.0/{plen}' + else: + continue + + if prefix in generated: + continue + generated.add(prefix) + + # Build realistic AS path + path_len = rng.choices([1, 2, 3, 4, 5], weights=path_len_weights, k=1)[0] + origin = rng.choice(_ORIGIN_POOL) if rng.random() < 0.3 else (64512 + rng.randint(0, 65535 - 64512)) + transits = rng.sample(_TRANSIT_ASNS, min(path_len - 1, len(_TRANSIT_ASNS))) + as_path = [65100] + transits[:path_len - 1] + [origin] + + # Occasionally add communities (~20% of routes) + communities = [] + if rng.random() < 0.2: + communities.append(f'65100:{rng.choice([100, 200, 300, 400, 500])}') + + routes.append(_r(prefix, as_path, communities=communities or None)) + + return routes + + SCENARIOS = { 'internet_sample': { 'description': 'Partial internet table (~80 IPv4 + 14 IPv6 prefixes with realistic AS paths)',
+ Inject a realistic IPv4 routing table into ExaBGP for stress testing. + Routes are generated with varied AS paths, prefix lengths, and communities matching real DFZ distribution. +