Bound ordinary Restricted Execution engine socket directories (#503)
This commit is contained in:
parent
3bb4324aa0
commit
43fcf6b27a
@@ -1,12 +1,17 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createServer, request as httpRequest } from "node:http";
|
import { createServer, request as httpRequest } from "node:http";
|
||||||
import { mkdtemp, readFile, writeFile, rm, open } from "node:fs/promises";
|
import { mkdtemp, readFile, writeFile, rm, open, lstat, readdir } from "node:fs/promises";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
|
|
||||||
import { checkedSocketPath } from "../socket-address.mjs";
|
import {
|
||||||
|
checkedSocketPath,
|
||||||
|
socketDirectory,
|
||||||
|
prepareSocketDirectory,
|
||||||
|
removeEmptySocketDirectory,
|
||||||
|
} from "../socket-address.mjs";
|
||||||
|
|
||||||
const abi = "quixos-transaction-ports-v1";
|
const abi = "quixos-transaction-ports-v1";
|
||||||
/** Reserved supervisor faults are not authored domain rejections. A graph
|
/** Reserved supervisor faults are not authored domain rejections. A graph
|
||||||
@@ -329,15 +334,57 @@ export async function serveRestrictedRegistry(config, io = process) {
|
|||||||
}
|
}
|
||||||
await engine.exited;
|
await engine.exited;
|
||||||
}
|
}
|
||||||
|
const sameDirectory = async (directory, owner) => {
|
||||||
|
const current = await lstat(directory);
|
||||||
|
if (
|
||||||
|
!owner ||
|
||||||
|
!current.isDirectory() ||
|
||||||
|
current.dev !== owner.dev ||
|
||||||
|
current.ino !== owner.ino ||
|
||||||
|
current.uid !== owner.uid ||
|
||||||
|
current.mode & 0o077
|
||||||
|
)
|
||||||
|
throw fixedError("RESTRICTED_START_FAILED");
|
||||||
|
};
|
||||||
|
// A bounded ordinary-engine parent is private to this admitted lifetime.
|
||||||
|
// Never close/unlink through a replacement directory or release its lease
|
||||||
|
// while the original namespace's ownership remains uncertain.
|
||||||
|
if (engine.socketParent) {
|
||||||
|
await sameDirectory(engine.socketParent, engine.socketParentOwner);
|
||||||
|
if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner);
|
||||||
|
if (engine.broker) {
|
||||||
|
const current = await lstat(engine.broker).catch((error) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
(!engine.brokerOwner ||
|
||||||
|
!current.isSocket() ||
|
||||||
|
current.dev !== engine.brokerOwner.dev ||
|
||||||
|
current.ino !== engine.brokerOwner.ino)
|
||||||
|
)
|
||||||
|
throw fixedError("RESTRICTED_START_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
engine.server?.closeAllConnections();
|
engine.server?.closeAllConnections();
|
||||||
if (engine.server) await new Promise((resolve) => engine.server.close(resolve));
|
if (engine.server) await new Promise((resolve) => engine.server.close(resolve));
|
||||||
try {
|
if (engine.socketParent) {
|
||||||
if (engine.directory) await rm(engine.directory, { recursive: true, force: true });
|
await sameDirectory(engine.socketParent, engine.socketParentOwner);
|
||||||
} finally {
|
if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner);
|
||||||
engines.delete(engine);
|
|
||||||
await engine.release?.();
|
|
||||||
changed();
|
|
||||||
}
|
}
|
||||||
|
if (engine.directory) await rm(engine.directory, { recursive: true, force: true });
|
||||||
|
if (engine.socketParent) {
|
||||||
|
await removeEmptySocketDirectory(engine.socketIdentity);
|
||||||
|
const remaining = await lstat(engine.socketParent).catch((error) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (remaining) throw fixedError("RESTRICTED_START_FAILED");
|
||||||
|
}
|
||||||
|
engines.delete(engine);
|
||||||
|
await engine.release?.();
|
||||||
|
changed();
|
||||||
})();
|
})();
|
||||||
return engine.destroying;
|
return engine.destroying;
|
||||||
};
|
};
|
||||||
@@ -361,13 +408,24 @@ export async function serveRestrictedRegistry(config, io = process) {
|
|||||||
() => destroyEngine(engine),
|
() => destroyEngine(engine),
|
||||||
);
|
);
|
||||||
if (!call.open) return;
|
if (!call.open) return;
|
||||||
|
let directoryRoot;
|
||||||
if (config.sandboxIdentity) {
|
if (config.sandboxIdentity) {
|
||||||
// The privileged helper owns a fixed explicit /run root and requires
|
// The privileged helper owns a fixed explicit /run root and requires
|
||||||
// this exact basename. Keep its cross-UID ancestry and identity rules.
|
// this exact basename. Keep its cross-UID ancestry and identity rules.
|
||||||
for (const filename of ["broker.sock", "engine.sock"])
|
directoryRoot = tmpdir();
|
||||||
checkedSocketPath(tmpdir() + "/qx-engine-XXXXXX/" + filename);
|
} else {
|
||||||
|
engine.socketIdentity = { kind: "restricted-engine", process: process.pid, lifetime: randomUUID() };
|
||||||
|
directoryRoot = socketDirectory(engine.socketIdentity);
|
||||||
}
|
}
|
||||||
engine.directory = await mkdtemp(join(tmpdir(), "qx-engine-"));
|
for (const filename of ["broker.sock", "engine.sock"])
|
||||||
|
checkedSocketPath(directoryRoot + "/qx-engine-XXXXXX/" + filename);
|
||||||
|
if (!config.sandboxIdentity) {
|
||||||
|
engine.socketParent = await prepareSocketDirectory(engine.socketIdentity);
|
||||||
|
engine.socketParentOwner = await lstat(engine.socketParent);
|
||||||
|
if ((await readdir(engine.socketParent)).length) throw fixedError("RESTRICTED_START_FAILED");
|
||||||
|
}
|
||||||
|
engine.directory = await mkdtemp(join(directoryRoot, "qx-engine-"));
|
||||||
|
if (engine.socketParent) engine.directoryOwner = await lstat(engine.directory);
|
||||||
if (!call.open) return;
|
if (!call.open) return;
|
||||||
const broker = checkedSocketPath(join(engine.directory, "broker.sock"));
|
const broker = checkedSocketPath(join(engine.directory, "broker.sock"));
|
||||||
engine.socket = checkedSocketPath(join(engine.directory, "engine.sock"));
|
engine.socket = checkedSocketPath(join(engine.directory, "engine.sock"));
|
||||||
@@ -416,10 +474,14 @@ export async function serveRestrictedRegistry(config, io = process) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (engine.socketParent) engine.broker = broker;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
engine.server.once("error", reject);
|
engine.server.once("error", reject);
|
||||||
engine.server.listen(broker, resolve);
|
engine.server.listen(broker, resolve);
|
||||||
});
|
});
|
||||||
|
if (engine.socketParent) {
|
||||||
|
engine.brokerOwner = await lstat(broker);
|
||||||
|
}
|
||||||
const modules = { "entry.mjs": { js: workerSource }, "package.mjs": { js: packageSource } };
|
const modules = { "entry.mjs": { js: workerSource }, "package.mjs": { js: packageSource } };
|
||||||
for (const module of sdk) modules[module.name] = { js: module.source };
|
for (const module of sdk) modules[module.name] = { js: module.source };
|
||||||
const setting = { registryExport: config.registryExport, exports: config.exports, maxBytes: limits.maxBytes };
|
const setting = { registryExport: config.registryExport, exports: config.exports, maxBytes: limits.maxBytes };
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
import { mkdtemp, writeFile, rm, mkdir, readdir, lstat } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { serveRestrictedRegistry, RestrictedEngineBudget } from "./service.mjs";
|
import { serveRestrictedRegistry, RestrictedEngineBudget } from "./service.mjs";
|
||||||
|
import { socketDirectory, checkedSocketPath } from "../socket-address.mjs";
|
||||||
|
|
||||||
const workerd = process.env.QUIXOS_TEST_WORKERD;
|
const workerd = process.env.QUIXOS_TEST_WORKERD;
|
||||||
if (!workerd) throw Error("Set QUIXOS_TEST_WORKERD to the pinned engine");
|
if (!workerd) throw Error("Set QUIXOS_TEST_WORKERD to the pinned engine");
|
||||||
@@ -95,6 +96,50 @@ async function fixture(source, options = {}) {
|
|||||||
}
|
}
|
||||||
const registry = (code) => `export const registry={exports:['test'],async invoke(request,channel){${code}}};`;
|
const registry = (code) => `export const registry={exports:['test'],async invoke(request,channel){${code}}};`;
|
||||||
|
|
||||||
|
test("real engine sockets and retirement are independent of deep Unicode TMPDIR", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "restricted-deep-"));
|
||||||
|
const deep = join(root, "保留された状態".repeat(12));
|
||||||
|
await mkdir(deep);
|
||||||
|
const oldTmp = process.env.TMPDIR;
|
||||||
|
const socketRoot = dirname(socketDirectory({ kind: "qualification-probe" }));
|
||||||
|
const names = async () =>
|
||||||
|
readdir(socketRoot).catch((error) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const before = new Set(await names());
|
||||||
|
let f, namespace;
|
||||||
|
process.env.TMPDIR = deep;
|
||||||
|
try {
|
||||||
|
assert.ok(Buffer.byteLength(join(deep, "qx-engine-XXXXXX", "engine.sock")) > 100);
|
||||||
|
f = await fixture(registry("return request.input;"));
|
||||||
|
f.start("deep runtime");
|
||||||
|
const result = await f.receive();
|
||||||
|
assert.equal(result.status, "returned");
|
||||||
|
assert.equal(result.output, "deep runtime");
|
||||||
|
assert.equal(f.stats().starts, 1);
|
||||||
|
const created = (await names()).filter((name) => !before.has(name));
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
namespace = join(socketRoot, created[0]);
|
||||||
|
const engines = await readdir(namespace);
|
||||||
|
assert.equal(engines.length, 1);
|
||||||
|
for (const name of ["broker.sock", "engine.sock"]) {
|
||||||
|
const endpoint = checkedSocketPath(join(namespace, engines[0], name));
|
||||||
|
assert.ok((await lstat(endpoint)).isSocket());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await f?.close();
|
||||||
|
} finally {
|
||||||
|
if (oldTmp === undefined) delete process.env.TMPDIR;
|
||||||
|
else process.env.TMPDIR = oldTmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await assert.rejects(lstat(namespace), { code: "ENOENT" });
|
||||||
|
assert.ok((await lstat(deep)).isDirectory());
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
test("uncertain OS cleanup cannot release engine capacity", async () => {
|
test("uncertain OS cleanup cannot release engine capacity", async () => {
|
||||||
const budget = new RestrictedEngineBudget(1, async () => ({
|
const budget = new RestrictedEngineBudget(1, async () => ({
|
||||||
async release() {
|
async release() {
|
||||||
|
|||||||
Reference in New Issue
Block a user