#!/usr/bin/env python3 """ Traffic Generator API Server Scapy-based traffic generator with a Flask API. Runs in two modes controlled by TRAFFIC_GEN_MODE env var: - sender (default): generates traffic, runs RFC 2544 tests - responder: listens for test packets, echoes/timestamps, reports rx stats API endpoints: GET /healthz - health + active flows/tests count GET /interfaces - list available network interfaces GET /mode - current mode Sender mode: GET /flows - list all flows POST /flows - create flow GET /flows/ - get flow details + stats PUT /flows/ - update flow (only if idle) DELETE /flows/ - delete flow POST /flows//start - start sending POST /flows//stop - stop sending GET /flows//stats - real-time stats GET /tests - list all tests POST /tests - create RFC 2544 test GET /tests/ - test details + results POST /tests//start - start test POST /tests//stop - abort test GET /tests//results - exportable results GET /presets - list presets POST /presets/ - create flow/test from preset GET /stats/history - historical stats for all flows Responder mode: GET /responder/stats - receive statistics POST /responder/reset - reset receive counters """ import sys import os import json import threading import time import logging import uuid import psutil from flask import Flask, request, jsonify # --- logging to stderr so stdout stays clean --- logging.basicConfig( stream=sys.stderr, level=logging.INFO, format='%(asctime)s [TGEN] %(levelname)s %(message)s', ) log = logging.getLogger(__name__) app = Flask(__name__) # --------------------------------------------------------------------------- # Global state (set in main()) # --------------------------------------------------------------------------- MODE = 'sender' # or 'responder' # Sender-mode globals _sender = None # FlowSender instance _stats_collector = None # StatsCollector instance _flows_meta = {} # flow_id -> metadata dict (includes config) _flows_lock = threading.Lock() _tests = {} # test_id -> RFC 2544 test instance _tests_lock = threading.Lock() # Responder-mode globals _responder = None # Responder instance # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- def _now_iso(): return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) def _flow_response(flow_id: str) -> dict: """Build a serializable flow dict.""" with _flows_lock: meta = _flows_meta.get(flow_id) if meta is None: return None result = dict(meta) if _sender: result['is_running'] = _sender.is_running(flow_id) result['stats'] = _sender.get_stats(flow_id) return result # --------------------------------------------------------------------------- # Common endpoints # --------------------------------------------------------------------------- @app.route('/healthz') def health(): with _flows_lock: active_flows = sum( 1 for fid in _flows_meta if _sender and _sender.is_running(fid) ) if MODE == 'sender' else 0 with _tests_lock: active_tests = sum( 1 for t in _tests.values() if t.state == 'running' ) if MODE == 'sender' else 0 resp = { 'status': 'ok', 'mode': MODE, 'active_flows': active_flows, 'active_tests': active_tests, } if MODE == 'responder' and _responder: resp['responder_running'] = _responder.is_running() return jsonify(resp) @app.route('/interfaces') def list_interfaces(): ifaces = [] addrs = psutil.net_if_addrs() stats = psutil.net_if_stats() for name, addr_list in addrs.items(): info = {'name': name, 'ip': None, 'mac': None, 'is_up': False} for addr in addr_list: if addr.family.name == 'AF_INET': info['ip'] = addr.address elif addr.family.name == 'AF_PACKET': info['mac'] = addr.address if name in stats: info['is_up'] = stats[name].isup ifaces.append(info) return jsonify({'interfaces': ifaces}) @app.route('/mode') def get_mode(): return jsonify({'mode': MODE}) # --------------------------------------------------------------------------- # Sender-mode: Flow endpoints # --------------------------------------------------------------------------- @app.route('/flows', methods=['GET']) def list_flows(): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _flows_lock: flow_ids = list(_flows_meta.keys()) flows = [_flow_response(fid) for fid in flow_ids] return jsonify({'count': len(flows), 'flows': flows}) @app.route('/flows', methods=['POST']) def create_flow(): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 data = request.get_json(force=True) # Validate required fields if 'dst_ip' not in data: return jsonify({'error': 'dst_ip is required'}), 400 if 'protocol' not in data: return jsonify({'error': 'protocol is required'}), 400 if data['protocol'].lower() not in ('udp', 'tcp', 'icmp'): return jsonify({'error': 'protocol must be udp, tcp, or icmp'}), 400 flow_id = str(uuid.uuid4()) flow_config = { 'id': flow_id, 'name': data.get('name', f'flow-{flow_id[:8]}'), 'src_mac': data.get('src_mac', 'auto'), 'dst_mac': data.get('dst_mac'), 'src_ip': data.get('src_ip'), 'dst_ip': data['dst_ip'], 'protocol': data['protocol'].lower(), 'src_port': data.get('src_port'), 'dst_port': data.get('dst_port'), 'frame_size': int(data.get('frame_size', 512)), 'rate_pps': int(data.get('rate_pps', 1000)), 'duration': int(data.get('duration', 30)), 'dscp': int(data.get('dscp', 0)), 'vlan_id': data.get('vlan_id'), 'state': 'idle', 'responder_url': data.get('responder_url'), 'created_at': _now_iso(), } with _flows_lock: _flows_meta[flow_id] = flow_config _sender.add_flow(flow_id, dict(flow_config)) log.info('Created flow %s (%s -> %s, %s)', flow_id[:8], flow_config.get('src_ip', 'auto'), flow_config['dst_ip'], flow_config['protocol']) return jsonify(_flow_response(flow_id)), 201 @app.route('/flows/', methods=['GET']) def get_flow(flow_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 result = _flow_response(flow_id) if result is None: return jsonify({'error': 'Flow not found'}), 404 return jsonify(result) @app.route('/flows/', methods=['PUT']) def update_flow(flow_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _flows_lock: meta = _flows_meta.get(flow_id) if meta is None: return jsonify({'error': 'Flow not found'}), 404 if meta.get('state') == 'running': return jsonify({'error': 'Cannot update a running flow'}), 409 data = request.get_json(force=True) updatable = [ 'name', 'src_mac', 'dst_mac', 'src_ip', 'dst_ip', 'protocol', 'src_port', 'dst_port', 'frame_size', 'rate_pps', 'duration', 'dscp', 'vlan_id', 'responder_url', ] with _flows_lock: for key in updatable: if key in data: _flows_meta[flow_id][key] = data[key] _sender.update_flow(flow_id, dict(_flows_meta[flow_id])) return jsonify(_flow_response(flow_id)) @app.route('/flows/', methods=['DELETE']) def delete_flow(flow_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _flows_lock: if flow_id not in _flows_meta: return jsonify({'error': 'Flow not found'}), 404 _sender.remove_flow(flow_id) if _stats_collector: _stats_collector.remove_flow(flow_id) with _flows_lock: _flows_meta.pop(flow_id, None) log.info('Deleted flow %s', flow_id[:8]) return jsonify({'deleted': flow_id}) @app.route('/flows//start', methods=['POST']) def start_flow(flow_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _flows_lock: if flow_id not in _flows_meta: return jsonify({'error': 'Flow not found'}), 404 _flows_meta[flow_id]['state'] = 'running' try: _sender.start(flow_id) except KeyError: return jsonify({'error': 'Flow not found in sender'}), 404 log.info('Started flow %s', flow_id[:8]) return jsonify(_flow_response(flow_id)) @app.route('/flows//stop', methods=['POST']) def stop_flow(flow_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _flows_lock: if flow_id not in _flows_meta: return jsonify({'error': 'Flow not found'}), 404 _flows_meta[flow_id]['state'] = 'stopped' _sender.stop(flow_id) log.info('Stopped flow %s', flow_id[:8]) return jsonify(_flow_response(flow_id)) @app.route('/flows//stats', methods=['GET']) def flow_stats(flow_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _flows_lock: if flow_id not in _flows_meta: return jsonify({'error': 'Flow not found'}), 404 stats = _sender.get_stats(flow_id) latest = _stats_collector.get_latest(flow_id) if _stats_collector else {} return jsonify({'flow_id': flow_id, 'counters': stats, 'rates': latest}) # --------------------------------------------------------------------------- # Sender-mode: Test endpoints # --------------------------------------------------------------------------- @app.route('/tests', methods=['GET']) def list_tests(): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _tests_lock: tests = [t.get_info() for t in _tests.values()] return jsonify({'count': len(tests), 'tests': tests}) @app.route('/tests', methods=['POST']) def create_test(): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 from engine.rfc2544 import create_test as _create_test data = request.get_json(force=True) flow_id = data.get('flow_id') test_type = data.get('type') if not flow_id or not test_type: return jsonify({'error': 'flow_id and type are required'}), 400 with _flows_lock: flow_meta = _flows_meta.get(flow_id) if flow_meta is None: return jsonify({'error': 'Flow not found'}), 404 test_id = str(uuid.uuid4()) kwargs = { 'frame_sizes': data.get('frame_sizes', [64, 512, 1518]), 'trial_duration': float(data.get('trial_duration', 60)), 'max_rate_pps': int(data.get('max_rate_pps', flow_meta.get('rate_pps', 10000))), 'acceptable_loss_pct': float(data.get('acceptable_loss_pct', 0.0)), } try: test = _create_test(test_id, test_type, dict(flow_meta), **kwargs) except ValueError as e: return jsonify({'error': str(e)}), 400 with _tests_lock: _tests[test_id] = test log.info('Created test %s (type=%s, flow=%s)', test_id[:8], test_type, flow_id[:8]) return jsonify(test.get_info()), 201 @app.route('/tests/', methods=['GET']) def get_test(test_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _tests_lock: test = _tests.get(test_id) if test is None: return jsonify({'error': 'Test not found'}), 404 return jsonify(test.get_info()) @app.route('/tests//start', methods=['POST']) def start_test(test_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _tests_lock: test = _tests.get(test_id) if test is None: return jsonify({'error': 'Test not found'}), 404 test.start() log.info('Started test %s', test_id[:8]) return jsonify(test.get_info()) @app.route('/tests//stop', methods=['POST']) def stop_test(test_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _tests_lock: test = _tests.get(test_id) if test is None: return jsonify({'error': 'Test not found'}), 404 test.stop() log.info('Stopped test %s', test_id[:8]) return jsonify(test.get_info()) @app.route('/tests//results', methods=['GET']) def test_results(test_id): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 with _tests_lock: test = _tests.get(test_id) if test is None: return jsonify({'error': 'Test not found'}), 404 return jsonify({ 'test_id': test_id, 'type': test.__class__.__name__, 'state': test.state, 'results': test.results, 'started_at': test.started_at, 'completed_at': test.completed_at, }) # --------------------------------------------------------------------------- # Sender-mode: Presets # --------------------------------------------------------------------------- @app.route('/presets', methods=['GET']) def list_presets(): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 from presets import PRESETS out = {} for name, preset in PRESETS.items(): out[name] = { 'description': preset.get('description', ''), 'has_test': 'test' in preset, } return jsonify({'presets': out}) @app.route('/presets/', methods=['POST']) def load_preset(name): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 from presets import PRESETS from engine.rfc2544 import create_test as _create_test if name not in PRESETS: return jsonify({'error': f'Unknown preset: {name}. Available: {list(PRESETS)}'}), 404 preset = PRESETS[name] data = request.get_json(silent=True) or {} # The preset flow needs a dst_ip; caller can override flow_data = dict(preset['flow']) flow_data['dst_ip'] = data.get('dst_ip', flow_data.get('dst_ip', '10.0.0.1')) if 'src_ip' in data: flow_data['src_ip'] = data['src_ip'] if 'responder_url' in data: flow_data['responder_url'] = data['responder_url'] # Validate protocol present if 'protocol' not in flow_data: flow_data['protocol'] = 'udp' flow_id = str(uuid.uuid4()) flow_config = { 'id': flow_id, 'name': data.get('name', f'{name}-{flow_id[:8]}'), 'src_mac': flow_data.get('src_mac', 'auto'), 'dst_mac': flow_data.get('dst_mac'), 'src_ip': flow_data.get('src_ip'), 'dst_ip': flow_data['dst_ip'], 'protocol': flow_data['protocol'], 'src_port': flow_data.get('src_port'), 'dst_port': flow_data.get('dst_port'), 'frame_size': int(flow_data.get('frame_size', 512)), 'rate_pps': int(flow_data.get('rate_pps', 1000)), 'duration': int(flow_data.get('duration', 30)), 'dscp': int(flow_data.get('dscp', 0)), 'vlan_id': flow_data.get('vlan_id'), 'state': 'idle', 'responder_url': flow_data.get('responder_url'), 'created_at': _now_iso(), } with _flows_lock: _flows_meta[flow_id] = flow_config _sender.add_flow(flow_id, dict(flow_config)) result = {'flow': _flow_response(flow_id)} # Optionally create a test if 'test' in preset: test_cfg = preset['test'] test_id = str(uuid.uuid4()) kwargs = { 'frame_sizes': test_cfg.get('frame_sizes', [64, 512, 1518]), 'trial_duration': float(test_cfg.get('trial_duration', 60)), 'max_rate_pps': int(test_cfg.get('max_rate_pps', flow_config.get('rate_pps', 10000))), 'acceptable_loss_pct': float(test_cfg.get('acceptable_loss_pct', 0.0)), } test = _create_test(test_id, test_cfg['type'], dict(flow_config), **kwargs) with _tests_lock: _tests[test_id] = test result['test'] = test.get_info() log.info('Loaded preset %s -> flow %s', name, flow_id[:8]) return jsonify(result), 201 # --------------------------------------------------------------------------- # Sender-mode: Stats history # --------------------------------------------------------------------------- @app.route('/stats/history', methods=['GET']) def stats_history(): if MODE != 'sender': return jsonify({'error': 'Not in sender mode'}), 400 count = int(request.args.get('count', 60)) with _flows_lock: flow_ids = list(_flows_meta.keys()) history = {} for fid in flow_ids: history[fid] = _stats_collector.get_history(fid, count) return jsonify({'history': history}) # --------------------------------------------------------------------------- # Responder-mode endpoints # --------------------------------------------------------------------------- @app.route('/responder/stats', methods=['GET']) def responder_stats(): if MODE != 'responder' or _responder is None: return jsonify({'error': 'Not in responder mode'}), 400 return jsonify(_responder.get_stats()) @app.route('/responder/reset', methods=['POST']) def responder_reset(): if MODE != 'responder' or _responder is None: return jsonify({'error': 'Not in responder mode'}), 400 _responder.reset_stats() return jsonify({'status': 'reset'}) # --------------------------------------------------------------------------- # Stats collection loop (sender mode) # --------------------------------------------------------------------------- def _stats_loop(stop_event: threading.Event): """Runs every 1s, recording per-flow stats into the StatsCollector.""" while not stop_event.is_set(): try: all_stats = _sender.get_all_stats() for flow_id, s in all_stats.items(): latency = None samples = s.get('latency_samples', []) if samples: latency = { 'min_ms': round(min(samples), 3), 'max_ms': round(max(samples), 3), 'avg_ms': round(sum(samples) / len(samples), 3), } _stats_collector.record( flow_id, tx_packets=s.get('tx_packets', 0), tx_bytes=s.get('tx_bytes', 0), rx_packets=s.get('rx_packets', 0), rx_bytes=s.get('rx_bytes', 0), latency=latency, ) except Exception as e: log.debug('Stats loop error: %s', e) stop_event.wait(1.0) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): global MODE, _sender, _stats_collector, _responder MODE = os.environ.get('TRAFFIC_GEN_MODE', 'sender').lower() api_port = int(os.environ.get('TRAFFIC_GEN_PORT', 5051)) listen_iface = os.environ.get('TRAFFIC_GEN_INTERFACE', None) responder_sub_mode = os.environ.get('TRAFFIC_GEN_RESPONDER_MODE', 'log') # echo or log log.info('Traffic Generator starting in %s mode', MODE) if MODE == 'sender': from engine.sender import FlowSender from engine.stats import StatsCollector _sender = FlowSender() _stats_collector = StatsCollector() # Start stats collection loop stats_stop = threading.Event() stats_thread = threading.Thread( target=_stats_loop, args=(stats_stop,), daemon=True, name='stats-loop' ) stats_thread.start() # Start Flask in background thread flask_thread = threading.Thread( target=lambda: app.run(host='0.0.0.0', port=api_port, threaded=True), daemon=True, ) flask_thread.start() log.info('Flask API listening on port %d', api_port) # Main thread: keep alive try: while True: time.sleep(1) except KeyboardInterrupt: log.info('Shutting down sender...') stats_stop.set() elif MODE == 'responder': from engine.responder import Responder _responder = Responder(mode=responder_sub_mode) _responder.start(interface=listen_iface) # Start Flask in background thread flask_thread = threading.Thread( target=lambda: app.run(host='0.0.0.0', port=api_port, threaded=True), daemon=True, ) flask_thread.start() log.info('Flask API listening on port %d', api_port) # Main thread: keep alive try: while True: time.sleep(1) except KeyboardInterrupt: log.info('Shutting down responder...') _responder.stop() else: log.error('Unknown mode: %s. Use "sender" or "responder".', MODE) sys.exit(1) if __name__ == '__main__': main()