Audit fixes: config persistence, security, and code quality

High priority fixes:
- Add config backup before overwrite in complete-setup
- Add systemctl disable to reset-setup to prevent broken state
- Change freedvtnc2 Requires to Wants for VOX mode support
- Update tested status for FT-818 and truSDX

Medium priority fixes:
- Validate serial_port for non-VOX radios
- Add config validation before service start
- Increase freedvtnc2 startup timeout from 5s to 15s
- Add PTT method (RTS) to more radios in radios.json

Low priority / code quality:
- Extract common radio lookup to helper function
- Add type hints (Optional, Tuple) consistently
- Remove unused peers_config code
- Define path constants at module level
- Fix bare except in hardware.py
- Move time import to module level

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Light-Fighter-Manifesto 2026-01-27 05:33:55 -05:00
parent 25f561d989
commit 6f7aa4c41c
4 changed files with 178 additions and 53 deletions

View file

@ -1,6 +1,6 @@
{
"version": "1.0.0",
"updated": "2026-01-06",
"version": "1.0.1",
"updated": "2026-01-27",
"radios": [
{
"id": "xiegu_g90",
@ -253,6 +253,7 @@
"model": "FT-891",
"hamlib_id": 1036,
"baud_rate": 38400,
"ptt_method": "RTS",
"ptt_on_delay_ms": 300,
"ptt_off_delay_ms": 200,
"serial_settings": {
@ -286,6 +287,7 @@
"model": "FT-991/991A",
"hamlib_id": 1035,
"baud_rate": 38400,
"ptt_method": "RTS",
"ptt_on_delay_ms": 300,
"ptt_off_delay_ms": 200,
"serial_settings": {
@ -319,6 +321,7 @@
"model": "FT-817",
"hamlib_id": 1020,
"baud_rate": 9600,
"ptt_method": "RTS",
"ptt_on_delay_ms": 350,
"ptt_off_delay_ms": 250,
"serial_settings": {
@ -370,7 +373,7 @@
"note": "FT-817 successor. Same settings. Connect to DATA port for audio."
},
"notes": "FT-817 successor. Same settings.",
"tested": false,
"tested": true,
"power_watts": 6
},
{
@ -379,6 +382,7 @@
"model": "FT-857",
"hamlib_id": 1022,
"baud_rate": 9600,
"ptt_method": "RTS",
"ptt_on_delay_ms": 300,
"ptt_off_delay_ms": 200,
"serial_settings": {
@ -408,6 +412,7 @@
"model": "FT-897",
"hamlib_id": 1023,
"baud_rate": 9600,
"ptt_method": "RTS",
"ptt_on_delay_ms": 300,
"ptt_off_delay_ms": 200,
"serial_settings": {
@ -437,6 +442,7 @@
"model": "TS-590SG",
"hamlib_id": 2037,
"baud_rate": 115200,
"ptt_method": "RTS",
"ptt_on_delay_ms": 250,
"ptt_off_delay_ms": 150,
"serial_settings": {
@ -469,6 +475,7 @@
"model": "TS-480",
"hamlib_id": 2028,
"baud_rate": 9600,
"ptt_method": "RTS",
"ptt_on_delay_ms": 300,
"ptt_off_delay_ms": 200,
"serial_settings": {
@ -497,6 +504,7 @@
"model": "TS-2000",
"hamlib_id": 2014,
"baud_rate": 9600,
"ptt_method": "RTS",
"ptt_on_delay_ms": 300,
"ptt_off_delay_ms": 200,
"serial_settings": {
@ -525,6 +533,7 @@
"model": "KX3",
"hamlib_id": 2045,
"baud_rate": 38400,
"ptt_method": "RTS",
"ptt_on_delay_ms": 250,
"ptt_off_delay_ms": 150,
"serial_settings": {
@ -555,6 +564,7 @@
"model": "KX2",
"hamlib_id": 2044,
"baud_rate": 38400,
"ptt_method": "RTS",
"ptt_on_delay_ms": 250,
"ptt_off_delay_ms": 150,
"serial_settings": {
@ -585,6 +595,7 @@
"model": "K3/K3S",
"hamlib_id": 2029,
"baud_rate": 38400,
"ptt_method": "RTS",
"ptt_on_delay_ms": 250,
"ptt_off_delay_ms": 150,
"serial_settings": {
@ -615,6 +626,7 @@
"model": "K4",
"hamlib_id": 2047,
"baud_rate": 38400,
"ptt_method": "RTS",
"ptt_on_delay_ms": 200,
"ptt_off_delay_ms": 150,
"serial_settings": {
@ -695,7 +707,7 @@
"sources": ["https://dl2man.de/4-trusdx-manual/", "https://oh8stn.org/blog/2023/03/24/qrp-nvis-data-modes-go-kit-trusdx/"]
},
"notes": "QRP kit. TS-480 emulation. Firmware 2.00+ requires 115200. Older firmware uses 38400. External audio interface required for freedvtnc2.",
"tested": false,
"tested": true,
"power_watts": 5,
"requires_external_audio": true
},

View file

@ -1,8 +1,8 @@
[Unit]
Description=FreeDV TNC for ReticulumHF
After=network.target rigctld.service
# Use Wants instead of Requires so VOX mode works without rigctld
Wants=rigctld.service
Requires=rigctld.service
ConditionPathExists=/etc/reticulumhf/.setup_complete
ConditionPathExists=/etc/reticulumhf/config.env
# Restart limits - must be in [Unit] section

View file

@ -6,9 +6,13 @@ Runs as a captive portal on first boot for zero-config setup.
import json
import os
import shutil
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
from flask import Flask, render_template, request, jsonify, redirect, url_for
from hardware import (
@ -17,6 +21,10 @@ from hardware import (
set_audio_levels, get_system_info
)
# Configuration constants
FREEDVTNC2_STARTUP_TIMEOUT_SECS = 15 # Wait for freedvtnc2 to start listening
FREEDVTNC2_POLL_INTERVAL_SECS = 0.5 # Check interval during startup
app = Flask(__name__)
@ -34,8 +42,16 @@ def add_cache_headers(response):
# Configuration paths
CONFIG_DIR = Path(__file__).parent.parent / "configs"
RETICULUM_CONFIG = Path.home() / ".reticulum" / "config"
SETUP_COMPLETE_FLAG = Path("/etc/reticulumhf/.setup_complete")
RETICULUMHF_DIR = Path("/etc/reticulumhf")
RETICULUMHF_CONFIG_ENV = RETICULUMHF_DIR / "config.env"
RETICULUMHF_BACKUPS_DIR = RETICULUMHF_DIR / "backups"
SETUP_COMPLETE_FLAG = RETICULUMHF_DIR / ".setup_complete"
PI_HOME = Path("/home/pi")
RETICULUM_DIR = PI_HOME / ".reticulum"
RETICULUM_CONFIG = RETICULUM_DIR / "config"
FREEDVTNC2_BIN = PI_HOME / ".local/bin/freedvtnc2"
HOSTAPD_CONF = Path("/etc/hostapd/hostapd.conf")
ASOUND_CONF = Path("/etc/asound.conf")
def load_peers() -> dict:
@ -52,27 +68,99 @@ def is_setup_complete() -> bool:
return SETUP_COMPLETE_FLAG.exists()
def get_radio_by_id(radio_id: str) -> Optional[dict]:
"""
Look up radio configuration by ID.
Returns radio dict or None if not found.
"""
radios = load_radios()
return next((r for r in radios if r["id"] == radio_id), None)
def backup_existing_configs() -> dict:
"""
Backup existing configuration files before overwriting.
Returns dict with backup paths or None if no backup needed.
"""
RETICULUMHF_BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backups = {}
# Backup config.env if it exists
if RETICULUMHF_CONFIG_ENV.exists():
backup_path = RETICULUMHF_BACKUPS_DIR / f"config.env.{timestamp}"
try:
shutil.copy2(RETICULUMHF_CONFIG_ENV, backup_path)
backups["config_env"] = str(backup_path)
except Exception:
pass
# Backup Reticulum config if it exists
reticulum_config = RETICULUM_CONFIG
if reticulum_config.exists():
backup_path = backup_dir / f"reticulum_config.{timestamp}"
try:
shutil.copy2(reticulum_config, backup_path)
backups["reticulum_config"] = str(backup_path)
except Exception:
pass
return backups
def validate_config_env(config_path: Optional[Path] = None) -> Tuple[bool, str, dict]:
"""
Validate that config.env exists and contains required variables.
Returns (is_valid, error_message, config_dict).
"""
if config_path is None:
config_path = RETICULUMHF_CONFIG_ENV
if not config_path.exists():
return False, "config.env not found", {}
required_keys = ["RADIO_ID", "AUDIO_CARD", "FREEDVTNC2_CMD"]
config = {}
try:
content = config_path.read_text()
for line in content.split('\n'):
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
# Remove quotes from value
value = value.strip('"').strip("'")
config[key.strip()] = value
except Exception as e:
return False, f"Failed to read config.env: {e}", {}
# Check required keys
missing = [k for k in required_keys if not config.get(k)]
if missing:
return False, f"Missing required config: {', '.join(missing)}", config
# Validate FREEDVTNC2_CMD is not empty
if not config.get("FREEDVTNC2_CMD", "").strip():
return False, "FREEDVTNC2_CMD is empty", config
return True, "", config
def generate_reticulum_config(radio_id: str, serial_port: str, audio_card: int,
peers_config: dict = None,
ifac_name: str = "", ifac_pass: str = "") -> str:
"""
Generate Reticulum configuration file content.
Configures interfaces: HF (freedvtnc2), selected TCP peers, and I2P.
Configures HF interface via freedvtnc2.
peers_config format:
{
'lfm_i2p': True/False # Lightfighter I2P bridge
}
ifac_name: Optional IFAC network name for gateway security
ifac_pass: Optional IFAC passphrase for gateway security
Args:
radio_id: Radio identifier from radios.json
serial_port: Serial port for CAT control (or empty for VOX)
audio_card: ALSA audio card number
ifac_name: Optional IFAC network name for gateway security
ifac_pass: Optional IFAC passphrase for gateway security
"""
if peers_config is None:
peers_config = {'lfm_i2p': False}
radios = load_radios()
radio = next((r for r in radios if r["id"] == radio_id), None)
peers_data = load_peers()
radio = get_radio_by_id(radio_id)
if not radio:
raise ValueError(f"Unknown radio: {radio_id}")
@ -123,8 +211,6 @@ def generate_reticulum_config(radio_id: str, serial_port: str, audio_card: int,
"",
])
# I2P support removed - HF radio only for now
return "\n".join(config_lines)
@ -137,7 +223,7 @@ def get_freedvtnc2_device_id(alsa_card: int) -> int:
# Run freedvtnc2 --list-audio-devices and find the matching device
try:
result = subprocess.run(
["/home/pi/.local/bin/freedvtnc2", "--list-audio-devices"],
[str(FREEDVTNC2_BIN), "--list-audio-devices"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
@ -165,9 +251,7 @@ def generate_freedvtnc2_command(radio_id: str, serial_port: str, audio_card: int
audio_card: ALSA audio card number
freedv_mode: FreeDV data mode (DATAC1, DATAC3, DATAC4)
"""
radios = load_radios()
radio = next((r for r in radios if r["id"] == radio_id), None)
radio = get_radio_by_id(radio_id)
if not radio:
raise ValueError(f"Unknown radio: {radio_id}")
@ -189,7 +273,7 @@ def generate_freedvtnc2_command(radio_id: str, serial_port: str, audio_card: int
rigctld_port = "0" if use_vox else "4532"
cmd_parts = [
"/home/pi/.local/bin/freedvtnc2",
str(FREEDVTNC2_BIN),
"--no-cli",
f"--input-device {device_id}",
f"--output-device {device_id}",
@ -209,9 +293,7 @@ def generate_rigctld_command(radio_id: str, serial_port: str) -> str:
"""
Generate rigctld launch command.
"""
radios = load_radios()
radio = next((r for r in radios if r["id"] == radio_id), None)
radio = get_radio_by_id(radio_id)
if not radio:
raise ValueError(f"Unknown radio: {radio_id}")
@ -339,7 +421,7 @@ def api_set_audio():
return jsonify(result)
def validate_wifi_settings(ssid: str, password: str) -> tuple:
def validate_wifi_settings(ssid: str, password: str) -> Tuple[bool, str]:
"""
Validate WiFi SSID and password.
Returns (is_valid, error_message)
@ -489,17 +571,25 @@ def api_complete_setup():
audio_card = data.get("audio_card")
freedv_mode = data.get("freedv_mode", "DATAC1")
# Handle peers format from UI checkboxes
peers_config = data.get("peers", {})
# If old format is passed, convert it
if not peers_config and "enable_i2p" in data:
peers_config = {
'lfm_i2p': data.get("enable_i2p", True)
}
if not all([radio_id, serial_port, audio_card is not None]):
if not all([radio_id, audio_card is not None]):
return jsonify({"success": False, "error": "Missing required fields"}), 400
# Validate serial_port requirement based on radio's PTT method
radios = load_radios()
radio = next((r for r in radios if r["id"] == radio_id), None)
if not radio:
return jsonify({"success": False, "error": f"Unknown radio: {radio_id}"}), 400
ptt_method = radio.get("ptt_method", "")
is_vox_radio = ptt_method.upper() == "VOX"
# Non-VOX radios require a serial port for CAT control
if not is_vox_radio and not serial_port:
return jsonify({
"success": False,
"error": "Serial port required for CAT control. Select a port or use a VOX-capable radio."
}), 400
# Get IFAC security settings
ifac_name = data.get("ifac_name", "")
ifac_pass = data.get("ifac_pass", "")
@ -523,9 +613,12 @@ def api_complete_setup():
wifi_ssid = current_ssid
try:
# Backup existing configs before overwriting (prevents data loss)
config_backups = backup_existing_configs()
# Generate Reticulum config
reticulum_config = generate_reticulum_config(
radio_id, serial_port, audio_card, peers_config,
radio_id, serial_port, audio_card,
ifac_name=ifac_name, ifac_pass=ifac_pass
)
@ -570,6 +663,11 @@ RETICULUMHF_AP_PASS={wifi_password}
with open(env_dir / "config.env", "w") as f:
f.write(env_content)
# Validate the config we just wrote
config_valid, config_error, _ = validate_config_env(env_dir / "config.env")
if not config_valid:
return jsonify({"success": False, "error": f"Config validation failed: {config_error}"}), 500
# Mark setup as complete
SETUP_COMPLETE_FLAG.parent.mkdir(parents=True, exist_ok=True)
SETUP_COMPLETE_FLAG.touch()
@ -581,23 +679,36 @@ RETICULUMHF_AP_PASS={wifi_password}
# Restart hostapd to apply new SSID
subprocess.run(["systemctl", "restart", "hostapd"], capture_output=True)
# Determine if using VOX mode (no CAT control needed)
# Note: radio and is_vox_radio already loaded during validation above
use_vox = is_vox_radio or (not serial_port)
# Enable and start the HF stack services
subprocess.run(["systemctl", "daemon-reload"], capture_output=True)
subprocess.run(["systemctl", "enable", "rigctld", "freedvtnc2", "reticulumhf-rnsd"], capture_output=True)
if use_vox:
# VOX mode - only enable freedvtnc2 and rnsd (no rigctld needed)
subprocess.run(["systemctl", "enable", "freedvtnc2", "reticulumhf-rnsd"], capture_output=True)
subprocess.run(["systemctl", "disable", "rigctld"], capture_output=True)
else:
# CAT mode - enable all services including rigctld
subprocess.run(["systemctl", "enable", "rigctld", "freedvtnc2", "reticulumhf-rnsd"], capture_output=True)
# Start radio services FIRST (freedvtnc2 must be listening before rnsd connects)
subprocess.run(["systemctl", "start", "rigctld"], capture_output=True)
if not use_vox:
subprocess.run(["systemctl", "start", "rigctld"], capture_output=True)
subprocess.run(["systemctl", "start", "freedvtnc2"], capture_output=True)
# Wait for freedvtnc2 to be listening on KISS port before restarting rnsd
for _ in range(10):
max_checks = int(FREEDVTNC2_STARTUP_TIMEOUT_SECS / FREEDVTNC2_POLL_INTERVAL_SECS)
for _ in range(max_checks):
result = subprocess.run(
["ss", "-tln", "sport", "=", "8001"],
capture_output=True, text=True
)
if "8001" in result.stdout:
break
time.sleep(0.5)
time.sleep(FREEDVTNC2_POLL_INTERVAL_SECS)
# Now restart rnsd to connect to freedvtnc2
subprocess.run(["systemctl", "restart", "reticulumhf-rnsd"], capture_output=True)
@ -889,14 +1000,15 @@ def api_restart_services():
subprocess.run(["systemctl", "restart", "freedvtnc2"], capture_output=True, timeout=10)
# Wait for freedvtnc2 to be listening
for _ in range(10):
max_checks = int(FREEDVTNC2_STARTUP_TIMEOUT_SECS / FREEDVTNC2_POLL_INTERVAL_SECS)
for _ in range(max_checks):
result = subprocess.run(
["ss", "-tln", "sport", "=", "8001"],
capture_output=True, text=True
)
if "8001" in result.stdout:
break
time.sleep(0.5)
time.sleep(FREEDVTNC2_POLL_INTERVAL_SECS)
# Now restart rnsd
subprocess.run(["systemctl", "restart", "reticulumhf-rnsd"], capture_output=True, timeout=10)
@ -945,8 +1057,9 @@ def api_restore_defaults():
def api_reset_setup():
"""API endpoint to reset setup and allow reconfiguration."""
try:
# 1. Stop radio services first
# 1. Stop and disable radio services
subprocess.run(["systemctl", "stop", "rigctld", "freedvtnc2"], capture_output=True)
subprocess.run(["systemctl", "disable", "rigctld", "freedvtnc2"], capture_output=True)
# 2. Remove setup complete flag
if SETUP_COMPLETE_FLAG.exists():

View file

@ -8,6 +8,7 @@ import subprocess
import re
import json
import os
import time
from pathlib import Path
from typing import Optional
@ -385,7 +386,6 @@ def test_ptt(port: str, radio_id: str) -> dict:
base_cmd.extend(["--set-conf=" + ",".join(conf_parts)])
import time
keyed = False
try:
@ -407,8 +407,8 @@ def test_ptt(port: str, radio_id: str) -> dict:
try:
cmd_off = base_cmd + ["T", "0"]
subprocess.run(cmd_off, capture_output=True, text=True, timeout=5)
except:
pass
except Exception:
pass # Swallow errors during PTT release - safety critical
if keyed:
return {