Prepare FreeDMR 1.5 HDStack release candidate

master
Simon 3 weeks ago
parent 3738f563a7
commit 7e6ef587c8

@ -72,7 +72,7 @@ DEFAULT_DIAL_TS2: 0
DEFAULT_REFLECTOR: 0
ANNOUNCEMENT_LANGUAGE: en_GB
GENERATOR: 100
ALLOW_UNREG_ID: False
ALLOW_UNREG_ID: True
PROXY_CONTROL: True
OVERRIDE_IDENT_TG:

@ -7,5 +7,5 @@ Please see the wiki for documentation.
For the single-client SQL importer and dashboard reporting relay, see
[docs/reporting-relay.md](docs/reporting-relay.md).
For the optional two-to-five-worker container deployment and reporting MUX, see
[docs/hdstack.md](docs/hdstack.md).
For the CPU-sized one-to-five-worker HDStack container deployment and reporting
MUX, see [docs/hdstack.md](docs/hdstack.md).

@ -381,7 +381,7 @@ def build_config(_config_file):
'DEFAULT_REFLECTOR': config.getint(section, 'DEFAULT_DIAL_TS2', fallback=config.getint(section, 'DEFAULT_REFLECTOR', fallback=0)),
'GENERATOR': config.getint(section, 'GENERATOR', fallback=100),
'ANNOUNCEMENT_LANGUAGE': config.get(section, 'ANNOUNCEMENT_LANGUAGE', fallback='en_GB'),
'ALLOW_UNREG_ID': config.getboolean(section,'ALLOW_UNREG_ID', fallback=False),
'ALLOW_UNREG_ID': config.getboolean(section,'ALLOW_UNREG_ID', fallback=True),
'PROXY_CONTROL' : config.getboolean(section,'PROXY_CONTROL', fallback=True),
'OVERRIDE_IDENT_TG': config.get(section, 'OVERRIDE_IDENT_TG', fallback=False)
}})

@ -24,9 +24,8 @@ services:
mem_reservation: 600m
volumes:
- '/etc/freedmr/freedmr.cfg:/opt/freedmr/freedmr.cfg'
# HDSTACK=2..5 requires these unless HDSTACK_BRIDGE=0.
#- '/etc/freedmr/freedmr-bridge.cfg:/opt/freedmr/freedmr-bridge.cfg:ro'
#- '/etc/freedmr/rules-bridge.py:/opt/freedmr/rules.py:ro'
- '/etc/freedmr/freedmr-bridge.cfg:/opt/freedmr/freedmr-bridge.cfg:ro'
- '/etc/freedmr/rules-bridge.py:/opt/freedmr/rules.py:ro'
#Write JSON files outside of container
- '/etc/freedmr/json/:/opt/freedmr/json/'
@ -51,11 +50,14 @@ services:
- FDPROXY_DEBUG=0
#Override proxy external port
#- FDPROXY_LISTENPORT=62031
# Optional 2..5 worker HDStack deployment. Omit for current mode.
#- HDSTACK=2
# Omitted HDSTACK uses host CPU count minus one, clamped to 1..5.
# Set an explicit integer from 1 to 5 to override it.
#- HDSTACK=5
# Aggregator ID. Workers use this value plus 1..HDSTACK.
#- HDSTACK_BASEID=23400
# Disable the separate bridge.py process when it is not required.
#- HDSTACK_BRIDGE=0
# The separate bridge.py process is optional.
- HDSTACK_BRIDGE=0
# Override these only when using different mounted files.
#- HDSTACK_BRIDGE_CONFIG=/opt/freedmr/freedmr-bridge.cfg
#- HDSTACK_BRIDGE_RULES=/opt/freedmr/rules.py
tmpfs: /tmp

