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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,14 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"@evilmartians/lefthook",
"better-sqlite3"
],
"ignoredBuiltDependencies": [
"@evilmartians/lefthook",
"@parcel/watcher",
"esbuild",
"msgpackr-extract",
"protobufjs",
"sharp"
]
}
Expand Down
6 changes: 6 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
allowBuilds:
'@evilmartians/lefthook': false
'@parcel/watcher': false
better-sqlite3: true
esbuild: false
msgpackr-extract: false
protobufjs: false
sharp: false
103 changes: 71 additions & 32 deletions src/lib/daemon/daemon-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,6 @@ async function dispatchTaggedRequest(
// Mutable context so lifecycle functions can store server references back.

export interface DaemonLifecycleContext {
port: number;
host: string;
httpServer: HttpServer | null;
/** HTTP-only onboarding server on port+1 (only when TLS is active). */
onboardingServer: HttpServer | null;
Expand All @@ -386,15 +384,23 @@ export interface DaemonLifecycleContext {
router: {
handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void>;
} | null;
/** When provided, the HTTP server is created as HTTPS with these certs. */
}

export interface HttpServerStartConfig {
port: number;
host: string;
tls?: { key: Buffer; cert: Buffer };
}

// ─── HTTP Server ────────────────────────────────────────────────────────────

/** Create and start the HTTP(S) server, storing it in ctx.httpServer. */
export function startHttpServer(ctx: DaemonLifecycleContext): Promise<void> {
export function startHttpServer(
ctx: DaemonLifecycleContext,
config: HttpServerStartConfig,
): Promise<number> {
return new Promise((resolve, reject) => {
let actualPort = config.port;
const handler = (req: IncomingMessage, res: ServerResponse) => {
// biome-ignore lint/style/noNonNullAssertion: safe — router set before startHttpServer
ctx.router!.handleRequest(req, res).catch((err) => {
Expand All @@ -406,23 +412,23 @@ export function startHttpServer(ctx: DaemonLifecycleContext): Promise<void> {
});
};

if (ctx.tls) {
if (config.tls) {
// ─── TLS mode: protocol detection ─────────────────────────────
// A net.Server listens on the port. Each connection's first byte
// is peeked: 0x16 (TLS ClientHello) → HTTPS server, otherwise →
// plain HTTP redirect to https://.
const httpsServer = createHttpsServer(
{ key: ctx.tls.key, cert: ctx.tls.cert },
{ key: config.tls.key, cert: config.tls.cert },
handler,
);
ctx.upgradeServer = httpsServer;

// Lightweight HTTP redirect handler for plain-HTTP connections
const httpRedirect = createServer((req, res) => {
const host = req.headers.host ?? `localhost:${ctx.port}`;
const host = req.headers.host ?? `localhost:${actualPort}`;
const hostBase = host.replace(/:\d+$/, "");
res.writeHead(301, {
Location: `https://${hostBase}:${ctx.port}${req.url ?? "/"}`,
Location: `https://${hostBase}:${actualPort}${req.url ?? "/"}`,
});
res.end();
});
Expand Down Expand Up @@ -456,22 +462,28 @@ export function startHttpServer(ctx: DaemonLifecycleContext): Promise<void> {
reject(err);
});

ctx.httpServer.listen(ctx.port, ctx.host, () => {
ctx.httpServer.listen(config.port, config.host, () => {
// Resolve actual port (important when port 0 is used for OS-assigned ephemeral port)
// biome-ignore lint/style/noNonNullAssertion: safe — inside listen callback
const addr = ctx.httpServer!.address();
if (addr && typeof addr !== "string") {
ctx.port = addr.port;
actualPort = addr.port;
}
resolve();
resolve(actualPort);
});
});
}

/** Gracefully close the HTTP server. */
export function closeHttpServer(ctx: DaemonLifecycleContext): Promise<void> {
function closeServerHandle(server: HttpServer): Promise<void> {
return new Promise((resolve) => {
if (!ctx.httpServer) {
try {
server.closeIdleConnections?.();
server.closeAllConnections?.();
} catch {
// Best-effort drain before close.
}

if (!server.listening) {
resolve();
return;
}
Expand All @@ -480,9 +492,37 @@ export function closeHttpServer(ctx: DaemonLifecycleContext): Promise<void> {
resolve();
}, SHUTDOWN_TIMEOUT_MS);

ctx.httpServer.close(() => {
try {
server.close(() => {
clearTimeout(timeout);
resolve();
});
} catch {
clearTimeout(timeout);
resolve();
}
});
}

/** Gracefully close the HTTP server. */
export function closeHttpServer(ctx: DaemonLifecycleContext): Promise<void> {
return new Promise((resolve) => {
const httpServer = ctx.httpServer;
const upgradeServer = ctx.upgradeServer;
if (!httpServer && !upgradeServer) {
resolve();
return;
}

const closes = [
httpServer ? closeServerHandle(httpServer) : Promise.resolve(),
upgradeServer && upgradeServer !== httpServer
? closeServerHandle(upgradeServer)
: Promise.resolve(),
];
Promise.all(closes).then(() => {
ctx.httpServer = null;
ctx.upgradeServer = null;
resolve();
});
});
Expand All @@ -497,28 +537,27 @@ export interface OnboardingServerDeps {
staticDir: string;
}

export interface OnboardingServerStartConfig {
/** Main HTTPS port used in redirect/setup URLs. */
httpsPort: number;
/** Onboarding listen port, usually httpsPort + 1, or 0 for OS assignment. */
listenPort: number;
host: string;
}

/**
* Start an HTTP-only onboarding server on ctx.port + 1.
* Only call when ctx.tls is present (TLS active).
* Start an HTTP-only onboarding server.
*
* Serves: /ca/download, /setup (index.html), /api/setup-info, SPA static assets.
* Everything else 302-redirects to the HTTPS main server.
*/
export function startOnboardingServer(
ctx: DaemonLifecycleContext,
deps: OnboardingServerDeps,
config: OnboardingServerStartConfig,
): Promise<void> {
// Only start when TLS is active
if (!ctx.tls) {
return Promise.resolve();
}

// When ctx.port is 0 (OS-assigned), also use 0 for the onboarding server
// so it gets its own ephemeral port. Otherwise use port+1.
const listenPort = ctx.port === 0 ? 0 : ctx.port + 1;

// Resolved after listen — may differ from listenPort when 0 is used.
let actualPort = listenPort;
let actualPort = config.listenPort;

// Pre-read CA cert (if available) so we don't hit disk per request.
// DER format preferred (passed in from ensureCerts), PEM as fallback.
Expand Down Expand Up @@ -595,7 +634,7 @@ export function startOnboardingServer(
const host = req.headers.host ?? `localhost:${actualPort}`;
const hostBase = host.replace(/:\d+$/, "");
// httpsUrl uses the MAIN port, httpUrl uses the ONBOARDING port
const httpsUrl = `https://${hostBase}:${ctx.port}`;
const httpsUrl = `https://${hostBase}:${config.httpsPort}`;
const httpUrl = `http://${hostBase}:${actualPort}`;
res.writeHead(200, {
"Content-Type": "application/json",
Expand Down Expand Up @@ -626,7 +665,7 @@ export function startOnboardingServer(
const redirectHost = req.headers.host ?? `localhost:${actualPort}`;
const redirectHostBase = redirectHost.replace(/:\d+$/, "");
res.writeHead(302, {
Location: `https://${redirectHostBase}:${ctx.port}/setup`,
Location: `https://${redirectHostBase}:${config.httpsPort}/setup`,
});
res.end();
} catch (err) {
Expand All @@ -643,7 +682,7 @@ export function startOnboardingServer(
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
log.warn(
`Onboarding server: port ${listenPort} already in use — skipping`,
`Onboarding server: port ${config.listenPort} already in use — skipping`,
);
server.close();
resolve();
Expand All @@ -652,15 +691,15 @@ export function startOnboardingServer(
reject(err);
});

server.listen(listenPort, ctx.host, () => {
server.listen(config.listenPort, config.host, () => {
// Resolve actual port (important when listenPort is 0)
const addr = server.address();
if (addr && typeof addr !== "string") {
actualPort = addr.port;
}
ctx.onboardingServer = server;
log.info(
`Onboarding HTTP server listening on ${ctx.host}:${actualPort}`,
`Onboarding HTTP server listening on ${config.host}:${actualPort}`,
);
resolve();
});
Expand Down
3 changes: 2 additions & 1 deletion src/lib/effect/daemon-config-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const DaemonConfigRefLive = (initial: DaemonRuntimeConfig) =>
export const makeDaemonConfigFromOptions = (options: {
port?: number;
host?: string;
hostExplicit?: boolean;
pinHash?: string;
tlsEnabled?: boolean;
keepAwake?: boolean;
Expand All @@ -63,6 +64,6 @@ export const makeDaemonConfigFromOptions = (options: {
shuttingDown: false,
dismissedPaths: new Set(options.dismissedPaths ?? []),
startTime: options.startTime ?? Date.now(),
hostExplicit: options.host !== undefined,
hostExplicit: options.hostExplicit ?? false,
persistedSessionCounts: new Map(options.persistedSessionCounts ?? []),
});
Loading