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 5e5a6544bc
commit c836903db5
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?
///
/// Tests:
@@ -8,10 +9,8 @@
/// 5. Send a _numa._tcp.local announcement — does it conflict?
///
/// Run: cargo run --example mdns_coexist
use std::mem::MaybeUninit;
use std::net::{Ipv4Addr, SocketAddrV4};
use socket2::{Domain, Protocol, Socket, Type};
const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251);
const MDNS_PORT: u16 = 5353;
@@ -66,7 +65,8 @@ fn main() -> std::io::Result<()> {
loop {
match socket.recv_from(&mut buf) {
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;
let flags = u16::from_be_bytes([data[2], data[3]]);
let is_response = flags & 0x8000 != 0;
@@ -98,7 +98,8 @@ fn main() -> std::io::Result<()> {
}
// 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()) {
Ok(n) => println!("\n[OK] Sent _numa._tcp.local announcement ({} bytes)", n),
Err(e) => println!("\n[FAIL] Cannot send announcement: {}", e),
@@ -111,7 +112,8 @@ fn main() -> std::io::Result<()> {
loop {
match socket.recv_from(&mut buf2) {
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 is_response = flags & 0x8000 != 0;
if is_response {
@@ -133,7 +135,10 @@ fn main() -> std::io::Result<()> {
// Verdict
println!("\n=== Verdict ===");
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");
} else {
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.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
}
@@ -173,17 +178,17 @@ fn build_mdns_announcement(service_type: &str, instance_name: &str, port: u16) -
let mut pkt = Vec::new();
// 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(&[0, 0]); // QDCOUNT
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]); // ARCOUNT
pkt.extend_from_slice(&[0, 0]); // QDCOUNT
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]); // ARCOUNT
// PTR record: _numa._tcp.local → test-numa._numa._tcp.local
encode_name(&mut pkt, service_type);
pkt.extend_from_slice(&[0, 12]); // TYPE = PTR
pkt.extend_from_slice(&[0, 1]); // CLASS = IN
pkt.extend_from_slice(&[0, 12]); // TYPE = PTR
pkt.extend_from_slice(&[0, 1]); // CLASS = IN
pkt.extend_from_slice(&[0, 0, 0, 120]); // TTL = 120s
// 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) {
Ok(StatusCode::CREATED)
} 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
fn instance_id() -> String {
format!("{}:{}", std::process::id(), std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() % 1_000_000)
format!(
"{}:{}",
std::process::id(),
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) {
@@ -116,7 +121,10 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
let std_socket = match create_mdns_socket() {
Ok(s) => s,
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;
}
};
@@ -141,13 +149,19 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
ticker.tick().await;
let services: Vec<(String, u16)> = {
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() {
continue;
}
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;
}
}
@@ -170,14 +184,21 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
};
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)
if peer_id.as_deref() == Some(our_instance_id.as_str()) {
if ann.instance_id.as_deref() == Some(our_instance_id.as_str()) {
continue;
}
if !services.is_empty() {
ctx.lan_peers.lock().unwrap().update(peer_ip, &services);
debug!("LAN: {} services from {} (mDNS)", services.len(), peer_ip);
if !ann.services.is_empty() {
ctx.lan_peers
.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
// 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();
buf.write_u16(0)?;
let rdata_start = buf.pos();
buf.write_u16(0)?; // priority
buf.write_u16(0)?; // weight
buf.write_u16(0)?; // port (services have individual ports in TXT)
buf.write_u16(0)?; // priority
buf.write_u16(0)?; // weight
buf.write_u16(0)?; // port (services have individual ports in TXT)
buf.write_qname(&host_local)?;
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// 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();
buf.write_u16(0)?;
let rdata_start = buf.pos();
@@ -248,7 +281,13 @@ fn build_announcement(
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// 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)?;
for &b in &ip.octets() {
buf.write_u8(b)?;
@@ -271,7 +310,11 @@ fn write_record_header(
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;
buf.set_u16(rdlen_pos, rdlen)
}
@@ -289,8 +332,13 @@ fn write_txt_string(buf: &mut BytePacketBuffer, s: &str) -> crate::Result<()> {
// --- mDNS Packet Parsing ---
/// Returns (services, peer_ip, instance_id) if this is a Numa mDNS announcement
fn parse_mdns_response(data: &[u8]) -> Option<(Vec<(String, u16)>, IpAddr, Option<String>)> {
struct MdnsAnnouncement {
services: Vec<(String, u16)>,
peer_ip: IpAddr,
instance_id: Option<String>,
}
fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
if data.len() < 12 {
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
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> {

View File

@@ -217,7 +217,11 @@ async fn main() -> numa::Result<()> {
let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled {
std::net::Ipv4Addr::UNSPECIFIED
} 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

View File

@@ -24,11 +24,14 @@ impl ServiceEntry {
/// Resolve backend port and (possibly rewritten) path for a request
pub fn resolve_route(&self, request_path: &str) -> (u16, String) {
// Longest prefix match
let matched = self.routes.iter()
let matched = self
.routes
.iter()
.filter(|r| {
request_path == 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());