[refactor](trx-rs): resolve all improvement areas (P0-P3)

Addresses every item in docs/Improvement-Areas.md:

P0 - Plugin signing: new src/trx-app/src/plugins.rs with SHA-256 checksum
     manifest, filename allowlisting, API version compatibility checks,
     and cross-platform file permission validation.

P1 - Session store mutex poisoning: all .unwrap() calls on RwLock/Mutex in
     auth.rs replaced with .unwrap_or_else(|e| e.into_inner()) + warning logs.
   - TCP listener rate limiting: added ConnectionTracker with per-IP connection
     cap (10 concurrent connections per IP).
   - RigState refactoring: decoder fields grouped into DecoderConfig and
     DecoderResetSeqs sub-structs with #[serde(flatten)] for wire compat.
   - spawn_blocking timeout: satellite pass computation wrapped in 30s timeout.

P2 - Command handler macro: rig_command! macro generates 7 unit-struct command
     implementations, reducing ~200 lines of boilerplate.
   - Protocol versioning: added protocol_version field to ClientEnvelope and
     ClientResponse; improved unknown command error handling in parse_envelope.
   - Unsafe string: replaced from_utf8_unchecked with safe from_utf8().expect().
   - Dead code: removed 2 unnecessary annotations, documented remaining 4.

P3 - Tests: added 4 unit tests for history_store.rs (round-trip, expiry, etc).
   - FT-817 VFO: improved inference for ambiguous same-frequency case.
   - Configurator: implemented serial port detection via tokio_serial.
   - Plugin versioning: integrated into plugin manifest (api_version field).
   - Naming: documented as intentional semantic distinctions, not inconsistencies.

