diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts index c4d5bd45..912cd627 100644 --- a/cmd/hwlab-device-pod/main.test.ts +++ b/cmd/hwlab-device-pod/main.test.ts @@ -4,6 +4,7 @@ import { createServer } from "node:http"; import test from "node:test"; const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun"); +const INTERNAL_TOKEN = "test-internal-token"; test("device pod executor exposes cloud-api authority boundary and method guards", async () => { const service = await startDevicePod("device-pod-test"); @@ -42,9 +43,17 @@ test("device pod executor exposes cloud-api authority boundary and method guards assert.equal(externalJob.status, 403); assert.equal((await externalJob.json()).error.code, "cloud_api_authority_required"); - const internalJob = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { + const headerOnlyJob = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { method: "POST", headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" }, + body: JSON.stringify({ intent: "workspace.ls" }) + }); + assert.equal(headerOnlyJob.status, 403); + assert.equal((await headerOnlyJob.json()).error.code, "cloud_api_authority_required"); + + const internalJob = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { + method: "POST", + headers: internalHeaders({ "content-type": "application/json" }), body: JSON.stringify({ jobId: "job_devicepod_test", intent: "workspace.ls", traceId: "trc_device_pod_test", operationId: "op_device_pod_test" }) }); assert.equal(internalJob.status, 409); @@ -54,13 +63,13 @@ test("device pod executor exposes cloud-api authority boundary and method guards assert.equal(internalJobPayload.outputUrl, "/v1/device-pods/device-pod-test/jobs/job_devicepod_test/output"); const storedJob = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test`, { - headers: { "x-hwlab-internal-service": "hwlab-cloud-api" } + headers: internalHeaders() }); assert.equal(storedJob.job.id, "job_devicepod_test"); assert.equal(storedJob.status, "blocked"); const storedOutput = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test/output`, { - headers: { "x-hwlab-internal-service": "hwlab-cloud-api" } + headers: internalHeaders() }); assert.equal(storedOutput.job.id, "job_devicepod_test"); assert.equal(storedOutput.output.summary, "HWLAB_CLOUD_API_INTERNAL_URL is not configured for gateway dispatch"); @@ -73,7 +82,7 @@ test("device pod executor exposes cloud-api authority boundary and method guards const cancelStored = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test/cancel`, { method: "POST", - headers: { "x-hwlab-internal-service": "hwlab-cloud-api" } + headers: internalHeaders() }); assert.equal(cancelStored.status, 200); assert.equal((await cancelStored.json()).job.id, "job_devicepod_test"); @@ -114,7 +123,7 @@ test("device-pod executor bounds gateway output text", async () => { try { const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { method: "POST", - headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" }, + headers: internalHeaders({ "content-type": "application/json" }), body: JSON.stringify({ jobId: "job_bounded_output_test", intent: "workspace.ls", @@ -149,7 +158,7 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada const dispatches = []; const cloudApi = createServer(async (request, response) => { const body = await requestJson(request); - dispatches.push({ method: request.method, url: request.url, internalService: request.headers["x-hwlab-internal-service"], body }); + dispatches.push({ method: request.method, url: request.url, internalService: request.headers["x-hwlab-internal-service"], internalToken: request.headers["x-hwlab-internal-token"], body }); response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ jsonrpc: "2.0", @@ -183,7 +192,7 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada try { const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { method: "POST", - headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" }, + headers: internalHeaders({ "content-type": "application/json" }), body: JSON.stringify({ jobId: "job_devicepod_gateway_test", intent: "workspace.ls", @@ -220,6 +229,7 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada assert.equal(output.output.auditId, "aud_devicepod_test"); assert.equal(dispatches.length, 1); assert.equal(dispatches[0].internalService, "hwlab-device-pod"); + assert.equal(dispatches[0].internalToken, INTERNAL_TOKEN); assert.equal(dispatches[0].body.params.gatewaySessionId, "gws_devicepod_test"); assert.equal(dispatches[0].body.params.input.cwd, "F:\\Work\\Project"); assert.match(dispatches[0].body.params.input.command, /^node tools\\device-host-cli\.mjs --profile-json-b64 /u); @@ -254,7 +264,7 @@ test("device-pod executor maps v0.1 CLI options to device-host-cli argv", async try { const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { method: "POST", - headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" }, + headers: internalHeaders({ "content-type": "application/json" }), body: JSON.stringify({ jobId: "job_options_test", intent: "debug.download", @@ -293,6 +303,7 @@ async function startDevicePod(devicePodId, extraEnv = {}) { HWLAB_DEVICE_POD_PORT: String(port), HWLAB_DEVICE_POD_ID: devicePodId, HWLAB_ENVIRONMENT: "v02", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, ...extraEnv }, stdio: ["ignore", "pipe", "pipe"] @@ -314,7 +325,7 @@ async function waitForJobStatus(port, devicePodId, jobId, status) { let last; for (let attempt = 0; attempt < 30; attempt += 1) { last = await fetchJson(`http://127.0.0.1:${port}/v1/device-pods/${devicePodId}/jobs/${jobId}/output`, { - headers: { "x-hwlab-internal-service": "hwlab-cloud-api" } + headers: internalHeaders() }); if (last.status === status) return last; await new Promise((resolve) => setTimeout(resolve, 50)); @@ -323,6 +334,10 @@ async function waitForJobStatus(port, devicePodId, jobId, status) { return last; } +function internalHeaders(extra = {}) { + return { ...extra, "x-hwlab-internal-service": "hwlab-cloud-api", "x-hwlab-internal-token": INTERNAL_TOKEN }; +} + async function requestJson(request) { const chunks = []; for await (const chunk of request) chunks.push(chunk); diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index 278f5644..54301102 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -11,6 +11,7 @@ const port = parsePort(process.env.HWLAB_DEVICE_POD_PORT, parsePort(process.env. const devicePodId = process.env.HWLAB_DEVICE_POD_ID || DEFAULT_DEVICE_POD_ID; const environment = process.env.HWLAB_ENVIRONMENT || process.env.HWLAB_GITOPS_PROFILE || "dev"; const cloudApiInternalUrl = normalizeBaseUrl(process.env.HWLAB_CLOUD_API_INTERNAL_URL || process.env.HWLAB_CLOUD_API_URL); +const internalToken = textOr(process.env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); const dispatchTimeoutMs = numberOr(process.env.HWLAB_DEVICE_POD_GATEWAY_DISPATCH_TIMEOUT_MS, 120000); const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000; const jobs = new Map(); @@ -236,6 +237,7 @@ async function dispatchGatewayJob({ job, route }) { accept: "application/json", "content-type": "application/json", "x-hwlab-internal-service": SERVICE_ID, + "x-hwlab-internal-token": internalToken, "x-trace-id": job.traceId, "x-request-id": `req_${job.id}` }, @@ -318,8 +320,11 @@ function parseDevicePodPath(pathname) { } function isInternalCaller(request) { - const header = request.headers["x-hwlab-internal-service"]; - return String(Array.isArray(header) ? header[0] : header ?? "").trim() === "hwlab-cloud-api"; + if (!internalToken) return false; + const serviceHeader = request.headers["x-hwlab-internal-service"]; + const tokenHeader = request.headers["x-hwlab-internal-token"]; + return String(Array.isArray(serviceHeader) ? serviceHeader[0] : serviceHeader ?? "").trim() === "hwlab-cloud-api" + && String(Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader ?? "").trim() === internalToken; } function sourcePayload() { diff --git a/deploy/deploy.json b/deploy/deploy.json index 5610f965..430899d7 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -151,7 +151,14 @@ "HWLAB_BOOTSTRAP_ADMIN_ID": "usr_v02_admin", "HWLAB_BOOTSTRAP_ADMIN_USERNAME": "admin", "HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME": "HWLAB v0.2 Admin", - "HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH": "secretRef:hwlab-v02-bootstrap-admin/password-hash" + "HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH": "secretRef:hwlab-v02-bootstrap-admin/password-hash", + "HWLAB_DEVICE_POD_INTERNAL_TOKEN": "secretRef:hwlab-v02-device-pod-internal/token" + } + }, + { + "serviceId": "hwlab-device-pod", + "env": { + "HWLAB_DEVICE_POD_INTERNAL_TOKEN": "secretRef:hwlab-v02-device-pod-internal/token" } } ] diff --git a/docs/reference/spec-v02-hwlab-device-pod-service.md b/docs/reference/spec-v02-hwlab-device-pod-service.md index 7f447e22..093c3a0d 100644 --- a/docs/reference/spec-v02-hwlab-device-pod-service.md +++ b/docs/reference/spec-v02-hwlab-device-pod-service.md @@ -14,7 +14,7 @@ - `cmd/hwlab-device-pod/main.ts` 返回 `contractVersion=device-pod-executor-v1`,并声明 `authority=hwlab-cloud-api`、`fake=false`。 - `internal/device-pod/fake-data.mjs` 只保留为 legacy 前端/smoke fixture,不是 `hwlab-device-pod` 服务运行契约。 - `HWLAB_DEVICE_POD_ID` 可指定默认 executor devicePodId;正式 profile 仍由 `hwlab-cloud-api` 的 `device_pods.profile_json` 管理。 -- `HWLAB_CLOUD_API_INTERNAL_URL` 配置后,executor 可用内部服务凭据回调 `hwlab-cloud-api` 的 gateway dispatch route,把已授权 job 下发到 gateway/device-host-cli;未配置、profile route 缺失或 gateway 不在线时返回 blocker。 +- `HWLAB_CLOUD_API_INTERNAL_URL` 配置后,executor 可用内部服务凭据回调 `hwlab-cloud-api` 的 gateway dispatch route,把已授权 job 下发到 gateway/device-host-cli;内部 job 和 dispatch 必须同时携带 `x-hwlab-internal-service` 与 `HWLAB_DEVICE_POD_INTERNAL_TOKEN` 对应的 `x-hwlab-internal-token`。未配置内部 token、profile route 缺失或 gateway 不在线时返回 blocker/拒绝执行。 ## API 接口说明 @@ -24,8 +24,8 @@ | `GET /v1/device-pods` | 返回 executor boundary 摘要,不表达用户可见 device pod authority。 | | `GET /v1/device-pods/{devicePodId}/status` | 返回 blocked executor status;真实 profile/status 以 cloud-api `/v1/device-pods/{devicePodId}/status` 为准。 | | `GET /v1/device-pods/{devicePodId}/events` | 返回 bounded executor boundary event,不伪造硬件事件。 | -| `POST /v1/device-pods/{devicePodId}/jobs` | 只接受 `hwlab-cloud-api` 内部调用;创建内部 executor job 并返回 job、freshness、output/cancel URL;有可用 profile route 和 `HWLAB_CLOUD_API_INTERNAL_URL` 时通过 cloud-api gateway dispatch 下发到 device-host-cli,否则返回 `gateway_dispatch_unavailable`。 | -| `GET /v1/device-pods/{devicePodId}/jobs/{jobId}`、`GET /output`、`POST /cancel` | 只接受 `hwlab-cloud-api` 内部调用,用于查询内部 job、最大 12000 bytes 的 bounded output 和取消非终态 job;普通用户仍必须走 cloud-api 用户态 API。 | +| `POST /v1/device-pods/{devicePodId}/jobs` | 只接受带内部 token 的 `hwlab-cloud-api` 调用;创建内部 executor job 并返回 job、freshness、output/cancel URL;有可用 profile route 和 `HWLAB_CLOUD_API_INTERNAL_URL` 时通过 cloud-api gateway dispatch 下发到 device-host-cli,否则返回 `gateway_dispatch_unavailable`。 | +| `GET /v1/device-pods/{devicePodId}/jobs/{jobId}`、`GET /output`、`POST /cancel` | 只接受带内部 token 的 `hwlab-cloud-api` 调用,用于查询内部 job、最大 12000 bytes 的 bounded output 和取消非终态 job;普通用户仍必须走 cloud-api 用户态 API。 | 用户态 `POST /jobs`、job output/cancel、admin profile/grant API 和正式 `device-pod-cli` REST 调用由 `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。`hwlab-device-pod` 不接受 CLI、浏览器或 Code Agent 直接上传 profile snapshot。 diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 6ac3db4a..e65b1224 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -4,6 +4,8 @@ import { test } from "bun:test"; import { createCloudApiServer } from "./server.ts"; +const INTERNAL_TOKEN = "test-internal-token"; + test("cloud api access control grants visible device pods and blocks unavailable gateway jobs", async () => { const server = createCloudApiServer({ env: { @@ -209,7 +211,7 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe const executorRequests = []; const executor = createServer(async (request, response) => { const body = await requestJson(request); - executorRequests.push({ method: request.method, url: request.url, internalService: request.headers["x-hwlab-internal-service"], body }); + executorRequests.push({ method: request.method, url: request.url, internalService: request.headers["x-hwlab-internal-service"], internalToken: request.headers["x-hwlab-internal-token"], body }); if (request.method === "POST" && body.args?.path === "src") { response.writeHead(202, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ @@ -259,6 +261,7 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` }, now: () => "2026-05-28T00:00:00.000Z" @@ -282,6 +285,7 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe assert.equal(job.body.job.status, "blocked"); assert.equal(executorRequests.length, 1); assert.equal(executorRequests[0].internalService, "hwlab-cloud-api"); + assert.equal(executorRequests[0].internalToken, INTERNAL_TOKEN); assert.equal(executorRequests[0].body.profileHash, job.body.profileHash); assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id); assert.equal(executorRequests[0].body.intent, "workspace.ls"); @@ -330,6 +334,7 @@ test("cloud api bounds device-pod job output payloads", async () => { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` }, now: () => "2026-05-28T00:00:00.000Z" @@ -390,6 +395,7 @@ test("cloud api routes device-pod probe GET requests through executor jobs", asy HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` }, now: () => "2026-05-28T00:00:00.000Z" @@ -441,7 +447,7 @@ test("cloud api routes device-pod probe GET requests through executor jobs", asy test("cloud api internal device-pod gateway dispatch is service-only and fail-closed without online gateway", async () => { const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN }, now: () => "2026-05-28T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -452,6 +458,20 @@ test("cloud api internal device-pod gateway dispatch is service-only and fail-cl assert.equal(external.status, 403); assert.equal(external.body.error.code, "device_pod_internal_authority_required"); + const headerOnly = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", { + id: "req_devicepod_dispatch_test", + params: { + gatewaySessionId: "gws_missing", + resourceId: "res_devicepod_test", + capabilityId: "cap_device_host_cli", + operationId: "op_devicepod_dispatch_test", + traceId: "trc_devicepod_dispatch_test", + input: { command: "node tools/device-host-cli.mjs health" } + } + }, null, { "x-hwlab-internal-service": "hwlab-device-pod" }); + assert.equal(headerOnly.status, 403); + assert.equal(headerOnly.body.error.code, "device_pod_internal_authority_required"); + const blocked = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", { id: "req_devicepod_dispatch_test", params: { @@ -462,7 +482,7 @@ test("cloud api internal device-pod gateway dispatch is service-only and fail-cl traceId: "trc_devicepod_dispatch_test", input: { command: "node tools/device-host-cli.mjs health" } } - }, null, { "x-hwlab-internal-service": "hwlab-device-pod" }); + }, null, devicePodInternalHeaders()); assert.equal(blocked.status, 409); assert.equal(blocked.body.blocker.code, "gateway_dispatch_unavailable"); } finally { @@ -472,7 +492,7 @@ test("cloud api internal device-pod gateway dispatch is service-only and fail-cl test("cloud api internal device-pod gateway dispatch uses gateway poll result", async () => { const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_ENVIRONMENT: "v02" }, + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_ENVIRONMENT: "v02", HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN }, now: () => "2026-05-28T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -498,7 +518,7 @@ test("cloud api internal device-pod gateway dispatch uses gateway poll result", traceId: "trc_devicepod_dispatch_test", input: { command: "node tools/device-host-cli.mjs health", timeoutMs: 1000 } } - }, null, { "x-hwlab-internal-service": "hwlab-device-pod" }); + }, null, devicePodInternalHeaders()); const queued = await postJson(port, "/v1/gateway/poll", { gatewayId: "gtw_devicepod_test", gatewaySessionId: "gws_devicepod_test" }); assert.equal(queued.status, 200); @@ -717,6 +737,10 @@ async function postJson(port, path, body, cookie = null, extraHeaders = {}) { }; } +function devicePodInternalHeaders(extra = {}) { + return { ...extra, "x-hwlab-internal-service": "hwlab-device-pod", "x-hwlab-internal-token": INTERNAL_TOKEN }; +} + async function getJson(port, path, cookie = null) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { headers: cookie ? { cookie } : {} diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index bcdd2f24..39a8ab53 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -140,6 +140,7 @@ class AccessController { this.gatewayRegistry = gatewayRegistry; this.fetchImpl = fetchImpl; this.devicePodExecutorUrl = normalizeBaseUrl(devicePodExecutorUrl); + this.devicePodInternalToken = textOr(env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); this.devicePodExecutorTimeoutMs = Number.parseInt(String(devicePodExecutorTimeoutMs), 10) || 1200; this.now = now; this.required = required; @@ -783,7 +784,7 @@ class AccessController { headers: { accept: "application/json", "content-type": "application/json", - "x-hwlab-internal-service": CLOUD_API_SERVICE_ID + ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) }, body: stableJson({ jobId: job.id, @@ -852,7 +853,7 @@ class AccessController { try { const response = await fetchJsonWithTimeout(this.fetchImpl, target, { method: "GET", - headers: { accept: "application/json", "x-hwlab-internal-service": CLOUD_API_SERVICE_ID } + headers: { accept: "application/json", ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) } }, this.devicePodExecutorTimeoutMs); return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); } catch (error) { @@ -865,7 +866,7 @@ class AccessController { try { const response = await fetchJsonWithTimeout(this.fetchImpl, target, { method: "POST", - headers: { accept: "application/json", "x-hwlab-internal-service": CLOUD_API_SERVICE_ID } + headers: { accept: "application/json", ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) } }, this.devicePodExecutorTimeoutMs); return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); } catch (error) { @@ -1180,6 +1181,7 @@ function boundedDurationMs(value, fallback = 1000) { const parsed = Number.parse function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: boundedDurationMs(searchParams.get("durationMs") ?? searchParams.get("duration-ms")), maxBytes: boundedOutputMaxBytes(searchParams) }; } function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } function leaseTokenFromRequest(request, body) { return textOr(body?.leaseToken, "") || textOr(getHeader(request, "x-hwlab-device-lease-token"), ""); } +function devicePodInternalHeaders(serviceId, token) { return token ? { "x-hwlab-internal-service": serviceId, "x-hwlab-internal-token": token } : { "x-hwlab-internal-service": serviceId }; } function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; } function verifyPassword(stored, password) { const [, salt, digest] = String(stored ?? "").split(":"); return Boolean(salt && digest && sha256(`${salt}:${password}`) === digest); } function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 4aadb272..48c06ec7 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -475,7 +475,7 @@ async function handleInternalDevicePodGatewayDispatch(request, response, options sendJson(response, 405, { error: { code: "method_not_allowed", message: "Internal device-pod gateway dispatch only supports POST" } }); return; } - if (internalServiceHeader(request) !== "hwlab-device-pod") { + if (!isDevicePodInternalCaller(request, options.env ?? process.env)) { sendJson(response, 403, { error: { code: "device_pod_internal_authority_required", message: "Only hwlab-device-pod may use the internal gateway dispatch route" } }); return; } @@ -581,6 +581,13 @@ function internalServiceHeader(request) { return String(getHeader(request, "x-hwlab-internal-service") ?? "").trim(); } +function isDevicePodInternalCaller(request, env = process.env) { + const token = String(env.HWLAB_DEVICE_POD_INTERNAL_TOKEN ?? "").trim(); + return Boolean(token) + && internalServiceHeader(request) === "hwlab-device-pod" + && String(getHeader(request, "x-hwlab-internal-token") ?? "").trim() === token; +} + async function handleGatewayPollHttp(request, response, options) { const body = await readBody(request, options.bodyLimitBytes); let params = {};