Skip to content

Commit 9111507

Browse files
andysnellclaude
andcommitted
Fix final code review findings: docs, iface validation, test env guards
Corrects the crate docs and README to describe sip-tunnel as the static control-plane daemon it actually is (it never inspects SIP/RTP packets), fixes the masquerade troubleshooting note to name the Tailscale interface instead of WAN, and marks --rtp-end-port as inclusive throughout. Adds input validation for --wan-iface/--tailscale-iface (mirroring the existing table-name check) since both are interpolated unquoted-adjacent into the generated nftables ruleset. Warns on stderr when --log-level/RUST_LOG fails to parse instead of silently falling back to info. Hardens missing_freeswitch_ip_exits_1 and defaults_are_used_when_nothing_else_is_set against inherited shell environment variables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f4e34f4 commit 9111507

7 files changed

Lines changed: 136 additions & 13 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "sip-tunnel"
33
version = "0.1.0"
44
edition = "2024"
55
rust-version = "1.96"
6-
description = "A userspace SIP-aware tunnel daemon that manages nftables rules for SIP/RTP traffic"
6+
description = "A static control-plane daemon that generates and applies nftables DNAT/masquerade rules to relay SIP/RTP traffic to FreeSWITCH over Tailscale"
77
license = "MIT OR Apache-2.0"
88

99
[lib]

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ precedence is **CLI flag > env var > default**.
7474
| `--sip-tcp-port` | `SIP_TUNNEL_SIP_TCP_PORT` | `5060` | SIP TCP listen port |
7575
| `--sip-tls-port` | `SIP_TUNNEL_SIP_TLS_PORT` | `5061` | SIP TLS listen port (TCP passthrough) |
7676
| `--rtp-start-port` | `SIP_TUNNEL_RTP_START_PORT` | `16384` | Lower bound (inclusive) of the managed RTP port range |
77-
| `--rtp-end-port` | `SIP_TUNNEL_RTP_END_PORT` | `32768` | Upper bound (exclusive) of the managed RTP port range |
77+
| `--rtp-end-port` | `SIP_TUNNEL_RTP_END_PORT` | `32768` | Upper bound (inclusive) of the managed RTP port range |
7878
| `--provider` | `SIP_TUNNEL_PROVIDER` | `none` | Upstream SIP trunking provider preset: `twilio`, `bandwidth`, `telnyx`, or `none` |
7979
| `--allowlist` | `SIP_TUNNEL_ALLOWLIST` | *(empty)* | Extra source CIDRs to allow, comma-delimited, in addition to any provider preset |
8080
| `--enable-allowlist` | `SIP_TUNNEL_ENABLE_ALLOWLIST` | `false` | Enable source-IP allowlist enforcement |
@@ -221,8 +221,8 @@ The RTP port range FreeSWITCH actually binds must match the range
221221
so Twilio will accept SIP from it.
222222
- Run `sip-tunnel` with `--provider twilio` — this bakes in Twilio's 8
223223
published signaling `/30` CIDRs plus its `168.86.128.0/18` media (RTP)
224-
range as the allowlist, so `--enable-allowlist` works out of the box with
225-
no extra `--allowlist` entries required.
224+
range (UDP 10000-60000) as the allowlist, so `--enable-allowlist` works
225+
out of the box with no extra `--allowlist` entries required.
226226
- Prefer G.711 (PCMU/PCMA) codecs; they avoid transcoding and keep RTP
227227
packet sizing predictable end-to-end.
228228

@@ -294,7 +294,8 @@ always constructs its `HealthServer` with `probe: None`, so in production
294294
`sip-tunnel`'s `--rtp-start-port`/`--rtp-end-port`. A mismatch means some
295295
RTP ports aren't DNAT'd and those streams silently drop.
296296
- **Call connects but no return audio at all**: confirm the masquerade rule
297-
is present and correctly targeting the WAN interface:
297+
is present and correctly targeting the Tailscale interface (`oifname
298+
"tailscale0" ip daddr <freeswitch-ip> masquerade`):
298299
`nft list table ip sip_tunnel` (substitute your `--table-name` if
299300
non-default) on the VPS, and check for a `masquerade` statement in the
300301
postrouting chain.

