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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
src/lan.rs
96
src/lan.rs
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user