@ -18,32 +18,153 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
echo FreeDMR Docker installer...
echo Installing required packages...
echo Install Docker Community Edition...
apt-get -y remove docker docker-engine docker.io &&
apt-get -y update &&
apt-get -y install apt-transport-https ca-certificates curl gnupg2 software-properties-common &&
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add - &&
ARCH=`/usr/bin/arch`
echo "System architecture is $ARCH"
if [ "$ARCH" == "x86_64" ]
set -e
BASE_URL='https://gitlab.hacknix.net/hacknix/FreeDMR/-/raw/master'
prompt_value()
{
prompt_text="$1"
default_value="$2"
if [ ! -r /dev/tty ]
then
echo "Interactive input is required unless installer values are supplied as environment variables." >&2
exit 2
fi
if [ -n "$default_value" ]
then
printf '%s [%s]: ' "$prompt_text" "$default_value" >/dev/tty
else
printf '%s: ' "$prompt_text" >/dev/tty
fi
read -r entered_value </dev/tty
if [ -z "$entered_value" ]
then
printf '%s' "$default_value"
else
printf '%s' "$entered_value"
fi
}
is_unsigned_integer()
{
case "$1" in
''|*[!0-9]*) return 1 ;;
*) return 0 ;;
esac
}
validate_server_id()
{
description="$1"
value="$2"
if ! is_unsigned_integer "$value" || [ "$value" -lt 1 ] || [ "$value" -gt 4294967295 ]
then
echo "$description must be an unsigned non-zero DMR/server ID." >&2
exit 2
fi
}
CPU_COUNT=$(getconf _NPROCESSORS_ONLN 2>/dev/null) || CPU_COUNT=2
if ! is_unsigned_integer "$CPU_COUNT" || [ "$CPU_COUNT" -lt 1 ]
then
CPU_COUNT=2
fi
DEFAULT_HDSTACK=$((CPU_COUNT - 1))
if [ "$DEFAULT_HDSTACK" -lt 1 ]
then
DEFAULT_HDSTACK=1
elif [ "$DEFAULT_HDSTACK" -gt 5 ]
then
DEFAULT_HDSTACK=5
fi
echo 'FreeDMR Docker installer'
echo
echo 'Assigned Server ID, or your own DMR ID if you do not have one.'
HDSTACK_BASEID="${HDSTACK_BASEID:-}"
if [ -z "$HDSTACK_BASEID" ]
then
HDSTACK_BASEID=$(prompt_value 'Main/aggregator server ID' '')
fi
validate_server_id 'Main/aggregator server ID' "$HDSTACK_BASEID"
echo
echo 'Enabling HDStack allows FreeDMR to use more than one CPU core and support more than 100 HBP connections.'
HDSTACK_COUNT="${HDSTACK:-}"
if [ -z "$HDSTACK_COUNT" ]
then
HDSTACK_COUNT=$(prompt_value 'Number of HDStack workers (1-5)' "$DEFAULT_HDSTACK")
fi
if ! is_unsigned_integer "$HDSTACK_COUNT" || [ "$HDSTACK_COUNT" -lt 1 ] || [ "$HDSTACK_COUNT" -gt 5 ]
then
echo 'HDSTACK must be an integer from 1 to 5.' >&2
exit 2
fi
if [ $((HDSTACK_BASEID + HDSTACK_COUNT)) -gt 4294967295 ]
then
echo 'Main/aggregator ID plus worker IDs exceed the supported ID range.' >&2
exit 2
fi
HDSTACK_BRIDGE_VALUE="${HDSTACK_BRIDGE:-}"
if [ -z "$HDSTACK_BRIDGE_VALUE" ]
then
HDSTACK_BRIDGE_VALUE=$(prompt_value 'Enable the separate bridge server? (y/N)' 'N')
fi
case "$HDSTACK_BRIDGE_VALUE" in
1|y|Y|yes|YES) HDSTACK_BRIDGE_VALUE=1 ;;
0|n|N|no|NO) HDSTACK_BRIDGE_VALUE=0 ;;
*)
echo 'HDSTACK_BRIDGE must be 0 or 1 (or yes/no).' >&2
exit 2
;;
esac
HDSTACK_BRIDGE_ID="${HDSTACK_BRIDGE_ID:-0}"
if [ "$HDSTACK_BRIDGE_VALUE" -eq 1 ]
then
if [ "$HDSTACK_COUNT" -eq 1 ]
then
echo 'The separate bridge process requires HDSTACK to be 2..5.' >&2
exit 2
fi
if [ "$HDSTACK_BRIDGE_ID" -eq 0 ] 2>/dev/null
then
echo
echo 'The bridge server requires its own assigned server ID.'
HDSTACK_BRIDGE_ID=$(prompt_value 'Bridge server ID' '')
fi
validate_server_id 'Bridge server ID' "$HDSTACK_BRIDGE_ID"
if [ "$HDSTACK_BRIDGE_ID" -ge "$HDSTACK_BASEID" ] &&
[ "$HDSTACK_BRIDGE_ID" -le $((HDSTACK_BASEID + HDSTACK_COUNT)) ]
then
echo 'Bridge server ID must not duplicate the aggregator or a worker ID.' >&2
exit 2
fi
fi
echo
echo 'Installing required packages and Docker Community Edition...'
apt-get -y remove docker docker-engine docker.io
apt-get -y update
apt-get -y install apt-transport-https ca-certificates curl gnupg2 software-properties-common
curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add -
ARCH=$(/usr/bin/arch)
echo "System architecture is $ARCH"
if [ "$ARCH" = "x86_64" ]
then
ARCH="amd64"
fi
add-apt-repository \
"deb [arch=$ARCH] https://download.docker.com/linux/debian \
$(lsb_release -cs) \
stable" &&
apt-get -y update &&
apt-get -y install docker-ce &&
stable"
apt-get -y update
apt-get -y install docker-ce docker-compose
echo Install Docker Compose...
apt-get -y install docker-compose &&
echo Set userland-proxy to false...
cat <<EOF > /etc/docker/daemon.json &&
echo 'Configuring Docker...'
cat <<EOF > /etc/docker/daemon.json
{
"userland-proxy": false,
"experimental": true,
@ -54,50 +175,54 @@ cat <<EOF > /etc/docker/daemon.json &&
}
}
EOF
systemctl restart docker
echo Restart docker...
systemctl restart docker &&
echo Make config directory...
mkdir -p /etc/freedmr &&
mkdir -p /etc/freedmr/acme.sh &&
mkdir -p /etc/freedmr/certs &&
chmod -R 755 /etc/freedmr &&
echo 'Installing FreeDMR configuration...'
mkdir -p /etc/freedmr/acme.sh /etc/freedmr/certs /etc/freedmr/json
chmod -R 755 /etc/freedmr
chown 54000:54000 /etc/freedmr/json
echo make json directory...
mkdir -p /etc/freedmr/json &&
chown 54000:54000 /etc/freedmr/json &&
curl -fsSL "$BASE_URL/FreeDMR.cfg" -o /etc/freedmr/freedmr.cfg
sed -i "0,/^SERVER_ID:/s//SERVER_ID: $HDSTACK_BASEID/" /etc/freedmr/freedmr.cfg
echo Install /etc/freedmr/freedmr.cfg ...
curl -fsSL https://gitlab.hacknix.net/hacknix/FreeDMR/-/raw/master/FreeDMR.cfg -o /etc/freedmr/freedmr.cfg &&
echo Set perms on config directory...
chown -R 54000 /etc/freedmr &&
if [ ! -e /etc/freedmr/freedmr-bridge.cfg ]
then
curl -fsSL "$BASE_URL/docker-configs/freedmr-bridge.cfg" -o /etc/freedmr/freedmr-bridge.cfg
fi
if [ "$HDSTACK_BRIDGE_VALUE" -eq 1 ]
then
sed -i "0,/^SERVER_ID:/s//SERVER_ID: $HDSTACK_BRIDGE_ID/" /etc/freedmr/freedmr-bridge.cfg
fi
if [ ! -e /etc/freedmr/rules-bridge.py ]
then
curl -fsSL "$BASE_URL/docker-configs/rules-bridge.py" -o /etc/freedmr/rules-bridge.py
fi
echo Get docker-compose.yml...
cd /etc/freedmr &&
curl -fsSL https://gitlab.hacknix.net/hacknix/FreeDMR/-/raw/master/docker-configs/docker-compose.yml -o docker-compose.yml &&
curl -fsSL "$BASE_URL/docker-configs/docker-compose.yml" -o /etc/freedmr/docker-compose.yml
sed -i "s|^[[:space:]]*#*- HDSTACK=.*| - HDSTACK=$HDSTACK_COUNT|" /etc/freedmr/docker-compose.yml
sed -i "s|^[[:space:]]*#*- HDSTACK_BASEID=.*| - HDSTACK_BASEID=$HDSTACK_BASEID|" /etc/freedmr/docker-compose.yml
sed -i "s|^[[:space:]]*- HDSTACK_BRIDGE=.*| - HDSTACK_BRIDGE=$HDSTACK_BRIDGE_VALUE|" /etc/freedmr/docker-compose.yml
chown -R 54000:54000 /etc/freedmr
if [ -e /etc/cron.daily/lastheard ]; then
if [ -e /etc/cron.daily/lastheard ]
then
chmod 755 /etc/cron.daily/lastheard
fi
echo Tune network stack...
cat << EOF > /etc/sysctl.conf &&
echo 'Tuning network stack...'
cat <<EOF > /etc/sysctl.conf
net.core.rmem_default=134217728
net.core.rmem_max=134217728
net.core.wmem_max=134217728
net.core.rmem_default=134217728
net.core.wmem_max=134217728
net.core.netdev_max_backlog=250000
net.netfilter.nf_conntrack_udp_timeout=15
net.netfilter.nf_conntrack_udp_timeout_stream=35
EOF
/usr/sbin/sysctl -p
/usr/sbin/sysctl -p &&
echo Run FreeDMR container...
echo 'Starting the FreeDMR release-candidate container...'
cd /etc/freedmr
docker-compose up -d
echo Read notes in /etc/freedmr/docker-compose.yml to understand how to implement extra functionality.
echo FreeDMR setup complete!
echo 'Read /etc/freedmr/docker-compose.yml and docs/hdstack.md before adding optional services or custom links.'
echo 'FreeDMR setup complete.'

