feat(desktop): Electron shell M2 — embedded server, desktop login, instance lock (#173)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,10 +72,25 @@ export interface MeResponse {
|
||||
* request, since it depends on the host the caller is using.
|
||||
*/
|
||||
previewIsolated: boolean;
|
||||
/**
|
||||
* Whether this server runs in desktop mode (spawned by the desktop shell with
|
||||
* PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
|
||||
* initial-password banner and the self-update entry, and omits the old-password
|
||||
* field when changing the password. See design § "桌面端原型".
|
||||
*/
|
||||
desktopMode: boolean;
|
||||
/**
|
||||
* How THIS session was established. Distinct from desktopMode: a browser signed into a
|
||||
* desktop-mode server holds a "password" session and must still provide the old
|
||||
* password when changing it — only "desktop" sessions (opened by the shell's one-shot
|
||||
* token) may omit it.
|
||||
*/
|
||||
sessionVia: "password" | "desktop";
|
||||
}
|
||||
|
||||
export interface PasswordChangeRequest {
|
||||
oldPassword: string;
|
||||
/** Omitted only by desktop-established sessions (desktop mode); required otherwise. */
|
||||
oldPassword?: string;
|
||||
/** At least 8 characters. */
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ import { TitleGenerator } from "./runtime/title-generator.js";
|
||||
import type { TitleNotifier } from "./runtime/title-generator.js";
|
||||
import { UsageRecorder } from "./runtime/usage-recorder.js";
|
||||
import { AdminService } from "./services/admin-service.js";
|
||||
import { DesktopService } from "./services/desktop-service.js";
|
||||
import { desktopRoutes } from "./http/routes/desktop.js";
|
||||
import { AgentConfigService } from "./services/agent-config-service.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { BenchmarkService } from "./services/benchmark-service.js";
|
||||
@@ -114,6 +116,8 @@ export interface AppDeps {
|
||||
sessionSources: SessionSources;
|
||||
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
|
||||
errors: ErrorRecorder;
|
||||
/** Desktop mode (PENGUIN_DESKTOP_TOKEN): one-shot login + shutdown token holder; null outside desktop mode. */
|
||||
desktop: DesktopService | null;
|
||||
/** Request log output (minimal one-liner); tests inject a noop. */
|
||||
log: (line: string) => void;
|
||||
}
|
||||
@@ -276,6 +280,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
manager,
|
||||
sessionSources,
|
||||
errors,
|
||||
desktop: config.desktopToken !== null ? new DesktopService(config.desktopToken) : null,
|
||||
log,
|
||||
};
|
||||
}
|
||||
@@ -355,6 +360,11 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
|
||||
|
||||
// Public routes (no login required).
|
||||
app.route("/api/auth", authRoutes(deps));
|
||||
// Desktop shutdown authenticates with the shell's Bearer token, not the cookie
|
||||
// session, so it mounts outside authMiddleware (and only in desktop mode).
|
||||
if (deps.desktop) {
|
||||
app.route("/api/desktop", desktopRoutes(deps));
|
||||
}
|
||||
|
||||
// Protected routes: cookie -> auth_session -> user.
|
||||
const auth = authMiddleware(deps.authService);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { MiddlewareHandler } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { HttpError } from "../http/errors.js";
|
||||
import type { UserRow } from "../db/repos/users.js";
|
||||
import type { AuthService } from "./service.js";
|
||||
import type { AuthService, SessionVia } from "./service.js";
|
||||
|
||||
/** Session cookie name. */
|
||||
export const SESSION_COOKIE = "penguin_session";
|
||||
@@ -20,6 +20,8 @@ export const SESSION_COOKIE = "penguin_session";
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
user: UserRow;
|
||||
/** How the current session was established ("password" | "desktop"); legacy rows read as "password". */
|
||||
sessionVia: SessionVia;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,11 +33,12 @@ export function currentUser(c: { var: { user: UserRow } }): UserRow {
|
||||
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
const user = token ? auth.authenticate(token) : null;
|
||||
if (!user) {
|
||||
const authed = token ? auth.authenticateWithMeta(token) : null;
|
||||
if (!authed) {
|
||||
throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
|
||||
}
|
||||
c.set("user", user);
|
||||
c.set("user", authed.user);
|
||||
c.set("sessionVia", authed.via);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* How a session was established: "password" via the login form, "desktop" via the
|
||||
* desktop shell's one-shot token (see design § "桌面端原型 · 桌面登录"). Persisted per
|
||||
* session so desktop-specific allowances (password change without the old password)
|
||||
* apply only to sessions the shell itself opened. Legacy rows (NULL) read as "password".
|
||||
*/
|
||||
export type SessionVia = "password" | "desktop";
|
||||
|
||||
export function toUserInfo(row: UserRow): UserInfo {
|
||||
return {
|
||||
userId: row.userId,
|
||||
@@ -159,7 +167,22 @@ export class AuthService {
|
||||
}
|
||||
this.loginFailures.delete(userId);
|
||||
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
|
||||
* (the desktop-login route) has already redeemed the shell's one-shot token, which is
|
||||
* the credential here. Throws if the admin has not been seeded yet (desktop-login runs
|
||||
* after startup seeding, so this only trips on a broken deployment).
|
||||
*/
|
||||
loginDesktop(): { user: UserInfo; token: string } {
|
||||
const row = this.deps.users.findById(ADMIN_USER_ID);
|
||||
if (!row) {
|
||||
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
|
||||
}
|
||||
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
|
||||
}
|
||||
|
||||
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
|
||||
@@ -174,12 +197,25 @@ export class AuthService {
|
||||
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-session password set: no old-password check. Only reachable for sessions
|
||||
* established via desktop-login (the me route gates on sessionVia) — the seed password
|
||||
* of a desktop-created root is random and never shown, so its holder has nothing to
|
||||
* type into an old-password field; the shell's token already proved machine ownership.
|
||||
*/
|
||||
async setPasswordDesktop(userId: string, newPassword: string): Promise<void> {
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
||||
}
|
||||
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
||||
}
|
||||
|
||||
logout(token: string): void {
|
||||
this.deps.authSessions.delete(sha256Hex(token));
|
||||
}
|
||||
|
||||
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
|
||||
authenticate(token: string): UserRow | null {
|
||||
authenticateWithMeta(token: string): { user: UserRow; via: SessionVia } | null {
|
||||
const tokenHash = sha256Hex(token);
|
||||
const session = this.deps.authSessions.findByTokenHash(tokenHash);
|
||||
if (!session) return null;
|
||||
@@ -195,10 +231,12 @@ export class AuthService {
|
||||
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
||||
);
|
||||
}
|
||||
return this.deps.users.findById(session.userId);
|
||||
const user = this.deps.users.findById(session.userId);
|
||||
if (!user) return null;
|
||||
return { user, via: session.via === "desktop" ? "desktop" : "password" };
|
||||
}
|
||||
|
||||
private issueSession(userId: string): string {
|
||||
private issueSession(userId: string, via: SessionVia): string {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const now = this.now();
|
||||
this.deps.authSessions.insert({
|
||||
@@ -206,6 +244,7 @@ export class AuthService {
|
||||
userId,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
||||
via,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* detected to exist.
|
||||
* Docs: /docs/configuration § "Environment variables".
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -35,13 +36,29 @@ export interface ServerConfig {
|
||||
/**
|
||||
* Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD),
|
||||
* used by automated tests and e2e; null (the norm) makes the seed generate a random
|
||||
* `penguin-<4 digits>` password, printed once to the server console.
|
||||
* `penguin-<4 digits>` password, printed once to the server console. In desktop mode
|
||||
* an unpinned value resolves to a FULLY random password instead (never printed):
|
||||
* sign-in there goes through the shell's one-shot token, so nobody needs to read the
|
||||
* seed. See design § "桌面端原型 · 桌面登录".
|
||||
*/
|
||||
seedAdminPassword: string | null;
|
||||
/** Login session validity period (7 days). */
|
||||
authSessionTtlMs: number;
|
||||
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
|
||||
authSessionRenewMs: number;
|
||||
/**
|
||||
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the per-launch token minted by the desktop
|
||||
* shell. Non-null enables the one-shot desktop-login and Bearer-token shutdown
|
||||
* endpoints and requires a loopback HOST — desktop mode passes the token through a
|
||||
* URL, which must never leave the machine. See design § "桌面端原型".
|
||||
*/
|
||||
desktopToken: string | null;
|
||||
/**
|
||||
* Port announcement file (PENGUIN_PORT_FILE): after the listener is up, the actual
|
||||
* bound port is written here — the supervising process's way to learn the port when
|
||||
* it starts the server with PORT=0.
|
||||
*/
|
||||
portFile: string | null;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -79,7 +96,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null {
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD). */
|
||||
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD / PENGUIN_DESKTOP_TOKEN / PENGUIN_PORT_FILE). */
|
||||
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
|
||||
const root = env.PENGUIN_HOME ?? resolveRoot();
|
||||
// An empty PORT string is treated as unset (the common `.env` case of an empty
|
||||
@@ -89,16 +106,29 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`Invalid port configuration PORT=${env.PORT}`);
|
||||
}
|
||||
const host = env.HOST ?? "127.0.0.1";
|
||||
const desktopToken = env.PENGUIN_DESKTOP_TOKEN?.trim() || null;
|
||||
// Desktop mode redeems its token through a URL: never allow it off loopback.
|
||||
if (desktopToken !== null && host !== "127.0.0.1" && host !== "localhost") {
|
||||
throw new Error(`Desktop mode requires a loopback HOST (got HOST=${host})`);
|
||||
}
|
||||
return {
|
||||
root,
|
||||
host: env.HOST ?? "127.0.0.1",
|
||||
host,
|
||||
port,
|
||||
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
|
||||
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
||||
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
|
||||
// An empty/whitespace value is treated as unset (→ random seed password).
|
||||
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null,
|
||||
// An empty/whitespace value is treated as unset (→ random seed password). Desktop
|
||||
// mode without a pinned value seeds a FULLY random password rather than the
|
||||
// printable penguin-<4 digits>: desktop sign-in goes through the shell's token, so
|
||||
// the seed never needs to be read — and index.ts deliberately does not print it.
|
||||
seedAdminPassword:
|
||||
env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() ||
|
||||
(desktopToken !== null ? randomBytes(24).toString("base64url") : null),
|
||||
authSessionTtlMs: 7 * DAY_MS,
|
||||
authSessionRenewMs: 6 * DAY_MS,
|
||||
desktopToken,
|
||||
portFile: env.PENGUIN_PORT_FILE?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function openDatabase(dbPath: string): DatabaseSync {
|
||||
// schema.ts; drop entries only in a release allowed to break existing web.db files.
|
||||
ensureColumn(db, "sessions", "client", "TEXT");
|
||||
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn(db, "auth_sessions", "via", "TEXT");
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AuthSessionRow {
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
/** How the session was established ("password" | "desktop"); null on rows formed before the column existed (treated as "password"). */
|
||||
via: string | null;
|
||||
}
|
||||
|
||||
export class AuthSessionsRepo {
|
||||
@@ -18,9 +20,9 @@ export class AuthSessionsRepo {
|
||||
insert(row: AuthSessionRow): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt);
|
||||
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
|
||||
}
|
||||
|
||||
findByTokenHash(tokenHash: string): AuthSessionRow | null {
|
||||
@@ -31,6 +33,7 @@ export class AuthSessionsRepo {
|
||||
userId: r.user_id as string,
|
||||
createdAt: r.created_at as string,
|
||||
expiresAt: r.expires_at as string,
|
||||
via: (r.via as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL -- 7-day sliding renewal (topped up when <6 days remain)
|
||||
expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
|
||||
via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Auth routes: POST /api/auth/login | logout.
|
||||
* Auth routes: POST /api/auth/login | logout, GET /api/auth/desktop-login (desktop mode).
|
||||
* No self-registration: users are created by an admin in the user backend (/api/admin/users).
|
||||
* Login issues a cookie session; logout deletes the server-side session and clears the cookie.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import type { AuthResponse } from "../../api/types.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { SESSION_COOKIE } from "../../auth/middleware.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { readJson, requireString } from "../validate.js";
|
||||
@@ -42,5 +43,22 @@ export function authRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
// Desktop-mode sign-in: the window's FIRST navigation redeems the shell's one-shot
|
||||
// token for a standard admin cookie session and lands on the app — the desktop user
|
||||
// never sees the login page. 404 outside desktop mode (the route "doesn't exist");
|
||||
// a wrong or already-used token is a plain 401 with no distinction, so a leaked URL
|
||||
// reveals nothing and cannot be replayed. See design § "桌面端原型 · 桌面登录".
|
||||
app.get("/desktop-login", (c) => {
|
||||
const desktop = deps.desktop;
|
||||
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
|
||||
const token = c.req.query("token") ?? "";
|
||||
if (token === "" || !desktop.redeemLoginToken(token)) {
|
||||
throw new HttpError(401, "unauthorized", "Invalid or already-used desktop token.");
|
||||
}
|
||||
const { token: session } = deps.authService.loginDesktop();
|
||||
setCookie(c, SESSION_COOKIE, session, cookieOptions(c));
|
||||
return c.redirect("/", 302);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Desktop-mode routes: POST /api/desktop/shutdown.
|
||||
*
|
||||
* Authenticated by the shell's Bearer token, not the cookie session (the shell holds no
|
||||
* cookie), so this mounts OUTSIDE authMiddleware and only when desktop mode is enabled.
|
||||
* Responds 202 first, then triggers the graceful shutdown a beat later so the response
|
||||
* isn't cut off by the closing listener.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { HttpError } from "../errors.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
/** Delay between answering 202 and starting shutdown: lets the response flush. */
|
||||
const SHUTDOWN_DELAY_MS = 50;
|
||||
|
||||
export function desktopRoutes(deps: AppDeps): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/shutdown", (c) => {
|
||||
const desktop = deps.desktop;
|
||||
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
|
||||
const header = c.req.header("authorization") ?? "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
|
||||
if (token === "" || !desktop.verifyToken(token)) {
|
||||
throw new HttpError(401, "unauthorized", "Invalid desktop token.");
|
||||
}
|
||||
setTimeout(() => desktop.requestShutdown(), SHUTDOWN_DELAY_MS).unref();
|
||||
return c.body(null, 202);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -28,15 +28,25 @@ export function meRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
return c.json({
|
||||
user: toUserInfo(c.var.user),
|
||||
previewIsolated: target !== null,
|
||||
desktopMode: deps.desktop !== null,
|
||||
sessionVia: c.var.sessionVia,
|
||||
} satisfies MeResponse);
|
||||
});
|
||||
|
||||
// Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me.
|
||||
// Desktop sessions may omit oldPassword: the seed password of a desktop-created root is
|
||||
// random and never shown, so its holder has nothing to type — the shell's redeemed
|
||||
// token already proved machine ownership (see design § "桌面端原型 · 桌面登录").
|
||||
app.put("/password", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
|
||||
const newPassword = requireString(body, "newPassword", { label: "newPassword" });
|
||||
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
|
||||
const desktopSession = deps.desktop !== null && c.var.sessionVia === "desktop";
|
||||
if (desktopSession && body.oldPassword === undefined) {
|
||||
await deps.authService.setPasswordDesktop(c.var.user.userId, newPassword);
|
||||
} else {
|
||||
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
|
||||
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
|
||||
}
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,15 +9,35 @@
|
||||
* persist + log, with the fatal one still shutting down per existing semantics (see the
|
||||
* comment below).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { buildAppDeps, createApp } from "./app.js";
|
||||
import { resolveServerConfig } from "./config.js";
|
||||
import { loopbackHostRoles } from "./services/preview-token.js";
|
||||
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
|
||||
|
||||
loadDotenv({ quiet: true });
|
||||
|
||||
/** Exit code for "another server already owns this data root" (see lock.ts). */
|
||||
const EXIT_ALREADY_RUNNING = 3;
|
||||
|
||||
const config = resolveServerConfig();
|
||||
|
||||
// Single instance per data root: web.db is single-writer and the scheduler must not run
|
||||
// twice, so refuse to start when a live server already owns this root — BEFORE opening
|
||||
// the database. The CLI and the desktop shell pre-check the same lock for a friendlier
|
||||
// path (open / attach to the existing instance); this is the in-process backstop.
|
||||
const existingLock = await liveServerLock(config.root);
|
||||
if (existingLock) {
|
||||
console.error(
|
||||
`Another PenguinHarness server is already running on this data root (pid ${existingLock.pid}).`,
|
||||
);
|
||||
console.error(`Existing instance: http://localhost:${existingLock.port}/`);
|
||||
process.exit(EXIT_ALREADY_RUNNING);
|
||||
}
|
||||
|
||||
const deps = buildAppDeps(config);
|
||||
const app = createApp(deps);
|
||||
|
||||
@@ -25,7 +45,10 @@ const app = createApp(deps);
|
||||
// users table is empty. The returned initial password (random unless pinned via
|
||||
// PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown.
|
||||
const seededAdminPassword = await deps.authService.seedAdmin();
|
||||
if (seededAdminPassword !== null) {
|
||||
// Never printed in desktop mode: the seed there is fully random by design (config.ts)
|
||||
// and sign-in goes through the shell's one-shot token, so showing it would only leak a
|
||||
// credential into a log nobody needs.
|
||||
if (seededAdminPassword !== null && config.desktopToken === null) {
|
||||
console.log(
|
||||
`Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`,
|
||||
);
|
||||
@@ -44,11 +67,6 @@ deps.goalsRepo.abortOrphanedActive();
|
||||
// counterpart is reserved for previews, so advertise the canonical name — the other one
|
||||
// only 302s back here for App routes (see the canonical-host guard in app.ts).
|
||||
const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
||||
console.log(`Data root: ${config.root}`);
|
||||
console.log(`SQLite: ${config.dbPath}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Second loopback listener so the preview origin is actually reachable.
|
||||
@@ -58,17 +76,56 @@ const server = serve({ fetch: app.fetch, hostname: config.host, port: config.por
|
||||
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
|
||||
* would leave every preview URL refusing connections. Binding `::1` as well closes that
|
||||
* gap. Failure is non-fatal — the App keeps working, previews just fall back.
|
||||
*
|
||||
* Created inside the main listener's callback so it reuses the ACTUAL bound port: with
|
||||
* PORT=0 both listeners resolving 0 independently would land on two different ports and
|
||||
* every preview URL (same port, counterpart host) would refuse connections.
|
||||
*/
|
||||
const ipv6Loopback =
|
||||
config.host === "127.0.0.1" || config.host === "localhost"
|
||||
? serve({ fetch: app.fetch, hostname: "::1", port: config.port })
|
||||
: null;
|
||||
ipv6Loopback?.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(
|
||||
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
|
||||
);
|
||||
let ipv6Loopback: ReturnType<typeof serve> | null = null;
|
||||
|
||||
/** Port announcement (PENGUIN_PORT_FILE): tmp + rename, so a polling reader never sees a partial write. */
|
||||
function writePortFile(file: string, port: number): void {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, `${port}\n`);
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
||||
console.log(`Data root: ${config.root}`);
|
||||
console.log(`SQLite: ${config.dbPath}`);
|
||||
if (config.desktopToken !== null) console.log("Desktop mode: enabled");
|
||||
// The root exists by now (openDatabase created it), and the pre-start check found no
|
||||
// live owner — record ourselves as this root's server.
|
||||
acquireServerLock(config.root, {
|
||||
pid: process.pid,
|
||||
port: info.port,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
if (config.portFile !== null) writePortFile(config.portFile, info.port);
|
||||
if (config.host === "127.0.0.1" || config.host === "localhost") {
|
||||
ipv6Loopback = serve({ fetch: app.fetch, hostname: "::1", port: info.port });
|
||||
ipv6Loopback.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(
|
||||
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Removes the instance lock and port file (best-effort; runs on both exit paths). */
|
||||
function cleanupInstanceFiles(): void {
|
||||
releaseServerLock(config.root);
|
||||
if (config.portFile !== null) {
|
||||
try {
|
||||
fs.rmSync(config.portFile, { force: true });
|
||||
} catch {
|
||||
// Best-effort: a stale port file is rewritten by the next server.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: string, exitCode = 0): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
@@ -80,15 +137,24 @@ async function shutdown(signal: string, exitCode = 0): Promise<void> {
|
||||
ipv6Loopback?.close();
|
||||
server.close(() => {
|
||||
deps.db.close();
|
||||
cleanupInstanceFiles();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
// Fallback: a long-lived SSE connection may block the close callback, so force exit after 1s.
|
||||
setTimeout(() => process.exit(exitCode), 1000).unref();
|
||||
setTimeout(() => {
|
||||
cleanupInstanceFiles();
|
||||
process.exit(exitCode);
|
||||
}, 1000).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Desktop shell quit path: POST /api/desktop/shutdown lands here — the same graceful
|
||||
// shutdown as the signals, reachable over HTTP because a Windows child kill is a hard
|
||||
// TerminateProcess with no signal delivery.
|
||||
deps.desktop?.onShutdownRequest(() => void shutdown("desktop-shutdown"));
|
||||
|
||||
// Process-level error fallback: once a background
|
||||
// fire-and-forget promise (title generation, Session drive, etc.) throws, the error
|
||||
// reaches the process without passing through any catch — persist it first for a
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Root-level server instance lock (`<root>/server.lock`).
|
||||
*
|
||||
* web.db is single-process / single-writer (see db/database.ts), and two servers on one
|
||||
* data root would also double-run the schedule scheduler — so a data root admits one
|
||||
* server at a time. The lock records {pid, port, startedAt}; liveness requires BOTH the
|
||||
* pid to be alive AND the recorded port to accept a TCP connection, because either signal
|
||||
* alone false-positives (pids get recycled, ports get taken by unrelated processes). A
|
||||
* lock that fails the liveness check is stale and is simply overwritten by the next
|
||||
* server.
|
||||
*
|
||||
* Published as `@prismshadow/penguin-server/lock` (side-effect-free) so the CLI and the
|
||||
* desktop shell can pre-check a root without importing the package entry, which starts
|
||||
* listening. Docs: design § "桌面端原型 · 数据根与实例互斥".
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface ServerLock {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/** TCP probe budget: loopback either connects immediately or the port is dead. */
|
||||
const PROBE_TIMEOUT_MS = 500;
|
||||
|
||||
export function serverLockPath(root: string): string {
|
||||
return path.join(root, "server.lock");
|
||||
}
|
||||
|
||||
/** Reads the lock file; a missing or malformed file reads as "no lock". */
|
||||
export function readServerLock(root: string): ServerLock | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(serverLockPath(root), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ServerLock>;
|
||||
if (
|
||||
typeof parsed.pid !== "number" ||
|
||||
!Number.isInteger(parsed.pid) ||
|
||||
typeof parsed.port !== "number" ||
|
||||
!Number.isInteger(parsed.port)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? "") };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM = the process exists but belongs to another user — still alive.
|
||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function portAccepts(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// 127.0.0.1 rather than localhost: this is a raw TCP liveness probe, not an App
|
||||
// request, and the server binds 127.0.0.1 (plus ::1) on loopback setups.
|
||||
const socket = net.connect({ host: "127.0.0.1", port, timeout: PROBE_TIMEOUT_MS });
|
||||
const done = (ok: boolean) => {
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("timeout", () => done(false));
|
||||
socket.once("error", () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the lock's process is alive AND its port accepts connections. */
|
||||
export async function isServerLockAlive(lock: ServerLock): Promise<boolean> {
|
||||
return pidAlive(lock.pid) && (await portAccepts(lock.port));
|
||||
}
|
||||
|
||||
/** Convenience for pre-checks: the live lock on this root, or null (absent or stale). */
|
||||
export async function liveServerLock(root: string): Promise<ServerLock | null> {
|
||||
const lock = readServerLock(root);
|
||||
if (!lock) return null;
|
||||
return (await isServerLockAlive(lock)) ? lock : null;
|
||||
}
|
||||
|
||||
/** Writes the lock atomically (tmp + rename; the parent directory must already exist). */
|
||||
export function acquireServerLock(root: string, lock: ServerLock): void {
|
||||
const target = serverLockPath(root);
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(lock) + "\n");
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
|
||||
/** Removes the lock if it is still ours (best-effort; never throws on shutdown paths). */
|
||||
export function releaseServerLock(root: string): void {
|
||||
try {
|
||||
const lock = readServerLock(root);
|
||||
if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));
|
||||
} catch {
|
||||
// Best-effort: a stale leftover is overwritten by the next server anyway.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the shell that spawned this server proves itself
|
||||
* with a per-launch random token, which backs two endpoints with different consumption
|
||||
* rules:
|
||||
*
|
||||
* - `GET /api/auth/desktop-login?token=…` — ONE-SHOT: the window's first navigation
|
||||
* redeems the token for a standard admin cookie session; every later attempt fails,
|
||||
* so a leaked URL cannot be replayed.
|
||||
* - `POST /api/desktop/shutdown` (Authorization: Bearer <token>) — REUSABLE for the
|
||||
* process lifetime: the token here identifies the supervising shell, which may need
|
||||
* the endpoint at any point (POSIX quit, and the only graceful path on Windows,
|
||||
* where killing a child is a hard TerminateProcess).
|
||||
*
|
||||
* Comparisons hash both sides first so timingSafeEqual gets equal-length buffers.
|
||||
* Docs: design § "桌面端原型 · 桌面登录".
|
||||
*/
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
export class DesktopService {
|
||||
private readonly tokenDigest: Buffer;
|
||||
private loginConsumed = false;
|
||||
private shutdownHandler: (() => void) | null = null;
|
||||
|
||||
constructor(token: string) {
|
||||
this.tokenDigest = digest(token);
|
||||
}
|
||||
|
||||
/** Constant-time token check (no consumption). */
|
||||
verifyToken(candidate: string): boolean {
|
||||
return timingSafeEqual(digest(candidate), this.tokenDigest);
|
||||
}
|
||||
|
||||
/** One-shot login redemption: true exactly once, for the correct token. */
|
||||
redeemLoginToken(candidate: string): boolean {
|
||||
if (this.loginConsumed || !this.verifyToken(candidate)) return false;
|
||||
this.loginConsumed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** index.ts registers the actual graceful-shutdown trigger after assembly. */
|
||||
onShutdownRequest(handler: () => void): void {
|
||||
this.shutdownHandler = handler;
|
||||
}
|
||||
|
||||
/** Invoked by the shutdown route; false when no handler is registered (tests). */
|
||||
requestShutdown(): boolean {
|
||||
if (!this.shutdownHandler) return false;
|
||||
this.shutdownHandler();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user