diff --git a/.gitignore b/.gitignore index ba5db9c..4447151 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ vendor/ # Generated files *.generated.* + +# Built SPA (Vite build output served by the server; rebuilt from dashboard/) +/server/static/app/ diff --git a/dashboard/README.md b/dashboard/README.md index ecc94ed..b269800 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -89,21 +89,50 @@ same-origin requests reach the backend with no CORS setup. To develop the UI against a *remote* backend instead, set `VITE_API_URL` (see `.env.example`). -## Production serving — follow-up (NOT wired in this pass) +## Production serving — WIRED -The build uses `base: "./"` so emitted assets use relative paths. Production -serving means copying `dist/` into the GC server's static directory and adding a -catch-all route that returns `index.html` for non-API, non-asset paths (so deep -links like `/machines` survive a hard reload under the `BrowserRouter`). +The SPA is served by the GC Axum server from the server root. No manual copy +step: `vite.config.ts` sets `build.outDir` to `../server/static/app/`, so the +build lands exactly where the server serves it. -That Rust-side wiring is a **deploy concern** and is intentionally left for a -later step: +### Build & deploy flow -1. Copy `dist/` → `server/static/` (or serve `dist/` directly). -2. Add an Axum fallback route serving `index.html` for unmatched GET paths, - *after* the `/api/*`, `/ws/*`, and static-asset routes. -3. If the dashboard is mounted under a sub-path rather than the server root, - switch Vite `base` to that path and pass the same `basename` to - ``. +```bash +# from dashboard/ +npm run build # tsc -b && vite build -> ../server/static/app/ +``` -No server/Rust changes were made in this pass. +That single command refreshes the served SPA. `emptyOutDir` clears only +`server/static/app/` (the dedicated SPA subdir), so the v1 portal files in the +static root are never touched. + +### How the server serves it (`server/src/main.rs`) + +- `base` is **`/`** (absolute asset paths). The SPA uses `BrowserRouter`, so a + hard reload of a deep link (`/machines`) must still load `/assets/*`; relative + (`./`) paths would resolve against the deep-link path and 404. Absolute is + required. +- The Router's `fallback_service` is `ServeDir::new("static/app")` with + `.fallback(ServeFile::new("static/app/index.html"))`. Real files under + `/assets/*` are served from disk; any other unmatched path returns + `index.html` (HTTP **200**) so React Router resolves the route. +- **Precedence / safety:** the fallback runs only after every explicit + `/api/*`, `/ws/*`, `/health`, `/metrics` route and the `/downloads` nest. Two + catch-all routes — `/api/*rest` and `/ws/*rest` — return a JSON **404** for + unrouted API/WS paths, so the SPA fallback never answers an API/WS path with + HTML (which would break this client's error-envelope parsing). +- **Caching:** `/assets/*` (content-hashed) → `immutable`, one year; + `index.html` and everything else → `no-cache, must-revalidate`. + +### Build output in git + +`server/static/app/` is a build artifact. Whether to commit it or `.gitignore` +it depends on the deploy model (server-side `npm run build` vs shipping the +repo's static dir). Decide at commit time. The old `dashboard/dist/` path is no +longer used. + +### Sub-path mounting (not used) + +The dashboard is mounted at the server root. If it is ever moved under a +sub-path, switch Vite `base` to that path and pass the same `basename` to +``. diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 9340c8a..b35db76 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -5,14 +5,23 @@ import react from "@vitejs/plugin-react"; // forwarded to the Rust server on :3002 so `npm run dev` works against a real // backend without CORS gymnastics. // -// `base` is "./" so the built assets reference relative paths — production -// serving copies `dist/` into the server's static dir and a catch-all route -// serves index.html. Wiring that catch-all in the Rust server is a DEPLOY -// concern (see README), not done in this pass. +// PRODUCTION SERVING (wired in the GC server): +// - `base` is "/" (absolute asset paths). The SPA is served from the server +// root and uses `BrowserRouter`, so a hard reload of a deep link such as +// `/machines` must still resolve `/assets/*` correctly. Relative ("./") +// asset paths would break here: the browser would resolve them against the +// deep-link path (`/machines/assets/...`) and 404. Absolute "/" is required. +// - `build.outDir` points straight into the server's static tree at +// `server/static/app/`, so `npm run build` lands the SPA exactly where the +// Axum `fallback_service` serves it — no manual copy step at deploy time. +// - `emptyOutDir` is true, which is SAFE because the target is the dedicated +// `app/` SUBDIR. It wipes only `server/static/app/`, never the static root, +// so the v1 portal files (login.html, dashboard.html, viewer.html, +// downloads/) are untouched. const GC_SERVER = "http://localhost:3002"; export default defineConfig({ - base: "./", + base: "/", plugins: [react()], server: { port: 5273, @@ -29,7 +38,11 @@ export default defineConfig({ }, }, build: { - outDir: "dist", + // Emit directly into the server's static tree. The Axum server serves this + // directory as its SPA fallback (see server/src/main.rs). Dedicated subdir, + // so emptyOutDir only clears the SPA build — not the v1 portal files. + outDir: "../server/static/app", + emptyOutDir: true, sourcemap: true, }, }); diff --git a/server/src/main.rs b/server/src/main.rs index b104ad3..c973b6b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -24,20 +24,36 @@ use axum::{ extract::{ConnectInfo, Json, Path, Query, Request, State}, http::StatusCode, middleware::{self as axum_middleware, Next}, - response::{Html, IntoResponse}, - routing::{delete, get, post, put}, + response::IntoResponse, + routing::{any, delete, get, post, put}, Router, }; use serde::Deserialize; use std::net::SocketAddr; use std::sync::Arc; use tower_http::cors::CorsLayer; -use tower_http::services::ServeDir; +use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; use auth::{generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist}; + +/// Root of the static asset tree, relative to the server's working directory. +/// Holds the agent `downloads/` tree AND the v2 SPA build under `app/`. +const STATIC_DIR: &str = "static"; + +/// Directory the React/Vite SPA is built into (`dashboard/` Vite `build.outDir` +/// points here). The Axum `fallback_service` serves this tree at the server +/// root, so `npm run build` lands the SPA exactly where it is served — no copy +/// step. A dedicated subdir so the Vite build's `emptyOutDir` clears only the +/// SPA, never the agent downloads tree in the static root. +const SPA_DIR: &str = "static/app"; + +/// The SPA entry document. Returned (with 200) for any unmatched, non-API, +/// non-WS, non-asset GET so `BrowserRouter` deep links (`/machines`, +/// `/sessions`, `/login`) survive a hard reload. +const SPA_INDEX: &str = "static/app/index.html"; use metrics::SharedMetrics; use prometheus_client::registry::Registry; use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager}; @@ -413,15 +429,43 @@ async fn main() -> Result<()> { get(api::downloads::download_support), ) .route("/api/download/agent", get(api::downloads::download_agent)) - // HTML page routes (clean URLs) - .route("/login", get(serve_login)) - .route("/dashboard", get(serve_dashboard)) - .route("/users", get(serve_users)) + // Namespace 404 guards. These wildcard routes catch any /api/* or /ws/* + // path that no explicit route above matched, returning a JSON 404 so the + // SPA fallback_service never answers an API/WS path with index.html. They + // are intentionally the LEAST specific routes in each namespace: matchit + // (axum 0.7) prefers a static segment over a `*` capture, so every real + // route above still wins. `any(...)` covers every method (a bad WS path + // is a GET, but POST/PUT/etc. to a dead /api/* path must 404 too, not 405). + .route("/api/*rest", any(api_not_found)) + .route("/ws/*rest", any(api_not_found)) + // Public agent download tree (e.g. /downloads/guruconnect.exe). Mounted + // explicitly so it keeps working after the v2 SPA takes over the root + // fallback below — CLAUDE.md documents this as the public download URL. + // `nest_service` is matched BEFORE `fallback_service`, so these binaries + // are served from disk and never fall through to the SPA index.html. + .nest_service("/downloads", ServeDir::new(format!("{STATIC_DIR}/downloads"))) + // NOTE: there are intentionally no /login, /dashboard, /users routes. + // The v2 SPA (BrowserRouter) owns those paths and resolves them via the + // fallback_service below; registering server-side handlers for them would + // shadow the SPA on a hard reload. // State and middleware .with_state(state.clone()) .layer(axum_middleware::from_fn_with_state(state, auth_layer)) - // Serve static files for portal (fallback) - .fallback_service(ServeDir::new("static").append_index_html_on_directories(true)) + // SPA fallback: serve the React/Vite build from SPA_DIR and, for any + // unmatched path, return the SPA index.html WITH 200 (via `.fallback`, + // not `.not_found_service` which would force a 404) so BrowserRouter + // deep links resolve. This is the Router's `fallback_service`, so it runs + // ONLY after every explicit /api/*, /ws/*, /health, /metrics route and + // the /downloads nest fail to match. An unknown /api/... path therefore + // never reaches here — it hits the per-router 404 and returns the normal + // (non-HTML) 404 the typed client expects. Real assets under /assets/* + // are served from disk by ServeDir with correct content-types; only + // genuinely missing files fall through to index.html. + .fallback_service( + ServeDir::new(SPA_DIR) + .append_index_html_on_directories(true) + .fallback(ServeFile::new(SPA_INDEX)), + ) // Middleware .layer(axum_middleware::from_fn(middleware::add_security_headers)) // SEC-7 & SEC-12 .layer(TraceLayer::new_for_http()) @@ -474,6 +518,23 @@ async fn health() -> &'static str { "OK" } +/// Explicit 404 for unmatched paths under the `/api` and `/ws` namespaces. +/// +/// CRITICAL: without these catch-all routes, an unknown `/api/...` or `/ws/...` +/// path would fall through to the SPA `fallback_service` and be answered with +/// `index.html` (HTTP 200, text/html). That would mask real 404s and break the +/// dashboard's typed client, which parses a JSON error envelope from API 404s. +/// These routes are LESS specific than every real `/api/...` / `/ws/...` route +/// (matchit matches a static segment before a `*` capture), so they only catch +/// genuinely-unrouted API/WS paths and return a proper JSON 404 — never HTML. +async fn api_not_found() -> impl IntoResponse { + ( + StatusCode::NOT_FOUND, + [(axum::http::header::CONTENT_TYPE, "application/json")], + r#"{"error":"Not Found","status_code":404}"#, + ) +} + /// Prometheus metrics endpoint async fn prometheus_metrics(State(state): State) -> String { use prometheus_client::encoding::text::encode; @@ -849,24 +910,3 @@ async fn trigger_machine_update( } } -// Static page handlers -async fn serve_login() -> impl IntoResponse { - match tokio::fs::read_to_string("static/login.html").await { - Ok(content) => Html(content).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(), - } -} - -async fn serve_dashboard() -> impl IntoResponse { - match tokio::fs::read_to_string("static/dashboard.html").await { - Ok(content) => Html(content).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(), - } -} - -async fn serve_users() -> impl IntoResponse { - match tokio::fs::read_to_string("static/users.html").await { - Ok(content) => Html(content).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(), - } -} diff --git a/server/src/middleware/security_headers.rs b/server/src/middleware/security_headers.rs index 13afbcd..58ec4b3 100644 --- a/server/src/middleware/security_headers.rs +++ b/server/src/middleware/security_headers.rs @@ -7,9 +7,33 @@ use axum::{extract::Request, middleware::Next, response::Response}; /// Add security headers to all responses pub async fn add_security_headers(request: Request, next: Next) -> Response { + // Capture the path before the request is consumed downstream, so we can pick + // the right Cache-Control for the SPA: hashed assets are immutable and can be + // cached forever; everything else (index.html for `/`, deep links, and API + // JSON) must revalidate so a fresh deploy is picked up immediately. + let path = request.uri().path().to_owned(); + let mut response = next.run(request).await; let headers = response.headers_mut(); + // Cache-Control. Vite emits content-hashed filenames under `/assets/`, so + // those are safe to cache aggressively (`immutable`); a new build changes the + // hash and therefore the URL. The SPA shell (index.html), served for `/` and + // every BrowserRouter deep link, must NOT be cached stale or clients would + // load an old shell pointing at assets that no longer exist after a deploy — + // hence `no-cache, must-revalidate`. Only set this when a downstream handler + // hasn't already chosen its own caching policy. + if !headers.contains_key(axum::http::header::CACHE_CONTROL) { + let cache_value = if path.starts_with("/assets/") { + "public, max-age=31536000, immutable" + } else { + "no-cache, must-revalidate" + }; + if let Ok(v) = cache_value.parse() { + headers.insert(axum::http::header::CACHE_CONTROL, v); + } + } + // SEC-7: Content Security Policy (XSS Prevention) // This CSP allows inline scripts/styles (needed for dashboard) but blocks external resources headers.insert( diff --git a/server/static/dashboard.html b/server/static/dashboard.html deleted file mode 100644 index 8942b30..0000000 --- a/server/static/dashboard.html +++ /dev/null @@ -1,1436 +0,0 @@ - - - - - - GuruConnect - Dashboard - - - -
-
- -
-
- - -
-
- - - -
- -
-
-
-
-

