diff --git a/docker-configs/upgrade-daprs-to-data-gateway.sh b/docker-configs/upgrade-daprs-to-data-gateway.sh index 49c3203..afa2a60 100755 --- a/docker-configs/upgrade-daprs-to-data-gateway.sh +++ b/docker-configs/upgrade-daprs-to-data-gateway.sh @@ -15,12 +15,9 @@ Usage: upgrade-daprs-to-data-gateway.sh [--config-dir DIR] [--dry-run] [--yes] Migrates a recognised legacy D-APRS service in docker-compose.yml to the native FreeDMR Data Gateway and enables its FBP v5 relationship in freedmr.cfg. -Environment values may be supplied instead of answering prompts: - HDSTACK worker count, 1 through 5 - HDSTACK_BASEID main/aggregator server ID - FREEDMR_GATEWAY_APRS_LOGIN_CALL APRS-IS login callsign - FREEDMR_GATEWAY_APRS_PASSCODE APRS-IS passcode - FREEDMR_GATEWAY_FBP_KEY private FBP key, at most 20 characters +Existing server, HDStack and APRS values are read from freedmr.cfg and the +legacy D-APRS service in docker-compose.yml. A new private FBP key is generated +unless freedmr.cfg already contains a non-placeholder Data Gateway passphrase. USAGE } @@ -36,7 +33,8 @@ prompt_value() default_value="$2" if [ ! -r /dev/tty ] then - fail "interactive input is required; supply the documented environment values" + fail \ + "interactive confirmation is required; rerun with --yes for non-interactive use" fi if [ -n "$default_value" ] then @@ -111,6 +109,8 @@ build_relationships() count="$1" base_id="$2" key="$3" + bind_base_port="$4" + peer_base_port="$5" relationships='[' number=1 while [ "$number" -le "$count" ] @@ -121,8 +121,8 @@ build_relationships() else server_id=$((base_id + number)) fi - bind_port=$((62030 + number)) - peer_port=$((62040 + number)) + bind_port=$((bind_base_port + number - 1)) + peer_port=$((peer_base_port + number - 1)) if [ "$number" -gt 1 ] then relationships="${relationships}," @@ -196,14 +196,22 @@ printf '%s\n' "$legacy_block" | grep -q 'APRS_CALL' || \ printf '%s\n' "$legacy_block" | grep -q 'APRS_PASSCODE' || \ fail "the D-APRS service has no recognised APRS_PASSCODE setting" -HDSTACK_COUNT="${HDSTACK:-}" +HDSTACK_COUNT=$(compose_environment_value HDSTACK) if [ -z "$HDSTACK_COUNT" ] then - HDSTACK_COUNT=$(compose_environment_value HDSTACK) -fi -if [ -z "$HDSTACK_COUNT" ] -then - HDSTACK_COUNT=$(prompt_value 'Number of active HDStack workers (1-5)' '1') + 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 + 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 if ! is_unsigned_integer "$HDSTACK_COUNT" || \ [ "$HDSTACK_COUNT" -lt 1 ] || [ "$HDSTACK_COUNT" -gt 5 ] @@ -211,11 +219,7 @@ then fail "HDSTACK must be an integer from 1 to 5" fi -BASE_ID="${HDSTACK_BASEID:-}" -if [ -z "$BASE_ID" ] -then - BASE_ID=$(compose_environment_value HDSTACK_BASEID) -fi +BASE_ID=$(compose_environment_value HDSTACK_BASEID) if [ -z "$BASE_ID" ] then BASE_ID=$(config_value GLOBAL SERVER_ID) @@ -230,33 +234,20 @@ then fail "the base server ID plus worker IDs exceeds the supported range" fi -APRS_CALL="${FREEDMR_GATEWAY_APRS_LOGIN_CALL:-}" -if [ -z "$APRS_CALL" ] -then - APRS_CALL=$(compose_environment_value APRS_CALL) -fi -if [ -z "$APRS_CALL" ] -then - APRS_CALL=$(prompt_value 'APRS-IS login callsign' '') -fi +APRS_CALL=$(compose_environment_value APRS_CALL) +[ -n "$APRS_CALL" ] || fail "the legacy D-APRS service has no usable APRS_CALL value" case "$APRS_CALL" in ''|*[!A-Za-z0-9-]*) fail "APRS-IS login callsign contains invalid characters" ;; esac [ "${#APRS_CALL}" -le 9 ] || fail "APRS-IS login callsign must not exceed 9 characters" -APRS_PASSCODE="${FREEDMR_GATEWAY_APRS_PASSCODE:-}" -if [ -z "$APRS_PASSCODE" ] -then - APRS_PASSCODE=$(compose_environment_value APRS_PASSCODE) -fi -if [ -z "$APRS_PASSCODE" ] -then - APRS_PASSCODE=$(prompt_value 'APRS-IS passcode' '') -fi +APRS_PASSCODE=$(compose_environment_value APRS_PASSCODE) +[ -n "$APRS_PASSCODE" ] || \ + fail "the legacy D-APRS service has no usable APRS_PASSCODE value" is_unsigned_integer "$APRS_PASSCODE" || fail "APRS-IS passcode must be numeric" -FBP_KEY="${FREEDMR_GATEWAY_FBP_KEY:-}" -if [ -z "$FBP_KEY" ] +FBP_KEY=$(config_value DATA-GATEWAY PASSPHRASE) +if [ -z "$FBP_KEY" ] || [ "$FBP_KEY" = "change-this" ] then FBP_KEY=$(od -An -N10 -tx1 /dev/urandom | tr -d ' \n') fi @@ -265,13 +256,34 @@ case "$FBP_KEY" in esac [ "${#FBP_KEY}" -le 20 ] || fail "the Data Gateway FBP key must not exceed 20 characters" -RELATIONSHIPS=$(build_relationships "$HDSTACK_COUNT" "$BASE_ID" "$FBP_KEY") +GATEWAY_PEER_PORT=$(config_value DATA-GATEWAY PORT) +[ -n "$GATEWAY_PEER_PORT" ] || GATEWAY_PEER_PORT=62041 +GATEWAY_BIND_PORT=$(config_value DATA-GATEWAY TARGET_PORT) +[ -n "$GATEWAY_BIND_PORT" ] || GATEWAY_BIND_PORT=62031 +for gateway_port in "$GATEWAY_PEER_PORT" "$GATEWAY_BIND_PORT" +do + if ! is_unsigned_integer "$gateway_port" || \ + [ "$gateway_port" -lt 1 ] || [ "$gateway_port" -gt 65535 ] + then + fail "Data Gateway ports must be integers from 1 to 65535" + fi + if [ $((gateway_port + HDSTACK_COUNT - 1)) -gt 65535 ] + then + fail "the Data Gateway base port plus worker ports exceeds 65535" + fi +done + +RELATIONSHIPS=$(build_relationships \ + "$HDSTACK_COUNT" "$BASE_ID" "$FBP_KEY" \ + "$GATEWAY_BIND_PORT" "$GATEWAY_PEER_PORT") WORK_DIR=$(mktemp -d "$CONFIG_DIR/.data-gateway-upgrade.XXXXXX") trap 'rm -rf "$WORK_DIR"' EXIT PROPOSED_CONFIG="$WORK_DIR/freedmr.cfg" PROPOSED_COMPOSE="$WORK_DIR/docker-compose.yml" -awk -v gateway_key="$FBP_KEY" ' +awk -v gateway_key="$FBP_KEY" \ + -v gateway_port="$GATEWAY_PEER_PORT" \ + -v gateway_target_port="$GATEWAY_BIND_PORT" ' function finish_section() { if (section == "GLOBAL" && !global_value_seen) print "DATA_GATEWAY: True" @@ -283,11 +295,11 @@ awk -v gateway_key="$FBP_KEY" ' print "MODE: OPENBRIDGE" print "ENABLED: True" print "IP: 0.0.0.0" - print "PORT: 62041" + print "PORT: " gateway_port print "NETWORK_ID: 0" print "PASSPHRASE: " gateway_key print "TARGET_IP: freedmr-data-gateway" - print "TARGET_PORT: 62031" + print "TARGET_PORT: " gateway_target_port print "USE_ACL: False" print "SUB_ACL: PERMIT:ALL" print "TGID_ACL: PERMIT:ALL" diff --git a/docs/hdstack.md b/docs/hdstack.md index 97cf1ce..dbbacd3 100644 --- a/docs/hdstack.md +++ b/docs/hdstack.md @@ -192,11 +192,14 @@ sudo /tmp/upgrade-daprs-to-data-gateway.sh --dry-run sudo /tmp/upgrade-daprs-to-data-gateway.sh ``` -The script reuses the legacy `APRS_CALL` and `APRS_PASSCODE`, prompts for any -missing values, generates a private FBP key, enables the FreeDMR -`[DATA-GATEWAY]` relationship, disables the old `[D-APRS]` listener, and -replaces only the recognised legacy Compose service. If `HDSTACK` is not set -in the Compose file, it asks for the active worker count and defaults to one. +The script reads the server ID and any existing Data Gateway ports and +non-placeholder passphrase from `freedmr.cfg`. It reads the HDStack settings +and legacy `APRS_CALL` and `APRS_PASSCODE` from `docker-compose.yml`. If +`HDSTACK` is +omitted, it applies the container's CPU-count-minus-one default, clamped to one +through five. A private FBP key is generated only when no usable one exists. +Missing or invalid existing values cause the migration to stop rather than +prompt for replacement configuration. Before changing either file, the script validates the proposed Compose configuration and asks for confirmation. It creates timestamped backups beside diff --git a/tests/test_data_gateway_upgrade.py b/tests/test_data_gateway_upgrade.py index 657a20e..780bdb4 100644 --- a/tests/test_data_gateway_upgrade.py +++ b/tests/test_data_gateway_upgrade.py @@ -35,27 +35,20 @@ class DataGatewayUpgradeTests(unittest.TestCase): ) docker.chmod(0o755) self.environment = os.environ.copy() - self.environment.update( - { - "PATH": str(bin_dir) + os.pathsep + self.environment["PATH"], - "HDSTACK": "2", - "HDSTACK_BASEID": "23400", - "FREEDMR_GATEWAY_APRS_LOGIN_CALL": "M0ABC", - "FREEDMR_GATEWAY_APRS_PASSCODE": "12345", - "FREEDMR_GATEWAY_FBP_KEY": "private-key", - } + self.environment["PATH"] = ( + str(bin_dir) + os.pathsep + self.environment["PATH"] ) - def run_upgrade(self, *arguments, environment=None): + def run_upgrade(self, *arguments): return subprocess.run( [str(SCRIPT), "--config-dir", str(self.config_dir), *arguments], check=False, capture_output=True, text=True, - env=environment or self.environment, + env=self.environment, ) - def test_migrates_legacy_service_and_builds_hdstack_relationships(self): + def test_migrates_using_values_from_config_and_compose(self): result = self.run_upgrade("--yes") self.assertEqual(result.returncode, 0, result.stderr) @@ -65,7 +58,6 @@ class DataGatewayUpgradeTests(unittest.TestCase): self.assertTrue(parser.getboolean("DATA-GATEWAY", "ENABLED")) self.assertEqual(parser.get("DATA-GATEWAY", "MODE"), "OPENBRIDGE") self.assertEqual(parser.getint("DATA-GATEWAY", "PROTO_VER"), 5) - self.assertEqual(parser.get("DATA-GATEWAY", "PASSPHRASE"), "private-key") self.assertEqual( parser.get("DATA-GATEWAY", "TARGET_IP"), "freedmr-data-gateway", @@ -76,23 +68,51 @@ class DataGatewayUpgradeTests(unittest.TestCase): self.assertNotIn(" D-APRS:\n", compose) self.assertIn(" data-gateway:\n", compose) self.assertIn(" freedmrmonitor2:\n", compose) + self.assertIn("FREEDMR_GATEWAY_APRS_LOGIN_CALL: 'M0OLD'", compose) + self.assertIn("FREEDMR_GATEWAY_APRS_PASSCODE: '54321'", compose) + relationships = self._relationships(compose) - self.assertEqual([item["link_id"] for item in relationships], ["hdstack-1", "hdstack-2"]) - self.assertEqual([item["bind_port"] for item in relationships], [62031, 62032]) - self.assertEqual([item["peer_port"] for item in relationships], [62041, 62042]) + self.assertEqual( + [item["link_id"] for item in relationships], + ["hdstack-1", "hdstack-2"], + ) + self.assertEqual( + [item["bind_port"] for item in relationships], + [62031, 62032], + ) + self.assertEqual( + [item["peer_port"] for item in relationships], + [62041, 62042], + ) self.assertEqual( [item["peer_network_id"] for item in relationships], [23401, 23402], ) - self.assertTrue(all(item["key"] == "private-key" for item in relationships)) - self.assertEqual(len(list(self.config_dir.glob("freedmr.cfg.before-data-gateway.*"))), 1) - self.assertEqual(len(list(self.config_dir.glob("docker-compose.yml.before-data-gateway.*"))), 1) + generated_key = parser.get("DATA-GATEWAY", "PASSPHRASE") + self.assertRegex(generated_key, r"^[0-9a-f]{20}$") + self.assertTrue(all(item["key"] == generated_key for item in relationships)) + self.assertEqual( + len(list(self.config_dir.glob("freedmr.cfg.before-data-gateway.*"))), + 1, + ) + self.assertEqual( + len( + list( + self.config_dir.glob( + "docker-compose.yml.before-data-gateway.*" + ) + ) + ), + 1, + ) def test_single_worker_uses_main_server_id(self): - environment = self.environment.copy() - environment["HDSTACK"] = "1" + self.compose.write_text( + COMPOSE.replace("- HDSTACK=2", "- HDSTACK=1"), + encoding="utf-8", + ) - result = self.run_upgrade("--yes", environment=environment) + result = self.run_upgrade("--yes") self.assertEqual(result.returncode, 0, result.stderr) relationships = self._relationships(self.compose.read_text(encoding="utf-8")) @@ -100,40 +120,45 @@ class DataGatewayUpgradeTests(unittest.TestCase): self.assertEqual(relationships[0]["peer_network_id"], 23400) self.assertEqual(relationships[0]["direct_source_server_id"], 23400) - def test_reuses_legacy_credentials_and_explicit_compose_hdstack(self): + def test_reads_three_worker_base_id_from_compose(self): self.compose.write_text( - COMPOSE.replace( - " image: freedmr:test\n", - " image: freedmr:test\n" - " environment:\n" - " - HDSTACK=3\n" - " - HDSTACK_BASEID=24500\n", + COMPOSE.replace("- HDSTACK=2", "- HDSTACK=3").replace( + "- HDSTACK_BASEID=23400", + "- HDSTACK_BASEID=24500", ), encoding="utf-8", ) - environment = self.environment.copy() - for name in ( - "HDSTACK", - "HDSTACK_BASEID", - "FREEDMR_GATEWAY_APRS_LOGIN_CALL", - "FREEDMR_GATEWAY_APRS_PASSCODE", - ): - environment.pop(name) - result = self.run_upgrade("--yes", environment=environment) + result = self.run_upgrade("--yes") self.assertEqual(result.returncode, 0, result.stderr) - compose = self.compose.read_text(encoding="utf-8") - self.assertIn("FREEDMR_GATEWAY_APRS_LOGIN_CALL: 'M0OLD'", compose) - self.assertIn("FREEDMR_GATEWAY_APRS_PASSCODE: '54321'", compose) - relationships = self._relationships(compose) + relationships = self._relationships(self.compose.read_text(encoding="utf-8")) self.assertEqual(len(relationships), 3) self.assertEqual( [item["peer_network_id"] for item in relationships], [24501, 24502, 24503], ) - def test_replaces_existing_disabled_gateway_section_once(self): + def test_omitted_hdstack_uses_current_cpu_derived_default(self): + self.compose.write_text( + COMPOSE.replace(" - HDSTACK=2\n", ""), + encoding="utf-8", + ) + cpu_count = int( + subprocess.check_output( + ["getconf", "_NPROCESSORS_ONLN"], + text=True, + ) + ) + expected = max(1, min(5, cpu_count - 1)) + + result = self.run_upgrade("--yes") + + self.assertEqual(result.returncode, 0, result.stderr) + relationships = self._relationships(self.compose.read_text(encoding="utf-8")) + self.assertEqual(len(relationships), expected) + + def test_reuses_existing_non_placeholder_gateway_key(self): self.config.write_text(CONFIG_WITH_GATEWAY, encoding="utf-8") result = self.run_upgrade("--yes") @@ -141,9 +166,20 @@ class DataGatewayUpgradeTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) updated = self.config.read_text(encoding="utf-8") self.assertEqual(updated.count("[DATA-GATEWAY]"), 1) - self.assertNotIn("PASSPHRASE: old-key", updated) - self.assertIn("PASSPHRASE: private-key", updated) + self.assertIn("PASSPHRASE: old-key", updated) + self.assertIn("PORT: 63041", updated) + self.assertIn("TARGET_PORT: 63031", updated) self.assertIn("[SYSTEM]\nMODE: MASTER", updated) + relationships = self._relationships(self.compose.read_text(encoding="utf-8")) + self.assertTrue(all(item["key"] == "old-key" for item in relationships)) + self.assertEqual( + [item["bind_port"] for item in relationships], + [63031, 63032], + ) + self.assertEqual( + [item["peer_port"] for item in relationships], + [63041, 63042], + ) def test_dry_run_validates_without_modifying_or_backing_up(self): original_config = self.config.read_bytes() @@ -223,7 +259,9 @@ DATA_GATEWAY: False [DATA-GATEWAY] MODE: OPENBRIDGE ENABLED: False +PORT: 63041 PASSPHRASE: old-key +TARGET_PORT: 63031 PROTO_VER: 5 [D-APRS] @@ -240,6 +278,9 @@ services: freedmr: container_name: freedmr image: freedmr:test + environment: + - HDSTACK=2 + - HDSTACK_BASEID=23400 networks: app_net: ipv4_address: 172.16.238.10