src/config.rs

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ pub struct ConfigArgs {
105105
)]
106106
pub rtp_start_port: u16,
107107

108-
/// Upper bound (exclusive) of the RTP port range to manage.
108+
/// Upper bound (inclusive) of the RTP port range to manage.
109109
#[arg(
110110
long = "rtp-end-port",
111111
env = "SIP_TUNNEL_RTP_END_PORT",
@@ -229,6 +229,21 @@ pub enum ConfigError {
229229
/// list is empty.
230230
#[error("allowlist enabled but no source CIDRs configured")]
231231
EmptyAllowlist,
232+
233+
/// An interface-name field (`--wan-iface`/`--tailscale-iface`) is not a
234+
/// safe Linux interface name.
235+
#[error(
236+
"{field} {name:?} is not a valid interface name: must be 1-15 \
237+
characters with no whitespace, control characters, '\"', '/', and \
238+
not \".\" or \"..\""
239+
)]
240+
InvalidInterfaceName {
241+
/// Which field failed validation (`"wan-iface"` or
242+
/// `"tailscale-iface"`).
243+
field: &'static str,
244+
/// The rejected interface name.
245+
name: String,
246+
},
232247
}
233248

234249
/// The validated, effective configuration for a run of `sip-tunnel`.
@@ -248,7 +263,7 @@ pub struct Config {
248263
pub sip_tls_port: u16,
249264
/// Lower bound (inclusive) of the RTP port range to manage.
250265
pub rtp_start_port: u16,
251-
/// Upper bound (exclusive) of the RTP port range to manage.
266+
/// Upper bound (inclusive) of the RTP port range to manage.
252267
pub rtp_end_port: u16,
253268
/// Upstream SIP trunking provider preset.
254269
pub provider: Provider,
@@ -308,6 +323,20 @@ impl ConfigArgs {
308323
});
309324
}
310325

326+
if !is_valid_iface_name(&self.wan_iface) {
327+
return Err(ConfigError::InvalidInterfaceName {
328+
field: "wan-iface",
329+
name: self.wan_iface,
330+
});
331+
}
332+
333+
if !is_valid_iface_name(&self.tailscale_iface) {
334+
return Err(ConfigError::InvalidInterfaceName {
335+
field: "tailscale-iface",
336+
name: self.tailscale_iface,
337+
});
338+
}
339+
311340
let allow_cidrs = combined_cidrs(self.provider, &self.allowlist);
312341
if self.enable_allowlist && allow_cidrs.is_empty() {
313342
return Err(ConfigError::EmptyAllowlist);
@@ -415,6 +444,22 @@ fn is_valid_table_name(name: &str) -> bool {
415444
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
416445
}
417446

447+
/// Returns whether `name` is safe to interpolate into generated nftables
448+
/// syntax (inside double quotes) as an interface name: 1-15 characters
449+
/// (`IFNAMSIZ - 1`, matching the Linux kernel's interface name limit), no
450+
/// whitespace, no control characters, no `"` or `/`, and not `.` or `..`.
451+
fn is_valid_iface_name(name: &str) -> bool {
452+
if name.is_empty() || name.chars().count() > 15 {
453+
return false;
454+
}
455+
if name == "." || name == ".." {
456+
return false;
457+
}
458+
!name
459+
.chars()
460+
.any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '/')
461+
}
462+
418463
/// The `clap::ValueEnum` name for a [`Provider`] variant, used for
419464
/// deterministic `summary_lines` output.
420465
fn provider_name(provider: Provider) -> &'static str {
@@ -538,6 +583,68 @@ mod tests {
538583
));
539584
}
540585

