add local service proxy with .numa domains

HTTP reverse proxy on port 80 lets developers use clean domain names
(frontend.numa, api.numa) instead of localhost:PORT. Includes WebSocket
upgrade support for HMR, TCP health checks, dashboard UI panel, and
REST API for service management. numa.numa is preconfigured for the
dashboard itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-20 15:07:15 +02:00
parent 14a9e9e7e3
commit 8f959ce0a5
15 changed files with 762 additions and 53 deletions

View File

@@ -42,6 +42,9 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
"/blocking/allowlist/{domain}",
delete(blocking_allowlist_remove),
)
.route("/services", get(list_services))
.route("/services", post(create_service))
.route("/services/{name}", delete(remove_service))
.with_state(ctx)
}
@@ -572,3 +575,107 @@ async fn blocking_allowlist_remove(
StatusCode::NOT_FOUND
}
}
// --- Service proxy handlers ---
#[derive(Serialize)]
struct ServiceResponse {
name: String,
target_port: u16,
url: String,
healthy: bool,
}
#[derive(Deserialize)]
struct CreateServiceRequest {
name: String,
target_port: u16,
}
async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceResponse>> {
let entries: Vec<_> = {
let store = ctx.services.lock().unwrap();
store
.list()
.into_iter()
.map(|e| (e.name.clone(), e.target_port))
.collect()
};
let tld = &ctx.proxy_tld;
// Run all health checks concurrently
let health_futures: Vec<_> = entries.iter().map(|(_, port)| check_health(*port)).collect();
let health_results = futures::future::join_all(health_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,
})
.collect();
Json(results)
}
async fn create_service(
State(ctx): State<Arc<ServerCtx>>,
Json(req): Json<CreateServiceRequest>,
) -> Result<(StatusCode, Json<ServiceResponse>), (StatusCode, String)> {
let name = req.name.to_lowercase();
// Validate name: alphanumeric + hyphens only, 1-63 chars
if name.is_empty() || name.len() > 63 {
return Err((StatusCode::BAD_REQUEST, "name must be 1-63 characters".into()));
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err((
StatusCode::BAD_REQUEST,
"name must contain only alphanumeric characters and hyphens".into(),
));
}
if req.target_port == 0 {
return Err((StatusCode::BAD_REQUEST, "target_port must be > 0".into()));
}
let tld = &ctx.proxy_tld;
ctx.services.lock().unwrap().insert(&name, req.target_port);
let healthy = check_health(req.target_port).await;
Ok((
StatusCode::CREATED,
Json(ServiceResponse {
url: format!("http://{}.{}", name, tld),
name,
target_port: req.target_port,
healthy,
}),
))
}
async fn remove_service(
State(ctx): State<Arc<ServerCtx>>,
Path(name): Path<String>,
) -> StatusCode {
if name.eq_ignore_ascii_case("numa") {
return StatusCode::FORBIDDEN;
}
let mut store = ctx.services.lock().unwrap();
if store.remove(&name) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
async fn check_health(port: u16) -> bool {
tokio::time::timeout(
std::time::Duration::from_millis(100),
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false)
}