@ -19,12 +19,42 @@
cd /opt/freedmr
HDSTACK_COUNT="${HDSTACK:-1}"
HDSTACK_BRIDGE="${HDSTACK_BRIDGE:-1}"
HDSTACK_COUNT="${HDSTACK:-}"
HDSTACK_BRIDGE="${HDSTACK_BRIDGE:-0}"
HDSTACK_OUTPUT_DIR="${HDSTACK_OUTPUT_DIR:-/dev/shm/freedmr-hdstack}"
HDSTACK_BRIDGE_CONFIG="${HDSTACK_BRIDGE_CONFIG:-/opt/freedmr/freedmr-bridge.cfg}"
HDSTACK_BRIDGE_RULES="${HDSTACK_BRIDGE_RULES:-/opt/freedmr/rules.py}"
if [ -z "$HDSTACK_COUNT" ]
then
CPU_COUNT=$(getconf _NPROCESSORS_ONLN 2>/dev/null)
case "$CPU_COUNT" in
''|*[!0-9]*) CPU_COUNT=2 ;;
esac
HDSTACK_COUNT=$((CPU_COUNT - 1))
if [ "$HDSTACK_COUNT" -lt 1 ]
then
HDSTACK_COUNT=1
elif [ "$HDSTACK_COUNT" -gt 5 ]
then
HDSTACK_COUNT=5
fi
fi
HDSTACK_BASEID="${HDSTACK_BASEID:-}"
if [ -z "$HDSTACK_BASEID" ]
then
HDSTACK_BASEID=$(awk -F: '
/^\[GLOBAL\]/ { in_global = 1; next }
/^\[/ { in_global = 0 }
in_global && $1 ~ /^[[:space:]]*SERVER_ID[[:space:]]*$/ {
gsub(/[[:space:]]/, "", $2)
print $2
exit
}
' /opt/freedmr/freedmr.cfg)
fi
if [ "$BRIDGE_SERVER" == 1 ]
then
echo 'Starting in Bridge mode...'
@ -46,7 +76,7 @@ then
exit 2
fi
echo "Starting HDStack with $HDSTACK_COUNT workers..."
echo "Starting HDStack with $HDSTACK_COUNT workers (base server ID $HDSTACK_BASEID, bridge $HDSTACK_BRIDGE)..."
python /opt/freedmr/hdstack_runtime.py \
--main-config /opt/freedmr/freedmr.cfg \
--bridge-config "$HDSTACK_BRIDGE_CONFIG" \
@ -54,6 +84,7 @@ then
--output-dir "$HDSTACK_OUTPUT_DIR" \
--instances "$HDSTACK_COUNT" \
--base-id "$HDSTACK_BASEID" \
--loro-config /opt/freedmr/loro.cfg \
--bridge-enabled "$HDSTACK_BRIDGE"
status=$?
if [ "$status" -ne 0 ]

@ -0,0 +1,49 @@
# Skeleton configuration for the optional bridge.py process.
# HDStack adds its private FBP v5 connection to the aggregator at startup.
[GLOBAL]
PATH: ./
PING_TIME: 10
MAX_MISSED: 3
USE_ACL: True
REG_ACL: PERMIT:ALL
SUB_ACL: DENY:1
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
GEN_STAT_BRIDGES: False
ALLOW_NULL_PASSPHRASE: False
SERVER_ID: 0
DATA_GATEWAY: False
VALIDATE_SERVER_IDS: True
ENABLE_API: False
[REPORTS]
REPORT: False
REPORT_INTERVAL: 60
REPORT_PORT: 4421
REPORT_CLIENTS: 127.0.0.1
[LOGGER]
LOG_FILE: /dev/null
LOG_HANDLERS: console-timed
LOG_LEVEL: INFO
LOG_NAME: FreeDMR-Bridge
[ALIASES]
TRY_DOWNLOAD: False
PATH: ./json/
PEER_FILE: peer_ids.json
SUBSCRIBER_FILE: subscriber_ids.json
TGID_FILE: talkgroup_ids.json
LOCAL_SUBSCRIBER_FILE: local_subscriber_ids.json
SERVER_ID_FILE: server_ids.tsv
SUB_MAP_FILE: sub_map-bridge.pkl
KEYS_FILE: keys-bridge.json
[ALLSTAR]
ENABLED: False
USER: llcgi
PASS: mypass
SERVER: 127.0.0.1
PORT: 5038
NODE: 0

@ -72,7 +72,7 @@ DEFAULT_DIAL_TS2: 0
DEFAULT_REFLECTOR: 0
ANNOUNCEMENT_LANGUAGE: en_GB
GENERATOR: 100
ALLOW_UNREG_ID: False
ALLOW_UNREG_ID: True
PROXY_CONTROL: True
OVERRIDE_IDENT_TG:

@ -0,0 +1,4 @@
# Add bridge rules here when the optional bridge process is enabled.
# An empty dictionary is a valid idle bridge configuration.
BRIDGES = {}

@ -1,17 +1,20 @@
# HDStack multi-process deployment
HDStack raises the aggregate hotspot capacity of one FreeDMR container by
running two to five independent FreeDMR HBP workers. It does not raise the
connection limit of an individual worker.
running up to five independent FreeDMR HBP workers. It does not raise the
connection limit of an individual worker. The default worker count is the
available CPU count minus one, clamped to the supported range of one through
five.
Omitting `HDSTACK`, or setting `HDSTACK=1`, preserves the existing standard
single-FreeDMR process layout. Setting `HDSTACK` to a value from 2 through 5
selects the generated multi-process layout:
Omitting `HDSTACK` selects that CPU-derived default. Setting `HDSTACK=1`
explicitly preserves the existing standard single-FreeDMR process layout.
Setting `HDSTACK` to a value from 2 through 5 selects the generated
multi-process layout:
```text
hotspots -> proxy -> HBP workers -> no-HBP aggregator -> external FBP/OBP
|
+-> optional separate bridge.py instance
| |
+-> Loro +-> optional bridge.py over FBP v5/FBCP
worker reports -> reporting MUX -> existing dashboard/reporting consumer
```
@ -21,35 +24,41 @@ has no HBP `MASTER`, `PEER` or `XLXPEER` system. The optional `bridge.py`
process retains its own configuration and rules. The reporting MUX reads only
worker reports; aggregator and bridge reporting are not included.
## Container configuration
## Installer and container configuration
Add these environment values to the standard `freedmr` service:
The maintained installer asks for the main server ID, proposes the CPU-derived
worker count, and asks whether the separate bridge process is required. It
writes the resulting values directly into `/etc/freedmr/docker-compose.yml`.
```yaml
environment:
- HDSTACK=2
- HDSTACK=5
- HDSTACK_BASEID=23400
- HDSTACK_BRIDGE=0
```
`HDSTACK_BASEID` is the aggregator server ID. Worker IDs are allocated by
adding their one-based worker number. For the example above, the aggregator is
`23400` and the two workers are `23401` and `23402`. With `HDSTACK=5`, worker
IDs continue through `23405`.
`23400` and worker IDs continue from `23401` through `23405`.
The separate `bridge.py` process is enabled by default. Disable it when the
deployment does not require bridge rules or HBP peer/XLX connections:
The separate `bridge.py` process is disabled by default. Enable it when the
deployment requires bridge rules or HBP peer/XLX connections:
```yaml
environment:
- HDSTACK_BRIDGE=0
- HDSTACK_BRIDGE=1
```
`HDSTACK_BRIDGE` accepts only `0` or `1`. It affects only multi-process mode.
When disabled, no bridge configuration or rules file is required and no
`bridge.py` process is started. Remove or disable any aggregator OpenBridge
section intended only to connect to that bridge instance.
When disabled, no `bridge.py` process is started.
When the bridge is enabled, multi-process mode requires its existing inputs:
The installer provides `freedmr-bridge.cfg` and `rules-bridge.py` skeletons.
The latter contains the required `BRIDGES = {}` structure, so the optional
process starts idle until routing rules are configured. Enabling the bridge
also requires a separate bridge server ID which must not collide with the
aggregator or worker IDs.
The standard Compose file mounts these inputs:
```yaml
volumes:
@ -69,20 +78,24 @@ environment:
The main `freedmr.cfg` remains the sysop-facing aggregator and worker-template
configuration. It must contain one enabled `[SYSTEM]` stanza in `MASTER` mode.
All enabled and disabled `OPENBRIDGE` sections are copied to the aggregator;
this includes external FBP/OBP links and the configured link to `bridge.py`.
Other protocol systems, including `[SYSTEM]` and `[ECHO]`, are not copied to
the aggregator.
this includes external FBP/OBP links. Other protocol systems, including
`[SYSTEM]` and `[ECHO]`, are not copied to the aggregator.
Each worker receives the same `[SYSTEM]` policy. Only its base HBP port is
derived. Worker and aggregator configuration files are generated privately in
`/dev/shm/freedmr-hdstack` when the container starts. The mounted source
configuration and bridge files are not changed.
When enabled, the bridge configuration must contain the reciprocal link to the
aggregator and any `PEER` or `XLXPEER` systems used by `bridge.py`. Its
`rules.py` remains the authority for bridge routing. Bridge reporting must
either be disabled or use a port which does not collide with the worker reports
or MUX.
When `[ECHO]` is enabled, each worker receives its own generated ECHO peer and
Loro playback process on a private loopback port pair. Calls cannot leak
between worker playback state, and each Loro accepts only its corresponding
worker.
When enabled, the bridge configuration supplies its distinct server ID and any
`PEER` or `XLXPEER` systems used by `bridge.py`. HDStack generates the
reciprocal private FBP v5/FBCP connection to the aggregator. Its `rules.py` remains
the authority for bridge routing. Bridge reporting must either be disabled or
use a port which does not collide with the worker reports or MUX.
## Worker ports
@ -100,9 +113,26 @@ worker 5 -> 54400..54499
The generated ranges must fit within the UDP port range and must not collide
with configured bridge, external OpenBridge or reporting ports.
Workers connect to the aggregator over generated loopback OBP v1 links. Worker
ports are `7001` through `7005`; reciprocal aggregator ports are `7101` through
`7105`. These links are private to the container.
Workers connect to the aggregator over generated loopback FBP v5 links with
FBCP enabled. FBP v5 preserves origin metadata and carries unit data as well as
group traffic; FBCP provides source quench and the existing link-control
behaviour. Strict reciprocal endpoint checks remain enabled because both ends
use fixed loopback addresses and ports. Worker ports are `7001` through
`7005`; reciprocal aggregator ports are `7101` through `7105`. These links
are private to the container.
The generated section names retain the historical `OBP-HDSTACK-*` prefix even
though their configured wire version is FBP v5. Existing
`bridge_master.py` dynamic bridge construction recognises that prefix.
When the optional bridge process is enabled, it connects to the aggregator over
the same strict private FBP v5/FBCP profile. The aggregator binds UDP port
`7200` and the bridge binds UDP port `7201`. The generated peer identity on
each side is the other process's configured server ID. External FBP and OBP
relationships remain on the aggregator.
Each generated Loro pair uses successive private ports beginning with the
existing `[ECHO]` peer and master ports, normally `54916` and `54915`.
## Session assignment
@ -193,5 +223,6 @@ reporting is disabled, worker reporting and the MUX are both disabled.
HDStack deliberately provides no least-connections balancing, active backend
failover, live migration, persistent affinity or automatic reconfiguration.
A failed worker does not cause its active sessions to move to another worker.
The separate bridge config and rules remain sysop-managed; the runtime
generator does not create routing policy.
The separate bridge config and rules remain sysop-managed. The runtime
generator creates the private transport link but does not create routing
policy.

@ -72,7 +72,7 @@ DEFAULT_DIAL_TS2: 0
DEFAULT_REFLECTOR: 0
ANNOUNCEMENT_LANGUAGE: en_GB
GENERATOR: 100
ALLOW_UNREG_ID: False
ALLOW_UNREG_ID: True
PROXY_CONTROL: True
OVERRIDE_IDENT_TG:

@ -15,6 +15,7 @@ import json
import shlex
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
MIN_INSTANCES = 2
@ -23,6 +24,9 @@ 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):
@ -37,6 +41,8 @@ class HDStackLayout:
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
@ -50,23 +56,47 @@ def generate_hdstack(
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)
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:
@ -83,6 +113,25 @@ def generate_hdstack(
_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')
@ -102,6 +151,8 @@ def generate_hdstack(
worker_report_ports,
instances,
bridge_enabled,
loro_port_pairs,
loro_report_ports,
)
aggregator = _support_config(source)
@ -125,7 +176,36 @@ def generate_hdstack(
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)
@ -141,6 +221,25 @@ def generate_hdstack(
_copy_section(source, worker, 'SYSTEM')
worker['SYSTEM']['PORT'] = str(start)
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)
@ -162,14 +261,28 @@ def generate_hdstack(
)
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,
bridge_path,
loro_paths,
generated_bridge_path,
rules_path,
backend_ranges,
report_enabled,
@ -186,6 +299,8 @@ def generate_hdstack(
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,
@ -215,6 +330,38 @@ def _validated_base_id(value, instances):
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')
@ -269,6 +416,13 @@ def _copy_section(source, destination, section):
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()
@ -331,6 +485,8 @@ def _validate_generated_port_collisions(
worker_report_ports,
instances,
bridge_enabled,
loro_port_pairs,
loro_report_ports,
):
generated = {}
@ -350,11 +506,19 @@ def _validate_generated_port_collisions(
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_WORKER_PORT_BASE + number, 'worker {} FBP'.format(number))
reserve(
INTERNAL_AGGREGATOR_PORT_BASE + number,
'aggregator worker {} OBP'.format(number),
'aggregator worker {} FBP'.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):
@ -421,8 +585,8 @@ def _add_internal_openbridge(
'SUB_ACL': 'PERMIT:ALL',
'TGID_ACL': 'PERMIT:ALL',
'RELAX_CHECKS': 'False',
'ENHANCED_OBP': 'False',
'PROTO_VER': '1',
'ENHANCED_OBP': 'True',
'PROTO_VER': '5',
})
@ -473,6 +637,7 @@ def _program(name, command, priority, environment=None):
def _supervisor_config(
aggregator_path,
worker_paths,
loro_paths,
bridge_path,
rules_path,
backend_ranges,
@ -506,6 +671,16 @@ def _supervisor_config(
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(
@ -518,11 +693,6 @@ def _supervisor_config(
)
)
parts.extend([
_program(
'playback',
'/opt/freedmr/playback.py -c /opt/freedmr/loro.cfg',
30,
),
_program(
'proxy',
'python /opt/freedmr/hotspot_proxy_v2.py',
@ -563,6 +733,7 @@ def main(argv=None):
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')
@ -576,6 +747,7 @@ def main(argv=None):
args.instances,
args.base_id,
bridge_enabled=args.bridge_enabled == '1',
loro_config=args.loro_config,
)
except HDStackConfigurationError as err:
parser.error(str(err))

@ -99,6 +99,7 @@ DEFAULT_REFLECTOR: 0
self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DIAL_A_TG"])
self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DYNAMIC_TG_ROUTING"])
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 1)
self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["ALLOW_UNREG_ID"])
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"], 0)
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 0)

@ -103,7 +103,7 @@ class HDStackRuntimeTests(unittest.TestCase):
section for section in worker.sections()
if worker.has_option(section, 'MODE')
],
['SYSTEM', 'OBP-HDSTACK-AGGREGATOR'],
['SYSTEM', 'ECHO', 'OBP-HDSTACK-AGGREGATOR'],
)
self.assertEqual(
worker.getint('OBP-HDSTACK-AGGREGATOR', 'NETWORK_ID'),
@ -112,7 +112,7 @@ class HDStackRuntimeTests(unittest.TestCase):
self.assertFalse(worker.getboolean('GLOBAL', 'ENABLE_API'))
self.assertFalse(worker.getboolean('GLOBAL', 'DATA_GATEWAY'))
def test_internal_obp_links_are_reciprocal_and_use_expected_ids(self):
def test_internal_fbp_links_are_reciprocal_and_use_expected_ids(self):
layout = self.generate()
aggregator = _read_config(layout.aggregator_config)
workers = [_read_config(path) for path in layout.worker_configs]
@ -135,10 +135,84 @@ class HDStackRuntimeTests(unittest.TestCase):
),
(7000 + number, 7100 + number, 23400),
)
self.assertEqual(worker.getint('OBP-HDSTACK-AGGREGATOR', 'PROTO_VER'), 1)
self.assertFalse(
self.assertEqual(
aggregator.getint(aggregator_link, 'PROTO_VER'),
5,
)
self.assertTrue(
aggregator.getboolean(aggregator_link, 'ENHANCED_OBP')
)
self.assertEqual(worker.getint('OBP-HDSTACK-AGGREGATOR', 'PROTO_VER'), 5)
self.assertTrue(
worker.getboolean('OBP-HDSTACK-AGGREGATOR', 'ENHANCED_OBP')
)
self.assertFalse(
aggregator.getboolean(aggregator_link, 'RELAX_CHECKS')
)
self.assertFalse(
worker.getboolean('OBP-HDSTACK-AGGREGATOR', 'RELAX_CHECKS')
)
def test_bridge_link_is_reciprocal_fbp_v5_with_expected_ids(self):
layout = self.generate()
aggregator = _read_config(layout.aggregator_config)
bridge = _read_config(layout.bridge_config)
self.assertEqual(
(
aggregator.getint('OBP-HDSTACK-BRIDGE', 'PORT'),
aggregator.getint('OBP-HDSTACK-BRIDGE', 'TARGET_PORT'),
aggregator.getint('OBP-HDSTACK-BRIDGE', 'NETWORK_ID'),
),
(7200, 7201, 23500),
)
self.assertEqual(
(
bridge.getint('OBP-HDSTACK-AGGREGATOR', 'PORT'),
bridge.getint('OBP-HDSTACK-AGGREGATOR', 'TARGET_PORT'),
bridge.getint('OBP-HDSTACK-AGGREGATOR', 'NETWORK_ID'),
),
(7201, 7200, 23400),
)
for parser, section in (
(aggregator, 'OBP-HDSTACK-BRIDGE'),
(bridge, 'OBP-HDSTACK-AGGREGATOR'),
):
self.assertEqual(parser.get(section, 'MODE'), 'OPENBRIDGE')
self.assertEqual(parser.getint(section, 'PROTO_VER'), 5)
self.assertTrue(parser.getboolean(section, 'ENHANCED_OBP'))
self.assertFalse(parser.getboolean(section, 'RELAX_CHECKS'))
self.assertEqual(parser.get(section, 'PASSPHRASE'), 'internal')
def test_each_worker_has_an_isolated_loro_peer(self):
layout = self.generate(instances=3)
workers = [_read_config(path) for path in layout.worker_configs]
loros = [_read_config(path) for path in layout.loro_configs]
self.assertEqual(len(loros), 3)
for number, (worker, loro) in enumerate(zip(workers, loros), 1):
expected_peer_port = 54916 + (number - 1) * 2
expected_master_port = 54915 + (number - 1) * 2
self.assertEqual(worker.get('ECHO', 'IP'), '127.0.0.1')
self.assertEqual(worker.getint('ECHO', 'PORT'), expected_peer_port)
self.assertEqual(worker.get('ECHO', 'MASTER_IP'), '127.0.0.1')
self.assertEqual(
worker.getint('ECHO', 'MASTER_PORT'),
expected_master_port,
)
self.assertEqual(loro.get('PARROT', 'IP'), '127.0.0.1')
self.assertEqual(loro.getint('PARROT', 'PORT'), expected_master_port)
self.assertEqual(loro.getint('PARROT', 'MAX_PEERS'), 1)
self.assertEqual(
loro.get('PARROT', 'PASSPHRASE'),
worker.get('ECHO', 'PASSPHRASE'),
)
self.assertFalse(loro.getboolean('REPORTS', 'REPORT'))
self.assertEqual(
loro.getint('REPORTS', 'REPORT_PORT'),
4821 + number - 1,
)
self.assertEqual(loro.get('LOGGER', 'LOG_NAME'), 'ECHO-{}'.format(number))
def test_reporting_mux_uses_workers_only_and_preserves_client_acl(self):
layout = self.generate()
@ -170,13 +244,15 @@ class HDStackRuntimeTests(unittest.TestCase):
'hdstack-worker-1',
'hdstack-worker-2',
'bridge',
'playback',
'playback-1',
'playback-2',
'proxy',
'report-mux',
):
self.assertIn('[program:{}]'.format(program), supervisor)
self.assertNotIn('[program:playback]', supervisor)
self.assertIn(
'bridge.py -c {} -r {}'.format(self.bridge_config, self.bridge_rules),
'bridge.py -c {} -r {}'.format(layout.bridge_config, self.bridge_rules),
supervisor,
)
self.assertIn(
@ -196,11 +272,13 @@ class HDStackRuntimeTests(unittest.TestCase):
'hdstack-aggregator',
'hdstack-worker-1',
'hdstack-worker-2',
'playback',
'playback-1',
'playback-2',
'proxy',
'report-mux',
):
self.assertIn('[program:{}]'.format(program), supervisor)
self.assertIsNone(layout.bridge_config)
def test_reporting_disabled_omits_mux_and_worker_reporting(self):
self.main_config.write_text(
@ -216,7 +294,13 @@ class HDStackRuntimeTests(unittest.TestCase):
def test_generated_configs_are_accepted_by_current_freedmr_parser(self):
layout = self.generate()
for path in (layout.aggregator_config,) + layout.worker_configs:
generated = (
(layout.aggregator_config,)
+ layout.worker_configs
+ layout.loro_configs
+ (layout.bridge_config,)
)
for path in generated:
parsed = config.build_config(str(path))
self.assertIn('GLOBAL', parsed)
self.assertIn('SYSTEMS', parsed)
@ -273,6 +357,25 @@ class HDStackRuntimeTests(unittest.TestCase):
with self.assertRaises(hdstack_runtime.HDStackConfigurationError):
self.generate(bridge_enabled=1)
def test_enabled_bridge_requires_a_distinct_nonzero_server_id(self):
for bridge_id in (0, 23400, 23401, 23402):
with self.subTest(bridge_id=bridge_id):
self.bridge_config.write_text(
BRIDGE_CONFIG.replace(
'SERVER_ID: 23500',
'SERVER_ID: {}'.format(bridge_id),
),
encoding='utf-8',
)
with self.assertRaises(hdstack_runtime.HDStackConfigurationError):
self.generate()
self.bridge_config.write_text(
BRIDGE_CONFIG.replace('SERVER_ID: 23500', 'SERVER_ID: 23399'),
encoding='utf-8',
)
self.assertIsNotNone(self.generate().bridge_config)
def test_missing_bridge_files_and_port_collisions_fail_before_generation(self):
self.bridge_rules.unlink()
with self.assertRaises(hdstack_runtime.HDStackConfigurationError):
@ -287,26 +390,40 @@ class HDStackRuntimeTests(unittest.TestCase):
class HDStackContainerTests(unittest.TestCase):
def test_standard_entrypoint_preserves_single_mode_and_generates_two_to_five(self):
def test_standard_entrypoint_derives_default_and_preserves_explicit_single_mode(self):
entrypoint = (DOCKER_CONFIGS / 'entrypoint-proxy').read_text(encoding='utf-8')
self.assertIn('HDSTACK_COUNT="${HDSTACK:-1}"', entrypoint)
self.assertIn('HDSTACK_COUNT="${HDSTACK:-}"', entrypoint)
self.assertIn('getconf _NPROCESSORS_ONLN', entrypoint)
self.assertIn('HDSTACK_COUNT=$((CPU_COUNT - 1))', entrypoint)
self.assertIn('HDSTACK_COUNT=5', entrypoint)
self.assertIn('[ "$HDSTACK_COUNT" = 1 ]', entrypoint)
self.assertIn('/etc/supervisor/conf.d/supervisord.conf', entrypoint)
self.assertIn('[ "$HDSTACK_COUNT" -ge 2 ]', entrypoint)
self.assertIn('[ "$HDSTACK_COUNT" -le 5 ]', entrypoint)
self.assertIn('--base-id "$HDSTACK_BASEID"', entrypoint)
self.assertIn('HDSTACK_BRIDGE="${HDSTACK_BRIDGE:-1}"', entrypoint)
self.assertIn('HDSTACK_BRIDGE="${HDSTACK_BRIDGE:-0}"', entrypoint)
self.assertIn('--bridge-enabled "$HDSTACK_BRIDGE"', entrypoint)
self.assertIn('--loro-config /opt/freedmr/loro.cfg', entrypoint)
self.assertIn('HDSTACK_BRIDGE_CONFIG', entrypoint)
self.assertIn('HDSTACK_BRIDGE_RULES', entrypoint)
self.assertIn('/^\\[GLOBAL\\]/', entrypoint)
def test_standard_compose_documents_opt_in_without_changing_default(self):
def test_standard_compose_enables_cpu_derived_hdstack_and_skeleton_mounts(self):
compose = (DOCKER_CONFIGS / 'docker-compose.yml').read_text(encoding='utf-8')
self.assertIn('#- HDSTACK=2', compose)
self.assertIn('#- HDSTACK_BASEID=23400', compose)
self.assertIn('#- HDSTACK_BRIDGE=0', compose)
self.assertIn("#- HDSTACK=5", compose)
self.assertIn("#- HDSTACK_BASEID=23400", compose)
self.assertIn("- HDSTACK_BRIDGE=0", compose)
self.assertIn('freedmr:latest', compose)
self.assertIn(
'/etc/freedmr/freedmr-bridge.cfg:/opt/freedmr/freedmr-bridge.cfg:ro',
compose,
)
self.assertIn(
'/etc/freedmr/rules-bridge.py:/opt/freedmr/rules.py:ro',
compose,
)
self.assertIn('#- HDSTACK_BRIDGE_CONFIG=', compose)
self.assertIn('#- HDSTACK_BRIDGE_RULES=', compose)
self.assertIn(
@ -314,6 +431,41 @@ class HDStackContainerTests(unittest.TestCase):
compose,
)
def test_installer_records_guided_hdstack_and_bridge_choices(self):
installer = (
DOCKER_CONFIGS / 'docker-compose_install.sh'
).read_text(encoding='utf-8')
self.assertIn('Assigned Server ID, or your own DMR ID', installer)
self.assertIn('HDStack allows FreeDMR to use more than one CPU core', installer)
self.assertIn('DEFAULT_HDSTACK=$((CPU_COUNT - 1))', installer)
self.assertIn("prompt_value 'Number of HDStack workers (1-5)'", installer)
self.assertIn("prompt_value 'Enable the separate bridge server?", installer)
self.assertIn("prompt_value 'Bridge server ID'", installer)
self.assertIn('docker-configs/freedmr-bridge.cfg', installer)
self.assertIn('docker-configs/rules-bridge.py', installer)
self.assertIn('- HDSTACK=$HDSTACK_COUNT', installer)
self.assertIn('- HDSTACK_BASEID=$HDSTACK_BASEID', installer)
self.assertIn('- HDSTACK_BRIDGE=$HDSTACK_BRIDGE_VALUE', installer)
self.assertNotIn('/etc/freedmr/.env', installer)
def test_bridge_templates_are_valid_and_idle(self):
bridge = _read_config(DOCKER_CONFIGS / 'freedmr-bridge.cfg')
rules_text = (
DOCKER_CONFIGS / 'rules-bridge.py'
).read_text(encoding='utf-8')
rules = {}
exec(compile(rules_text, 'rules-bridge.py', 'exec'), rules)
self.assertEqual(bridge.getint('GLOBAL', 'SERVER_ID'), 0)
self.assertFalse(bridge.getboolean('REPORTS', 'REPORT'))
self.assertFalse(any(
bridge.has_option(section, 'MODE')
for section in bridge.sections()
))
self.assertEqual(rules['BRIDGES'], {})
def test_image_has_no_obsolete_static_hdstack_supervisor(self):
dockerfile = (DOCKER_CONFIGS / 'Dockerfile-ci').read_text(encoding='utf-8')
@ -336,6 +488,7 @@ SERVER_ID: 9999
GEN_STAT_BRIDGES: False
ENABLE_API: True
DATA_GATEWAY: True
VALIDATE_SERVER_IDS: False
USE_ACL: True
REG_ACL: PERMIT:ALL
SUB_ACL: DENY:1
@ -408,7 +561,35 @@ ANNOUNCEMENT_LANGUAGE: en_GB
[ECHO]
MODE: PEER
ENABLED: True
LOOSE: False
EXPORT_AMBE: False
IP: 127.0.0.1
PORT: 54916
MASTER_IP: 127.0.0.1
MASTER_PORT: 54915
PASSPHRASE: passw0rd
CALLSIGN: TEST
RADIO_ID: 2340210
RX_FREQ: 449000000
TX_FREQ: 444000000
TX_POWER: 25
COLORCODE: 1
SLOTS: 1
LATITUDE: 00.0000
LONGITUDE: 000.0000
HEIGHT: 0
LOCATION: Test
DESCRIPTION: ECHO
URL: example.invalid
SOFTWARE_ID: test
PACKAGE_ID: test
GROUP_HANGTIME: 5
OPTIONS:
USE_ACL: True
SUB_ACL: DENY:1
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
ANNOUNCEMENT_LANGUAGE: en_GB
""".lstrip()
@ -432,22 +613,6 @@ TRY_DOWNLOAD: False
[ALLSTAR]
ENABLED: False
[TO-AGGREGATOR]
MODE: OPENBRIDGE
ENABLED: True
IP: 127.0.0.1
PORT: 63001
NETWORK_ID: 23400
PASSPHRASE: bridge
TARGET_IP: 127.0.0.1
TARGET_PORT: 63000
USE_ACL: False
SUB_ACL: DENY:1
TGID_ACL: PERMIT:ALL
RELAX_CHECKS: False
ENHANCED_OBP: True
PROTO_VER: 5
""".lstrip()

@ -12,6 +12,7 @@ import hdstack_runtime
from tests.harness.fdmr_monitor_compat import consume_config_message
from reporting_const import REPORT_OPCODES
from tests.harness.udp_blackbox import (
HBPF_DATA_SYNC,
DependencySandbox,
FreeDmrProcess,
HbpRepeater,
@ -32,28 +33,31 @@ ROOT = Path(__file__).resolve().parents[1]
class HDStackUdpBlackBoxTests(unittest.TestCase):
def test_two_workers_route_through_aggregator_and_report_through_mux(self):
require_udp_integration_enabled()
if not _udp_ports_available((7001, 7002, 7101, 7102)):
generated_ports = (
7001,
7002,
7101,
7102,
7200,
7201,
54915,
54916,
54917,
54918,
)
if not _udp_ports_available(generated_ports):
self.skipTest('generated internal HDStack UDP ports are in use')
with tempfile.TemporaryDirectory(prefix='freedmr-hdstack-udp-') as directory:
temporary = Path(directory)
hbp_base = free_udp_port_range(4)
report_base = _free_tcp_port_range(3)
bridge_port = free_udp_port()
bridge_peer_port = free_udp_port()
proxy_port = free_udp_port()
main_config = temporary / 'freedmr.cfg'
bridge_config = temporary / 'freedmr-bridge.cfg'
bridge_rules = temporary / 'rules.py'
proxy_config = temporary / 'proxy.cfg'
bridge_link = _openbridge(
'OBP-BRIDGE',
bridge_port,
bridge_peer_port,
23500,
'bridge',
)
main_text = MAIN_CONFIG.replace(
'[EXTERNAL-FBP]\nMODE: OPENBRIDGE\nENABLED: True',
'[EXTERNAL-FBP]\nMODE: OPENBRIDGE\nENABLED: False',
@ -63,15 +67,9 @@ class HDStackUdpBlackBoxTests(unittest.TestCase):
main_text = main_text.replace('PORT: 54000', 'PORT: {}'.format(hbp_base))
main_text = main_text.replace('GENERATOR: 100', 'GENERATOR: 2')
main_text = main_text.replace('REPORT_PORT: 4321', 'REPORT_PORT: {}'.format(report_base))
main_text = main_text.replace('\n[SYSTEM]', '\n{}\n[SYSTEM]'.format(bridge_link))
main_config.write_text(main_text, encoding='utf-8')
bridge_text = BRIDGE_CONFIG.replace('PORT: 63001', 'PORT: {}'.format(bridge_peer_port))
bridge_text = bridge_text.replace(
'TARGET_PORT: 63000',
'TARGET_PORT: {}'.format(bridge_port),
)
bridge_config.write_text(bridge_text, encoding='utf-8')
bridge_config.write_text(BRIDGE_CONFIG, encoding='utf-8')
bridge_rules.write_text('BRIDGES = {}\n', encoding='utf-8')
layout = hdstack_runtime.generate_hdstack(
@ -114,6 +112,21 @@ class HDStackUdpBlackBoxTests(unittest.TestCase):
logs = []
repeaters = []
try:
for number, config_path in enumerate(layout.loro_configs, 1):
loro_process, loro_log = _start_process(
[
python,
'playback.py',
'-c',
str(config_path),
'-l',
'INFO',
],
temporary / 'loro-{}.log'.format(number),
)
processes.append(loro_process)
logs.append(loro_log)
for config_path in (layout.aggregator_config,) + layout.worker_configs:
process = FreeDmrProcess(ROOT, config_path, python)
process.__enter__()
@ -125,7 +138,7 @@ class HDStackUdpBlackBoxTests(unittest.TestCase):
python,
'bridge.py',
'-c',
str(bridge_config),
str(layout.bridge_config),
'-r',
str(bridge_rules),
'-l',
@ -162,15 +175,54 @@ class HDStackUdpBlackBoxTests(unittest.TestCase):
proxy.wait_for_start()
time.sleep(0.5)
_assert_processes_running(processes)
first = HbpRepeater(proxy_port, 1001, bind_host='127.0.0.2')
second = HbpRepeater(proxy_port, 1002, bind_host='127.0.0.2')
# A first BCKA can precede the reciprocal listener. Allow the
# next scheduled exchange to open both FBP send gates.
time.sleep(10.5)
first_peer_id = 312000101
second_peer_id = 312000201
first = HbpRepeater(
proxy_port,
first_peer_id,
bind_host='127.0.0.2',
)
second = HbpRepeater(
proxy_port,
second_peer_id,
bind_host='127.0.0.2',
)
repeaters.extend((first, second))
first.login()
second.login()
first.send_dmr(
PacketSpec(
peer_id=1001,
peer_id=first_peer_id,
rf_src=3120001,
dst_id=3120002,
slot=2,
stream_id=0x11223344,
call_type='unit',
frame_type=HBPF_DATA_SYNC,
dtype_vseq=6,
)
)
try:
received = second.recv(timeout=3.0)
except TimeoutError as err:
details = '\n'.join(
'process {}:\n{}'.format(number, process.output())
for number, process in enumerate(processes[:3])
)
raise AssertionError(details) from err
self.assertEqual(
received.fields['dst_id'],
(3120002).to_bytes(3, 'big'),
)
self.assertEqual(received.fields['call_type'], 'unit')
first.send_dmr(
PacketSpec(
peer_id=first_peer_id,
rf_src=3120001,
dst_id=2350,
slot=2,
@ -227,31 +279,6 @@ class HDStackUdpBlackBoxTests(unittest.TestCase):
dependencies.cleanup()
def _openbridge(name, port, target_port, network_id, passphrase):
return """[{name}]
MODE: OPENBRIDGE
ENABLED: True
IP: 127.0.0.1
PORT: {port}
NETWORK_ID: {network_id}
PASSPHRASE: {passphrase}
TARGET_IP: 127.0.0.1
TARGET_PORT: {target_port}
USE_ACL: False
SUB_ACL: DENY:1
TGID_ACL: PERMIT:ALL
RELAX_CHECKS: False
ENHANCED_OBP: True
PROTO_VER: 5
""".format(
name=name,
port=port,
target_port=target_port,
network_id=network_id,
passphrase=passphrase,
)
def _start_process(command, log_path):
log = log_path.open('w+')
process = subprocess.Popen(
@ -261,6 +288,7 @@ def _start_process(command, log_path):
stderr=subprocess.STDOUT,
text=True,
)
process.test_log_path = log_path
return process, log
@ -276,10 +304,23 @@ def _stop_process(process):
def _assert_processes_running(processes):
for process in processes:
for number, process in enumerate(processes, 1):
proc = process.proc if isinstance(process, FreeDmrProcess) else process
if proc.poll() is not None:
raise AssertionError('HDStack process exited during startup')
if isinstance(process, FreeDmrProcess):
output = process.output()
else:
log_path = process.test_log_path
try:
output = log_path.read_text(encoding='utf-8')
except OSError:
output = '<could not read {}>'.format(log_path)
raise AssertionError(
'HDStack process {} exited during startup:\n{}'.format(
number,
output,
)
)
def _udp_ports_available(ports):

Loading…
Cancel
Save

Powered by TurnKey Linux.