fix: require internal token for device pod executor
This commit is contained in:
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
|
|||||||
import test from "node:test";
|
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 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 () => {
|
test("device pod executor exposes cloud-api authority boundary and method guards", async () => {
|
||||||
const service = await startDevicePod("device-pod-test");
|
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(externalJob.status, 403);
|
||||||
assert.equal((await externalJob.json()).error.code, "cloud_api_authority_required");
|
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",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" },
|
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" })
|
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);
|
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");
|
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`, {
|
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.job.id, "job_devicepod_test");
|
||||||
assert.equal(storedJob.status, "blocked");
|
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`, {
|
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.job.id, "job_devicepod_test");
|
||||||
assert.equal(storedOutput.output.summary, "HWLAB_CLOUD_API_INTERNAL_URL is not configured for gateway dispatch");
|
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`, {
|
const cancelStored = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test/cancel`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "x-hwlab-internal-service": "hwlab-cloud-api" }
|
headers: internalHeaders()
|
||||||
});
|
});
|
||||||
assert.equal(cancelStored.status, 200);
|
assert.equal(cancelStored.status, 200);
|
||||||
assert.equal((await cancelStored.json()).job.id, "job_devicepod_test");
|
assert.equal((await cancelStored.json()).job.id, "job_devicepod_test");
|
||||||
@@ -114,7 +123,7 @@ test("device-pod executor bounds gateway output text", async () => {
|
|||||||
try {
|
try {
|
||||||
const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
|
const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" },
|
headers: internalHeaders({ "content-type": "application/json" }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
jobId: "job_bounded_output_test",
|
jobId: "job_bounded_output_test",
|
||||||
intent: "workspace.ls",
|
intent: "workspace.ls",
|
||||||
@@ -149,7 +158,7 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada
|
|||||||
const dispatches = [];
|
const dispatches = [];
|
||||||
const cloudApi = createServer(async (request, response) => {
|
const cloudApi = createServer(async (request, response) => {
|
||||||
const body = await requestJson(request);
|
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.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||||
response.end(JSON.stringify({
|
response.end(JSON.stringify({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
@@ -183,7 +192,7 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada
|
|||||||
try {
|
try {
|
||||||
const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
|
const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" },
|
headers: internalHeaders({ "content-type": "application/json" }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
jobId: "job_devicepod_gateway_test",
|
jobId: "job_devicepod_gateway_test",
|
||||||
intent: "workspace.ls",
|
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(output.output.auditId, "aud_devicepod_test");
|
||||||
assert.equal(dispatches.length, 1);
|
assert.equal(dispatches.length, 1);
|
||||||
assert.equal(dispatches[0].internalService, "hwlab-device-pod");
|
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.gatewaySessionId, "gws_devicepod_test");
|
||||||
assert.equal(dispatches[0].body.params.input.cwd, "F:\\Work\\Project");
|
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);
|
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 {
|
try {
|
||||||
const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
|
const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" },
|
headers: internalHeaders({ "content-type": "application/json" }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
jobId: "job_options_test",
|
jobId: "job_options_test",
|
||||||
intent: "debug.download",
|
intent: "debug.download",
|
||||||
@@ -293,6 +303,7 @@ async function startDevicePod(devicePodId, extraEnv = {}) {
|
|||||||
HWLAB_DEVICE_POD_PORT: String(port),
|
HWLAB_DEVICE_POD_PORT: String(port),
|
||||||
HWLAB_DEVICE_POD_ID: devicePodId,
|
HWLAB_DEVICE_POD_ID: devicePodId,
|
||||||
HWLAB_ENVIRONMENT: "v02",
|
HWLAB_ENVIRONMENT: "v02",
|
||||||
|
HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN,
|
||||||
...extraEnv
|
...extraEnv
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"]
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
@@ -314,7 +325,7 @@ async function waitForJobStatus(port, devicePodId, jobId, status) {
|
|||||||
let last;
|
let last;
|
||||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
last = await fetchJson(`http://127.0.0.1:${port}/v1/device-pods/${devicePodId}/jobs/${jobId}/output`, {
|
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;
|
if (last.status === status) return last;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
@@ -323,6 +334,10 @@ async function waitForJobStatus(port, devicePodId, jobId, status) {
|
|||||||
return last;
|
return last;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function internalHeaders(extra = {}) {
|
||||||
|
return { ...extra, "x-hwlab-internal-service": "hwlab-cloud-api", "x-hwlab-internal-token": INTERNAL_TOKEN };
|
||||||
|
}
|
||||||
|
|
||||||
async function requestJson(request) {
|
async function requestJson(request) {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for await (const chunk of request) chunks.push(chunk);
|
for await (const chunk of request) chunks.push(chunk);
|
||||||
|
|||||||
@@ -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 devicePodId = process.env.HWLAB_DEVICE_POD_ID || DEFAULT_DEVICE_POD_ID;
|
||||||
const environment = process.env.HWLAB_ENVIRONMENT || process.env.HWLAB_GITOPS_PROFILE || "dev";
|
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 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 dispatchTimeoutMs = numberOr(process.env.HWLAB_DEVICE_POD_GATEWAY_DISPATCH_TIMEOUT_MS, 120000);
|
||||||
const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000;
|
const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000;
|
||||||
const jobs = new Map();
|
const jobs = new Map();
|
||||||
@@ -236,6 +237,7 @@ async function dispatchGatewayJob({ job, route }) {
|
|||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"x-hwlab-internal-service": SERVICE_ID,
|
"x-hwlab-internal-service": SERVICE_ID,
|
||||||
|
"x-hwlab-internal-token": internalToken,
|
||||||
"x-trace-id": job.traceId,
|
"x-trace-id": job.traceId,
|
||||||
"x-request-id": `req_${job.id}`
|
"x-request-id": `req_${job.id}`
|
||||||
},
|
},
|
||||||
@@ -318,8 +320,11 @@ function parseDevicePodPath(pathname) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isInternalCaller(request) {
|
function isInternalCaller(request) {
|
||||||
const header = request.headers["x-hwlab-internal-service"];
|
if (!internalToken) return false;
|
||||||
return String(Array.isArray(header) ? header[0] : header ?? "").trim() === "hwlab-cloud-api";
|
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() {
|
function sourcePayload() {
|
||||||
|
|||||||
+8
-1
@@ -151,7 +151,14 @@
|
|||||||
"HWLAB_BOOTSTRAP_ADMIN_ID": "usr_v02_admin",
|
"HWLAB_BOOTSTRAP_ADMIN_ID": "usr_v02_admin",
|
||||||
"HWLAB_BOOTSTRAP_ADMIN_USERNAME": "admin",
|
"HWLAB_BOOTSTRAP_ADMIN_USERNAME": "admin",
|
||||||
"HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME": "HWLAB v0.2 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
- `cmd/hwlab-device-pod/main.ts` 返回 `contractVersion=device-pod-executor-v1`,并声明 `authority=hwlab-cloud-api`、`fake=false`。
|
- `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` 服务运行契约。
|
- `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_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 接口说明
|
## API 接口说明
|
||||||
|
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
| `GET /v1/device-pods` | 返回 executor boundary 摘要,不表达用户可见 device pod authority。 |
|
| `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}/status` | 返回 blocked executor status;真实 profile/status 以 cloud-api `/v1/device-pods/{devicePodId}/status` 为准。 |
|
||||||
| `GET /v1/device-pods/{devicePodId}/events` | 返回 bounded executor boundary event,不伪造硬件事件。 |
|
| `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`。 |
|
| `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` | 只接受 `hwlab-cloud-api` 内部调用,用于查询内部 job、最大 12000 bytes 的 bounded output 和取消非终态 job;普通用户仍必须走 cloud-api 用户态 API。 |
|
| `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。
|
用户态 `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。
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { test } from "bun:test";
|
|||||||
|
|
||||||
import { createCloudApiServer } from "./server.ts";
|
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 () => {
|
test("cloud api access control grants visible device pods and blocks unavailable gateway jobs", async () => {
|
||||||
const server = createCloudApiServer({
|
const server = createCloudApiServer({
|
||||||
env: {
|
env: {
|
||||||
@@ -209,7 +211,7 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe
|
|||||||
const executorRequests = [];
|
const executorRequests = [];
|
||||||
const executor = createServer(async (request, response) => {
|
const executor = createServer(async (request, response) => {
|
||||||
const body = await requestJson(request);
|
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") {
|
if (request.method === "POST" && body.args?.path === "src") {
|
||||||
response.writeHead(202, { "content-type": "application/json; charset=utf-8" });
|
response.writeHead(202, { "content-type": "application/json; charset=utf-8" });
|
||||||
response.end(JSON.stringify({
|
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_ACCESS_CONTROL_REQUIRED: "1",
|
||||||
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
||||||
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
||||||
|
HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN,
|
||||||
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
||||||
},
|
},
|
||||||
now: () => "2026-05-28T00:00:00.000Z"
|
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(job.body.job.status, "blocked");
|
||||||
assert.equal(executorRequests.length, 1);
|
assert.equal(executorRequests.length, 1);
|
||||||
assert.equal(executorRequests[0].internalService, "hwlab-cloud-api");
|
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.profileHash, job.body.profileHash);
|
||||||
assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id);
|
assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id);
|
||||||
assert.equal(executorRequests[0].body.intent, "workspace.ls");
|
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_ACCESS_CONTROL_REQUIRED: "1",
|
||||||
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
||||||
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
||||||
|
HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN,
|
||||||
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
||||||
},
|
},
|
||||||
now: () => "2026-05-28T00:00:00.000Z"
|
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_ACCESS_CONTROL_REQUIRED: "1",
|
||||||
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
||||||
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
||||||
|
HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN,
|
||||||
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
||||||
},
|
},
|
||||||
now: () => "2026-05-28T00:00:00.000Z"
|
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 () => {
|
test("cloud api internal device-pod gateway dispatch is service-only and fail-closed without online gateway", async () => {
|
||||||
const server = createCloudApiServer({
|
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"
|
now: () => "2026-05-28T00:00:00.000Z"
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
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.status, 403);
|
||||||
assert.equal(external.body.error.code, "device_pod_internal_authority_required");
|
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", {
|
const blocked = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", {
|
||||||
id: "req_devicepod_dispatch_test",
|
id: "req_devicepod_dispatch_test",
|
||||||
params: {
|
params: {
|
||||||
@@ -462,7 +482,7 @@ test("cloud api internal device-pod gateway dispatch is service-only and fail-cl
|
|||||||
traceId: "trc_devicepod_dispatch_test",
|
traceId: "trc_devicepod_dispatch_test",
|
||||||
input: { command: "node tools/device-host-cli.mjs health" }
|
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.status, 409);
|
||||||
assert.equal(blocked.body.blocker.code, "gateway_dispatch_unavailable");
|
assert.equal(blocked.body.blocker.code, "gateway_dispatch_unavailable");
|
||||||
} finally {
|
} 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 () => {
|
test("cloud api internal device-pod gateway dispatch uses gateway poll result", async () => {
|
||||||
const server = createCloudApiServer({
|
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"
|
now: () => "2026-05-28T00:00:00.000Z"
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
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",
|
traceId: "trc_devicepod_dispatch_test",
|
||||||
input: { command: "node tools/device-host-cli.mjs health", timeoutMs: 1000 }
|
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" });
|
const queued = await postJson(port, "/v1/gateway/poll", { gatewayId: "gtw_devicepod_test", gatewaySessionId: "gws_devicepod_test" });
|
||||||
assert.equal(queued.status, 200);
|
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) {
|
async function getJson(port, path, cookie = null) {
|
||||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||||
headers: cookie ? { cookie } : {}
|
headers: cookie ? { cookie } : {}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class AccessController {
|
|||||||
this.gatewayRegistry = gatewayRegistry;
|
this.gatewayRegistry = gatewayRegistry;
|
||||||
this.fetchImpl = fetchImpl;
|
this.fetchImpl = fetchImpl;
|
||||||
this.devicePodExecutorUrl = normalizeBaseUrl(devicePodExecutorUrl);
|
this.devicePodExecutorUrl = normalizeBaseUrl(devicePodExecutorUrl);
|
||||||
|
this.devicePodInternalToken = textOr(env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, "");
|
||||||
this.devicePodExecutorTimeoutMs = Number.parseInt(String(devicePodExecutorTimeoutMs), 10) || 1200;
|
this.devicePodExecutorTimeoutMs = Number.parseInt(String(devicePodExecutorTimeoutMs), 10) || 1200;
|
||||||
this.now = now;
|
this.now = now;
|
||||||
this.required = required;
|
this.required = required;
|
||||||
@@ -783,7 +784,7 @@ class AccessController {
|
|||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"x-hwlab-internal-service": CLOUD_API_SERVICE_ID
|
...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken)
|
||||||
},
|
},
|
||||||
body: stableJson({
|
body: stableJson({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
@@ -852,7 +853,7 @@ class AccessController {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchJsonWithTimeout(this.fetchImpl, target, {
|
const response = await fetchJsonWithTimeout(this.fetchImpl, target, {
|
||||||
method: "GET",
|
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);
|
}, this.devicePodExecutorTimeoutMs);
|
||||||
return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response });
|
return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -865,7 +866,7 @@ class AccessController {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchJsonWithTimeout(this.fetchImpl, target, {
|
const response = await fetchJsonWithTimeout(this.fetchImpl, target, {
|
||||||
method: "POST",
|
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);
|
}, this.devicePodExecutorTimeoutMs);
|
||||||
return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response });
|
return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response });
|
||||||
} catch (error) {
|
} 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 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 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 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 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 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"); }
|
function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); }
|
||||||
|
|||||||
@@ -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" } });
|
sendJson(response, 405, { error: { code: "method_not_allowed", message: "Internal device-pod gateway dispatch only supports POST" } });
|
||||||
return;
|
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" } });
|
sendJson(response, 403, { error: { code: "device_pod_internal_authority_required", message: "Only hwlab-device-pod may use the internal gateway dispatch route" } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -581,6 +581,13 @@ function internalServiceHeader(request) {
|
|||||||
return String(getHeader(request, "x-hwlab-internal-service") ?? "").trim();
|
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) {
|
async function handleGatewayPollHttp(request, response, options) {
|
||||||
const body = await readBody(request, options.bodyLimitBytes);
|
const body = await readBody(request, options.bodyLimitBytes);
|
||||||
let params = {};
|
let params = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user