Add LAN service discovery via UDP multicast #7

Merged
razvandimescu merged 6 commits from feat/lan-discovery into main 2026-03-22 14:03:32 +08:00
2 changed files with 58 additions and 17 deletions
Showing only changes of commit def89ffe59 - Show all commits

View File

@@ -382,6 +382,15 @@ body {
} }
.health-dot.up { background: var(--emerald); } .health-dot.up { background: var(--emerald); }
.health-dot.down { background: var(--rose); } .health-dot.down { background: var(--rose); }
.lan-badge {
font-family: var(--font-mono);
font-size: 0.58rem;
padding: 1px 5px;
border-radius: 3px;
margin-left: 0.3rem;
}
.lan-badge.shared { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
.lan-badge.local-only { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); }
/* Override form */ /* Override form */
.override-form { .override-form {
@@ -1082,16 +1091,22 @@ function renderServices(entries) {
el.innerHTML = '<div class="empty-state">No services configured</div>'; el.innerHTML = '<div class="empty-state">No services configured</div>';
return; return;
} }
el.innerHTML = entries.map(e => ` el.innerHTML = entries.map(e => {
const lanBadge = e.healthy
? (e.lan_accessible
? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>'
: '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>')
: '';
return `
<div class="service-item"> <div class="service-item">
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span> <span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
<div class="service-info"> <div class="service-info">
<div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a></div> <div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a>${lanBadge}</div>
<div class="service-port">localhost:${e.target_port} &rarr; proxied</div> <div class="service-port">localhost:${e.target_port} &rarr; proxied</div>
</div> </div>
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>`} ${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>`}
</div> </div>
`).join(''); `}).join('');
} }
async function addService(event) { async function addService(event) {

View File

@@ -590,6 +590,7 @@ struct ServiceResponse {
target_port: u16, target_port: u16,
url: String, url: String,
healthy: bool, healthy: bool,
lan_accessible: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -609,22 +610,38 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
}; };
let tld = &ctx.proxy_tld; let tld = &ctx.proxy_tld;
// Run all health checks concurrently let lan_ip = crate::lan::detect_lan_ip();
let health_futures: Vec<_> = entries
let check_futures: Vec<_> = entries
.iter() .iter()
.map(|(_, port)| check_health(*port)) .map(|(_, port)| {
let port = *port;
let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port));
async move {
let healthy = check_tcp(localhost).await;
let lan_accessible = match lan_addr {
Some(addr) => check_tcp(addr).await,
None => false,
};
(healthy, lan_accessible)
}
})
.collect(); .collect();
let health_results = futures::future::join_all(health_futures).await; let check_results = futures::future::join_all(check_futures).await;
let results: Vec<_> = entries let results: Vec<_> = entries
.into_iter() .into_iter()
.zip(health_results) .zip(check_results)
.map(|((name, port), healthy)| ServiceResponse { .map(
url: format!("http://{}.{}", name, tld), |((name, port), (healthy, lan_accessible))| ServiceResponse {
name, url: format!("http://{}.{}", name, tld),
target_port: port, name,
healthy, target_port: port,
}) healthy,
lan_accessible,
},
)
.collect(); .collect();
Json(results) Json(results)
} }
@@ -655,7 +672,15 @@ async fn create_service(
let tld = &ctx.proxy_tld; let tld = &ctx.proxy_tld;
ctx.services.lock().unwrap().insert(&name, req.target_port); ctx.services.lock().unwrap().insert(&name, req.target_port);
let healthy = check_health(req.target_port).await; let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], req.target_port));
let lan_addr =
crate::lan::detect_lan_ip().map(|ip| std::net::SocketAddr::new(ip.into(), req.target_port));
let (healthy, lan_accessible) = tokio::join!(check_tcp(localhost), async {
match lan_addr {
Some(a) => check_tcp(a).await,
None => false,
}
});
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(ServiceResponse { Json(ServiceResponse {
@@ -663,6 +688,7 @@ async fn create_service(
name, name,
target_port: req.target_port, target_port: req.target_port,
healthy, healthy,
lan_accessible,
}), }),
)) ))
} }
@@ -679,10 +705,10 @@ async fn remove_service(State(ctx): State<Arc<ServerCtx>>, Path(name): Path<Stri
} }
} }
async fn check_health(port: u16) -> bool { async fn check_tcp(addr: std::net::SocketAddr) -> bool {
tokio::time::timeout( tokio::time::timeout(
std::time::Duration::from_millis(100), std::time::Duration::from_millis(100),
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)), tokio::net::TcpStream::connect(addr),
) )
.await .await
.map(|r| r.is_ok()) .map(|r| r.is_ok())