simplify: unify route structs, fix prefix collision, lint fixes

- Unify RouteConfig/RouteEntry/RouteResponse into single RouteEntry
- Fix prefix collision: /api no longer matches /apiary (segment boundary check)
- Add path traversal rejection in route API
- Extract MdnsAnnouncement struct (clippy type_complexity)
- cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-23 06:57:57 +02:00
parent 9992418908
commit eaab406515
5 changed files with 107 additions and 40 deletions

View File

@@ -1,3 +1,4 @@
use socket2::{Domain, Protocol, Socket, Type};
/// Spike: can we bind to mDNS multicast (224.0.0.251:5353) alongside macOS mDNSResponder? /// Spike: can we bind to mDNS multicast (224.0.0.251:5353) alongside macOS mDNSResponder?
/// ///
/// Tests: /// Tests:
@@ -8,10 +9,8 @@
/// 5. Send a _numa._tcp.local announcement — does it conflict? /// 5. Send a _numa._tcp.local announcement — does it conflict?
/// ///
/// Run: cargo run --example mdns_coexist /// Run: cargo run --example mdns_coexist
use std::mem::MaybeUninit; use std::mem::MaybeUninit;
use std::net::{Ipv4Addr, SocketAddrV4}; use std::net::{Ipv4Addr, SocketAddrV4};
use socket2::{Domain, Protocol, Socket, Type};
const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251);
const MDNS_PORT: u16 = 5353; const MDNS_PORT: u16 = 5353;
@@ -66,7 +65,8 @@ fn main() -> std::io::Result<()> {
loop { loop {
match socket.recv_from(&mut buf) { match socket.recv_from(&mut buf) {
Ok((n, addr)) => { Ok((n, addr)) => {
let data: &[u8] = unsafe { &*(&buf[..n] as *const [MaybeUninit<u8>] as *const [u8]) }; let data: &[u8] =
unsafe { &*(&buf[..n] as *const [MaybeUninit<u8>] as *const [u8]) };
count += 1; count += 1;
let flags = u16::from_be_bytes([data[2], data[3]]); let flags = u16::from_be_bytes([data[2], data[3]]);
let is_response = flags & 0x8000 != 0; let is_response = flags & 0x8000 != 0;
@@ -98,7 +98,8 @@ fn main() -> std::io::Result<()> {
} }
// Step 6: Send a _numa._tcp.local announcement // Step 6: Send a _numa._tcp.local announcement
let announcement = build_mdns_announcement("_numa._tcp.local", "test-numa._numa._tcp.local", 5380); let announcement =
build_mdns_announcement("_numa._tcp.local", "test-numa._numa._tcp.local", 5380);
match socket.send_to(&announcement, &dest.into()) { match socket.send_to(&announcement, &dest.into()) {
Ok(n) => println!("\n[OK] Sent _numa._tcp.local announcement ({} bytes)", n), Ok(n) => println!("\n[OK] Sent _numa._tcp.local announcement ({} bytes)", n),
Err(e) => println!("\n[FAIL] Cannot send announcement: {}", e), Err(e) => println!("\n[FAIL] Cannot send announcement: {}", e),
@@ -111,7 +112,8 @@ fn main() -> std::io::Result<()> {
loop { loop {
match socket.recv_from(&mut buf2) { match socket.recv_from(&mut buf2) {
Ok((n, addr)) => { Ok((n, addr)) => {
let data: &[u8] = unsafe { &*(&buf2[..n] as *const [MaybeUninit<u8>] as *const [u8]) }; let data: &[u8] =
unsafe { &*(&buf2[..n] as *const [MaybeUninit<u8>] as *const [u8]) };
let flags = u16::from_be_bytes([data[2], data[3]]); let flags = u16::from_be_bytes([data[2], data[3]]);
let is_response = flags & 0x8000 != 0; let is_response = flags & 0x8000 != 0;
if is_response { if is_response {
@@ -133,7 +135,10 @@ fn main() -> std::io::Result<()> {
// Verdict // Verdict
println!("\n=== Verdict ==="); println!("\n=== Verdict ===");
if count > 0 { if count > 0 {
println!("[PASS] mDNS coexistence works — received {} packets alongside mDNSResponder", count); println!(
"[PASS] mDNS coexistence works — received {} packets alongside mDNSResponder",
count
);
println!(" Safe to proceed with mDNS-based LAN discovery"); println!(" Safe to proceed with mDNS-based LAN discovery");
} else { } else {
println!("[WARN] No mDNS packets received — may need further investigation"); println!("[WARN] No mDNS packets received — may need further investigation");
@@ -163,7 +168,7 @@ fn build_mdns_query(name: &str) -> Vec<u8> {
pkt.push(0); // root label pkt.push(0); // root label
pkt.extend_from_slice(&[0, 12]); // QTYPE = PTR (12) pkt.extend_from_slice(&[0, 12]); // QTYPE = PTR (12)
pkt.extend_from_slice(&[0, 1]); // QCLASS = IN (1) pkt.extend_from_slice(&[0, 1]); // QCLASS = IN (1)
pkt pkt
} }
@@ -173,17 +178,17 @@ fn build_mdns_announcement(service_type: &str, instance_name: &str, port: u16) -
let mut pkt = Vec::new(); let mut pkt = Vec::new();
// Header: ID=0, flags=0x8400 (response, authoritative), ANCOUNT=1 // Header: ID=0, flags=0x8400 (response, authoritative), ANCOUNT=1
pkt.extend_from_slice(&[0, 0]); // ID pkt.extend_from_slice(&[0, 0]); // ID
pkt.extend_from_slice(&[0x84, 0x00]); // Flags: QR=1, AA=1 pkt.extend_from_slice(&[0x84, 0x00]); // Flags: QR=1, AA=1
pkt.extend_from_slice(&[0, 0]); // QDCOUNT pkt.extend_from_slice(&[0, 0]); // QDCOUNT
pkt.extend_from_slice(&[0, 1]); // ANCOUNT = 1 (just PTR for now) pkt.extend_from_slice(&[0, 1]); // ANCOUNT = 1 (just PTR for now)
pkt.extend_from_slice(&[0, 0]); // NSCOUNT pkt.extend_from_slice(&[0, 0]); // NSCOUNT
pkt.extend_from_slice(&[0, 0]); // ARCOUNT pkt.extend_from_slice(&[0, 0]); // ARCOUNT
// PTR record: _numa._tcp.local → test-numa._numa._tcp.local // PTR record: _numa._tcp.local → test-numa._numa._tcp.local
encode_name(&mut pkt, service_type); encode_name(&mut pkt, service_type);
pkt.extend_from_slice(&[0, 12]); // TYPE = PTR pkt.extend_from_slice(&[0, 12]); // TYPE = PTR
pkt.extend_from_slice(&[0, 1]); // CLASS = IN pkt.extend_from_slice(&[0, 1]); // CLASS = IN
pkt.extend_from_slice(&[0, 0, 0, 120]); // TTL = 120s pkt.extend_from_slice(&[0, 0, 0, 120]); // TTL = 120s
// RDATA: the instance name // RDATA: the instance name

View File

@@ -761,7 +761,10 @@ async fn add_route(
if store.add_route(&name, req.path, req.port, req.strip) { if store.add_route(&name, req.path, req.port, req.strip) {
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} else { } else {
Err((StatusCode::NOT_FOUND, format!("service '{}' not found", name))) Err((
StatusCode::NOT_FOUND,
format!("service '{}' not found", name),
))
} }
} }

View File

@@ -96,10 +96,15 @@ fn get_hostname() -> String {
/// Generate a per-process instance ID for self-filtering on multi-instance hosts /// Generate a per-process instance ID for self-filtering on multi-instance hosts
fn instance_id() -> String { fn instance_id() -> String {
format!("{}:{}", std::process::id(), std::time::SystemTime::now() format!(
.duration_since(std::time::UNIX_EPOCH) "{}:{}",
.unwrap_or_default() std::process::id(),
.as_nanos() % 1_000_000) std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
% 1_000_000
)
} }
pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) { pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
@@ -116,7 +121,10 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
let std_socket = match create_mdns_socket() { let std_socket = match create_mdns_socket() {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
warn!("LAN: could not bind mDNS socket: {} — LAN discovery disabled", e); warn!(
"LAN: could not bind mDNS socket: {} — LAN discovery disabled",
e
);
return; return;
} }
}; };
@@ -141,13 +149,19 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
ticker.tick().await; ticker.tick().await;
let services: Vec<(String, u16)> = { let services: Vec<(String, u16)> = {
let store = sender_ctx.services.lock().unwrap(); let store = sender_ctx.services.lock().unwrap();
store.list().iter().map(|e| (e.name.clone(), e.target_port)).collect() store
.list()
.iter()
.map(|e| (e.name.clone(), e.target_port))
.collect()
}; };
if services.is_empty() { if services.is_empty() {
continue; continue;
} }
let current_ip = *sender_ctx.lan_ip.lock().unwrap(); let current_ip = *sender_ctx.lan_ip.lock().unwrap();
if let Ok(pkt) = build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) { if let Ok(pkt) =
build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id)
{
let _ = sender_socket.send_to(pkt.filled(), dest).await; let _ = sender_socket.send_to(pkt.filled(), dest).await;
} }
} }
@@ -170,14 +184,21 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
}; };
let data = &buf[..len]; let data = &buf[..len];
if let Some((services, peer_ip, peer_id)) = parse_mdns_response(data) { if let Some(ann) = parse_mdns_response(data) {
// Skip our own announcements via instance ID (works on multi-instance same-host) // Skip our own announcements via instance ID (works on multi-instance same-host)
if peer_id.as_deref() == Some(our_instance_id.as_str()) { if ann.instance_id.as_deref() == Some(our_instance_id.as_str()) {
continue; continue;
} }
if !services.is_empty() { if !ann.services.is_empty() {
ctx.lan_peers.lock().unwrap().update(peer_ip, &services); ctx.lan_peers
debug!("LAN: {} services from {} (mDNS)", services.len(), peer_ip); .lock()
.unwrap()
.update(ann.peer_ip, &ann.services);
debug!(
"LAN: {} services from {} (mDNS)",
ann.services.len(),
ann.peer_ip
);
} }
} }
} }
@@ -223,18 +244,30 @@ fn build_announcement(
// SRV: <instance>._numa._tcp.local → <hostname>.local // SRV: <instance>._numa._tcp.local → <hostname>.local
// Port in SRV is informational; actual service ports are in TXT // Port in SRV is informational; actual service ports are in TXT
write_record_header(&mut buf, &instance_name, QueryType::SRV.to_num(), 0x8001, MDNS_TTL)?; write_record_header(
&mut buf,
&instance_name,
QueryType::SRV.to_num(),
0x8001,
MDNS_TTL,
)?;
let rdlen_pos = buf.pos(); let rdlen_pos = buf.pos();
buf.write_u16(0)?; buf.write_u16(0)?;
let rdata_start = buf.pos(); let rdata_start = buf.pos();
buf.write_u16(0)?; // priority buf.write_u16(0)?; // priority
buf.write_u16(0)?; // weight buf.write_u16(0)?; // weight
buf.write_u16(0)?; // port (services have individual ports in TXT) buf.write_u16(0)?; // port (services have individual ports in TXT)
buf.write_qname(&host_local)?; buf.write_qname(&host_local)?;
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// TXT: services + instance ID for self-filtering // TXT: services + instance ID for self-filtering
write_record_header(&mut buf, &instance_name, QueryType::TXT.to_num(), 0x8001, MDNS_TTL)?; write_record_header(
&mut buf,
&instance_name,
QueryType::TXT.to_num(),
0x8001,
MDNS_TTL,
)?;
let rdlen_pos = buf.pos(); let rdlen_pos = buf.pos();
buf.write_u16(0)?; buf.write_u16(0)?;
let rdata_start = buf.pos(); let rdata_start = buf.pos();
@@ -248,7 +281,13 @@ fn build_announcement(
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// A: <hostname>.local → IP // A: <hostname>.local → IP
write_record_header(&mut buf, &host_local, QueryType::A.to_num(), 0x8001, MDNS_TTL)?; write_record_header(
&mut buf,
&host_local,
QueryType::A.to_num(),
0x8001,
MDNS_TTL,
)?;
buf.write_u16(4)?; buf.write_u16(4)?;
for &b in &ip.octets() { for &b in &ip.octets() {
buf.write_u8(b)?; buf.write_u8(b)?;
@@ -271,7 +310,11 @@ fn write_record_header(
Ok(()) Ok(())
} }
fn patch_rdlen(buf: &mut BytePacketBuffer, rdlen_pos: usize, rdata_start: usize) -> crate::Result<()> { fn patch_rdlen(
buf: &mut BytePacketBuffer,
rdlen_pos: usize,
rdata_start: usize,
) -> crate::Result<()> {
let rdlen = (buf.pos() - rdata_start) as u16; let rdlen = (buf.pos() - rdata_start) as u16;
buf.set_u16(rdlen_pos, rdlen) buf.set_u16(rdlen_pos, rdlen)
} }
@@ -289,8 +332,13 @@ fn write_txt_string(buf: &mut BytePacketBuffer, s: &str) -> crate::Result<()> {
// --- mDNS Packet Parsing --- // --- mDNS Packet Parsing ---
/// Returns (services, peer_ip, instance_id) if this is a Numa mDNS announcement struct MdnsAnnouncement {
fn parse_mdns_response(data: &[u8]) -> Option<(Vec<(String, u16)>, IpAddr, Option<String>)> { services: Vec<(String, u16)>,
peer_ip: IpAddr,
instance_id: Option<String>,
}
fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
if data.len() < 12 { if data.len() < 12 {
return None; return None;
} }
@@ -381,7 +429,11 @@ fn parse_mdns_response(data: &[u8]) -> Option<(Vec<(String, u16)>, IpAddr, Optio
// Trust the A record IP if present, otherwise this isn't a complete announcement // Trust the A record IP if present, otherwise this isn't a complete announcement
let peer_ip = a_ip?; let peer_ip = a_ip?;
Some((services, peer_ip, peer_instance_id)) Some(MdnsAnnouncement {
services,
peer_ip,
instance_id: peer_instance_id,
})
} }
fn create_mdns_socket() -> std::io::Result<std::net::UdpSocket> { fn create_mdns_socket() -> std::io::Result<std::net::UdpSocket> {

View File

@@ -217,7 +217,11 @@ async fn main() -> numa::Result<()> {
let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled { let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled {
std::net::Ipv4Addr::UNSPECIFIED std::net::Ipv4Addr::UNSPECIFIED
} else { } else {
config.proxy.bind_addr.parse().unwrap_or(std::net::Ipv4Addr::LOCALHOST) config
.proxy
.bind_addr
.parse()
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
}; };
// Spawn HTTP reverse proxy for .numa domains // Spawn HTTP reverse proxy for .numa domains

View File

@@ -24,11 +24,14 @@ impl ServiceEntry {
/// Resolve backend port and (possibly rewritten) path for a request /// Resolve backend port and (possibly rewritten) path for a request
pub fn resolve_route(&self, request_path: &str) -> (u16, String) { pub fn resolve_route(&self, request_path: &str) -> (u16, String) {
// Longest prefix match // Longest prefix match
let matched = self.routes.iter() let matched = self
.routes
.iter()
.filter(|r| { .filter(|r| {
request_path == r.path request_path == r.path
|| request_path.starts_with(&r.path) || request_path.starts_with(&r.path)
&& (r.path.ends_with('/') || request_path.as_bytes().get(r.path.len()) == Some(&b'/')) && (r.path.ends_with('/')
|| request_path.as_bytes().get(r.path.len()) == Some(&b'/'))
}) })
.max_by_key(|r| r.path.len()); .max_by_key(|r| r.path.len());