You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
FreeDMR/hdstack_runtime.py

587 lines
19 KiB

###############################################################################
# Copyright (C) 2026 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
#
# 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
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
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
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,
):
"""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')
bridge_path = None
rules_path = None
bridge_source = 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)
output_path = Path(output_dir)
source = _read_config(main_path)
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')
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')
_validate_generated_port_collisions(
source,
bridge_source,
backend_ranges,
report_enabled,
report_port,
worker_report_ports,
instances,
bridge_enabled,
)
aggregator = _support_config(source)
aggregator['GLOBAL']['SERVER_ID'] = str(base_server_id)
aggregator['GLOBAL']['GEN_STAT_BRIDGES'] = 'True'
aggregator['REPORTS']['REPORT'] = 'False'
for section in source.sections():
if _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,
)
workers = []
for number, (start, _end) in enumerate(backend_ranges, 1):
worker = _support_config(source)
worker['GLOBAL']['SERVER_ID'] = str(base_server_id + number)
worker['GLOBAL']['GEN_STAT_BRIDGES'] = 'False'
worker['GLOBAL']['ENABLE_API'] = 'False'
worker['GLOBAL']['DATA_GATEWAY'] = 'False'
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)
name = 'OBP-HDSTACK-AGGREGATOR'
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'
_write_config(aggregator, aggregator_path)
for worker, path in zip(workers, worker_paths):
_write_config(worker, path)
supervisor_path.write_text(
_supervisor_config(
aggregator_path,
worker_paths,
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,
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 _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 _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 _validate_generated_port_collisions(
source,
bridge_source,
backend_ranges,
report_enabled,
report_port,
worker_report_ports,
instances,
bridge_enabled,
):
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 {} OBP'.format(number))
reserve(
INTERNAL_AGGREGATOR_PORT_BASE + number,
'aggregator worker {} OBP'.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'))
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': 'False',
'PROTO_VER': '1',
})
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,
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,
)
)
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(
'playback',
'/opt/freedmr/playback.py -c /opt/freedmr/loro.cfg',
30,
),
_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('--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',
)
except HDStackConfigurationError as err:
parser.error(str(err))
return 0
if __name__ == '__main__':
raise SystemExit(main())

Powered by TurnKey Linux.