fix(dev): share auth across dev frontend proxy

This commit is contained in:
Codex
2026-05-19 04:41:28 +00:00
parent dfb344ef0c
commit 0c833a7be2
9 changed files with 184 additions and 22 deletions
+2 -2
View File
@@ -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.
+5 -2
View File
@@ -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.
+24
View File
@@ -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);
@@ -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<string, JsonValue
return headers;
}
function copyForwardedResponseHeaders(from: Headers, to: Headers): void {
for (const name of microserviceForwardResponseHeaders) {
const value = from.get(name);
if (value !== null && value.length > 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<string, string> = {
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);
@@ -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<AppState>, service_id: &str) -> Option<MicroserviceConfig> {
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<String, Value> {
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
}
@@ -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;
@@ -95,12 +95,29 @@ const nativeServiceFailures = new Map<string, number>();
const nativeServiceEndpointCache = new Map<string, { endpoint: NativeServiceEndpoint; expiresAt: number }>();
const nativeServiceTunnels = new Map<string, NativeServiceTunnel>();
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);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@unidesk/provider-gateway",
"version": "0.2.24",
"version": "0.2.25",
"private": true,
"type": "module",
"scripts": {
@@ -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<string, JsonValue
return headers;
}
function forwardedMicroserviceResponseHeaders(response: Response): Record<string, JsonValue> {
const headers: Record<string, JsonValue> = {};
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<string, JsonValue>): Promise<JsonValue> {
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<string, JsonValue>): 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",