588 lines
17 KiB
TypeScript
588 lines
17 KiB
TypeScript
#!/usr/bin/env bun
|
|
import os from "node:os";
|
|
import { spawn } from "node:child_process";
|
|
|
|
import { createAuditEvent, createEvidenceRecord, operationContext } from "../../internal/sim/l2-runtime.mjs";
|
|
import { createGatewayState, createHealth } from "../../internal/sim/model.mjs";
|
|
import { createJsonServer, fetchJson, listen, parsePort } from "../../internal/sim/http.mjs";
|
|
import { ENVIRONMENT_DEV, ERROR_CODES, JSON_RPC_VERSION, createRpcErrorBody, isProtocolEnvironment } from "../../internal/protocol/index.mjs";
|
|
|
|
const gatewayId = process.env.HWLAB_GATEWAY_ID ?? "gateway_dev_stub";
|
|
const gatewaySessionId = process.env.HWLAB_GATEWAY_SESSION_ID ?? `gws_${gatewayId}`;
|
|
const port = parsePort(process.env.PORT, 7001);
|
|
const cloudUrl = trimUrl(process.env.HWLAB_GATEWAY_CLOUD_URL);
|
|
const pollIntervalMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_POLL_INTERVAL_MS, 500);
|
|
const commandTimeoutMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_TIMEOUT_MS, 120000);
|
|
const outputLimitBytes = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES, 65536);
|
|
const maxInflightRequests = parsePositiveInteger(process.env.HWLAB_GATEWAY_MAX_INFLIGHT, 3);
|
|
// CI probe: touches only the hwlab-gateway code identity for env reuse validation round 2.
|
|
const gatewayEnvironment = normalizeEnvironment(process.env.HWLAB_ENVIRONMENT || process.env.HWLAB_GITOPS_PROFILE || ENVIRONMENT_DEV);
|
|
const resourceId = process.env.HWLAB_GATEWAY_RESOURCE_ID ?? "res_windows_host";
|
|
const boxId = process.env.HWLAB_GATEWAY_BOX_ID ?? "box_windows_host";
|
|
const commandExecutionEnabled = truthy(process.env.HWLAB_GATEWAY_CMD_EXEC_ENABLED) || truthy(process.env.HWLAB_GATEWAY_DEMO_OPEN);
|
|
const inflightRequests = new Map();
|
|
const state = createGatewayState({
|
|
gatewayId,
|
|
serviceId: "hwlab-gateway",
|
|
endpoint: process.env.HWLAB_GATEWAY_ENDPOINT ?? `http://127.0.0.1:${port}`,
|
|
boxes: [resourceId]
|
|
});
|
|
state.session.gatewaySessionId = gatewaySessionId;
|
|
state.session.status = "connected";
|
|
state.registry = {
|
|
gatewayId,
|
|
gatewaySessionId,
|
|
resourceId,
|
|
boxId,
|
|
capabilities: shellCapabilities()
|
|
};
|
|
state.outbound = {
|
|
enabled: Boolean(cloudUrl),
|
|
cloudUrl,
|
|
pollIntervalMs,
|
|
commandExecutionEnabled,
|
|
maxInflightRequests,
|
|
inflightCount: 0,
|
|
inflightRequests: [],
|
|
lastPollAt: null,
|
|
lastPollError: null,
|
|
lastResultAt: null,
|
|
lastResultError: null,
|
|
lastBusyAt: null
|
|
};
|
|
|
|
const routes = new Map();
|
|
|
|
routes.set("GET /health/live", () => ({
|
|
...createHealth({ serviceId: "hwlab-gateway", name: gatewayId }),
|
|
gatewayId,
|
|
gatewaySessionId,
|
|
outboundConnected: Boolean(state.outbound.lastPollAt && !state.outbound.lastPollError)
|
|
}));
|
|
routes.set("GET /status", () => ({
|
|
...state,
|
|
note: "Outbound poll demo gateway; cloud dispatches through a gateway-initiated connection."
|
|
}));
|
|
routes.set("GET /capabilities", () => ({
|
|
serviceId: "hwlab-gateway",
|
|
gatewayId,
|
|
gatewaySessionId,
|
|
resourceId,
|
|
boxId,
|
|
capabilities: shellCapabilities()
|
|
}));
|
|
|
|
listen(createJsonServer({ routes }), port);
|
|
|
|
if (cloudUrl) {
|
|
ensureNoProxyForLocalCloud(cloudUrl);
|
|
schedulePoll(0);
|
|
}
|
|
|
|
function schedulePoll(delayMs = pollIntervalMs) {
|
|
setTimeout(async () => {
|
|
await pollCloudOnce();
|
|
schedulePoll();
|
|
}, delayMs).unref?.();
|
|
}
|
|
|
|
async function pollCloudOnce() {
|
|
try {
|
|
const response = await fetchJson(new URL("/v1/gateway/poll", ensureTrailingSlash(cloudUrl)), {
|
|
method: "POST",
|
|
body: registrationPayload()
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`cloud poll failed: HTTP ${response.status}`);
|
|
}
|
|
state.outbound.lastPollAt = new Date().toISOString();
|
|
state.outbound.lastPollError = null;
|
|
state.session.lastSeenAt = state.outbound.lastPollAt;
|
|
if (response.body?.request) {
|
|
startCloudRequest(response.body.request);
|
|
}
|
|
} catch (error) {
|
|
state.outbound.lastPollError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
|
|
function startCloudRequest(request) {
|
|
const requestId = String(request?.id ?? `req_gateway_${Date.now()}`);
|
|
if (inflightRequests.size >= maxInflightRequests) {
|
|
state.outbound.lastBusyAt = new Date().toISOString();
|
|
postGatewayBusyResult({
|
|
...request,
|
|
id: requestId
|
|
}).catch((error) => {
|
|
state.outbound.lastResultError = error instanceof Error ? error.message : String(error);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const startedAt = new Date().toISOString();
|
|
inflightRequests.set(requestId, {
|
|
requestId,
|
|
method: request?.method ?? null,
|
|
operationId: request?.params?.operationId ?? null,
|
|
traceId: request?.meta?.traceId ?? null,
|
|
startedAt
|
|
});
|
|
refreshInflightState();
|
|
|
|
Promise.resolve()
|
|
.then(() => handleCloudRequest({
|
|
...request,
|
|
id: requestId
|
|
}))
|
|
.catch((error) => {
|
|
state.outbound.lastResultError = error instanceof Error ? error.message : String(error);
|
|
})
|
|
.finally(() => {
|
|
inflightRequests.delete(requestId);
|
|
refreshInflightState();
|
|
});
|
|
}
|
|
|
|
async function handleCloudRequest(request) {
|
|
const environment = requestEnvironment(request?.meta);
|
|
const context = operationContext({
|
|
operationId: request.params?.operationId,
|
|
traceId: request.meta?.traceId,
|
|
requestId: request.id,
|
|
actorId: request.meta?.actorId ?? "svc_hwlab-cloud-api"
|
|
}, {
|
|
serviceId: "hwlab-gateway",
|
|
actorId: "svc_hwlab-gateway"
|
|
});
|
|
|
|
let response;
|
|
try {
|
|
if (request.method !== "hardware.invoke.shell") {
|
|
throw rpcError(ERROR_CODES.methodNotFound, `Gateway method ${request.method ?? "unknown"} is not implemented`, {
|
|
method: request.method ?? null
|
|
});
|
|
}
|
|
const result = await invokeShell(request.params ?? {}, context, environment);
|
|
response = {
|
|
jsonrpc: JSON_RPC_VERSION,
|
|
id: request.id,
|
|
result,
|
|
meta: responseMeta(context.traceId, environment)
|
|
};
|
|
} catch (error) {
|
|
const body = error?.body ?? createRpcErrorBody({
|
|
code: error?.code ?? ERROR_CODES.hwlabUnknown,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
data: error?.data ?? {}
|
|
});
|
|
response = {
|
|
jsonrpc: JSON_RPC_VERSION,
|
|
id: request.id,
|
|
error: body.error,
|
|
meta: responseMeta(context.traceId, environment)
|
|
};
|
|
}
|
|
|
|
await postGatewayResult(response);
|
|
}
|
|
|
|
async function postGatewayBusyResult(request) {
|
|
const traceId = request?.meta?.traceId ?? `trc_gateway_busy_${Date.now()}`;
|
|
const environment = requestEnvironment(request?.meta);
|
|
const body = createRpcErrorBody({
|
|
code: ERROR_CODES.operationRejected,
|
|
message: "Gateway is busy; retry after an in-flight command finishes or increase HWLAB_GATEWAY_MAX_INFLIGHT.",
|
|
data: {
|
|
reason: "gateway_busy",
|
|
operationId: request?.params?.operationId ?? null,
|
|
inflightCount: inflightRequests.size,
|
|
maxInflightRequests
|
|
}
|
|
});
|
|
await postGatewayResult({
|
|
jsonrpc: JSON_RPC_VERSION,
|
|
id: request?.id ?? null,
|
|
error: body.error,
|
|
meta: responseMeta(traceId, environment)
|
|
});
|
|
}
|
|
|
|
async function postGatewayResult(response) {
|
|
const result = await fetchJson(new URL("/v1/gateway/result", ensureTrailingSlash(cloudUrl)), {
|
|
method: "POST",
|
|
body: {
|
|
gatewayId,
|
|
gatewaySessionId,
|
|
response
|
|
}
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(`cloud result failed: HTTP ${result.status}`);
|
|
}
|
|
state.outbound.lastResultAt = new Date().toISOString();
|
|
state.outbound.lastResultError = null;
|
|
}
|
|
|
|
async function invokeShell(params, context, environment = gatewayEnvironment) {
|
|
if (!commandExecutionEnabled) {
|
|
throw rpcError(ERROR_CODES.operationRejected, "Gateway command execution is disabled", {
|
|
reason: "cmd_exec_disabled",
|
|
requiredEnv: "HWLAB_GATEWAY_CMD_EXEC_ENABLED=1"
|
|
});
|
|
}
|
|
|
|
const shellInput = normalizeShellInput(params);
|
|
const startedAt = Date.now();
|
|
const execution = await executeCommand(shellInput, {
|
|
timeoutMs: shellInput.timeoutMs ?? commandTimeoutMs,
|
|
outputLimitBytes
|
|
});
|
|
const durationMs = Date.now() - startedAt;
|
|
const now = new Date().toISOString();
|
|
const status = execution.timedOut ? "timed_out" : execution.exitCode === 0 ? "succeeded" : "failed";
|
|
const audit = createAuditEvent({
|
|
serviceId: "hwlab-gateway",
|
|
action: "hardware.invoke.shell",
|
|
targetType: "box_resource",
|
|
targetId: params.resourceId ?? resourceId,
|
|
projectId: params.projectId,
|
|
gatewaySessionId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
actorType: "service",
|
|
actorId: "svc_hwlab-gateway",
|
|
outcome: status === "succeeded" ? "succeeded" : "failed",
|
|
environment,
|
|
metadata: {
|
|
gatewayId,
|
|
resourceId: params.resourceId ?? resourceId,
|
|
capabilityId: params.capabilityId ?? "cap_windows_cmd_exec",
|
|
command: redactCommand(shellInput.command),
|
|
cwd: execution.cwd,
|
|
exitCode: execution.exitCode,
|
|
timedOut: execution.timedOut,
|
|
durationMs
|
|
},
|
|
occurredAt: now
|
|
});
|
|
const evidence = createEvidenceRecord({
|
|
serviceId: "hwlab-gateway",
|
|
projectId: params.projectId ?? "prj_mvp_topology",
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
kind: "trace",
|
|
environment,
|
|
payload: {
|
|
gatewayId,
|
|
gatewaySessionId,
|
|
resourceId: params.resourceId ?? resourceId,
|
|
capabilityId: params.capabilityId ?? "cap_windows_cmd_exec",
|
|
shell: execution
|
|
},
|
|
metadata: {
|
|
gatewaySessionId,
|
|
producerServiceId: "hwlab-gateway"
|
|
},
|
|
createdAt: now
|
|
});
|
|
|
|
state.lastDispatch = {
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
resourceId: params.resourceId ?? resourceId,
|
|
observedAt: now,
|
|
accepted: true,
|
|
status
|
|
};
|
|
state.updatedAt = now;
|
|
|
|
return {
|
|
accepted: true,
|
|
status,
|
|
serviceId: "hwlab-gateway",
|
|
environment,
|
|
gatewayId,
|
|
gatewaySessionId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
shellExecuted: true,
|
|
dispatchStatus: status,
|
|
...execution,
|
|
durationMs,
|
|
auditId: audit.auditId,
|
|
evidenceId: evidence.evidenceId,
|
|
audit,
|
|
evidence,
|
|
gateway: {
|
|
gatewayId,
|
|
gatewaySessionId
|
|
}
|
|
};
|
|
}
|
|
|
|
function executeCommand(input, { timeoutMs, outputLimitBytes: limitBytes }) {
|
|
return new Promise((resolve) => {
|
|
const startedAt = new Date().toISOString();
|
|
const shell = shellCommand(input.command);
|
|
const child = spawn(shell.command, shell.args, {
|
|
cwd: input.cwd,
|
|
windowsHide: true,
|
|
env: {
|
|
...process.env,
|
|
PYTHONUTF8: process.env.PYTHONUTF8 ?? "1",
|
|
PYTHONIOENCODING: process.env.PYTHONIOENCODING ?? "utf-8",
|
|
NO_PROXY: process.env.NO_PROXY,
|
|
no_proxy: process.env.no_proxy
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
const stdout = createLimitedCollector(limitBytes);
|
|
const stderr = createLimitedCollector(limitBytes);
|
|
let timedOut = false;
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
killProcessTree(child);
|
|
}, timeoutMs);
|
|
|
|
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
child.on("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({
|
|
shell: shell.command,
|
|
command: input.command,
|
|
cwd: input.cwd ?? process.cwd(),
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
exitCode: null,
|
|
signal: null,
|
|
timedOut,
|
|
stdout: stdout.text(),
|
|
stderr: stderr.text() || error.message,
|
|
stdoutTruncated: stdout.truncated,
|
|
stderrTruncated: stderr.truncated
|
|
});
|
|
});
|
|
child.on("close", (exitCode, signal) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({
|
|
shell: shell.command,
|
|
command: input.command,
|
|
cwd: input.cwd ?? process.cwd(),
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
exitCode,
|
|
signal,
|
|
timedOut,
|
|
stdout: stdout.text(),
|
|
stderr: stderr.text(),
|
|
stdoutTruncated: stdout.truncated,
|
|
stderrTruncated: stderr.truncated
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function shellCommand(command) {
|
|
if (process.platform === "win32") {
|
|
return {
|
|
command: process.env.ComSpec ?? "cmd.exe",
|
|
args: ["/d", "/s", "/c", String(command)]
|
|
};
|
|
}
|
|
return {
|
|
command: "sh",
|
|
args: ["-lc", String(command)]
|
|
};
|
|
}
|
|
|
|
function killProcessTree(child) {
|
|
if (!child.pid) return;
|
|
if (process.platform === "win32") {
|
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
windowsHide: true,
|
|
stdio: "ignore"
|
|
});
|
|
return;
|
|
}
|
|
child.kill("SIGTERM");
|
|
}
|
|
|
|
function createLimitedCollector(limitBytes) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
let truncated = false;
|
|
return {
|
|
get truncated() {
|
|
return truncated;
|
|
},
|
|
push(chunk) {
|
|
if (size >= limitBytes) {
|
|
truncated = true;
|
|
return;
|
|
}
|
|
const buffer = Buffer.from(chunk);
|
|
const available = limitBytes - size;
|
|
if (buffer.byteLength > available) {
|
|
chunks.push(buffer.subarray(0, available));
|
|
size += available;
|
|
truncated = true;
|
|
return;
|
|
}
|
|
chunks.push(buffer);
|
|
size += buffer.byteLength;
|
|
},
|
|
text() {
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
};
|
|
}
|
|
|
|
function registrationPayload() {
|
|
refreshInflightState();
|
|
return {
|
|
serviceId: "hwlab-gateway",
|
|
projectId: process.env.HWLAB_PROJECT_ID ?? "prj_mvp_topology",
|
|
gatewayId,
|
|
gatewaySessionId,
|
|
endpoint: state.session.endpoint,
|
|
resourceId,
|
|
boxId,
|
|
capabilities: shellCapabilities(),
|
|
outbound: {
|
|
pollIntervalMs,
|
|
commandExecutionEnabled,
|
|
maxInflightRequests,
|
|
inflightCount: inflightRequests.size,
|
|
inflightRequests: snapshotInflightRequests()
|
|
},
|
|
system: {
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
hostname: os.hostname(),
|
|
release: os.release()
|
|
}
|
|
};
|
|
}
|
|
|
|
function shellCapabilities() {
|
|
return [
|
|
{
|
|
capabilityId: process.env.HWLAB_GATEWAY_CMD_CAPABILITY_ID ?? "cap_windows_cmd_exec",
|
|
resourceId,
|
|
projectId: process.env.HWLAB_PROJECT_ID ?? "prj_mvp_topology",
|
|
name: "shell.exec",
|
|
description: "Execute a local shell command through an outbound HWLAB gateway demo connection.",
|
|
direction: "bidirectional",
|
|
valueType: "object",
|
|
constraints: {
|
|
gatewayOutboundOnly: true,
|
|
demoOpen: truthy(process.env.HWLAB_GATEWAY_DEMO_OPEN),
|
|
maxInflightRequests
|
|
},
|
|
mutatesState: true
|
|
}
|
|
];
|
|
}
|
|
|
|
function refreshInflightState() {
|
|
state.outbound.inflightCount = inflightRequests.size;
|
|
state.outbound.inflightRequests = snapshotInflightRequests();
|
|
}
|
|
|
|
function snapshotInflightRequests() {
|
|
const now = Date.now();
|
|
return [...inflightRequests.values()].map((request) => ({
|
|
...request,
|
|
durationMs: durationSince(request.startedAt, now)
|
|
}));
|
|
}
|
|
|
|
function durationSince(value, now = Date.now()) {
|
|
const started = Date.parse(value ?? "");
|
|
return Number.isFinite(started) ? Math.max(0, now - started) : 0;
|
|
}
|
|
|
|
function normalizeShellInput(params = {}) {
|
|
const input = params.input && typeof params.input === "object" && !Array.isArray(params.input) ? params.input : {};
|
|
const command = params.command ?? input.command ?? input.shell?.command;
|
|
if (!command) {
|
|
throw rpcError(ERROR_CODES.invalidParams, "hardware.invoke.shell requires input.command", {
|
|
required: ["input.command"]
|
|
});
|
|
}
|
|
return {
|
|
command: Array.isArray(command) ? command.map(String).join(" ") : String(command),
|
|
cwd: input.cwd ? String(input.cwd) : undefined,
|
|
timeoutMs: parsePositiveInteger(input.timeoutMs, commandTimeoutMs)
|
|
};
|
|
}
|
|
|
|
function responseMeta(traceId, environment = gatewayEnvironment) {
|
|
return {
|
|
traceId,
|
|
serviceId: "hwlab-gateway",
|
|
environment: normalizeEnvironment(environment)
|
|
};
|
|
}
|
|
|
|
function requestEnvironment(meta = {}) {
|
|
return normalizeEnvironment(meta?.environment || gatewayEnvironment);
|
|
}
|
|
|
|
function normalizeEnvironment(environment) {
|
|
return isProtocolEnvironment(environment) ? environment : ENVIRONMENT_DEV;
|
|
}
|
|
|
|
function rpcError(code, message, data = {}) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
error.data = data;
|
|
error.body = createRpcErrorBody({ code, message, data });
|
|
return error;
|
|
}
|
|
|
|
function parsePositiveInteger(value, fallback) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function truthy(value) {
|
|
return ["1", "true", "yes", "on", "enabled"].includes(String(value ?? "").trim().toLowerCase());
|
|
}
|
|
|
|
function trimUrl(value) {
|
|
const text = String(value ?? "").trim();
|
|
return text ? text : null;
|
|
}
|
|
|
|
function ensureTrailingSlash(url) {
|
|
return url.endsWith("/") ? url : `${url}/`;
|
|
}
|
|
|
|
function redactCommand(command) {
|
|
const text = String(command ?? "");
|
|
return text.length <= 200 ? text : `${text.slice(0, 200)}...`;
|
|
}
|
|
|
|
function ensureNoProxyForLocalCloud(url) {
|
|
let host = "";
|
|
try {
|
|
host = new URL(url).hostname;
|
|
} catch {
|
|
return;
|
|
}
|
|
const additions = new Set(["localhost", "127.0.0.1", "::1", host].filter(Boolean));
|
|
for (const key of ["NO_PROXY", "no_proxy"]) {
|
|
const current = String(process.env[key] ?? "");
|
|
const values = new Set(current.split(",").map((item) => item.trim()).filter(Boolean));
|
|
for (const addition of additions) values.add(addition);
|
|
process.env[key] = [...values].join(",");
|
|
}
|
|
}
|