############################################################################### # Copyright (C) 2026 Simon Adlem, G7RZU # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. ############################################################################### """Generate private FreeDMR HDStack process configuration.""" import argparse import configparser import json import shlex from dataclasses import dataclass from pathlib import Path from typing import Optional MIN_INSTANCES = 2 MAX_INSTANCES = 5 MAX_SERVER_ID = 0xFFFFFFFF SUPPORT_SECTIONS = ('GLOBAL', 'REPORTS', 'LOGGER', 'ALIASES', 'ALLSTAR') INTERNAL_WORKER_PORT_BASE = 7000 INTERNAL_AGGREGATOR_PORT_BASE = 7100 INTERNAL_AGGREGATOR_BRIDGE_PORT = 7200 INTERNAL_BRIDGE_PORT = 7201 LORO_PORT_STRIDE = 2 class HDStackConfigurationError(ValueError): """Raised when an HDStack deployment cannot be generated safely.""" @dataclass(frozen=True) class HDStackLayout: """Paths and derived addresses for one generated deployment.""" instances: int base_server_id: int aggregator_config: Path worker_configs: tuple loro_configs: tuple bridge_config: Optional[Path] supervisor_config: Path backend_ranges: tuple worker_report_ports: tuple def generate_hdstack( main_config, bridge_config, bridge_rules, output_dir, instances, base_server_id, bridge_enabled=True, loro_config=None, ): """Generate configs for an aggregator, workers, optional bridge, proxy and MUX.""" instances = _validated_instances(instances) base_server_id = _validated_base_id(base_server_id, instances) bridge_enabled = _validated_bridge_enabled(bridge_enabled) main_path = _required_file(main_config, 'main FreeDMR configuration') source = _read_config(main_path) echo_enabled = _echo_enabled(source) loro_path = None loro_source = None if echo_enabled: if loro_config is None: loro_config = Path(__file__).resolve().with_name('loro.cfg') loro_path = _required_file(loro_config, 'Loro configuration') loro_source = _read_config(loro_path) _validate_loro_config(source, loro_source) bridge_path = None rules_path = None bridge_source = None bridge_server_id = None if bridge_enabled: bridge_path = _required_file(bridge_config, 'bridge configuration') rules_path = _required_file(bridge_rules, 'bridge rules') bridge_source = _read_config(bridge_path) bridge_server_id = _section_integer( bridge_source, 'GLOBAL', 'SERVER_ID', 0, ) _validate_bridge_server_id( bridge_server_id, base_server_id, instances, ) output_path = Path(output_dir) system = _system_template(source) hbp_port = _integer(system, 'PORT', 54000, 'SYSTEM PORT') generator_size = _integer(system, 'GENERATOR', 100, 'SYSTEM GENERATOR') if generator_size < 1: raise HDStackConfigurationError('SYSTEM GENERATOR must be positive') backend_ranges = tuple( ( hbp_port + (number - 1) * generator_size, hbp_port + number * generator_size - 1, ) for number in range(1, instances + 1) ) for start, end in backend_ranges: _validate_port(start, 'generated HBP range start') _validate_port(end, 'generated HBP range end') loro_port_pairs = () if echo_enabled: echo_peer_port = _section_integer(source, 'ECHO', 'PORT', 54916) echo_master_port = _section_integer(source, 'ECHO', 'MASTER_PORT', 54915) loro_port_pairs = tuple( ( echo_peer_port + (number - 1) * LORO_PORT_STRIDE, echo_master_port + (number - 1) * LORO_PORT_STRIDE, ) for number in range(1, instances + 1) ) loro_report_ports = () if echo_enabled: loro_report_port = _section_integer(loro_source, 'REPORTS', 'REPORT_PORT', 4821) loro_report_ports = tuple( loro_report_port + number - 1 for number in range(1, instances + 1) ) report_enabled = _boolean(source, 'REPORTS', 'REPORT', True) report_port = _section_integer(source, 'REPORTS', 'REPORT_PORT', 4321) _validate_port(report_port, 'report MUX port') worker_report_ports = tuple( report_port + number for number in range(1, instances + 1) ) if report_enabled: for port in worker_report_ports: _validate_port(port, 'worker report port') data_gateway_enabled, data_gateway_ports, data_gateway_target_ports = ( _data_gateway_layout(source, instances) ) _validate_generated_port_collisions( source, bridge_source, backend_ranges, report_enabled, report_port, worker_report_ports, instances, bridge_enabled, loro_port_pairs, loro_report_ports, data_gateway_ports, data_gateway_target_ports, ) aggregator = _support_config(source) aggregator['GLOBAL']['SERVER_ID'] = str(base_server_id) aggregator['GLOBAL']['HDSTACK_INSTANCES'] = str(instances) aggregator['GLOBAL']['HDSTACK_BASE_SERVER_ID'] = str(base_server_id) aggregator['GLOBAL']['GEN_STAT_BRIDGES'] = 'True' aggregator['REPORTS']['REPORT'] = 'False' aggregator['GLOBAL']['DATA_GATEWAY'] = 'False' for section in source.sections(): if section != 'DATA-GATEWAY' and _mode(source, section) == 'OPENBRIDGE': _copy_section(source, aggregator, section) for number in range(1, instances + 1): name = 'OBP-HDSTACK-WORKER-{}'.format(number) if source.has_section(name): raise HDStackConfigurationError( 'main configuration already contains reserved section {}'.format(name) ) _add_internal_openbridge( aggregator, name, INTERNAL_AGGREGATOR_PORT_BASE + number, INTERNAL_WORKER_PORT_BASE + number, base_server_id + number, ) generated_bridge = None if bridge_enabled: aggregator_name = 'OBP-HDSTACK-BRIDGE' bridge_name = 'OBP-HDSTACK-AGGREGATOR' for config_source, name in ( (source, aggregator_name), (bridge_source, bridge_name), ): if config_source.has_section(name): raise HDStackConfigurationError( 'configuration already contains reserved section {}'.format(name) ) _add_internal_openbridge( aggregator, aggregator_name, INTERNAL_AGGREGATOR_BRIDGE_PORT, INTERNAL_BRIDGE_PORT, bridge_server_id, ) generated_bridge = _clone_config(bridge_source) _add_internal_openbridge( generated_bridge, bridge_name, INTERNAL_BRIDGE_PORT, INTERNAL_AGGREGATOR_BRIDGE_PORT, base_server_id, ) workers = [] loros = [] for number, (start, _end) in enumerate(backend_ranges, 1): worker = _support_config(source) worker['GLOBAL']['SERVER_ID'] = str(base_server_id + number) worker['GLOBAL']['HDSTACK_INSTANCES'] = str(instances) worker['GLOBAL']['HDSTACK_BASE_SERVER_ID'] = str(base_server_id) worker['GLOBAL']['GEN_STAT_BRIDGES'] = 'False' worker['GLOBAL']['ENABLE_API'] = 'False' worker['GLOBAL']['DATA_GATEWAY'] = str(data_gateway_enabled) worker['ALIASES']['TRY_DOWNLOAD'] = 'False' _number_mutable_alias_file(worker, 'SUB_MAP_FILE', number) _number_mutable_alias_file(worker, 'KEYS_FILE', number) worker['REPORTS']['REPORT'] = str(report_enabled) worker['REPORTS']['REPORT_PORT'] = str(worker_report_ports[number - 1]) worker['REPORTS']['REPORT_CLIENTS'] = '127.0.0.1' _copy_section(source, worker, 'SYSTEM') worker['SYSTEM']['PORT'] = str(start) if data_gateway_enabled: _copy_section(source, worker, 'DATA-GATEWAY') worker['DATA-GATEWAY']['PORT'] = str(data_gateway_ports[number - 1]) worker['DATA-GATEWAY']['TARGET_PORT'] = str(data_gateway_target_ports[number - 1]) name = 'OBP-HDSTACK-AGGREGATOR' if echo_enabled: echo_peer_port, echo_master_port = loro_port_pairs[number - 1] _copy_section(source, worker, 'ECHO') worker['ECHO']['IP'] = '127.0.0.1' worker['ECHO']['PORT'] = str(echo_peer_port) worker['ECHO']['MASTER_IP'] = '127.0.0.1' worker['ECHO']['MASTER_PORT'] = str(echo_master_port) loro = _clone_config(loro_source) loro['GLOBAL']['SERVER_ID'] = '9990' loro['REPORTS']['REPORT'] = 'False' loro['REPORTS']['REPORT_PORT'] = str(loro_report_ports[number - 1]) loro['LOGGER']['LOG_NAME'] = 'ECHO-{}'.format(number) loro['ALIASES']['TRY_DOWNLOAD'] = 'False' loro['PARROT']['IP'] = '127.0.0.1' loro['PARROT']['PORT'] = str(echo_master_port) loro['PARROT']['MAX_PEERS'] = '1' loro['PARROT']['PASSPHRASE'] = worker['ECHO']['PASSPHRASE'] loros.append(loro) if source.has_section(name): raise HDStackConfigurationError( 'main configuration already contains reserved section {}'.format(name) ) _add_internal_openbridge( worker, name, INTERNAL_WORKER_PORT_BASE + number, INTERNAL_AGGREGATOR_PORT_BASE + number, base_server_id, ) workers.append(worker) output_path.mkdir(parents=True, exist_ok=True) aggregator_path = output_path / 'aggregator.cfg' worker_paths = tuple( output_path / 'worker-{}.cfg'.format(number) for number in range(1, instances + 1) ) supervisor_path = output_path / 'supervisord.conf' loro_paths = tuple( output_path / 'loro-{}.cfg'.format(number) for number in range(1, instances + 1) ) if echo_enabled else () generated_bridge_path = ( output_path / 'bridge.cfg' if bridge_enabled else None ) _write_config(aggregator, aggregator_path) for worker, path in zip(workers, worker_paths): _write_config(worker, path) for loro, path in zip(loros, loro_paths): _write_config(loro, path) if generated_bridge_path is not None: _write_config(generated_bridge, generated_bridge_path) supervisor_path.write_text( _supervisor_config( aggregator_path, worker_paths, loro_paths, generated_bridge_path, rules_path, backend_ranges, report_enabled, report_port, worker_report_ports, _report_clients(source), bridge_enabled, ), encoding='utf-8', ) return HDStackLayout( instances=instances, base_server_id=base_server_id, aggregator_config=aggregator_path, worker_configs=worker_paths, loro_configs=loro_paths, bridge_config=generated_bridge_path, supervisor_config=supervisor_path, backend_ranges=backend_ranges, worker_report_ports=worker_report_ports, ) def _validated_instances(value): if isinstance(value, bool) or not isinstance(value, int): raise HDStackConfigurationError('HDSTACK must be an integer') if value < MIN_INSTANCES or value > MAX_INSTANCES: raise HDStackConfigurationError( 'generated HDStack requires HDSTACK in {}..{}'.format( MIN_INSTANCES, MAX_INSTANCES, ) ) return value def _validated_base_id(value, instances): if isinstance(value, bool) or not isinstance(value, int): raise HDStackConfigurationError('HDSTACK_BASEID must be an integer') if value < 1 or value + instances > MAX_SERVER_ID: raise HDStackConfigurationError( 'HDSTACK_BASEID and worker IDs must fit an unsigned 32-bit ID' ) return value def _validate_bridge_server_id(value, base_server_id, instances): if value < 1 or value > MAX_SERVER_ID: raise HDStackConfigurationError( 'bridge GLOBAL SERVER_ID must fit an unsigned 32-bit ID' ) if base_server_id <= value <= base_server_id + instances: raise HDStackConfigurationError( 'bridge GLOBAL SERVER_ID conflicts with an HDStack server ID' ) def _echo_enabled(source): if not source.has_section('ECHO'): return False if not _boolean(source, 'ECHO', 'ENABLED', True): return False if _mode(source, 'ECHO') != 'PEER': raise HDStackConfigurationError('enabled [ECHO] must use MODE PEER') return True def _validate_loro_config(source, loro_source): if not loro_source.has_section('PARROT'): raise HDStackConfigurationError('Loro configuration requires [PARROT]') if _mode(loro_source, 'PARROT') != 'MASTER': raise HDStackConfigurationError('Loro [PARROT] must use MODE MASTER') if not _boolean(loro_source, 'PARROT', 'ENABLED', True): raise HDStackConfigurationError('Loro [PARROT] must be enabled') _section_integer(source, 'ECHO', 'PORT', 54916) _section_integer(source, 'ECHO', 'MASTER_PORT', 54915) def _validated_bridge_enabled(value): if not isinstance(value, bool): raise HDStackConfigurationError('HDSTACK_BRIDGE must be 0 or 1') return value def _required_file(value, description): path = Path(value) if not path.is_file(): raise HDStackConfigurationError('{} not found: {}'.format(description, path)) return path def _read_config(path): parser = configparser.ConfigParser(interpolation=None) try: loaded = parser.read(path) except configparser.Error as err: raise HDStackConfigurationError( 'invalid configuration {}: {}'.format(path, err) ) from err if not loaded: raise HDStackConfigurationError('could not read configuration {}'.format(path)) return parser def _system_template(source): if not source.has_section('SYSTEM'): raise HDStackConfigurationError('main configuration requires [SYSTEM]') if _mode(source, 'SYSTEM') != 'MASTER': raise HDStackConfigurationError('[SYSTEM] must use MODE MASTER') if not _boolean(source, 'SYSTEM', 'ENABLED', True): raise HDStackConfigurationError('[SYSTEM] must be enabled') return source['SYSTEM'] def _support_config(source): result = configparser.ConfigParser(interpolation=None) for section in SUPPORT_SECTIONS: result.add_section(section) if source.has_section(section): for key, value in source.items(section, raw=True): result[section][key] = value return result def _copy_section(source, destination, section): if destination.has_section(section): destination.remove_section(section) destination.add_section(section) for key, value in source.items(section, raw=True): destination[section][key] = value def _clone_config(source): result = configparser.ConfigParser(interpolation=None) for section in source.sections(): _copy_section(source, result, section) return result def _mode(source, section): return source.get(section, 'MODE', fallback='').strip().upper() def _boolean(source, section, option, fallback): if not source.has_section(section): return fallback try: return source.getboolean(section, option, fallback=fallback) except ValueError as err: raise HDStackConfigurationError( '{} {} must be boolean'.format(section, option) ) from err def _integer(section, option, fallback, description): try: return section.getint(option, fallback=fallback) except ValueError as err: raise HDStackConfigurationError('{} must be an integer'.format(description)) from err def _section_integer(source, section, option, fallback): if not source.has_section(section): return fallback try: return source.getint(section, option, fallback=fallback) except ValueError as err: raise HDStackConfigurationError( '{} {} must be an integer'.format(section, option) ) from err def _validate_port(port, description): if port < 1 or port > 65535: raise HDStackConfigurationError('{} must be within 1..65535'.format(description)) def _enabled_bind_ports(source, excluded_sections=()): excluded = set(excluded_sections) ports = {} for section in source.sections(): if section in excluded or not _mode(source, section): continue if not _boolean(source, section, 'ENABLED', True): continue if source.has_option(section, 'PORT'): port = _section_integer(source, section, 'PORT', 0) _validate_port(port, '{} PORT'.format(section)) ports.setdefault(port, []).append(section) return ports def _data_gateway_layout(source, instances): if not _boolean(source, 'GLOBAL', 'DATA_GATEWAY', False): return False, (), () if ( not source.has_section('DATA-GATEWAY') or not _boolean(source, 'DATA-GATEWAY', 'ENABLED', True) ): return False, (), () if _mode(source, 'DATA-GATEWAY') != 'OPENBRIDGE': raise HDStackConfigurationError('[DATA-GATEWAY] must use MODE OPENBRIDGE') if _section_integer(source, 'DATA-GATEWAY', 'PROTO_VER', 5) != 5: raise HDStackConfigurationError('[DATA-GATEWAY] must use PROTO_VER 5') if not _boolean(source, 'DATA-GATEWAY', 'ENHANCED_OBP', True): raise HDStackConfigurationError('[DATA-GATEWAY] must enable FBCP') bind_port = _section_integer(source, 'DATA-GATEWAY', 'PORT', 0) target_port = _section_integer(source, 'DATA-GATEWAY', 'TARGET_PORT', 0) bind_ports = tuple( bind_port + number - 1 for number in range(1, instances + 1) ) target_ports = tuple( target_port + number - 1 for number in range(1, instances + 1) ) return True, bind_ports, target_ports def _validate_generated_port_collisions( source, bridge_source, backend_ranges, report_enabled, report_port, worker_report_ports, instances, bridge_enabled, loro_port_pairs, loro_report_ports, data_gateway_ports, data_gateway_target_ports, ): generated = {} def reserve(port, owner): _validate_port(port, owner) if port in generated: raise HDStackConfigurationError( 'generated port {} is used by both {} and {}'.format( port, generated[port], owner, ) ) generated[port] = owner for number, (start, end) in enumerate(backend_ranges, 1): for port in range(start, end + 1): reserve(port, 'worker {} HBP'.format(number)) for number in range(1, instances + 1): reserve(INTERNAL_WORKER_PORT_BASE + number, 'worker {} FBP'.format(number)) reserve( INTERNAL_AGGREGATOR_PORT_BASE + number, 'aggregator worker {} FBP'.format(number), ) for number, (port, target_port) in enumerate( zip(data_gateway_ports, data_gateway_target_ports), 1, ): reserve(port, 'worker {} DATA-GATEWAY'.format(number)) _validate_port(target_port, 'worker {} DATA-GATEWAY target'.format(number)) if bridge_enabled: reserve(INTERNAL_AGGREGATOR_BRIDGE_PORT, 'aggregator bridge FBP') reserve(INTERNAL_BRIDGE_PORT, 'bridge aggregator FBP') for number, (peer_port, master_port) in enumerate(loro_port_pairs, 1): reserve(peer_port, 'worker {} ECHO peer'.format(number)) reserve(master_port, 'worker {} Loro master'.format(number)) for number, port in enumerate(loro_report_ports, 1): reserve(port, 'worker {} Loro reporting'.format(number)) if report_enabled: reserve(report_port, 'report MUX') for number, port in enumerate(worker_report_ports, 1): reserve(port, 'worker {} reporting'.format(number)) configured = _enabled_bind_ports( source, excluded_sections=('SYSTEM', 'ECHO', 'DATA-GATEWAY'), ) bridge_ports = {} if bridge_enabled: bridge_ports = _enabled_bind_ports(bridge_source) for port, sections in configured.items(): if port in generated: raise HDStackConfigurationError( 'main section {} conflicts with {} on port {}'.format( ', '.join(sections), generated[port], port, ) ) for port, sections in bridge_ports.items(): if port in generated: raise HDStackConfigurationError( 'bridge section {} conflicts with {} on port {}'.format( ', '.join(sections), generated[port], port, ) ) if bridge_enabled and _boolean(bridge_source, 'REPORTS', 'REPORT', True): bridge_report_port = _section_integer( bridge_source, 'REPORTS', 'REPORT_PORT', 4321, ) _validate_port(bridge_report_port, 'bridge report port') if bridge_report_port in generated: raise HDStackConfigurationError( 'bridge reporting conflicts with {} on port {}'.format( generated[bridge_report_port], bridge_report_port, ) ) def _add_internal_openbridge( config, name, local_port, target_port, expected_network_id, ): config.add_section(name) config[name].update({ 'MODE': 'OPENBRIDGE', 'ENABLED': 'True', 'IP': '127.0.0.1', 'PORT': str(local_port), 'NETWORK_ID': str(expected_network_id), 'PASSPHRASE': 'internal', 'TARGET_IP': '127.0.0.1', 'TARGET_PORT': str(target_port), 'USE_ACL': 'False', 'SUB_ACL': 'PERMIT:ALL', 'TGID_ACL': 'PERMIT:ALL', 'RELAX_CHECKS': 'False', 'ENHANCED_OBP': 'True', 'PROTO_VER': '5', }) def _number_mutable_alias_file(config, option, number): value = config['ALIASES'].get(option, '').strip() if not value: return path = Path(value) suffix = ''.join(path.suffixes) if suffix: stem = path.name[:-len(suffix)] else: stem = path.name name = '{}-hdstack-{}{}'.format(stem, number, suffix) config['ALIASES'][option] = str(path.with_name(name)) def _report_clients(source): if not source.has_section('REPORTS'): return ('*',) value = source.get('REPORTS', 'REPORT_CLIENTS', fallback='*') clients = tuple(item.strip() for item in value.split(',') if item.strip()) return clients or ('*',) def _write_config(config, path): with path.open('w', encoding='utf-8') as config_file: config.write(config_file) def _program(name, command, priority, environment=None): lines = [ '[program:{}]'.format(name), 'directory=/opt/freedmr', 'stdout_logfile=/dev/fd/1', 'stdout_logfile_maxbytes=0', 'redirect_stderr=true', 'command={}'.format(command), 'stopwaitsecs=30', 'autorestart=true', 'priority={}'.format(priority), ] if environment: lines.append('environment={}'.format(environment)) return '\n'.join(lines) def _supervisor_config( aggregator_path, worker_paths, loro_paths, bridge_path, rules_path, backend_ranges, report_enabled, report_port, worker_report_ports, report_clients, bridge_enabled, ): parts = [ '[supervisord]\n' 'nodaemon=true\n' 'logfile=/dev/null\n' 'logfile_maxbytes=0\n' 'pidfile={}'.format(aggregator_path.parent / 'supervisord.pid'), _program( 'hdstack-aggregator', 'python /opt/freedmr/bridge_master.py -c {}'.format( shlex.quote(str(aggregator_path)) ), 10, ), ] for number, path in enumerate(worker_paths, 1): parts.append( _program( 'hdstack-worker-{}'.format(number), 'python /opt/freedmr/bridge_master.py -c {}'.format( shlex.quote(str(path)) ), 20, ) ) for number, path in enumerate(loro_paths, 1): parts.append( _program( 'playback-{}'.format(number), '/opt/freedmr/playback.py -c {}'.format( shlex.quote(str(path)) ), 30, ) ) if bridge_enabled: parts.append( _program( 'bridge', 'python /opt/freedmr/bridge.py -c {} -r {}'.format( shlex.quote(str(bridge_path)), shlex.quote(str(rules_path)), ), 30, ) ) parts.extend([ _program( 'proxy', 'python /opt/freedmr/hotspot_proxy_v2.py', 40, 'FDPROXY_BACKEND_PORT_RANGES="{}"'.format( json.dumps(backend_ranges, separators=(',', ':')) ), ), ]) if report_enabled: command = [ 'python', '/opt/freedmr/report_mux.py', '--listen-interface', '0.0.0.0', '--listen-port', str(report_port), ] for client in report_clients: command.extend(('--client', client)) for number, port in enumerate(worker_report_ports, 1): command.extend(('--backend', '{},127.0.0.1,{}'.format(number, port))) parts.append( _program( 'report-mux', ' '.join(shlex.quote(item) for item in command), 50, ) ) return '\n\n'.join(parts) + '\n' def main(argv=None): parser = argparse.ArgumentParser( description='Generate an HDStack FreeDMR container configuration', ) parser.add_argument('--main-config', required=True) parser.add_argument('--bridge-config', required=True) parser.add_argument('--bridge-rules', required=True) parser.add_argument('--output-dir', required=True) parser.add_argument('--loro-config', default='/opt/freedmr/loro.cfg') parser.add_argument('--instances', required=True, type=int) parser.add_argument('--base-id', required=True, type=int) parser.add_argument('--bridge-enabled', choices=('0', '1'), default='1') args = parser.parse_args(argv) try: generate_hdstack( args.main_config, args.bridge_config, args.bridge_rules, args.output_dir, args.instances, args.base_id, bridge_enabled=args.bridge_enabled == '1', loro_config=args.loro_config, ) except HDStackConfigurationError as err: parser.error(str(err)) return 0 if __name__ == '__main__': raise SystemExit(main())