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.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 {
@@ -1082,16 +1091,22 @@ function renderServices(entries) {
el.innerHTML = '<div class="empty-state">No services configured</div>';
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">
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
<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>
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>`}
</div>
`).join('');
`}).join('');
}
async function addService(event) {

View File

@@ -590,6 +590,7 @@ struct ServiceResponse {
target_port: u16,
url: String,
healthy: bool,
lan_accessible: bool,
}
#[derive(Deserialize)]
@@ -609,22 +610,38 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
};
let tld = &ctx.proxy_tld;
// Run all health checks concurrently
let health_futures: Vec<_> = entries
let lan_ip = crate::lan::detect_lan_ip();
let check_futures: Vec<_> = entries
.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();
let health_results = futures::future::join_all(health_futures).await;
let check_results = futures::future::join_all(check_futures).await;
let results: Vec<_> = entries
.into_iter()
.zip(health_results)
.map(|((name, port), healthy)| ServiceResponse {
url: format!("http://{}.{}", name, tld),
name,
target_port: port,
healthy,
})
.zip(check_results)
.map(
|((name, port), (healthy, lan_accessible))| ServiceResponse {
url: format!("http://{}.{}", name, tld),
name,
target_port: port,
healthy,
lan_accessible,
},
)
.collect();
Json(results)
}
@@ -655,7 +672,15 @@ async fn create_service(
let tld = &ctx.proxy_tld;
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((
StatusCode::CREATED,
Json(ServiceResponse {
@@ -663,6 +688,7 @@ async fn create_service(
name,
target_port: req.target_port,
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(
std::time::Duration::from_millis(100),
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)),
tokio::net::TcpStream::connect(addr),
)
.await
.map(|r| r.is_ok())