https://claude.ai/code/session_01Gj1vEkP6GKVcVaMqzFW885
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-29 11:06:23 +00:00
committed by Stan Grams
parent 8e3162d7e6
commit a69c5143e6
23 changed files with 1129 additions and 603 deletions
+1 -2
View File
@@ -262,8 +262,7 @@ impl Default for HttpAuthConfig {
}
impl HttpAuthConfig {
/// Convert session TTL from minutes to Duration
#[allow(dead_code)]
/// Convert session TTL from minutes to Duration.
pub fn session_ttl(&self) -> Duration {
Duration::from_secs(self.session_ttl_min * 60)
}
+2 -8
View File
@@ -1225,17 +1225,10 @@ mod tests {
server_longitude: None,
pskreporter_status: Some("Disabled".to_string()),
aprs_is_status: Some("Disabled".to_string()),
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
decoders: trx_core::DecoderConfig::default(),
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
lrpt_decode_enabled: false,
filter: None,
spectrum: None,
vchan_rds: None,
@@ -1251,6 +1244,7 @@ mod tests {
let response = serde_json::to_string(&ClientResponse {
success: true,
rig_id: Some("server".to_string()),
protocol_version: None,
state: None,
rigs: Some(vec![RigEntry {
rig_id: "default".to_string(),
@@ -105,6 +105,7 @@ async fn handle_client(
let resp = ClientResponse {
success: false,
rig_id: None,
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -119,6 +120,7 @@ async fn handle_client(
let resp = ClientResponse {
success: false,
rig_id: None,
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -138,6 +140,7 @@ async fn handle_client(
let resp = ClientResponse {
success: true,
rig_id: Some("client".to_string()),
protocol_version: None,
state: None,
rigs: Some(snapshot_remote_rigs(context.as_ref())),
sat_passes: None,
@@ -170,6 +173,7 @@ async fn handle_client(
let resp = ClientResponse {
success: false,
rig_id: active_rig_id.clone(),
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -182,6 +186,7 @@ async fn handle_client(
let resp = ClientResponse {
success: false,
rig_id: active_rig_id.clone(),
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -197,6 +202,7 @@ async fn handle_client(
let resp = ClientResponse {
success: true,
rig_id: active_rig_id.clone(),
protocol_version: None,
state: Some(snapshot),
rigs: None,
sat_passes: None,
@@ -208,6 +214,7 @@ async fn handle_client(
let resp = ClientResponse {
success: false,
rig_id: active_rig_id.clone(),
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -220,6 +227,7 @@ async fn handle_client(
let resp = ClientResponse {
success: false,
rig_id: active_rig_id.clone(),
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -231,6 +239,7 @@ async fn handle_client(
let resp = ClientResponse {
success: false,
rig_id: active_rig_id.clone(),
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -415,14 +424,7 @@ mod tests {
server_longitude: None,
pskreporter_status: Some("Disabled".to_string()),
aprs_is_status: Some("Disabled".to_string()),
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
decoders: trx_core::DecoderConfig::default(),
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
@@ -59,8 +59,7 @@ fn base64_encode(data: &[u8]) -> String {
b'='
});
}
// SAFETY: output contains only ASCII base64 characters.
unsafe { String::from_utf8_unchecked(out) }
String::from_utf8(out).expect("base64 output is always valid ASCII")
}
/// Encode spectrum bins as a compact base64 string of i8 values (1 dB/step).
@@ -1168,7 +1167,7 @@ pub async fn toggle_aprs_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().aprs_decode_enabled;
let enabled = state.get_ref().borrow().decoders.aprs_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetAprsDecodeEnabled(!enabled),
@@ -1183,7 +1182,7 @@ pub async fn toggle_hf_aprs_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().hf_aprs_decode_enabled;
let enabled = state.get_ref().borrow().decoders.hf_aprs_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetHfAprsDecodeEnabled(!enabled),
@@ -1198,7 +1197,7 @@ pub async fn toggle_cw_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().cw_decode_enabled;
let enabled = state.get_ref().borrow().decoders.cw_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetCwDecodeEnabled(!enabled),
@@ -1258,7 +1257,7 @@ pub async fn toggle_ft8_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().ft8_decode_enabled;
let enabled = state.get_ref().borrow().decoders.ft8_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetFt8DecodeEnabled(!enabled),
@@ -1273,7 +1272,7 @@ pub async fn toggle_ft4_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().ft4_decode_enabled;
let enabled = state.get_ref().borrow().decoders.ft4_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetFt4DecodeEnabled(!enabled),
@@ -1288,7 +1287,7 @@ pub async fn toggle_ft2_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().ft2_decode_enabled;
let enabled = state.get_ref().borrow().decoders.ft2_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetFt2DecodeEnabled(!enabled),
@@ -1303,7 +1302,7 @@ pub async fn toggle_wspr_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().wspr_decode_enabled;
let enabled = state.get_ref().borrow().decoders.wspr_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetWsprDecodeEnabled(!enabled),
@@ -1318,7 +1317,7 @@ pub async fn toggle_lrpt_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().lrpt_decode_enabled;
let enabled = state.get_ref().borrow().decoders.lrpt_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetLrptDecodeEnabled(!enabled),
@@ -2451,6 +2450,7 @@ async fn send_command(
Ok(Ok(snapshot)) => Ok(HttpResponse::Ok().json(ClientResponse {
success: true,
rig_id: None,
protocol_version: None,
state: Some(snapshot),
rigs: None,
sat_passes: None,
@@ -2459,6 +2459,7 @@ async fn send_command(
Ok(Err(err)) => Ok(HttpResponse::BadRequest().json(ClientResponse {
success: false,
rig_id: None,
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
@@ -2653,17 +2654,10 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
server_longitude: state.server_longitude,
pskreporter_status: state.pskreporter_status,
aprs_is_status: state.aprs_is_status,
aprs_decode_enabled: state.aprs_decode_enabled,
hf_aprs_decode_enabled: state.hf_aprs_decode_enabled,
cw_decode_enabled: state.cw_decode_enabled,
decoders: state.decoders.clone(),
cw_auto: state.cw_auto,
cw_wpm: state.cw_wpm,
cw_tone_hz: state.cw_tone_hz,
ft8_decode_enabled: state.ft8_decode_enabled,
ft4_decode_enabled: state.ft4_decode_enabled,
ft2_decode_enabled: state.ft2_decode_enabled,
wspr_decode_enabled: state.wspr_decode_enabled,
lrpt_decode_enabled: state.lrpt_decode_enabled,
filter: state.filter.clone(),
spectrum: None,
vchan_rds: None,
@@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use tracing::warn;
/// Unique session identifier (hex-encoded 128-bit random)
pub type SessionId = String;
@@ -86,14 +87,20 @@ impl SessionStore {
last_seen: now,
};
let mut store = self.sessions.write().unwrap();
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (create), recovering");
e.into_inner()
});
store.insert(session_id.clone(), record);
session_id
}
/// Get session by ID (returns None if expired or not found)
pub fn get(&self, session_id: &SessionId) -> Option<SessionRecord> {
let mut store = self.sessions.write().unwrap();
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (get), recovering");
e.into_inner()
});
if let Some(record) = store.get_mut(session_id) {
if !record.is_expired() {
record.update_last_seen();
@@ -107,13 +114,19 @@ impl SessionStore {
/// Invalidate a session
pub fn remove(&self, session_id: &SessionId) {
let mut store = self.sessions.write().unwrap();
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (remove), recovering");
e.into_inner()
});
store.remove(session_id);
}
/// Remove all expired sessions
pub fn cleanup_expired(&self) {
let mut store = self.sessions.write().unwrap();
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (cleanup), recovering");
e.into_inner()
});
let now = SystemTime::now();
store.retain(|_, record| record.expires_at > now);
}
@@ -226,7 +239,10 @@ impl LoginRateLimiter {
/// Check whether an IP is rate-limited. Returns `true` if the request
/// should be allowed, `false` if rate-limited.
pub fn check(&self, ip: &str) -> bool {
let mut map = self.attempts.lock().unwrap();
let mut map = self.attempts.lock().unwrap_or_else(|e| {
warn!("Rate limiter lock poisoned (check), recovering");
e.into_inner()
});
let now = Instant::now();
if let Some((count, window_start)) = map.get_mut(ip) {
if now.duration_since(*window_start) > self.window {
@@ -248,7 +264,10 @@ impl LoginRateLimiter {
/// Record a successful login — clears the rate-limit counter for the IP.
pub fn reset(&self, ip: &str) {
let mut map = self.attempts.lock().unwrap();
let mut map = self.attempts.lock().unwrap_or_else(|e| {
warn!("Rate limiter lock poisoned (reset), recovering");
e.into_inner()
});
map.remove(ip);
}
}
@@ -648,9 +667,8 @@ where
}
}
/// Check if a path is a TX/PTT endpoint (for future TX access control)
#[allow(dead_code)]
fn is_tx_endpoint(path: &str) -> bool {
/// Check if a path is a TX/PTT endpoint (used for TX access control).
pub fn is_tx_endpoint(path: &str) -> bool {
path.contains("ptt")
|| path.contains("set_ptt")
|| path.contains("toggle_ptt")
@@ -656,14 +656,7 @@ mod tests {
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
decoders: trx_core::DecoderConfig::default(),
cw_auto: false,
cw_wpm: 0,
cw_tone_hz: 0,