Active Support Sessions

-

Temporary sessions initiated by support codes

-
- -
-
- - - - - - - - - - - - - - - -
CodeStatusCreatedTechnicianActions
-
-

No active sessions

-

Generate a code to start a support session

-
-
-
-
-
- - -
-
- -
-
-

No machines

-

Install the agent on a machine to see it here

-
-
-
-
-

Select a machine

-

Click a machine to view details

-
-
-
-
- - -
- -
-
-
-

Quick Downloads

-

Download viewer or create temp support sessions

-
-
-
-
-

Viewer Only

-

- Installs the protocol handler for connecting to remote sessions. No agent functionality. -

- Download Viewer -
-
-

Temp Support Session

-

- Generate a support code first, then create a download link with that code embedded. -

-
- - -
- -
-
-
- - -
-
-
-

Permanent Agent Builder

-

Create customized agent installers for unattended access

-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-

- The downloaded agent will have company/site/tags embedded. It will auto-register when run. -

-
-
- - -
-
-
-
-

Settings

-

Configure your GuruConnect preferences

-
-
-
-
- - -
-
- - -
-
-

- Additional settings coming soon. -

-
-
- - -
-
-
-
-

User Management

-

Manage user accounts and permissions

-
-
-

- Open User Management -

-
-
-
- - - - - - - - - - diff --git a/server/static/index.html b/server/static/index.html deleted file mode 100644 index e56a3f2..0000000 --- a/server/static/index.html +++ /dev/null @@ -1,425 +0,0 @@ - - - - - - GuruConnect - Remote Support - - - -
- - -
- -
- -
- -
- - -
- -
- -
-