586+
#[test]
587+
fn valid_iface_names_pass() {
588+
for name in ["eth0", "tailscale0", "wg-vps0"] {
589+
let mut wan = valid_args();
590+
wan.wan_iface = name.to_string();
591+
assert!(wan.validate().is_ok(), "wan_iface {name:?} should be valid");
592+
593+
let mut ts = valid_args();
594+
ts.tailscale_iface = name.to_string();
595+
assert!(
596+
ts.validate().is_ok(),
597+
"tailscale_iface {name:?} should be valid"
598+
);
599+
}
600+
}
601+
602+
#[test]
603+
fn invalid_wan_iface_names_error() {
604+
let invalid = [
605+
"",
606+
"sixteen_chars123",
607+
"eth\"0",
608+
"eth\n0",
609+
"eth 0",
610+
".",
611+
"..",
612+
];
613+
for name in invalid {
614+
let mut args = valid_args();
615+
args.wan_iface = name.to_string();
616+
assert!(
617+
matches!(
618+
args.validate(),
619+
Err(ConfigError::InvalidInterfaceName {
620+
field: "wan-iface",
621+
..
622+
})
623+
),
624+
"wan_iface {name:?} should be rejected"
625+
);
626+
}
627+
}
628+
629+
#[test]
630+
fn invalid_tailscale_iface_names_error() {
631+
let invalid = ["", "sixteen_chars123", "ts\"0", "ts\n0", "ts 0", ".", ".."];
632+
for name in invalid {
633+
let mut args = valid_args();
634+
args.tailscale_iface = name.to_string();
635+
assert!(
636+
matches!(
637+
args.validate(),
638+
Err(ConfigError::InvalidInterfaceName {
639+
field: "tailscale-iface",
640+
..
641+
})
642+
),
643+
"tailscale_iface {name:?} should be rejected"
644+
);
645+
}
646+
}
647+
541648
#[test]
542649
fn enable_allowlist_with_no_cidrs_errors() {
543650
let mut args = valid_args();

src/lib.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
//! `sip-tunnel` is a userspace daemon that watches SIP signaling traffic and
2-
//! dynamically manages `nftables` rules to open and close the RTP media
3-
//! ports a SIP session actually negotiates, keeping the host firewall tight
4-
//! without hand-maintained wide-open port ranges.
1+
//! `sip-tunnel` is a static control-plane daemon that turns a cheap VPS into
2+
//! a SIP/RTP relay in front of a FreeSWITCH box reachable only over
3+
//! Tailscale. It generates a deterministic `nftables` DNAT+masquerade
4+
//! ruleset from configuration, applies it atomically via `nft -f -`,
5+
//! verifies it landed, serves `/healthz`, and flushes its owned table on
6+
//! shutdown — it never inspects SIP/RTP packets itself; the kernel forwards
7+
//! all signaling and media traffic.
58
69
pub mod config;
710
pub mod health;

src/main.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ fn config_args(command: &Command) -> &ConfigArgs {
5858
/// `print-config`'s summary, `dump-ruleset`'s script) stays clean and
5959
/// scriptable.
6060
fn init_tracing(args: &ConfigArgs) {
61-
let filter = EnvFilter::try_new(&args.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
61+
let filter = EnvFilter::try_new(&args.log_level).unwrap_or_else(|err| {
62+
eprintln!(
63+
"warning: invalid --log-level/RUST_LOG filter {:?} ({err}); falling back to \"info\"",
64+
args.log_level
65+
);
66+
EnvFilter::new("info")
67+
});
6268

6369
let result = match args.log_format {
6470
LogFormat::Json => tracing_subscriber::fmt()

tests/cli.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,9 @@ fn huge_rtp_range_with_flag_exits_0() {
105105

106106
#[test]
107107
fn missing_freeswitch_ip_exits_1() {
108-
sip_tunnel().arg("check").assert().code(1);
108+
sip_tunnel()
109+
.arg("check")
110+
.env_remove("SIP_TUNNEL_FREESWITCH_IP")
111+
.assert()
112+
.code(1);
109113
}

tests/config_precedence.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ fn sip_tunnel() -> Command {
1717
fn defaults_are_used_when_nothing_else_is_set() {
1818
sip_tunnel()
1919
.args(["print-config", "--freeswitch-ip", "100.64.0.5"])
20+
.env_remove("SIP_TUNNEL_TAILSCALE_IFACE")
21+
.env_remove("SIP_TUNNEL_RTP_START_PORT")
2022
.assert()
2123
.success()
2224
.stdout(predicate::str::contains("tailscale-iface = tailscale0"))

0 commit comments

Comments
 (0)