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:
107
src/api.rs
107
src/api.rs
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user