[fix](trx-server): comply with APRS-IS IGate spec
- Append mandatory q-construct (,qAR,<callsign>) to all forwarded TNC2 packets via updated format_tnc2(pkt, igate_call) - Add TCPIP/TCPXX loop-prevention check before forwarding - Drain server-sent data in select! loop to prevent TCP backpressure - Enable TCP_NODELAY for low-latency packet forwarding - Guard against history replays: skip packets older than 2 minutes - Use "trx-rs" in login string and keepalive comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -46,14 +46,19 @@ pub fn compute_passcode(callsign: &str) -> u16 {
|
|||||||
hash & 0x7fff
|
hash & 0x7fff
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an [`AprsPacket`] as a TNC2 line (CRLF-terminated) for APRS-IS.
|
/// Format an [`AprsPacket`] as a TNC2 line (CRLF-terminated) for APRS-IS,
|
||||||
fn format_tnc2(pkt: &AprsPacket) -> String {
|
/// appending the mandatory `,qAR,<igate_call>` q-construct that identifies
|
||||||
|
/// the RF entry point per the APRS-IS IGate specification.
|
||||||
|
fn format_tnc2(pkt: &AprsPacket, igate_call: &str) -> String {
|
||||||
if pkt.path.is_empty() {
|
if pkt.path.is_empty() {
|
||||||
format!("{}>{}:{}\r\n", pkt.src_call, pkt.dest_call, pkt.info)
|
format!(
|
||||||
|
"{}>{},qAR,{}:{}\r\n",
|
||||||
|
pkt.src_call, pkt.dest_call, igate_call, pkt.info
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"{}>{},{}:{}\r\n",
|
"{}>{},{},qAR,{}:{}\r\n",
|
||||||
pkt.src_call, pkt.dest_call, pkt.path, pkt.info
|
pkt.src_call, pkt.dest_call, pkt.path, igate_call, pkt.info
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +104,11 @@ pub async fn run_aprsfi_uplink(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Disable Nagle algorithm for low-latency packet forwarding.
|
||||||
|
if let Err(e) = stream.set_nodelay(true) {
|
||||||
|
warn!("APRS-IS IGate: set_nodelay failed: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
let (read_half, mut write_half) = stream.into_split();
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
let mut reader = BufReader::new(read_half);
|
let mut reader = BufReader::new(read_half);
|
||||||
|
|
||||||
@@ -106,7 +116,7 @@ pub async fn run_aprsfi_uplink(
|
|||||||
// Login
|
// Login
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
let login = format!(
|
let login = format!(
|
||||||
"user {} pass {} vers trx-server {}\r\n",
|
"user {} pass {} vers trx-rs {}\r\n",
|
||||||
callsign,
|
callsign,
|
||||||
passcode,
|
passcode,
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
@@ -184,11 +194,13 @@ pub async fn run_aprsfi_uplink(
|
|||||||
let first_at = time::Instant::now() + period;
|
let first_at = time::Instant::now() + period;
|
||||||
let mut keepalive_tick = time::interval_at(first_at, period);
|
let mut keepalive_tick = time::interval_at(first_at, period);
|
||||||
let mut stats_tick = time::interval_at(first_at, period);
|
let mut stats_tick = time::interval_at(first_at, period);
|
||||||
|
// Reuse a single allocation for draining server-sent lines.
|
||||||
|
let mut server_line = String::new();
|
||||||
|
|
||||||
'forward: loop {
|
'forward: loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = keepalive_tick.tick() => {
|
_ = keepalive_tick.tick() => {
|
||||||
if let Err(e) = write_half.write_all(b"# trx-server keepalive\r\n").await {
|
if let Err(e) = write_half.write_all(b"# trx-rs keepalive\r\n").await {
|
||||||
warn!("APRS-IS IGate: keepalive write failed: {}", e);
|
warn!("APRS-IS IGate: keepalive write failed: {}", e);
|
||||||
stats_write_errors += 1;
|
stats_write_errors += 1;
|
||||||
break 'forward;
|
break 'forward;
|
||||||
@@ -203,6 +215,20 @@ pub async fn run_aprsfi_uplink(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drain the server feed. The server sends a full APRS stream;
|
||||||
|
// if we never read it the TCP receive buffer fills and stalls
|
||||||
|
// the connection via flow control. EOF triggers reconnect.
|
||||||
|
result = reader.read_line(&mut server_line) => {
|
||||||
|
server_line.clear();
|
||||||
|
match result {
|
||||||
|
Ok(0) | Err(_) => {
|
||||||
|
warn!("APRS-IS IGate: server closed connection");
|
||||||
|
break 'forward;
|
||||||
|
}
|
||||||
|
Ok(_) => {} // discard — we do not gate IS→RF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
recv = decode_rx.recv() => {
|
recv = decode_rx.recv() => {
|
||||||
match recv {
|
match recv {
|
||||||
Ok(DecodedMessage::Aprs(pkt)) | Ok(DecodedMessage::HfAprs(pkt)) => {
|
Ok(DecodedMessage::Aprs(pkt)) | Ok(DecodedMessage::HfAprs(pkt)) => {
|
||||||
@@ -225,7 +251,13 @@ pub async fn run_aprsfi_uplink(
|
|||||||
continue 'forward;
|
continue 'forward;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let tnc2 = format_tnc2(&pkt);
|
// Loop prevention: do not re-gate packets that already
|
||||||
|
// passed through APRS-IS (TCPIP or TCPXX in path).
|
||||||
|
if pkt.path.contains("TCPIP") || pkt.path.contains("TCPXX") {
|
||||||
|
stats_skipped += 1;
|
||||||
|
continue 'forward;
|
||||||
|
}
|
||||||
|
let tnc2 = format_tnc2(&pkt, &callsign);
|
||||||
debug!("APRS-IS: forwarded {}>{},...", pkt.src_call, pkt.dest_call);
|
debug!("APRS-IS: forwarded {}>{},...", pkt.src_call, pkt.dest_call);
|
||||||
if let Err(e) = write_half.write_all(tnc2.as_bytes()).await {
|
if let Err(e) = write_half.write_all(tnc2.as_bytes()).await {
|
||||||
warn!("APRS-IS IGate: packet write failed: {}", e);
|
warn!("APRS-IS IGate: packet write failed: {}", e);
|
||||||
@@ -248,7 +280,7 @@ pub async fn run_aprsfi_uplink(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward loop exited due to a write error — reconnect with backoff
|
// Forward loop exited due to a write error or server EOF — reconnect with backoff
|
||||||
stats_reconnects += 1;
|
stats_reconnects += 1;
|
||||||
warn!(
|
warn!(
|
||||||
"APRS-IS IGate: disconnected from {}:{}, reconnecting in {}s",
|
"APRS-IS IGate: disconnected from {}:{}, reconnecting in {}s",
|
||||||
@@ -311,7 +343,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tnc2_with_path() {
|
fn tnc2_with_path_adds_qar() {
|
||||||
let pkt = make_pkt(
|
let pkt = make_pkt(
|
||||||
"N0CALL-9",
|
"N0CALL-9",
|
||||||
"APRS",
|
"APRS",
|
||||||
@@ -320,14 +352,17 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_tnc2(&pkt),
|
format_tnc2(&pkt, "SP2SJG"),
|
||||||
"N0CALL-9>APRS,WIDE1-1,WIDE2-1:!1234.56N/01234.56E-Test\r\n"
|
"N0CALL-9>APRS,WIDE1-1,WIDE2-1,qAR,SP2SJG:!1234.56N/01234.56E-Test\r\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tnc2_without_path() {
|
fn tnc2_without_path_adds_qar() {
|
||||||
let pkt = make_pkt("W1AW", "BEACON", "", ">Test status", true);
|
let pkt = make_pkt("W1AW", "BEACON", "", ">Test status", true);
|
||||||
assert_eq!(format_tnc2(&pkt), "W1AW>BEACON:>Test status\r\n");
|
assert_eq!(
|
||||||
|
format_tnc2(&pkt, "SP2SJG"),
|
||||||
|
"W1AW>BEACON,qAR,SP2SJG:>Test status\r\n"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user