@@ -3490,26 +3490,34 @@ pub async fn set_ports(req: HttpRequest, state: web::Data<AppState>, body: web::
34903490
34913491// ─── Cluster Services Discovery ─────────────────────────────────────────
34923492
3493- /// GET /api/cluster-services — list every web service we've discovered
3494- /// across the cluster (auto-discovered + manually added).
3493+ /// GET /api/cluster-services — every web service this user can see:
3494+ /// the cluster-wide auto-discovered list + their own pinned manual URLs.
3495+ /// Other users' pinned URLs are not returned.
34953496pub async fn cluster_services_list(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3496- if let Err(resp) = require_auth(&req, &state) { return resp; }
3497+ let user = match require_auth(&req, &state) {
3498+ Ok(u) => u,
3499+ Err(resp) => return resp,
3500+ };
34973501 HttpResponse::Ok().json(serde_json::json!({
3498- "services": crate::services_discovery::cached( ),
3499- "grouped": crate::services_discovery::grouped( ),
3502+ "services": crate::services_discovery::list_for_user(&user ),
3503+ "grouped": crate::services_discovery::grouped_for(&user ),
35003504 }))
35013505}
35023506
3503- /// POST /api/cluster-services/sweep — kick off an immediate discovery
3504- /// sweep instead of waiting for the next periodic one. Useful right
3505- /// after the user spins up a new container they want to see in the
3506- /// browser homepage .
3507+ /// POST /api/cluster-services/sweep — run a fresh discovery scan now.
3508+ /// Discovery is on-demand (no periodic loop), so the page calls this
3509+ /// when it loads. Returns once the sweep has finished, with the new
3510+ /// merged list for the requesting user .
35073511pub async fn cluster_services_sweep(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3508- if let Err(resp) = require_auth(&req, &state) { return resp; }
3512+ let user = match require_auth(&req, &state) {
3513+ Ok(u) => u,
3514+ Err(resp) => return resp,
3515+ };
35093516 let _ = tokio::task::spawn_blocking(crate::services_discovery::run_sweep).await;
35103517 HttpResponse::Ok().json(serde_json::json!({
35113518 "ok": true,
3512- "services": crate::services_discovery::cached(),
3519+ "services": crate::services_discovery::list_for_user(&user),
3520+ "grouped": crate::services_discovery::grouped_for(&user),
35133521 }))
35143522}
35153523
@@ -3525,15 +3533,18 @@ pub struct AddManualServiceRequest {
35253533
35263534fn default_other_category() -> String { "Other".to_string() }
35273535
3528- /// POST /api/cluster-services/manual — pin a service we didn't auto-detect
3529- /// (custom port, weird URL). Stays through discovery sweeps .
3536+ /// POST /api/cluster-services/manual — pin a service against the
3537+ /// requesting user. Other users won't see this entry .
35303538pub async fn cluster_services_add_manual(
35313539 req: HttpRequest, state: web::Data<AppState>,
35323540 body: web::Json<AddManualServiceRequest>,
35333541) -> HttpResponse {
3534- if let Err(resp) = require_auth(&req, &state) { return resp; }
3535- match crate::services_discovery::add_manual(
3536- body.name.clone(), body.url.clone(), body.icon.clone(), body.category.clone(),
3542+ let user = match require_auth(&req, &state) {
3543+ Ok(u) => u,
3544+ Err(resp) => return resp,
3545+ };
3546+ match crate::services_discovery::add_manual_for_user(
3547+ &user, body.name.clone(), body.url.clone(), body.icon.clone(), body.category.clone(),
35373548 ) {
35383549 Ok(svc) => HttpResponse::Ok().json(svc),
35393550 Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
@@ -3543,12 +3554,15 @@ pub async fn cluster_services_add_manual(
35433554pub async fn cluster_services_delete(
35443555 req: HttpRequest, state: web::Data<AppState>, path: web::Path<String>,
35453556) -> HttpResponse {
3546- if let Err(resp) = require_auth(&req, &state) { return resp; }
3557+ let user = match require_auth(&req, &state) {
3558+ Ok(u) => u,
3559+ Err(resp) => return resp,
3560+ };
35473561 let id = path.into_inner();
3548- if crate::services_discovery::remove( &id) {
3562+ if crate::services_discovery::remove_manual_for_user(&user, &id) {
35493563 HttpResponse::Ok().json(serde_json::json!({"removed": true}))
35503564 } else {
3551- HttpResponse::NotFound().json(serde_json::json!({"error": "not found"}))
3565+ HttpResponse::NotFound().json(serde_json::json!({"error": "not found in your pinned list "}))
35523566 }
35533567}
35543568
@@ -3579,11 +3593,22 @@ pub async fn cluster_browser_start(
35793593 Err(resp) => return resp,
35803594 };
35813595 // Default homepage = our /cluster-home on the host's WolfNet IP so
3582- // the in-container Firefox can reach it. Users can override with a
3583- // different URL (e.g. straight to a service they want).
3596+ // the in-container Firefox can reach it. Use plain HTTP — when
3597+ // WolfStack runs HTTPS the cert is usually self-signed, and a
3598+ // freshly-launched Firefox profile balks at that. The inter_node
3599+ // port is always plain HTTP on TLS-enabled installs and serves the
3600+ // same /cluster-home route, so target that. On non-TLS installs
3601+ // the api port is itself HTTP, so fall back to it.
35843602 let host_ip = local_wolfnet_ip().unwrap_or_else(|| "127.0.0.1".into());
35853603 let port_cfg = crate::ports::PortConfig::load();
3586- let default_homepage = format!("https://{}:{}/cluster-home", host_ip, port_cfg.api);
3604+ let homepage_port = if state.tls_enabled { port_cfg.inter_node } else { port_cfg.api };
3605+ // Embed the user as a query param so /cluster-home renders that
3606+ // user's pinned URLs alongside the cluster-wide discovered list.
3607+ let default_homepage = format!(
3608+ "http://{}:{}/cluster-home?user={}",
3609+ host_ip, homepage_port,
3610+ urlencoding_simple(&user)
3611+ );
35873612 let homepage = body.homepage.clone().filter(|s| !s.trim().is_empty()).unwrap_or(default_homepage);
35883613
35893614 let result = tokio::task::spawn_blocking(move || {
@@ -3602,6 +3627,82 @@ pub async fn cluster_browser_start(
36023627 }
36033628}
36043629
3630+ /// GET /api/cluster-browser/image-status — quick "is the browser image
3631+ /// pre-pulled on this node?" so the UI can show "First run will download
3632+ /// ~700 MB" if not.
3633+ pub async fn cluster_browser_image_status(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3634+ if let Err(resp) = require_auth(&req, &state) { return resp; }
3635+ HttpResponse::Ok().json(serde_json::json!({
3636+ "image_present": crate::cluster_browser::image_present(),
3637+ }))
3638+ }
3639+
3640+ /// POST /api/cluster-browser/sessions/start-stream — same as /sessions
3641+ /// but streams `docker pull` progress (image download, layers, container
3642+ /// start) as Server-Sent Events so the user has visible feedback during
3643+ /// the multi-minute first-time pull instead of a silent spinner.
3644+ /// Final event: `[done] {json}` with the connect_url.
3645+ pub async fn cluster_browser_start_stream(
3646+ req: HttpRequest, state: web::Data<AppState>,
3647+ body: web::Json<StartBrowserRequest>,
3648+ ) -> HttpResponse {
3649+ let user = match require_auth(&req, &state) {
3650+ Ok(u) => u,
3651+ Err(resp) => return resp,
3652+ };
3653+
3654+ let host_ip = local_wolfnet_ip().unwrap_or_else(|| "127.0.0.1".into());
3655+ let port_cfg = crate::ports::PortConfig::load();
3656+ let homepage_port = if state.tls_enabled { port_cfg.inter_node } else { port_cfg.api };
3657+ // Embed the user as a query param so /cluster-home renders that
3658+ // user's pinned URLs alongside the cluster-wide discovered list.
3659+ let default_homepage = format!(
3660+ "http://{}:{}/cluster-home?user={}",
3661+ host_ip, homepage_port,
3662+ urlencoding_simple(&user)
3663+ );
3664+ let homepage = body.homepage.clone().filter(|s| !s.trim().is_empty()).unwrap_or(default_homepage);
3665+
3666+ let (std_tx, std_rx) = std::sync::mpsc::channel::<String>();
3667+ let (tok_tx, mut tok_rx) = tokio::sync::mpsc::channel::<String>(256);
3668+
3669+ std::thread::spawn(move || {
3670+ let result = crate::cluster_browser::start_session_streamed(&user, &homepage, std_tx.clone());
3671+ match result {
3672+ Ok(session) => {
3673+ let host = local_wolfnet_ip().unwrap_or_else(|| "127.0.0.1".into());
3674+ let payload = serde_json::json!({
3675+ "session": session,
3676+ // Plain HTTP — KasmVNC inside the container is HTTP only.
3677+ "connect_url": format!("http://{}:{}", host, session.web_port),
3678+ });
3679+ let _ = std_tx.send(format!("[done] {}", payload));
3680+ }
3681+ Err(e) => { let _ = std_tx.send(format!("[error] {}", e)); }
3682+ }
3683+ });
3684+
3685+ tokio::task::spawn_blocking(move || {
3686+ while let Ok(msg) = std_rx.recv() {
3687+ if tok_tx.blocking_send(msg).is_err() { break; }
3688+ }
3689+ });
3690+
3691+ let stream = async_stream::stream! {
3692+ while let Some(msg) = tok_rx.recv().await {
3693+ let event = format!("data: {}\n\n", msg.replace('\n', "\ndata: "));
3694+ yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(event));
3695+ }
3696+ };
3697+
3698+ HttpResponse::Ok()
3699+ .insert_header(("Content-Type", "text/event-stream"))
3700+ .insert_header(("Cache-Control", "no-cache"))
3701+ .insert_header(("Connection", "keep-alive"))
3702+ .insert_header(("X-Accel-Buffering", "no"))
3703+ .streaming(stream)
3704+ }
3705+
36053706pub async fn cluster_browser_stop(
36063707 req: HttpRequest, state: web::Data<AppState>, path: web::Path<String>,
36073708) -> HttpResponse {
@@ -3617,15 +3718,23 @@ pub async fn cluster_browser_stop(
36173718 }
36183719}
36193720
3620- /// GET /cluster-home — the homepage HTML that the in-container Firefox
3621- /// loads at startup. Lists discovered services as click-cards plus an
3622- /// open-URL bar at the top. Intentionally unauthenticated: the Firefox
3623- /// inside the container has a fresh profile and no cookies, and the
3624- /// page only exposes information that's already visible to anything
3625- /// on the WolfNet routing fabric. The URLs are useless without WolfNet
3626- /// access in the first place.
3627- pub async fn cluster_browser_homepage(_req: HttpRequest, _state: web::Data<AppState>) -> HttpResponse {
3628- let html = crate::cluster_browser::render_homepage();
3721+ /// GET /cluster-home[?user=alice] — the homepage HTML that the
3722+ /// in-container Firefox loads at startup. Lists discovered services as
3723+ /// click-cards plus an open-URL bar at the top. Intentionally
3724+ /// unauthenticated: the Firefox inside the container has a fresh
3725+ /// profile and no cookies, and the page only exposes information
3726+ /// that's already visible to anything on the WolfNet routing fabric.
3727+ /// The optional `user` query param scopes the homepage to that user's
3728+ /// pinned manual URLs in addition to auto-discovered services — the
3729+ /// session-start endpoint embeds the user in the homepage URL when it
3730+ /// spawns the container so each user lands on their own grid.
3731+ pub async fn cluster_browser_homepage(
3732+ req: HttpRequest, _state: web::Data<AppState>,
3733+ query: web::Query<std::collections::HashMap<String, String>>,
3734+ ) -> HttpResponse {
3735+ let _ = req; // intentional — unauthenticated by design
3736+ let user = query.get("user").map(|s| s.as_str()).unwrap_or("");
3737+ let html = crate::cluster_browser::render_homepage(user);
36293738 HttpResponse::Ok()
36303739 .insert_header(("Content-Type", "text/html; charset=utf-8"))
36313740 .insert_header(("Cache-Control", "no-cache"))
@@ -3644,6 +3753,23 @@ fn local_wolfnet_ip() -> Option<String> {
36443753 .map(|s| s.to_string())
36453754}
36463755
3756+ /// Minimal percent-encoder for the `user` query param embedded in the
3757+ /// in-container browser's homepage URL. Avoids pulling in a full
3758+ /// urlencoding crate just for one call site. Encodes everything that
3759+ /// isn't alphanumeric or `-_.` (RFC 3986 unreserved-ish).
3760+ fn urlencoding_simple(s: &str) -> String {
3761+ let mut out = String::with_capacity(s.len());
3762+ for byte in s.as_bytes() {
3763+ let c = *byte;
3764+ if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' || c == b'.' || c == b'~' {
3765+ out.push(c as char);
3766+ } else {
3767+ out.push_str(&format!("%{:02X}", c));
3768+ }
3769+ }
3770+ out
3771+ }
3772+
36473773/// GET /api/wolfnet/used-ips — returns WolfNet IPs in use on this node
36483774/// Requires cluster auth — used for inter-node route discovery
36493775pub async fn wolfnet_used_ips_endpoint(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
@@ -16986,7 +17112,9 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1698617112 .route("/api/cluster-services/{id}", web::delete().to(cluster_services_delete))
1698717113 .route("/api/cluster-browser/sessions", web::get().to(cluster_browser_list))
1698817114 .route("/api/cluster-browser/sessions", web::post().to(cluster_browser_start))
17115+ .route("/api/cluster-browser/sessions/start-stream", web::post().to(cluster_browser_start_stream))
1698917116 .route("/api/cluster-browser/sessions/{id}", web::delete().to(cluster_browser_stop))
17117+ .route("/api/cluster-browser/image-status", web::get().to(cluster_browser_image_status))
1699017118 .route("/cluster-home", web::get().to(cluster_browser_homepage))
1699117119 .route("/api/agent/cluster-name", web::post().to(agent_set_cluster_name))
1699217120 .route("/api/agent/wolfnet-routes", web::post().to(agent_set_wolfnet_routes))
0 commit comments