Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 20 additions & 15 deletions src/backends/native-https-owner-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
chmod,
lstat,
mkdtemp,
readdir,
Expand Down Expand Up @@ -51,6 +50,11 @@ export interface NativeHttpsOwnerServerDependencies {
phase: "acquire" | "release"
) => Promise<void>;
readonly verifyIdle: (binding: NativeHttpsOwnerBinding) => Promise<void>;
/**
* Test seam: runs after the control socket is published and before its first
* ownership observation. Production passes none.
*/
readonly afterPublish?: (socketPath: string) => Promise<void>;
}

function verifyCleanGraphObservations(
Expand Down Expand Up @@ -251,9 +255,10 @@ export async function serveNativeHttpsOwner(opts: {
}
};
const closeServer = async () => {
// Refuse before closing when any owned path changed. The runtime's close-time
// unlink reaches only the retired staging name (listenPublishedUnixSocket).
await checkPaths();
// Always close the listener: the runtime's close-time unlink reaches only the
// retired staging name (listenPublishedUnixSocket). retireSocketPath then
// removes the endpoint only while it is this listener's inode; a replacement
// is kept and the failure retained.
for (const client of clients) {
client.destroy();
}
Expand Down Expand Up @@ -584,21 +589,21 @@ export async function serveNativeHttpsOwner(opts: {
}
};
void frontend.exited.then(exited, exited);
// Published by link from a staging name: closing the server never removes
// the control endpoint, which only retireSocketPath removes by identity.
await listenPublishedUnixSocket(server, socketPath);
const before = await lstat(socketPath);
await chmod(socketPath, 0o600);
const after = await lstat(socketPath);
// Keep the published inode at once: failure cleanup then closes this listener
// and retires only this inode, and every later observation compares against it.
// The helper set its mode before publication, so the path is never chmodded.
socketIdentity = await listenPublishedUnixSocket(server, socketPath);
await opts.dependencies.afterPublish?.(socketPath);
const published = await lstat(socketPath);
if (
!after.isSocket() ||
before.dev !== after.dev ||
before.ino !== after.ino ||
after.uid !== process.getuid?.()
!published.isSocket() ||
published.dev !== socketIdentity.dev ||
published.ino !== socketIdentity.ino ||
published.uid !== process.getuid?.() ||
(published.mode & 0o777) !== 0o600
) {
throw nativeHttpsOwnerRefused();
}
socketIdentity = { dev: after.dev, ino: after.ino };
if (frontendFailed) {
throw nativeHttpsOwnerRefused();
}
Expand Down
22 changes: 14 additions & 8 deletions src/backends/native-project-https.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from "node:crypto";
import { closeSync, constants, mkdtempSync, openSync, rmSync } from "node:fs";
import {
chmod,
link,
lstat,
mkdir,
Expand Down Expand Up @@ -69,7 +68,11 @@ interface Dependencies {
readonly spawn: (input: SpawnInput) => HttpsChild;
readonly permissionPort: () => Promise<number>;
readonly adminReady: (socket: string) => Promise<boolean>;
readonly chmodOwnerSocket: typeof chmod;
/**
* Test seam: runs after the owner socket is published and before it is
* verified. Production does nothing.
*/
readonly afterOwnerSocketPublish: (path: string) => Promise<void>;
}
const SAFE_TLS_CODES = new Set([
"ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR",
Expand Down Expand Up @@ -684,7 +687,7 @@ function caDerSha256(pem: Buffer): string {
}
async function startOwnerChallenge(
path: string,
setMode: typeof chmod
afterPublish: (path: string) => Promise<void>
): Promise<{
readonly server: Server;
readonly publicKey: string;
Expand Down Expand Up @@ -712,10 +715,10 @@ async function startOwnerChallenge(
});
let identity: { dev: number; ino: number } | undefined;
try {
// Published by link from an owner-only staging name (a verified socket of
// this user): closing the server never removes a replacement at path.
// Created with mode 0600 and published by link from a staging name: closing
// the server never removes a replacement at path, and path is never chmodded.
identity = await listenPublishedUnixSocket(server, path);
await setMode(path, 0o600);
await afterPublish(path);
const prepared = await lstat(path);
if (
!prepared.isSocket() ||
Expand Down Expand Up @@ -1253,7 +1256,7 @@ export async function startNativeProjectHttps(opts: {
spawn: spawnNativeHttpsChild,
permissionPort,
adminReady,
chmodOwnerSocket: chmod,
afterOwnerSocketPublish: async () => undefined,
...opts.dependencies,
};
const limit = opts.certificateNameLimit ?? 256;
Expand Down Expand Up @@ -1372,7 +1375,10 @@ export async function startNativeProjectHttps(opts: {
await removeOwnedDirectory(lock, lockIdentity, false);
})());
try {
owner = await startOwnerChallenge(ownerSocket, deps.chmodOwnerSocket);
owner = await startOwnerChallenge(
ownerSocket,
deps.afterOwnerSocketPublish
);
const before = await inspect();
if (
!(
Expand Down
Loading
Loading