How to connect:

-
    -
  1. Enter the 6-digit code provided by your technician
  2. -
  3. Click "Connect" to start the session
  4. -
  5. If prompted, allow the download and run the file
  6. -
-
- - -
- - - - diff --git a/server/static/login.html b/server/static/login.html deleted file mode 100644 index 34ad38c..0000000 --- a/server/static/login.html +++ /dev/null @@ -1,229 +0,0 @@ - - - - - - GuruConnect - Login - - - -
- - - - - -
- - - - diff --git a/server/static/users.html b/server/static/users.html deleted file mode 100644 index 08bb946..0000000 --- a/server/static/users.html +++ /dev/null @@ -1,602 +0,0 @@ - - - - - - GuruConnect - User Management - - - -
-
- - ← Back to Dashboard -
-
- -
-
-
-
-

User Management

-

Create and manage user accounts

-
- -
- -
- - - - - - - - - - - - - - - - - -
UsernameEmailRoleStatusLast LoginActions
-
-

Loading users...

-
-
-
-
- - - - -
-
-
- - - - diff --git a/server/static/viewer.html b/server/static/viewer.html deleted file mode 100644 index 1383a6b..0000000 --- a/server/static/viewer.html +++ /dev/null @@ -1,694 +0,0 @@ - - - - - - GuruConnect Viewer - - - - -
- - - -
-
-
FPS: 0
-
Resolution: -
-
Frames: 0
-
-
Connecting...
-
- -
- -
- -
-
-
-
Connecting to remote desktop...
-
-
- - - -