From 0c833a7be272e0ec5df008f425b9faf77b4343c4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 04:41:28 +0000 Subject: [PATCH] fix(dev): share auth across dev frontend proxy --- docs/reference/deploy.md | 4 +- docs/reference/dev-environment.md | 7 ++- scripts/src/deploy.ts | 24 +++++++++ .../backend-core/src/microservice-proxy.ts | 37 +++++++++++--- .../backend-core/src/microservice_proxy.rs | 51 +++++++++++++++++++ src/components/dev-frontend-proxy/nginx.conf | 23 +++++++++ .../microservices/k3sctl-adapter/src/index.ts | 37 ++++++++++---- src/components/provider-gateway/package.json | 2 +- src/components/provider-gateway/src/index.ts | 21 ++++++++ 9 files changed, 184 insertions(+), 22 deletions(-) diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 32f278e4..ecf67e76 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -63,8 +63,8 @@ Phase 2 of the D601 dev environment creates only the isolated namespace and data It may create resources only in `unidesk-dev`: - `Namespace unidesk-dev`, plus quota and default limits. -- `Secret unidesk-dev-runtime-secrets` as a dev-only template for DB credentials, provider token, auth/session secret, and Code Queue model secret placeholders. -- `ConfigMap unidesk-dev-runtime-config` for dev identity, desired-state source `origin/master:deploy.json#environments.dev`, provider id `D601-dev`, Code Queue dev paths, and non-secret runtime defaults. +- `Secret unidesk-dev-runtime-secrets` as a dev-only template for DB credentials, provider token, auth/session secret, and Code Queue model secret placeholders. The frontend auth/session values are placeholders in this manifest; the controlled dev frontend deploy path syncs them from main-server `config.json.auth` so dev and production use the same login identity and session signer. +- `ConfigMap unidesk-dev-runtime-config` for dev identity, desired-state source `origin/master:deploy.json#environments.dev`, provider id `D601-dev`, Code Queue dev paths, and non-secret runtime defaults. `SESSION_TTL_SECONDS` follows the same main-server auth config when `frontend` is deployed. - `ConfigMap unidesk-dev-db-guard` with an executable guard script that rejects production-looking `DATABASE_URL` values. - `StatefulSet/Service postgres-dev` with a 5Gi persistent volume claim and bounded CPU/memory requests/limits. - `Job unidesk-dev-db-migrate`, which waits for `postgres-dev`, runs the guard, then prepares backend-core and Code Queue tables in the independent `unidesk_dev` database. diff --git a/docs/reference/dev-environment.md b/docs/reference/dev-environment.md index 2b6e3c80..1bab3552 100644 --- a/docs/reference/dev-environment.md +++ b/docs/reference/dev-environment.md @@ -28,6 +28,8 @@ The dev public port is configured in `config.json` as `network.devFrontend.port= The unrestricted public network entries are therefore production frontend, dev frontend, and provider ingress. backend-core REST, PostgreSQL, user-service backend ports, k3s Services, NodePorts and D601 host ports remain private or explicitly restricted. +Dev and production frontend authentication must use the same `config.json.auth` username, password, session secret and session TTL. The public dev and production entrypoints share the same IP/host with different ports, so the `unidesk_session` cookie is host-scoped rather than port-scoped. `deploy apply --env dev --service frontend` must sync `unidesk-dev-runtime-secrets` and `unidesk-dev-runtime-config` from the main-server config before rolling out `frontend-dev`; dev manifests may contain placeholders but must not establish a separate dev login identity. + ## Desired State `deploy.json` remains the only version intent file. Dev entries live under `environments.dev` and are read from `origin/master:deploy.json`, never from a dirty local file, when using `--env dev` or `ci run-dev-e2e`. @@ -76,8 +78,9 @@ Rust checking is enabled only when the process is already running inside the D60 5. Build the service image on D601 Docker, importing any required base images through the same egress boundary. 6. Import the image into native k3s containerd at `/run/k3s/containerd/containerd.sock`. 7. Apply only the selected `unidesk-dev` Service/Deployment objects from the dev manifest. -8. Stamp the Deployment with `UNIDESK_DEPLOY_*` env and `unidesk.ai/deploy-*` annotations. -9. Verify health through the Kubernetes API service proxy and require the live commit to match the requested commit. +8. For `frontend`, sync auth/session settings from main-server `config.json.auth` into the dev runtime Secret/ConfigMap before rollout. +9. Stamp the Deployment with `UNIDESK_DEPLOY_*` env and `unidesk.ai/deploy-*` annotations. +10. Verify health through the Kubernetes API service proxy and require the live commit to match the requested commit. The dev path is not a fallback system. If GitHub fetch, provider-gateway egress, Docker build, native k3s, containerd import, kubectl apply or live health verification fails, the job fails with logs. It must not fall back to building on the master server, using a dirty worktree, direct D601 public ports, NodePort, or another deployment command. diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index e20e374f..f6d7eaf9 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -1043,6 +1043,25 @@ function devK3sPrepullImages(service: UniDeskMicroserviceConfig): string[] { return ["oven/bun:1-alpine"]; } +function syncDevFrontendAuthScript(config: UniDeskConfig): string { + const data = { + AUTH_USERNAME: Buffer.from(config.auth.username, "utf8").toString("base64"), + AUTH_PASSWORD: Buffer.from(config.auth.password, "utf8").toString("base64"), + SESSION_SECRET: Buffer.from(config.auth.sessionSecret, "utf8").toString("base64"), + }; + const runtimeConfig = { + SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds), + }; + return [ + "set -euo pipefail", + `secret_patch=${shellQuote(JSON.stringify({ data }))}`, + `config_patch=${shellQuote(JSON.stringify({ data: runtimeConfig }))}`, + `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n unidesk-dev patch secret unidesk-dev-runtime-secrets --type merge -p "$secret_patch"`, + `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n unidesk-dev patch configmap unidesk-dev-runtime-config --type merge -p "$config_patch"`, + "echo dev_frontend_auth_synced=ok", + ].join("\n"); +} + function prepareSourceScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, exportDir: string): string { if (targetIsMain(service) && isUnideskRepo(desired.repo)) { return [ @@ -2315,6 +2334,11 @@ async function applyOneService(config: UniDeskConfig, service: UniDeskMicroservi if (!pushStep(steps, patchManifest)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; } + if (isDevK3sDeployService(service) && service.id === "frontend") { + const authSync = await step(config, service, "sync-dev-frontend-auth", syncDevFrontendAuthScript(config), "/home/ubuntu", 60_000, false); + if (!pushStep(steps, authSync)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; + } + const buildScript = isDirectComposeDeployMode(service) ? buildDirectImageScript(service, desired, resolvedCommit) : buildImageScript(service, desired, resolvedCommit); diff --git a/src/components/backend-core/src/microservice-proxy.ts b/src/components/backend-core/src/microservice-proxy.ts index c8af47ee..d8576c30 100644 --- a/src/components/backend-core/src/microservice-proxy.ts +++ b/src/components/backend-core/src/microservice-proxy.ts @@ -16,6 +16,7 @@ const providerHttpTunnelMaxAttempts = 3; const microserviceForwardRequestHeaders = [ "accept", "content-type", + "cookie", "range", "x-auth", "x-requested-with", @@ -28,6 +29,16 @@ const microserviceForwardRequestHeaders = [ "upload-metadata", "upload-offset", ] as const; +const microserviceForwardResponseHeaders = [ + "cache-control", + "content-disposition", + "etag", + "expires", + "last-modified", + "location", + "set-cookie", + "www-authenticate", +] as const; // --------------------------------------------------------------------------- // Internal helpers @@ -140,6 +151,13 @@ function headersFromMicroserviceRequest(requestHeaders: Record 0) to.set(name, value); + } +} + function boundedMicroserviceBodyText( bodyText: string, contentType: string, @@ -622,15 +640,16 @@ async function directMicroserviceResponse( }); const upstreamContentType = upstream.headers.get("content-type") ?? "text/plain; charset=utf-8"; if (upstreamContentType.toLowerCase().includes("text/event-stream")) { - const responseHeaders: Record = { + const responseHeaders = new Headers({ "content-type": upstreamContentType, "cache-control": upstream.headers.get("cache-control") || "no-store, no-transform", "connection": "keep-alive", "x-unidesk-proxy-mode": "direct", "x-unidesk-response-truncated": "false", - }; + }); const buffering = upstream.headers.get("x-accel-buffering"); - if (buffering !== null) responseHeaders["x-accel-buffering"] = buffering; + if (buffering !== null) responseHeaders.set("x-accel-buffering", buffering); + copyForwardedResponseHeaders(upstream.headers, responseHeaders); return new Response(upstream.body, { status: upstream.status, headers: responseHeaders }); } const rawBodyText = await upstream.text(); @@ -641,13 +660,15 @@ async function directMicroserviceResponse( status: upstream.status, upstreamBodyBytes: rawBodyText.length, }); + const responseHeaders = new Headers({ + "content-type": upstreamContentType, + "x-unidesk-proxy-mode": "direct", + "x-unidesk-response-truncated": bounded.truncated ? "true" : "false", + }); + copyForwardedResponseHeaders(upstream.headers, responseHeaders); return new Response(bounded.bodyText, { status: upstream.status, - headers: { - "content-type": upstreamContentType, - "x-unidesk-proxy-mode": "direct", - "x-unidesk-response-truncated": bounded.truncated ? "true" : "false", - }, + headers: responseHeaders, }); } catch (error) { return jsonResponse({ ok: false, error: "direct microservice proxy failed", serviceId: service.id, detail: errorToJson(error) }, 502); diff --git a/src/components/backend-core/src/microservice_proxy.rs b/src/components/backend-core/src/microservice_proxy.rs index f47c54be..29443249 100644 --- a/src/components/backend-core/src/microservice_proxy.rs +++ b/src/components/backend-core/src/microservice_proxy.rs @@ -35,6 +35,16 @@ const FORWARD_REQUEST_HEADERS: &[&str] = &[ "upload-metadata", "upload-offset", ]; +const FORWARD_RESPONSE_HEADERS: &[&str] = &[ + "cache-control", + "content-disposition", + "etag", + "expires", + "last-modified", + "location", + "set-cookie", + "www-authenticate", +]; fn service_by_id(state: &Arc, service_id: &str) -> Option { state @@ -531,6 +541,44 @@ fn headers_from_microservice_request(request_headers: &Value) -> reqwest::header headers } +fn forwarded_response_headers_from_reqwest( + headers: &reqwest::header::HeaderMap, +) -> serde_json::Map { + let mut result = serde_json::Map::new(); + for name in FORWARD_RESPONSE_HEADERS { + if let Some(value) = headers + .get(*name) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + { + result.insert((*name).to_string(), Value::String(truncate_text(value, 8192))); + } + } + result +} + +fn copy_forwarded_response_headers_from_json(response: &mut Response, result: &Value) { + let Some(headers) = result.get("responseHeaders").and_then(Value::as_object) else { + return; + }; + for name in FORWARD_RESPONSE_HEADERS { + if let Some(value) = headers.get(*name).and_then(Value::as_str) { + add_header(response, name, value); + } + } +} + +fn copy_forwarded_response_headers_from_reqwest( + response: &mut Response, + headers: &reqwest::header::HeaderMap, +) { + for name in FORWARD_RESPONSE_HEADERS { + if let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) { + add_header(response, name, value); + } + } +} + fn content_type_is_json(content_type: &str) -> bool { content_type.to_ascii_lowercase().contains("json") } @@ -644,6 +692,7 @@ fn response_from_provider_microservice_result(result: Value, proxy_mode: &str) - "false" }, ); + copy_forwarded_response_headers_from_json(&mut response, &result); response } @@ -705,6 +754,7 @@ async fn direct_microservice_response( } }; let status = response.status().as_u16(); + let response_headers = response.headers().clone(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) @@ -737,6 +787,7 @@ async fn direct_microservice_response( "x-unidesk-response-truncated", if truncated { "true" } else { "false" }, ); + copy_forwarded_response_headers_from_reqwest(&mut response, &response_headers); response } diff --git a/src/components/dev-frontend-proxy/nginx.conf b/src/components/dev-frontend-proxy/nginx.conf index 804045dc..2086d3e9 100644 --- a/src/components/dev-frontend-proxy/nginx.conf +++ b/src/components/dev-frontend-proxy/nginx.conf @@ -5,6 +5,29 @@ server { # The dev frontend is intentionally reached through the existing backend-core # microservice proxy so the public port does not need direct access to D601. + # Auth endpoints use the production frontend only to issue/clear the shared + # host-scoped UniDesk session cookie. Application routes and APIs still go to + # frontend-dev. + location = /login { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://frontend:8080$request_uri; + } + + location = /logout { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://frontend:8080$request_uri; + } + location / { proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/src/components/microservices/k3sctl-adapter/src/index.ts b/src/components/microservices/k3sctl-adapter/src/index.ts index e8a9d17f..65cd1496 100644 --- a/src/components/microservices/k3sctl-adapter/src/index.ts +++ b/src/components/microservices/k3sctl-adapter/src/index.ts @@ -95,12 +95,29 @@ const nativeServiceFailures = new Map(); const nativeServiceEndpointCache = new Map(); const nativeServiceTunnels = new Map(); logWriter?.prune(); +const forwardedResponseHeaderNames = [ + "cache-control", + "content-disposition", + "etag", + "expires", + "last-modified", + "location", + "set-cookie", + "www-authenticate", +] as const; function envString(name: string, fallback: string): string { const value = process.env[name]; return value === undefined || value.length === 0 ? fallback : value; } +function copyForwardedResponseHeaders(from: Headers, to: Headers): void { + for (const name of forwardedResponseHeaderNames) { + const value = from.get(name); + if (value !== null && value.length > 0) to.set(name, value); + } +} + function envNumber(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw.trim().length === 0) return fallback; @@ -636,17 +653,19 @@ async function fetchNativeServiceUrl( signal: controller.signal, }); const body = await boundedText(upstream, 8 * 1024 * 1024); + const responseHeaders = new Headers({ + "content-type": upstream.headers.get("content-type") ?? "application/octet-stream", + "x-unidesk-proxy-mode": "kubernetes-native-service", + "x-unidesk-k3s-service": service.id, + "x-unidesk-k3s-service-url": upstreamUrl.origin, + "x-unidesk-upstream-duration-ms": String(Date.now() - startedAt), + "x-unidesk-response-truncated": body.truncated ? "true" : "false", + ...extraHeaders, + }); + copyForwardedResponseHeaders(upstream.headers, responseHeaders); return new Response(body.text, { status: upstream.status, - headers: { - "content-type": upstream.headers.get("content-type") ?? "application/octet-stream", - "x-unidesk-proxy-mode": "kubernetes-native-service", - "x-unidesk-k3s-service": service.id, - "x-unidesk-k3s-service-url": upstreamUrl.origin, - "x-unidesk-upstream-duration-ms": String(Date.now() - startedAt), - "x-unidesk-response-truncated": body.truncated ? "true" : "false", - ...extraHeaders, - }, + headers: responseHeaders, }); } finally { clearTimeout(timer); diff --git a/src/components/provider-gateway/package.json b/src/components/provider-gateway/package.json index d989080e..88aa75a5 100644 --- a/src/components/provider-gateway/package.json +++ b/src/components/provider-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@unidesk/provider-gateway", - "version": "0.2.24", + "version": "0.2.25", "private": true, "type": "module", "scripts": { diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index e9e5c86b..60365222 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -130,6 +130,7 @@ const microserviceHttpMaxBodyTextLength = 8 * 1024 * 1024; const microserviceForwardRequestHeaders = [ "accept", "content-type", + "cookie", "range", "x-auth", "x-requested-with", @@ -142,6 +143,16 @@ const microserviceForwardRequestHeaders = [ "upload-metadata", "upload-offset", ] as const; +const microserviceForwardResponseHeaders = [ + "cache-control", + "content-disposition", + "etag", + "expires", + "last-modified", + "location", + "set-cookie", + "www-authenticate", +] as const; function readGatewayMetadataFile(path: string): { name: string; version: string } | null { try { @@ -2009,6 +2020,15 @@ function headersFromMicroserviceRequest(requestHeaders: Record { + const headers: Record = {}; + for (const name of microserviceForwardResponseHeaders) { + const value = response.headers.get(name); + if (value !== null && value.length > 0) headers[name] = value.slice(0, 8192); + } + return headers; +} + async function runMicroserviceHttp(payload: Record): Promise { const rawMethod = String(payload.method || "GET").toUpperCase(); const allowedMethods = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]); @@ -2081,6 +2101,7 @@ async function runMicroserviceHttp(payload: Record): Promise< returnedBodyBytes: bounded.bodyText.length, responseBodyLimitBytes: microserviceHttpMaxBodyTextLength, truncated: bounded.truncated, + responseHeaders: forwardedMicroserviceResponseHeaders(response), transform: transformed.transform, upstreamDurationMs: Date.now() - requestStartedAt, proxyMode: "provider-gateway-http-fetch",