feat: direct TCP URI with SSL toggle and optional password auth (#635)
* feat: direct TCP URI with SSL toggle and optional password auth Replaces the heuristic-driven direct-connection model with a user-controlled one. The Add Host dialog now exposes structured Host, Port, "Use SSL", and masked Password fields that compose the canonical `tcp://host:port?ssl=true&password=xxx` URI used as the storage form. The daemon gains optional shared-secret auth: `Authorization: Bearer <pw>` on HTTP and `Sec-WebSocket-Protocol: paseo.bearer.<pw>` on the WS upgrade (browser WebSocket can't set custom headers). Configured via config.json `auth.password` or `PASEO_PASSWORD` env. Off by default — old clients keep working unchanged. The `port === 443` heuristic for ws/wss is gone; the explicit `useTls` flag drives scheme selection at every call site. * fix: stabilize direct tcp auth ci checks * fix: restore fetch stub in bootstrap smoke test * fix(app): collapse Advanced section in add-host modal * feat(server): hash daemon password in config * fix: update lockfile signatures and Nix hash * Improve direct TCP auth failures * Fix workspace cwd updates after rebase --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
30f7842e8f
commit
088a6c14c0
@@ -10,11 +10,12 @@ export interface DaemonTransport {
|
||||
export type DaemonTransportFactory = (options: {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
protocols?: string[];
|
||||
}) => DaemonTransport;
|
||||
|
||||
export type WebSocketFactory = (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string> },
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) => WebSocketLike;
|
||||
|
||||
export interface WebSocketLike {
|
||||
|
||||
@@ -75,6 +75,27 @@ describe("daemon-client transport helpers", () => {
|
||||
expect(close).toHaveBeenCalledWith(1000, "bye");
|
||||
});
|
||||
|
||||
test("createWebSocketTransportFactory passes WebSocket protocols to the socket factory", () => {
|
||||
const socketFactory = vi.fn(() => ({
|
||||
readyState: 1,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
|
||||
createWebSocketTransportFactory(socketFactory)({
|
||||
url: "ws://example.test",
|
||||
headers: { Authorization: "Bearer shared-secret" },
|
||||
protocols: ["paseo.bearer.shared-secret"],
|
||||
});
|
||||
|
||||
expect(socketFactory).toHaveBeenCalledWith("ws://example.test", {
|
||||
headers: { Authorization: "Bearer shared-secret" },
|
||||
protocols: ["paseo.bearer.shared-secret"],
|
||||
});
|
||||
});
|
||||
|
||||
test("createWebSocketTransportFactory rejects sends when socket is not open", () => {
|
||||
const factory = createWebSocketTransportFactory(() => ({
|
||||
readyState: 3,
|
||||
|
||||
@@ -6,18 +6,22 @@ import type {
|
||||
|
||||
export function defaultWebSocketFactory(
|
||||
url: string,
|
||||
_options?: { headers?: Record<string, string> },
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
): WebSocketLike {
|
||||
const globalWs = (globalThis as { WebSocket?: new (url: string) => WebSocketLike }).WebSocket;
|
||||
const globalWs = (
|
||||
globalThis as {
|
||||
WebSocket?: new (url: string, protocols?: string | string[]) => WebSocketLike;
|
||||
}
|
||||
).WebSocket;
|
||||
if (!globalWs) {
|
||||
throw new Error("WebSocket is not available in this runtime");
|
||||
}
|
||||
return new globalWs(url);
|
||||
return new globalWs(url, options?.protocols);
|
||||
}
|
||||
|
||||
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
|
||||
return ({ url, headers }) => {
|
||||
const ws = factory(url, { headers });
|
||||
return ({ url, headers, protocols }) => {
|
||||
const ws = factory(url, { headers, protocols });
|
||||
if ("binaryType" in ws) {
|
||||
try {
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
@@ -184,6 +184,32 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("passes password as HTTP bearer header and WebSocket subprotocol", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
const transportFactory = vi.fn(() => mock.transport);
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
password: "shared-secret",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
expect(transportFactory).toHaveBeenCalledWith({
|
||||
url: "ws://test",
|
||||
headers: { Authorization: "Bearer shared-secret" },
|
||||
protocols: ["paseo.bearer.shared-secret"],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not reconnect after close when ensureConnected is called", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -129,6 +129,13 @@ export interface ImportAgentInput {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
function normalizePassword(value: string | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export type {
|
||||
DaemonTransport,
|
||||
DaemonTransportFactory,
|
||||
@@ -198,6 +205,7 @@ export interface DaemonClientConfig {
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp";
|
||||
appVersion?: string;
|
||||
runtimeGeneration?: number | null;
|
||||
password?: string;
|
||||
authHeader?: string;
|
||||
suppressSendErrors?: boolean;
|
||||
transportFactory?: DaemonTransportFactory;
|
||||
@@ -786,9 +794,13 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.config.authHeader) {
|
||||
headers["Authorization"] = this.config.authHeader;
|
||||
const password = normalizePassword(this.config.password);
|
||||
if (password) {
|
||||
headers.Authorization = `Bearer ${password}`;
|
||||
} else if (this.config.authHeader) {
|
||||
headers.Authorization = this.config.authHeader;
|
||||
}
|
||||
const protocols = password ? [`paseo.bearer.${password}`] : undefined;
|
||||
|
||||
try {
|
||||
// Reconnect can overlap with browser close/error delivery ordering.
|
||||
@@ -813,7 +825,11 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
const transportUrl = this.resolveTransportUrlForAttempt();
|
||||
const transport = transportFactory({ url: transportUrl, headers });
|
||||
const transport = transportFactory({
|
||||
url: transportUrl,
|
||||
headers,
|
||||
...(protocols ? { protocols } : {}),
|
||||
});
|
||||
this.transport = transport;
|
||||
this.lastServerInfoMessage = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
extractHttpBearerToken,
|
||||
extractWsBearerProtocol,
|
||||
extractWsBearerToken,
|
||||
hashDaemonPassword,
|
||||
isBearerTokenValidAsync,
|
||||
isBearerTokenValid,
|
||||
} from "./auth.js";
|
||||
|
||||
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||
|
||||
describe("daemon bearer validator", () => {
|
||||
test("allows any token when no password is configured", () => {
|
||||
expect(isBearerTokenValid({ password: undefined, token: null })).toBe(true);
|
||||
expect(isBearerTokenValid({ password: undefined, token: "anything" })).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts the plaintext token against the bcrypt hash and rejects missing or wrong tokens", async () => {
|
||||
expect(
|
||||
await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "correct-password" }),
|
||||
).toBe(true);
|
||||
expect(isBearerTokenValid({ password: CORRECT_PASSWORD_HASH, token: "correct-password" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: null })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "wrong" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("hashes a password into a bcrypt value", () => {
|
||||
const hash = hashDaemonPassword("correct-password");
|
||||
|
||||
expect(hash).toMatch(/^\$2[aby]\$12\$/);
|
||||
expect(isBearerTokenValid({ password: hash, token: "correct-password" })).toBe(true);
|
||||
});
|
||||
|
||||
test("extracts HTTP bearer tokens", () => {
|
||||
expect(extractHttpBearerToken("Bearer secret")).toBe("secret");
|
||||
expect(extractHttpBearerToken("Basic secret")).toBeNull();
|
||||
expect(extractHttpBearerToken(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test("extracts WebSocket paseo bearer subprotocol tokens", () => {
|
||||
const protocol = extractWsBearerProtocol("chat, paseo.bearer.secret.with.dots");
|
||||
|
||||
expect(protocol).toBe("paseo.bearer.secret.with.dots");
|
||||
expect(extractWsBearerToken(protocol)).toBe("secret.with.dots");
|
||||
expect(extractWsBearerToken("paseo.other.secret")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { compare, compareSync, hashSync } from "bcryptjs";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
export const DAEMON_PASSWORD_BCRYPT_COST = 12;
|
||||
|
||||
export interface DaemonAuthConfig {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface BearerAuthRejectContext {
|
||||
path: string;
|
||||
method: string;
|
||||
hasToken: boolean;
|
||||
}
|
||||
|
||||
interface BearerValidationInput {
|
||||
password: string | undefined;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
export function isBearerTokenValid(input: BearerValidationInput): boolean {
|
||||
return isBearerTokenValidSync(input);
|
||||
}
|
||||
|
||||
export async function isBearerTokenValidAsync(input: BearerValidationInput): Promise<boolean> {
|
||||
if (!input.password) {
|
||||
return true;
|
||||
}
|
||||
if (input.token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return compare(input.token, input.password);
|
||||
}
|
||||
|
||||
export function isBearerTokenValidSync(input: BearerValidationInput): boolean {
|
||||
if (!input.password) {
|
||||
return true;
|
||||
}
|
||||
if (input.token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return compareSync(input.token, input.password);
|
||||
}
|
||||
|
||||
export function hashDaemonPassword(password: string): string {
|
||||
return hashSync(password, DAEMON_PASSWORD_BCRYPT_COST);
|
||||
}
|
||||
|
||||
export function extractHttpBearerToken(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const [scheme, ...tokenParts] = value.trim().split(/\s+/);
|
||||
if (scheme !== "Bearer" || tokenParts.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return tokenParts[0] ?? null;
|
||||
}
|
||||
|
||||
export function extractWsBearerProtocol(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const protocol of value.split(",")) {
|
||||
const trimmed = protocol.trim();
|
||||
const segments = trimmed.split(".");
|
||||
if (segments[0] === "paseo" && segments[1] === "bearer" && segments.length >= 3) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractWsBearerToken(protocol: string | null): string | null {
|
||||
if (!protocol) {
|
||||
return null;
|
||||
}
|
||||
const segments = protocol.split(".");
|
||||
if (segments[0] !== "paseo" || segments[1] !== "bearer" || segments.length < 3) {
|
||||
return null;
|
||||
}
|
||||
return segments.slice(2).join(".");
|
||||
}
|
||||
|
||||
export function createRequireBearerMiddleware(
|
||||
auth: DaemonAuthConfig | undefined,
|
||||
onReject?: (context: BearerAuthRejectContext) => void,
|
||||
): RequestHandler {
|
||||
const password = auth?.password;
|
||||
return (req, res, next) => {
|
||||
if (!password || shouldBypassBearerAuth(req.method, req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const token = extractHttpBearerToken(req.header("authorization"));
|
||||
if (!(await isBearerTokenValidAsync({ password, token }))) {
|
||||
onReject?.({
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
hasToken: token !== null,
|
||||
});
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldBypassBearerAuth(method: string, path: string): boolean {
|
||||
if (method === "OPTIONS") {
|
||||
return true;
|
||||
}
|
||||
// Public liveness/version endpoints used by local supervisors and health probes.
|
||||
return path === "/api/health" || path === "/api/status";
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { WebSocket } from "ws";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||
|
||||
function connectWebSocket(params: {
|
||||
port: number;
|
||||
protocol?: string;
|
||||
}): Promise<{ ws: WebSocket; protocol: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(
|
||||
`ws://127.0.0.1:${params.port}/ws`,
|
||||
params.protocol ? [params.protocol] : undefined,
|
||||
);
|
||||
ws.once("open", () => resolve({ ws, protocol: ws.protocol }));
|
||||
ws.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectWebSocketCloses(params: {
|
||||
port: number;
|
||||
protocol?: string;
|
||||
code: number;
|
||||
reason: string;
|
||||
}): Promise<void> {
|
||||
const { ws } = await connectWebSocket(params);
|
||||
await expect(
|
||||
new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
ws.once("close", (code, reason) => {
|
||||
resolve({ code, reason: reason.toString() });
|
||||
});
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
code: params.code,
|
||||
reason: params.reason,
|
||||
});
|
||||
}
|
||||
|
||||
describe("daemon bearer auth", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...originalEnv, PASEO_SUPERVISED: "0" };
|
||||
});
|
||||
|
||||
test("leaves HTTP and WebSocket open when no password is configured", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon();
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const { ws, protocol } = await connectWebSocket({ port: daemonHandle.port });
|
||||
expect(protocol).toBe("");
|
||||
ws.close();
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("requires Authorization bearer on protected HTTP routes when password is configured", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
auth: { password: CORRECT_PASSWORD_HASH },
|
||||
});
|
||||
try {
|
||||
const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`);
|
||||
expect(missing.status).toBe(401);
|
||||
|
||||
const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
|
||||
headers: { Authorization: "Bearer wrong-password" },
|
||||
});
|
||||
expect(wrong.status).toBe(401);
|
||||
|
||||
const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
|
||||
headers: { Authorization: "Bearer correct-password" },
|
||||
});
|
||||
expect(correct.status).toBe(400);
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("bypasses bearer auth for preflight and liveness endpoints", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
auth: { password: CORRECT_PASSWORD_HASH },
|
||||
});
|
||||
try {
|
||||
const preflight = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: "https://app.paseo.sh" },
|
||||
});
|
||||
expect(preflight.status).toBe(204);
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/health`);
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const status = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
|
||||
expect(status.status).toBe(200);
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("closes WebSocket connections with readable auth failures when password is configured", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
auth: { password: CORRECT_PASSWORD_HASH },
|
||||
});
|
||||
try {
|
||||
await expectWebSocketCloses({
|
||||
port: daemonHandle.port,
|
||||
code: 4401,
|
||||
reason: "Password required",
|
||||
});
|
||||
await expectWebSocketCloses({
|
||||
port: daemonHandle.port,
|
||||
protocol: "paseo.bearer.wrong-password",
|
||||
code: 4401,
|
||||
reason: "Incorrect password",
|
||||
});
|
||||
|
||||
const { ws, protocol } = await connectWebSocket({
|
||||
port: daemonHandle.port,
|
||||
protocol: "paseo.bearer.correct-password",
|
||||
});
|
||||
expect(protocol).toBe("paseo.bearer.correct-password");
|
||||
ws.close();
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,8 @@ describe("paseo daemon bootstrap", () => {
|
||||
statusText: "test cleanup",
|
||||
}),
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
globalThis.fetch = originalFetch;
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,6 +135,7 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
||||
import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js";
|
||||
|
||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||
|
||||
@@ -190,6 +191,7 @@ export interface PaseoDaemonConfig {
|
||||
relayEndpoint?: string;
|
||||
relayPublicEndpoint?: string;
|
||||
appBaseUrl?: string;
|
||||
auth?: DaemonAuthConfig;
|
||||
openai?: PaseoOpenAIConfig;
|
||||
speech?: PaseoSpeechConfig;
|
||||
voiceLlmProvider?: AgentProvider | null;
|
||||
@@ -294,12 +296,6 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
}
|
||||
|
||||
// Script proxy — intercepts requests for registered *.localhost hostnames
|
||||
// and forwards them to the corresponding local script port. Placed after
|
||||
// the host allowlist (*.localhost is already allowed) but before CORS and
|
||||
// the rest of the routes so proxied requests skip unnecessary middleware.
|
||||
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
|
||||
|
||||
// CORS - allow same-origin + configured origins
|
||||
const allowedOrigins = new Set([
|
||||
...config.corsAllowedOrigins,
|
||||
@@ -330,6 +326,17 @@ export async function createPaseoDaemon(
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(
|
||||
createRequireBearerMiddleware(config.auth, (context) => {
|
||||
logger.warn(context, "Rejected HTTP request with invalid daemon password");
|
||||
}),
|
||||
);
|
||||
|
||||
// Script proxy — intercepts requests for registered *.localhost hostnames
|
||||
// and forwards them to the corresponding local script port. Placed after
|
||||
// host/CORS/auth checks but before the rest of the routes.
|
||||
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use("/public", express.static(staticDir));
|
||||
|
||||
@@ -771,16 +778,24 @@ export async function createPaseoDaemon(
|
||||
{
|
||||
host: boundListenTarget.host,
|
||||
port: boundListenTarget.port,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{ path: boundListenTarget.path, elapsed: elapsed() },
|
||||
{
|
||||
path: boundListenTarget.path,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on ${boundListenTarget.path}`,
|
||||
);
|
||||
}
|
||||
if (config.auth?.password) {
|
||||
logger.info("Daemon password authentication enabled");
|
||||
}
|
||||
|
||||
wsServer = new VoiceAssistantWebSocketServer(
|
||||
httpServer,
|
||||
@@ -793,6 +808,7 @@ export async function createPaseoDaemon(
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, hostnames: configuredHostnames },
|
||||
config.auth,
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { loadConfig } from "./config.js";
|
||||
import { isBearerTokenValid } from "./auth.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const CONFIG_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||
|
||||
async function createPaseoHome(config: unknown): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-"));
|
||||
roots.push(root);
|
||||
const paseoHome = path.join(root, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2));
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
describe("daemon auth config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("loads optional auth password hash from config.json", async () => {
|
||||
const paseoHome = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
auth: { password: CONFIG_PASSWORD_HASH },
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(paseoHome, { env: {} });
|
||||
|
||||
expect(config.auth?.password).toBe(CONFIG_PASSWORD_HASH);
|
||||
expect(isBearerTokenValid({ password: config.auth?.password, token: "correct-password" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("lets PASEO_PASSWORD override config.json auth password hash", async () => {
|
||||
const paseoHome = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
auth: { password: CONFIG_PASSWORD_HASH },
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(paseoHome, {
|
||||
env: { PASEO_PASSWORD: "from-env" },
|
||||
});
|
||||
|
||||
expect(config.auth?.password).not.toBe(CONFIG_PASSWORD_HASH);
|
||||
expect(config.auth?.password).toMatch(/^\$2[aby]\$12\$/);
|
||||
expect(isBearerTokenValid({ password: config.auth?.password, token: "from-env" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
||||
import { hashDaemonPassword } from "./auth.js";
|
||||
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
||||
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
|
||||
|
||||
@@ -184,6 +185,19 @@ function resolveListenAddress(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAuthConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): PaseoDaemonConfig["auth"] {
|
||||
const envPassword = env.PASEO_PASSWORD?.trim();
|
||||
if (envPassword) {
|
||||
return { password: hashDaemonPassword(envPassword) };
|
||||
}
|
||||
return persisted.daemon?.auth?.password
|
||||
? { password: persisted.daemon.auth.password }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveStaticLoadConfigSettings(
|
||||
env: NodeJS.ProcessEnv,
|
||||
cli: CliConfigOverrides | undefined,
|
||||
@@ -249,6 +263,7 @@ export function loadConfig(
|
||||
relayEndpoint: relay.endpoint,
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
appBaseUrl,
|
||||
auth: resolveAuthConfig(env, persisted),
|
||||
openai,
|
||||
speech,
|
||||
voiceLlmProvider: voiceLlm.provider,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type DaemonTestContext,
|
||||
DaemonClient,
|
||||
} from "./test-utils/index.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
|
||||
import type {
|
||||
@@ -74,6 +75,52 @@ function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-client-"));
|
||||
}
|
||||
|
||||
test("DaemonClient connects to a password-protected daemon", async () => {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
auth: { password: "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76" },
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
password: "shared-secret",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const agents = await client.fetchAgents();
|
||||
expect(agents.entries).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("DaemonClient surfaces password auth failures from WebSocket close reasons", async () => {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
auth: { password: "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76" },
|
||||
});
|
||||
const missingPasswordClient = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const wrongPasswordClient = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
password: "wrong-secret",
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(missingPasswordClient.connect()).rejects.toThrow("Password required");
|
||||
expect(missingPasswordClient.lastError).toBe("Password required");
|
||||
|
||||
await expect(wrongPasswordClient.connect()).rejects.toThrow("Incorrect password");
|
||||
expect(wrongPasswordClient.lastError).toBe("Incorrect password");
|
||||
} finally {
|
||||
await missingPasswordClient.close();
|
||||
await wrongPasswordClient.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
function waitForSignal<T>(
|
||||
timeoutMs: number,
|
||||
setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void,
|
||||
|
||||
@@ -106,6 +106,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${port}`,
|
||||
useTls: false,
|
||||
serverId,
|
||||
role: "server",
|
||||
});
|
||||
@@ -225,6 +226,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const ws = new WebSocket(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
useTls: false,
|
||||
serverId,
|
||||
role: "client",
|
||||
}),
|
||||
@@ -392,6 +394,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const ws = new WebSocket(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
useTls: false,
|
||||
serverId,
|
||||
role: "client",
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,12 @@ export { loadConfig, type CliConfigOverrides } from "./config.js";
|
||||
export { resolvePaseoHome } from "./paseo-home.js";
|
||||
export { getOrCreateServerId } from "./server-id.js";
|
||||
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||
export {
|
||||
loadPersistedConfig,
|
||||
savePersistedConfig,
|
||||
type PersistedConfig,
|
||||
} from "./persisted-config.js";
|
||||
export { hashDaemonPassword, isBearerTokenValid } from "./auth.js";
|
||||
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
||||
export {
|
||||
ConnectionOfferSchema,
|
||||
@@ -20,6 +25,18 @@ export {
|
||||
type DaemonEvent,
|
||||
} from "../client/daemon-client.js";
|
||||
export type { WebSocketFactory, WebSocketLike } from "../client/daemon-client-transport-types.js";
|
||||
export {
|
||||
buildDaemonWebSocketUrl,
|
||||
deriveLabelFromEndpoint,
|
||||
normalizeHostPort,
|
||||
parseConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "../shared/daemon-endpoints.js";
|
||||
export {
|
||||
DirectTcpHostConnectionSchema,
|
||||
type DirectTcpHostConnection,
|
||||
type NormalizedDirectTcpHostConnection,
|
||||
} from "../shared/host-connection-schema.js";
|
||||
export {
|
||||
ensureLocalSpeechModels,
|
||||
listLocalSpeechModels,
|
||||
|
||||
@@ -54,6 +54,20 @@ const DEFAULT_FILE_LEVEL: LogLevel = "debug";
|
||||
const DEFAULT_FILE_ROTATE_SIZE = "10m";
|
||||
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const REDACT_PATHS = [
|
||||
"authorization",
|
||||
"Authorization",
|
||||
"headers.authorization",
|
||||
"headers.Authorization",
|
||||
"req.headers.authorization",
|
||||
"req.headers.Authorization",
|
||||
'["sec-websocket-protocol"]',
|
||||
"Sec-WebSocket-Protocol",
|
||||
'headers["sec-websocket-protocol"]',
|
||||
"headers.Sec-WebSocket-Protocol",
|
||||
'req.headers["sec-websocket-protocol"]',
|
||||
"req.headers.Sec-WebSocket-Protocol",
|
||||
];
|
||||
|
||||
function parseLogLevel(value: string | undefined): LogLevel | undefined {
|
||||
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
|
||||
@@ -285,7 +299,7 @@ export function createRootLogger(
|
||||
});
|
||||
|
||||
return pino(
|
||||
{ level: config.level },
|
||||
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } },
|
||||
pino.multistream([
|
||||
{ level: config.console.level, stream: consoleStream },
|
||||
{ level: config.file.level, stream: fileStream },
|
||||
|
||||
@@ -2,6 +2,19 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PersistedConfigSchema } from "./persisted-config.js";
|
||||
|
||||
describe("PersistedConfigSchema daemon auth config", () => {
|
||||
test("accepts optional daemon password hash", () => {
|
||||
const hash = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
auth: { password: hash },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.auth?.password).toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
test("legacy append entries are skipped during migration", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -62,6 +62,16 @@ const ProvidersSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
|
||||
message: "Expected a bcrypt hash",
|
||||
});
|
||||
|
||||
const DaemonAuthSchema = z
|
||||
.object({
|
||||
password: BcryptHashSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const SpeechProviderIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -251,6 +261,7 @@ export const PersistedConfigSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
auth: DaemonAuthSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.transform(({ allowedHosts, ...daemon }) => {
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type Transport as RelayTransport,
|
||||
type KeyPair,
|
||||
} from "@getpaseo/relay/e2ee";
|
||||
import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||
import {
|
||||
buildRelayWebSocketUrl,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "../shared/daemon-endpoints.js";
|
||||
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
||||
|
||||
interface RelayTransportOptions {
|
||||
@@ -151,6 +154,7 @@ export function startRelayTransport({
|
||||
const connectionId = ++controlConnectionSeq;
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
|
||||
serverId,
|
||||
role: "server",
|
||||
});
|
||||
@@ -326,6 +330,7 @@ export function startRelayTransport({
|
||||
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
|
||||
serverId,
|
||||
role: "server",
|
||||
connectionId,
|
||||
|
||||
@@ -1080,6 +1080,10 @@ export class Session {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
|
||||
await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd)));
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
|
||||
@@ -30,7 +30,9 @@ export class DaemonClient extends SharedDaemonClient {
|
||||
...config,
|
||||
clientId,
|
||||
webSocketFactory: (url, options) =>
|
||||
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
|
||||
new WebSocket(url, options?.protocols, {
|
||||
headers: options?.headers,
|
||||
}) as unknown as WebSocketLike,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface TestPaseoDaemonOptions {
|
||||
voiceLlmProviderExplicit?: boolean;
|
||||
voiceLlmModel?: string | null;
|
||||
dictationFinalTimeoutMs?: number;
|
||||
auth?: PaseoDaemonConfig["auth"];
|
||||
}
|
||||
|
||||
export interface TestPaseoDaemon {
|
||||
@@ -155,6 +156,7 @@ async function prepareTestDaemonConfig(
|
||||
relayEnabled: options.relayEnabled ?? false,
|
||||
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
auth: options.auth,
|
||||
openai: options.openai,
|
||||
speech: options.speech,
|
||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||
|
||||
@@ -107,6 +107,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
|
||||
@@ -187,6 +187,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
daemonConfigStore as unknown as DaemonConfigStore,
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
undefined,
|
||||
speechReadiness
|
||||
? {
|
||||
getReadiness: () => speechReadiness,
|
||||
|
||||
@@ -108,6 +108,7 @@ function createServer(logger: ReturnType<typeof createLogger>) {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WebSocketServer } from "ws";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import type { IncomingMessage, Server as HTTPServer } from "http";
|
||||
import { basename, join } from "path";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
@@ -52,6 +52,14 @@ import {
|
||||
findLatestPermissionRequest,
|
||||
} from "../shared/agent-attention-notification.js";
|
||||
import { createGitHubService, type GitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
extractWsBearerProtocol,
|
||||
extractWsBearerToken,
|
||||
isBearerTokenValid,
|
||||
type DaemonAuthConfig,
|
||||
} from "./auth.js";
|
||||
|
||||
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
|
||||
|
||||
export interface ExternalSocketMetadata {
|
||||
transport: "relay";
|
||||
@@ -415,6 +423,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
daemonConfigStore: DaemonConfigStore,
|
||||
mcpBaseUrl: string | null,
|
||||
wsConfig: WebSocketServerConfig,
|
||||
auth?: DaemonAuthConfig,
|
||||
speech?: SpeechService | null,
|
||||
terminalManager?: TerminalManager | null,
|
||||
dictation?: {
|
||||
@@ -527,7 +536,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
});
|
||||
});
|
||||
|
||||
this.wss = this.createWebSocketServer(server, wsConfig);
|
||||
this.wss = this.createWebSocketServer(server, wsConfig, auth);
|
||||
this.startRuntimeMetricsInterval();
|
||||
|
||||
this.logger.info("WebSocket server initialized on /ws");
|
||||
@@ -568,17 +577,20 @@ export class VoiceAssistantWebSocketServer {
|
||||
private createWebSocketServer(
|
||||
server: HTTPServer,
|
||||
wsConfig: WebSocketServerConfig,
|
||||
auth: DaemonAuthConfig | undefined,
|
||||
): WebSocketServer {
|
||||
const { allowedOrigins, hostnames } = wsConfig;
|
||||
const password = auth?.password;
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
|
||||
verifyClient: ({ req }, callback) => {
|
||||
this.verifyWsClient(req, allowedOrigins, hostnames, callback);
|
||||
this.verifyWsUpgrade(req, allowedOrigins, hostnames, callback);
|
||||
},
|
||||
});
|
||||
wss.on("connection", (ws, request) => {
|
||||
void this.attachSocket(ws, request);
|
||||
void this.attachAuthenticatedSocket(ws, request, password);
|
||||
});
|
||||
return wss;
|
||||
}
|
||||
@@ -591,7 +603,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
(runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
private verifyWsClient(
|
||||
private verifyWsUpgrade(
|
||||
req: IncomingMessage,
|
||||
allowedOrigins: Set<string>,
|
||||
hostnames: HostnamesConfig | undefined,
|
||||
@@ -623,6 +635,30 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async attachAuthenticatedSocket(
|
||||
ws: WebSocket,
|
||||
request: IncomingMessage,
|
||||
password: string | undefined,
|
||||
): Promise<void> {
|
||||
if (password) {
|
||||
const requestMetadata = extractSocketRequestMetadata(request);
|
||||
const protocol = extractWsBearerProtocol(request.headers["sec-websocket-protocol"]);
|
||||
const token = extractWsBearerToken(protocol);
|
||||
const isAuthorized = isBearerTokenValid({ password, token });
|
||||
if (!isAuthorized) {
|
||||
const reason = token === null ? "Password required" : "Incorrect password";
|
||||
this.logger.warn(
|
||||
{ ...requestMetadata, hasToken: token !== null },
|
||||
"Rejected WebSocket connection with invalid daemon password",
|
||||
);
|
||||
ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.attachSocket(ws, request);
|
||||
}
|
||||
|
||||
public broadcast(message: WSOutboundMessage): void {
|
||||
const payload = JSON.stringify(message);
|
||||
for (const ws of this.sessions.keys()) {
|
||||
@@ -1840,6 +1876,24 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
function selectWebSocketProtocol(
|
||||
protocols: Set<string>,
|
||||
password: string | undefined,
|
||||
): string | false {
|
||||
if (!password) {
|
||||
return protocols.values().next().value ?? false;
|
||||
}
|
||||
|
||||
for (const protocol of protocols) {
|
||||
const token = extractWsBearerToken(protocol);
|
||||
if (token !== null) {
|
||||
return protocol;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function stringifyCloseReason(reason: unknown): string | null {
|
||||
if (typeof reason === "string") {
|
||||
return reason.length > 0 ? reason : null;
|
||||
|
||||
@@ -1,16 +1,105 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
CURRENT_RELAY_PROTOCOL_VERSION,
|
||||
normalizeRelayProtocolVersion,
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "./daemon-endpoints.js";
|
||||
|
||||
describe("connection URI parsing", () => {
|
||||
test("round-trips a tcp host and port", () => {
|
||||
const parsed = parseConnectionUri("tcp://localhost:6767");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "localhost",
|
||||
port: 6767,
|
||||
isIpv6: false,
|
||||
useTls: false,
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767");
|
||||
});
|
||||
|
||||
test("round-trips an SSL-enabled tcp host and port", () => {
|
||||
const parsed = parseConnectionUri("tcp://example.com:443?ssl=true");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
isIpv6: false,
|
||||
useTls: true,
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://example.com:443?ssl=true");
|
||||
});
|
||||
|
||||
test("round-trips an IPv6 host", () => {
|
||||
const parsed = parseConnectionUri("tcp://[::1]:6767?ssl=true");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "::1",
|
||||
port: 6767,
|
||||
isIpv6: true,
|
||||
useTls: true,
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://[::1]:6767?ssl=true");
|
||||
});
|
||||
|
||||
test("rejects a missing port", () => {
|
||||
expect(() => parseConnectionUri("tcp://localhost")).toThrow("Connection URI port is required");
|
||||
});
|
||||
|
||||
test("rejects an invalid scheme", () => {
|
||||
expect(() => parseConnectionUri("http://localhost:6767")).toThrow(
|
||||
"Connection URI protocol must be tcp:",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses password without including it in the public serializer", () => {
|
||||
const parsed = parseConnectionUri("tcp://localhost:6767?ssl=true&password=secret");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "localhost",
|
||||
port: 6767,
|
||||
isIpv6: false,
|
||||
useTls: true,
|
||||
password: "secret",
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767?ssl=true");
|
||||
expect(serializeConnectionUriForStorage(parsed)).toBe(
|
||||
"tcp://localhost:6767?ssl=true&password=secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects userinfo passwords", () => {
|
||||
expect(() => parseConnectionUri("tcp://:secret@localhost:6767?ssl=true")).toThrow(
|
||||
"Connection URI userinfo is not supported",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon websocket URLs", () => {
|
||||
test("uses ws for port 443 when TLS is disabled", () => {
|
||||
expect(buildDaemonWebSocketUrl("example.com:443", { useTls: false })).toBe(
|
||||
"ws://example.com:443/ws",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses wss for non-443 ports when TLS is enabled", () => {
|
||||
expect(buildDaemonWebSocketUrl("example.com:6767", { useTls: true })).toBe(
|
||||
"wss://example.com:6767/ws",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay websocket URL versioning", () => {
|
||||
test("defaults relay URLs to v2", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
}),
|
||||
@@ -24,6 +113,7 @@ describe("relay websocket URL versioning", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "server",
|
||||
connectionId: "conn_abc123",
|
||||
@@ -37,6 +127,7 @@ describe("relay websocket URL versioning", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "server",
|
||||
version: "1",
|
||||
@@ -55,3 +146,31 @@ describe("relay websocket URL versioning", () => {
|
||||
expect(() => normalizeRelayProtocolVersion("3")).toThrow('Relay version must be "1" or "2"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay websocket URLs", () => {
|
||||
test("uses ws for port 443 when TLS is disabled", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: false,
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.protocol).toBe("ws:");
|
||||
});
|
||||
|
||||
test("uses wss for non-443 ports when TLS is enabled", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:6767",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.protocol).toBe("wss:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,19 @@ export interface HostPortParts {
|
||||
isIpv6: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionUriParts extends HostPortParts {
|
||||
useTls: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedConnectionUri extends ConnectionUriParts {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type RelayRole = "server" | "client";
|
||||
export type RelayProtocolVersion = "1" | "2";
|
||||
|
||||
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
|
||||
export const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
|
||||
export function normalizeRelayProtocolVersion(
|
||||
value: unknown,
|
||||
@@ -73,6 +82,66 @@ export function normalizeHostPort(input: string): string {
|
||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function parseConnectionUri(input: string): ParsedConnectionUri {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Connection URI is required");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new Error("Invalid connection URI");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "tcp:") {
|
||||
throw new Error("Connection URI protocol must be tcp:");
|
||||
}
|
||||
if (!parsed.hostname) {
|
||||
throw new Error("Connection URI host is required");
|
||||
}
|
||||
if (!parsed.port) {
|
||||
throw new Error("Connection URI port is required");
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error("Connection URI userinfo is not supported");
|
||||
}
|
||||
|
||||
const isIpv6 = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]");
|
||||
const host = isIpv6 ? parsed.hostname.slice(1, -1) : parsed.hostname;
|
||||
const password = parsed.searchParams.get("password") || undefined;
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parsePort(parsed.port, "Invalid connection URI"),
|
||||
isIpv6,
|
||||
useTls: parsed.searchParams.get("ssl") === "true",
|
||||
...(password ? { password } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeConnectionUri(parts: ConnectionUriParts): string {
|
||||
return createConnectionUri(parts).toString();
|
||||
}
|
||||
|
||||
export function serializeConnectionUriForStorage(parts: ParsedConnectionUri): string {
|
||||
const url = createConnectionUri(parts);
|
||||
if (parts.password) {
|
||||
url.searchParams.set("password", parts.password);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function createConnectionUri(parts: ConnectionUriParts): URL {
|
||||
const hostPart = parts.isIpv6 ? `[${parts.host}]` : parts.host;
|
||||
const url = new URL(`tcp://${hostPart}:${parts.port}`);
|
||||
if (parts.useTls) {
|
||||
url.searchParams.set("ssl", "true");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function normalizeLoopbackToLocalhost(endpoint: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
|
||||
@@ -93,19 +162,20 @@ export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseSecureWebSocket(port: number): boolean {
|
||||
return port === 443;
|
||||
export interface WebSocketUrlOptions {
|
||||
useTls: boolean;
|
||||
}
|
||||
|
||||
export function buildDaemonWebSocketUrl(endpoint: string): string {
|
||||
export function buildDaemonWebSocketUrl(endpoint: string, opts: WebSocketUrlOptions): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const protocol = opts.useTls ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
endpoint: string;
|
||||
useTls: boolean;
|
||||
serverId: string;
|
||||
role: RelayRole;
|
||||
/**
|
||||
@@ -116,7 +186,7 @@ export function buildRelayWebSocketUrl(params: {
|
||||
version?: RelayProtocolVersion | 1 | 2;
|
||||
}): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const protocol = params.useTls ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
url.searchParams.set("serverId", params.serverId);
|
||||
@@ -128,6 +198,14 @@ export function buildRelayWebSocketUrl(params: {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean {
|
||||
try {
|
||||
return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
|
||||
const parsed = new URL(wsUrl);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const DirectTcpHostConnectionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("directTcp"),
|
||||
endpoint: z.string(),
|
||||
useTls: z.boolean().optional().default(false),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DirectTcpHostConnection = z.input<typeof DirectTcpHostConnectionSchema>;
|
||||
export type NormalizedDirectTcpHostConnection = z.output<typeof DirectTcpHostConnectionSchema>;
|
||||
Reference in New Issue
Block a user