Skip to content

Commit 15378ef

Browse files
author
Paul C
committed
v17.0.6: Cluster Browser fixes — HTTP-only, cluster-wide discovery, per-user pins
Sponsor feedback from first hands-on test of v17.0.5: 1. Connect URL was using HTTPS — KasmVNC inside the linuxserver/firefox container speaks HTTP only on port 3000, so HTTPS WolfStack installs were popping a "ERR_SSL_PROTOCOL_ERROR" on the new tab. Frontend now hard-codes `http://` on the session URL regardless of WolfStack's own scheme. 2. In-container Firefox homepage was https://host:8553/cluster-home. Self-signed WolfStack cert + a fresh Firefox profile = a "your connection is not secure" warning that blocks the homepage. Now uses plain HTTP on the inter_node port (8554) when TLS is on, or the api port (8553, plain) when TLS is off — both serve the same /cluster-home route. 3. Discovery only saw services on the local node. Now scans every WolfNet peer from /etc/wolfnet/config.toml in addition to the routes.json container/VM IPs and the local host's wolfnet0. Catches bare WolfStack hosts running services natively (no containers) and NAS satellites that joined as WolfNet peers. 4. Manual pinned URLs were stored in the shared services file — every WolfStack user on the node saw every other user's pinned services. Now split into: - /etc/wolfstack/cluster-services-discovered.json — auto-discovered, shared cluster-wide - /etc/wolfstack/cluster-services-by-user/<user>.json — pinned URLs scoped to the requesting user API endpoints stamp the requesting user; sweep doesn't touch the per-user files. One-time migration: any pre-v17.0.6 mixed file is split into the new layout (auto entries to discovered, manual entries parked under user "_legacy" so admins can re-attribute). /cluster-home accepts ?user=<username> so the in-container Firefox sees the launching user's pinned URLs alongside auto-discovered. start_session embeds the requesting user automatically. 5. 5-minute periodic discovery loop dropped — the cluster only had to stay quiet about new services for at most 5 minutes, but the loop was unnecessary background work and a synchronous in-container PBS-was-just-spun-up-and-I-want-to-see-it case wanted an immediate refresh anyway. Now: cache restored from disk on daemon startup, sweep runs purely on demand. The Cluster Browser page kicks off one in the background each time it loads (with an inline banner so the user sees "Scanning…") plus the manual "🔄 Rescan services" button. Bonus: streaming start endpoint POST /api/cluster-browser/sessions/ start-stream + new image-status endpoint + sticky progress modal in the UI so the multi-minute first-time `docker pull` of the linuxserver/firefox image (~700 MB) shows live status instead of a silent spinner.
1 parent 0950c61 commit 15378ef

6 files changed

Lines changed: 592 additions & 132 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "17.0.5"
3+
version = "17.0.6"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 159 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
34953496
pub 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.
35073511
pub 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

35263534
fn 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.
35303538
pub 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(
35433554
pub 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+
36053706
pub 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
36493775
pub 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

Comments
 (0)