mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Allow self-hosted relays to opt into wss:// (#767)
* Add relay TLS schema fields * Wire relay TLS into websocket URLs * Propagate relay TLS from pairing offers * Expose relay TLS opt-in * Mark relay TLS fallback for removal * Preserve explicit relay TLS false * Align relay TLS defaults outside config loader
This commit is contained in:
46
README.md
46
README.md
@@ -140,6 +140,52 @@ npm run typecheck
|
|||||||
|
|
||||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||||
|
|
||||||
|
### Self-hosted relay TLS
|
||||||
|
|
||||||
|
Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
|
||||||
|
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
|
||||||
|
PASEO_RELAY_USE_TLS=true \
|
||||||
|
paseo daemon start
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"daemon": {
|
||||||
|
"relay": {
|
||||||
|
"enabled": true,
|
||||||
|
"endpoint": "127.0.0.1:8080",
|
||||||
|
"publicEndpoint": "relay.example.com:443",
|
||||||
|
"useTls": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal nginx WebSocket proxy:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name relay.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ Enables remote access when the daemon is behind a firewall.
|
|||||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||||
- Pairing via QR code transfers the daemon's public key to the client
|
- Pairing via QR code transfers the daemon's public key to the client
|
||||||
|
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`
|
||||||
|
|
||||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
hostnames: true | string[],
|
hostnames: true | string[],
|
||||||
mcp: { enabled: boolean },
|
mcp: { enabled: boolean },
|
||||||
cors: { allowedOrigins: string[] },
|
cors: { allowedOrigins: string[] },
|
||||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean }
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export default function PairScanScreen() {
|
|||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
|
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
|
||||||
|
useTls: offer.relay.useTls,
|
||||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
},
|
},
|
||||||
{ serverId: offer.serverId },
|
{ serverId: offer.serverId },
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
|||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
||||||
|
useTls: parsedOffer.relay.useTls,
|
||||||
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
||||||
},
|
},
|
||||||
{ serverId: parsedOffer.serverId },
|
{ serverId: parsedOffer.serverId },
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
|
|||||||
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
|
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
|
||||||
relay: {
|
relay: {
|
||||||
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
|
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
|
||||||
|
useTls: input?.relay?.useTls ?? false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1616,6 +1617,43 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores relay TLS from a pairing offer", async () => {
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ host }) => ({
|
||||||
|
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: host.label ?? null,
|
||||||
|
}),
|
||||||
|
getClientId: async () => "cid_test_runtime",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.upsertConnectionFromOffer(
|
||||||
|
makeOffer({
|
||||||
|
relay: {
|
||||||
|
endpoint: "relay.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"tls relay",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||||
|
expect(pairedHost?.connections).toEqual([
|
||||||
|
{
|
||||||
|
id: "relay:wss:relay.example.com:443",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "relay.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
daemonPublicKeyB64: "pk_test_offer",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
|
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
...base,
|
...base,
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint: connection.relayEndpoint,
|
endpoint: connection.relayEndpoint,
|
||||||
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
}),
|
}),
|
||||||
e2ee: {
|
e2ee: {
|
||||||
@@ -1414,21 +1414,25 @@ export class HostRuntimeStore {
|
|||||||
async upsertRelayConnection(input: {
|
async upsertRelayConnection(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
relayEndpoint: string;
|
relayEndpoint: string;
|
||||||
|
useTls?: boolean;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}): Promise<HostProfile> {
|
}): Promise<HostProfile> {
|
||||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
||||||
|
const useTls = input.useTls ?? false;
|
||||||
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
|
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
|
||||||
if (!daemonPublicKeyB64) {
|
if (!daemonPublicKeyB64) {
|
||||||
throw new Error("daemonPublicKeyB64 is required");
|
throw new Error("daemonPublicKeyB64 is required");
|
||||||
}
|
}
|
||||||
|
const explicitUseTls = input.useTls !== undefined;
|
||||||
return this.upsertHostConnection({
|
return this.upsertHostConnection({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
connection: {
|
connection: {
|
||||||
id: `relay:${relayEndpoint}`,
|
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
|
||||||
type: "relay",
|
type: "relay",
|
||||||
relayEndpoint,
|
relayEndpoint,
|
||||||
|
...(explicitUseTls ? { useTls } : {}),
|
||||||
daemonPublicKeyB64,
|
daemonPublicKeyB64,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1438,6 +1442,7 @@ export class HostRuntimeStore {
|
|||||||
return this.upsertRelayConnection({
|
return this.upsertRelayConnection({
|
||||||
serverId: offer.serverId,
|
serverId: offer.serverId,
|
||||||
relayEndpoint: offer.relay.endpoint,
|
relayEndpoint: offer.relay.endpoint,
|
||||||
|
useTls: offer.relay.useTls,
|
||||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
@@ -1983,6 +1988,7 @@ export interface HostMutations {
|
|||||||
upsertRelayConnection: (input: {
|
upsertRelayConnection: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
relayEndpoint: string;
|
relayEndpoint: string;
|
||||||
|
useTls?: boolean;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) => Promise<HostProfile>;
|
}) => Promise<HostProfile>;
|
||||||
|
|||||||
@@ -27,4 +27,48 @@ describe("normalizeStoredHostProfile", () => {
|
|||||||
});
|
});
|
||||||
expect(profile?.connections[0]).not.toHaveProperty("password");
|
expect(profile?.connections[0]).not.toHaveProperty("password");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves legacy relay ids when TLS is absent", () => {
|
||||||
|
const profile = normalizeStoredHostProfile({
|
||||||
|
serverId: "srv_relay",
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "relay:relay.example.com:80",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "relay.example.com:80",
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile?.connections[0]).toEqual({
|
||||||
|
id: "relay:relay.example.com:80",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "relay.example.com:80",
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("namespaces relay ids only when TLS is true", () => {
|
||||||
|
const profile = normalizeStoredHostProfile({
|
||||||
|
serverId: "srv_relay",
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "relay:relay.example.com:443",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "relay.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile?.connections[0]).toEqual({
|
||||||
|
id: "relay:wss:relay.example.com:443",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "relay.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface RelayHostConnection {
|
|||||||
id: string;
|
id: string;
|
||||||
type: "relay";
|
type: "relay";
|
||||||
relayEndpoint: string;
|
relayEndpoint: string;
|
||||||
|
useTls?: boolean;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
|
|||||||
if (left.type === "relay" && right.type === "relay") {
|
if (left.type === "relay" && right.type === "relay") {
|
||||||
return (
|
return (
|
||||||
left.relayEndpoint === right.relayEndpoint &&
|
left.relayEndpoint === right.relayEndpoint &&
|
||||||
|
left.useTls === right.useTls &&
|
||||||
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
|
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,7 +245,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const type = typeof record.type === "string" ? record.type : null;
|
const type = record.type;
|
||||||
if (type === "directTcp") {
|
if (type === "directTcp") {
|
||||||
try {
|
try {
|
||||||
const endpoint = normalizeLoopbackToLocalhost(
|
const endpoint = normalizeLoopbackToLocalhost(
|
||||||
@@ -277,10 +279,12 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
|
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
|
||||||
).trim();
|
).trim();
|
||||||
if (!daemonPublicKeyB64) return null;
|
if (!daemonPublicKeyB64) return null;
|
||||||
|
const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined;
|
||||||
return {
|
return {
|
||||||
id: `relay:${relayEndpoint}`,
|
id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
|
||||||
type: "relay",
|
type: "relay",
|
||||||
relayEndpoint,
|
relayEndpoint,
|
||||||
|
...(useTls !== undefined ? { useTls } : {}),
|
||||||
daemonPublicKeyB64,
|
daemonPublicKeyB64,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -145,6 +145,37 @@ describe("test-daemon-connection connectToDaemon", () => {
|
|||||||
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
|
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses relay TLS from the stored connection", async () => {
|
||||||
|
const mod = await import("./test-daemon-connection");
|
||||||
|
|
||||||
|
const tlsResult = await mod.connectToDaemon(
|
||||||
|
{
|
||||||
|
id: "relay:wss:[::1]:443",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "[::1]:443",
|
||||||
|
useTls: true,
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
},
|
||||||
|
{ serverId: "srv_probe_test" },
|
||||||
|
);
|
||||||
|
await tlsResult.client.close();
|
||||||
|
|
||||||
|
const plainResult = await mod.connectToDaemon(
|
||||||
|
{
|
||||||
|
id: "relay:relay.paseo.sh:443",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
useTls: false,
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
},
|
||||||
|
{ serverId: "srv_probe_test" },
|
||||||
|
);
|
||||||
|
await plainResult.client.close();
|
||||||
|
|
||||||
|
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||||
|
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces auth rejection as an incorrect password", async () => {
|
it("surfaces auth rejection as an incorrect password", async () => {
|
||||||
const mod = await import("./test-daemon-connection");
|
const mod = await import("./test-daemon-connection");
|
||||||
daemonClientMock.setNextConnectFailure(
|
daemonClientMock.setNextConnectFailure(
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function buildClientConfig(
|
|||||||
...base,
|
...base,
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint: connection.relayEndpoint,
|
endpoint: connection.relayEndpoint,
|
||||||
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
||||||
serverId,
|
serverId,
|
||||||
}),
|
}),
|
||||||
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
||||||
|
|||||||
@@ -77,4 +77,23 @@ describe("local daemon launch supervision", () => {
|
|||||||
expectSupervisorLaunch(argv);
|
expectSupervisorLaunch(argv);
|
||||||
expect(argv).toContain("--no-mcp");
|
expect(argv).toContain("--no-mcp");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("relay TLS flag is passed to the supervised daemon", async () => {
|
||||||
|
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||||
|
|
||||||
|
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||||
|
const status = startLocalDaemonForeground({
|
||||||
|
home: "/tmp/paseo-test",
|
||||||
|
relayUseTls: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(0);
|
||||||
|
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
|
||||||
|
string,
|
||||||
|
string[],
|
||||||
|
{ env?: NodeJS.ProcessEnv },
|
||||||
|
];
|
||||||
|
expect(argv).toContain("--relay-use-tls");
|
||||||
|
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface DaemonStartOptions {
|
|||||||
home?: string;
|
home?: string;
|
||||||
foreground?: boolean;
|
foreground?: boolean;
|
||||||
relay?: boolean;
|
relay?: boolean;
|
||||||
|
relayUseTls?: boolean;
|
||||||
mcp?: boolean;
|
mcp?: boolean;
|
||||||
injectMcp?: boolean;
|
injectMcp?: boolean;
|
||||||
hostnames?: string;
|
hostnames?: string;
|
||||||
@@ -28,6 +29,9 @@ export interface LocalDaemonPidInfo {
|
|||||||
export interface LocalDaemonState {
|
export interface LocalDaemonState {
|
||||||
home: string;
|
home: string;
|
||||||
listen: string;
|
listen: string;
|
||||||
|
relayEnabled: boolean;
|
||||||
|
relayEndpoint: string;
|
||||||
|
relayUseTls: boolean;
|
||||||
logPath: string;
|
logPath: string;
|
||||||
pidPath: string;
|
pidPath: string;
|
||||||
pidInfo: LocalDaemonPidInfo | null;
|
pidInfo: LocalDaemonPidInfo | null;
|
||||||
@@ -93,6 +97,9 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
|||||||
if (options.relay === false) {
|
if (options.relay === false) {
|
||||||
args.push("--no-relay");
|
args.push("--no-relay");
|
||||||
}
|
}
|
||||||
|
if (options.relayUseTls === true) {
|
||||||
|
args.push("--relay-use-tls");
|
||||||
|
}
|
||||||
|
|
||||||
if (options.mcp === false) {
|
if (options.mcp === false) {
|
||||||
args.push("--no-mcp");
|
args.push("--no-mcp");
|
||||||
@@ -117,6 +124,9 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
|||||||
if (options.hostnames) {
|
if (options.hostnames) {
|
||||||
childEnv.PASEO_HOSTNAMES = options.hostnames;
|
childEnv.PASEO_HOSTNAMES = options.hostnames;
|
||||||
}
|
}
|
||||||
|
if (options.relayUseTls === true) {
|
||||||
|
childEnv.PASEO_RELAY_USE_TLS = "true";
|
||||||
|
}
|
||||||
return childEnv;
|
return childEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +363,9 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
|||||||
return {
|
return {
|
||||||
home,
|
home,
|
||||||
listen,
|
listen,
|
||||||
|
relayEnabled: config.relayEnabled ?? true,
|
||||||
|
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
|
||||||
|
relayUseTls: config.relayUseTls ?? false,
|
||||||
logPath,
|
logPath,
|
||||||
pidPath,
|
pidPath,
|
||||||
pidInfo,
|
pidInfo,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
|||||||
relayEnabled: config.relayEnabled,
|
relayEnabled: config.relayEnabled,
|
||||||
relayEndpoint: config.relayEndpoint,
|
relayEndpoint: config.relayEndpoint,
|
||||||
relayPublicEndpoint: config.relayPublicEndpoint,
|
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||||
|
relayUseTls: config.relayUseTls,
|
||||||
appBaseUrl: config.appBaseUrl,
|
appBaseUrl: config.appBaseUrl,
|
||||||
includeQr: true,
|
includeQr: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function startCommand(): Command {
|
|||||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||||
.option("--foreground", "Run in foreground (don't daemonize)")
|
.option("--foreground", "Run in foreground (don't daemonize)")
|
||||||
.option("--no-relay", "Disable relay connection")
|
.option("--no-relay", "Disable relay connection")
|
||||||
|
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
|
||||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||||
.option(
|
.option(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface DaemonStatus {
|
|||||||
connectedDaemon: "reachable" | "unreachable" | "not_probed";
|
connectedDaemon: "reachable" | "unreachable" | "not_probed";
|
||||||
home: string;
|
home: string;
|
||||||
listen: string;
|
listen: string;
|
||||||
|
relay: string;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
@@ -117,6 +118,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
|||||||
{ key: "Connected Daemon", value: status.connectedDaemon },
|
{ key: "Connected Daemon", value: status.connectedDaemon },
|
||||||
{ key: "Home", value: status.home },
|
{ key: "Home", value: status.home },
|
||||||
{ key: "Listen", value: status.listen },
|
{ key: "Listen", value: status.listen },
|
||||||
|
{ key: "Relay", value: status.relay },
|
||||||
{ key: "Hostname", value: status.hostname ?? "-" },
|
{ key: "Hostname", value: status.hostname ?? "-" },
|
||||||
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) },
|
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) },
|
||||||
{ key: "Started", value: status.startedAt ?? "-" },
|
{ key: "Started", value: status.startedAt ?? "-" },
|
||||||
@@ -312,6 +314,12 @@ async function resolveDaemonNodeLabel(
|
|||||||
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelayStatus(state: ReturnType<typeof resolveLocalDaemonState>): string {
|
||||||
|
if (!state.relayEnabled) return "disabled";
|
||||||
|
const scheme = state.relayUseTls ? "wss" : "ws";
|
||||||
|
return `${scheme}://${state.relayEndpoint}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type StatusResult = ListResult<StatusRow>;
|
export type StatusResult = ListResult<StatusRow>;
|
||||||
|
|
||||||
export async function runStatusCommand(
|
export async function runStatusCommand(
|
||||||
@@ -370,6 +378,7 @@ export async function runStatusCommand(
|
|||||||
connectedDaemon,
|
connectedDaemon,
|
||||||
home: state.home,
|
home: state.home,
|
||||||
listen: state.listen,
|
listen: state.listen,
|
||||||
|
relay: formatRelayStatus(state),
|
||||||
hostname: state.pidInfo?.hostname ?? null,
|
hostname: state.pidInfo?.hostname ?? null,
|
||||||
pid: state.pidInfo?.pid ?? null,
|
pid: state.pidInfo?.pid ?? null,
|
||||||
startedAt: state.pidInfo?.startedAt ?? null,
|
startedAt: state.pidInfo?.startedAt ?? null,
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
|||||||
relayEnabled: config.relayEnabled,
|
relayEnabled: config.relayEnabled,
|
||||||
relayEndpoint: config.relayEndpoint,
|
relayEndpoint: config.relayEndpoint,
|
||||||
relayPublicEndpoint: config.relayPublicEndpoint,
|
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||||
|
relayUseTls: config.relayUseTls,
|
||||||
appBaseUrl: config.appBaseUrl,
|
appBaseUrl: config.appBaseUrl,
|
||||||
includeQr: true,
|
includeQr: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ async function connectViaRelayOffer(
|
|||||||
endpoint: offer.relay.endpoint,
|
endpoint: offer.relay.endpoint,
|
||||||
serverId: offer.serverId,
|
serverId: offer.serverId,
|
||||||
role: "client",
|
role: "client",
|
||||||
useTls: shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
|
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
decrypt,
|
decrypt,
|
||||||
} from "./crypto.js";
|
} from "./crypto.js";
|
||||||
|
|
||||||
|
// This live test uses the hosted relay's real TLS endpoint. Self-hosted relay TLS
|
||||||
|
// opt-in is covered at URL-building/integration level so the local E2E does not
|
||||||
|
// need to provision trusted certificates.
|
||||||
const RELAY_BASE_URL = "wss://relay.paseo.sh";
|
const RELAY_BASE_URL = "wss://relay.paseo.sh";
|
||||||
|
|
||||||
async function withRetry<T>(
|
async function withRetry<T>(
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export interface PaseoDaemonConfig {
|
|||||||
relayEnabled?: boolean;
|
relayEnabled?: boolean;
|
||||||
relayEndpoint?: string;
|
relayEndpoint?: string;
|
||||||
relayPublicEndpoint?: string;
|
relayPublicEndpoint?: string;
|
||||||
|
relayUseTls?: boolean;
|
||||||
appBaseUrl?: string;
|
appBaseUrl?: string;
|
||||||
auth?: DaemonAuthConfig;
|
auth?: DaemonAuthConfig;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
@@ -772,6 +773,7 @@ export async function createPaseoDaemon(
|
|||||||
const relayEnabled = config.relayEnabled ?? true;
|
const relayEnabled = config.relayEnabled ?? true;
|
||||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||||
|
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
||||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||||
|
|
||||||
if (boundListenTarget.type === "tcp") {
|
if (boundListenTarget.type === "tcp") {
|
||||||
@@ -846,7 +848,7 @@ export async function createPaseoDaemon(
|
|||||||
const offer = await createConnectionOfferV2({
|
const offer = await createConnectionOfferV2({
|
||||||
serverId,
|
serverId,
|
||||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||||
relay: { endpoint: relayPublicEndpoint },
|
relay: { endpoint: relayPublicEndpoint, useTls: relayUseTls },
|
||||||
});
|
});
|
||||||
|
|
||||||
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||||
@@ -861,6 +863,7 @@ export async function createPaseoDaemon(
|
|||||||
return wsServer.attachExternalSocket(ws, metadata);
|
return wsServer.attachExternalSocket(ws, metadata);
|
||||||
},
|
},
|
||||||
relayEndpoint,
|
relayEndpoint,
|
||||||
|
relayUseTls,
|
||||||
serverId,
|
serverId,
|
||||||
daemonKeyPair: daemonKeyPair.keyPair,
|
daemonKeyPair: daemonKeyPair.keyPair,
|
||||||
});
|
});
|
||||||
|
|||||||
50
packages/server/src/server/config-relay.test.ts
Normal file
50
packages/server/src/server/config-relay.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
async function createPaseoHome(config: unknown): Promise<string> {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-relay-"));
|
||||||
|
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 relay config", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads relay TLS from env, persisted config, and hosted relay fallback", async () => {
|
||||||
|
const persistedHome = await createPaseoHome({
|
||||||
|
version: 1,
|
||||||
|
daemon: {
|
||||||
|
relay: {
|
||||||
|
endpoint: "relay.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadConfig(persistedHome, { env: {} }).relayUseTls).toBe(true);
|
||||||
|
|
||||||
|
const envHome = await createPaseoHome({
|
||||||
|
version: 1,
|
||||||
|
daemon: {
|
||||||
|
relay: {
|
||||||
|
endpoint: "relay.example.com:443",
|
||||||
|
useTls: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadConfig(envHome, { env: { PASEO_RELAY_USE_TLS: "true" } }).relayUseTls).toBe(true);
|
||||||
|
|
||||||
|
const hostedHome = await createPaseoHome({ version: 1, daemon: { relay: {} } });
|
||||||
|
expect(loadConfig(hostedHome, { env: {} }).relayUseTls).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ function normalizeLogEnv(value: string | undefined): string | undefined {
|
|||||||
export type CliConfigOverrides = Partial<{
|
export type CliConfigOverrides = Partial<{
|
||||||
listen: string;
|
listen: string;
|
||||||
relayEnabled: boolean;
|
relayEnabled: boolean;
|
||||||
|
relayUseTls: boolean;
|
||||||
mcpEnabled: boolean;
|
mcpEnabled: boolean;
|
||||||
mcpInjectIntoAgents: boolean;
|
mcpInjectIntoAgents: boolean;
|
||||||
hostnames: HostnamesConfig;
|
hostnames: HostnamesConfig;
|
||||||
@@ -139,12 +140,14 @@ interface ResolveRelayInput {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
persisted: ReturnType<typeof loadPersistedConfig>;
|
persisted: ReturnType<typeof loadPersistedConfig>;
|
||||||
cliRelayEnabled: boolean | undefined;
|
cliRelayEnabled: boolean | undefined;
|
||||||
|
cliRelayUseTls: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedRelay {
|
interface ResolvedRelay {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
publicEndpoint: string;
|
publicEndpoint: string;
|
||||||
|
useTls: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
|
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
|
||||||
@@ -161,7 +164,12 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
|
|||||||
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
|
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
|
||||||
input.persisted.daemon?.relay?.publicEndpoint ??
|
input.persisted.daemon?.relay?.publicEndpoint ??
|
||||||
endpoint;
|
endpoint;
|
||||||
return { enabled, endpoint, publicEndpoint };
|
const useTls =
|
||||||
|
input.cliRelayUseTls ??
|
||||||
|
(input.env.PASEO_RELAY_USE_TLS !== undefined
|
||||||
|
? (parseBooleanEnv(input.env.PASEO_RELAY_USE_TLS) ?? false)
|
||||||
|
: (input.persisted.daemon?.relay?.useTls ?? endpoint === DEFAULT_RELAY_ENDPOINT));
|
||||||
|
return { enabled, endpoint, publicEndpoint, useTls };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedVoiceLlm {
|
interface ResolvedVoiceLlm {
|
||||||
@@ -265,6 +273,7 @@ export function loadConfig(
|
|||||||
env,
|
env,
|
||||||
persisted,
|
persisted,
|
||||||
cliRelayEnabled: options?.cli?.relayEnabled,
|
cliRelayEnabled: options?.cli?.relayEnabled,
|
||||||
|
cliRelayUseTls: options?.cli?.relayUseTls,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { openai, speech } = resolveSpeechConfig({
|
const { openai, speech } = resolveSpeechConfig({
|
||||||
@@ -293,6 +302,7 @@ export function loadConfig(
|
|||||||
relayEnabled: relay.enabled,
|
relayEnabled: relay.enabled,
|
||||||
relayEndpoint: relay.endpoint,
|
relayEndpoint: relay.endpoint,
|
||||||
relayPublicEndpoint: relay.publicEndpoint,
|
relayPublicEndpoint: relay.publicEndpoint,
|
||||||
|
relayUseTls: relay.useTls,
|
||||||
appBaseUrl,
|
appBaseUrl,
|
||||||
auth: resolveAuthConfig(env, persisted),
|
auth: resolveAuthConfig(env, persisted),
|
||||||
openai,
|
openai,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function buildOfferEndpoints({ listenHost, port }: BuildOfferEndpointsArg
|
|||||||
export async function createConnectionOfferV2(args: {
|
export async function createConnectionOfferV2(args: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
relay: { endpoint: string };
|
relay: { endpoint: string; useTls?: boolean };
|
||||||
}): Promise<ConnectionOffer> {
|
}): Promise<ConnectionOffer> {
|
||||||
return ConnectionOfferV2Schema.parse({
|
return ConnectionOfferV2Schema.parse({
|
||||||
v: 2,
|
v: 2,
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
|||||||
if (process.argv.includes("--no-relay")) {
|
if (process.argv.includes("--no-relay")) {
|
||||||
config.relayEnabled = false;
|
config.relayEnabled = false;
|
||||||
}
|
}
|
||||||
|
if (process.argv.includes("--relay-use-tls")) {
|
||||||
|
config.relayUseTls = true;
|
||||||
|
}
|
||||||
if (process.argv.includes("--no-mcp")) {
|
if (process.argv.includes("--no-mcp")) {
|
||||||
config.mcpEnabled = false;
|
config.mcpEnabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export async function generateLocalPairingOffer(args: {
|
|||||||
relayEnabled?: boolean;
|
relayEnabled?: boolean;
|
||||||
relayEndpoint?: string;
|
relayEndpoint?: string;
|
||||||
relayPublicEndpoint?: string;
|
relayPublicEndpoint?: string;
|
||||||
|
relayUseTls?: boolean;
|
||||||
appBaseUrl?: string;
|
appBaseUrl?: string;
|
||||||
includeQr?: boolean;
|
includeQr?: boolean;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
@@ -31,13 +32,14 @@ export async function generateLocalPairingOffer(args: {
|
|||||||
|
|
||||||
const relayEndpoint = args.relayEndpoint ?? "relay.paseo.sh:443";
|
const relayEndpoint = args.relayEndpoint ?? "relay.paseo.sh:443";
|
||||||
const relayPublicEndpoint = args.relayPublicEndpoint ?? relayEndpoint;
|
const relayPublicEndpoint = args.relayPublicEndpoint ?? relayEndpoint;
|
||||||
|
const relayUseTls = args.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
||||||
const appBaseUrl = args.appBaseUrl ?? "https://app.paseo.sh";
|
const appBaseUrl = args.appBaseUrl ?? "https://app.paseo.sh";
|
||||||
const serverId = getOrCreateServerId(args.paseoHome, { logger: args.logger });
|
const serverId = getOrCreateServerId(args.paseoHome, { logger: args.logger });
|
||||||
const daemonKeyPair = await loadOrCreateDaemonKeyPair(args.paseoHome, args.logger);
|
const daemonKeyPair = await loadOrCreateDaemonKeyPair(args.paseoHome, args.logger);
|
||||||
const offer = await createConnectionOfferV2({
|
const offer = await createConnectionOfferV2({
|
||||||
serverId,
|
serverId,
|
||||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||||
relay: { endpoint: relayPublicEndpoint },
|
relay: { endpoint: relayPublicEndpoint, useTls: relayUseTls },
|
||||||
});
|
});
|
||||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ describe("PersistedConfigSchema daemon auth config", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PersistedConfigSchema daemon relay config", () => {
|
||||||
|
test("accepts optional relay TLS setting", () => {
|
||||||
|
const parsed = PersistedConfigSchema.parse({
|
||||||
|
daemon: {
|
||||||
|
relay: {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: "relay.example.com:443",
|
||||||
|
publicEndpoint: "public.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.daemon?.relay?.useTls).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PersistedConfigSchema agent provider runtime settings", () => {
|
describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||||
test("legacy append entries are skipped during migration", () => {
|
test("legacy append entries are skipped during migration", () => {
|
||||||
const parsed = PersistedConfigSchema.parse({
|
const parsed = PersistedConfigSchema.parse({
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const PersistedConfigSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
endpoint: z.string().optional(),
|
endpoint: z.string().optional(),
|
||||||
publicEndpoint: z.string().optional(),
|
publicEndpoint: z.string().optional(),
|
||||||
|
useTls: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ describe("relay-transport control lifecycle", () => {
|
|||||||
logger: logger as unknown as pino.Logger,
|
logger: logger as unknown as pino.Logger,
|
||||||
attachSocket: async () => {},
|
attachSocket: async () => {},
|
||||||
relayEndpoint: "relay.paseo.sh:443",
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
relayUseTls: true,
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
});
|
});
|
||||||
controllers.push(controller);
|
controllers.push(controller);
|
||||||
@@ -155,6 +156,7 @@ describe("relay-transport control lifecycle", () => {
|
|||||||
logger: logger as unknown as pino.Logger,
|
logger: logger as unknown as pino.Logger,
|
||||||
attachSocket: async () => {},
|
attachSocket: async () => {},
|
||||||
relayEndpoint: "relay.paseo.sh:443",
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
relayUseTls: true,
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
});
|
});
|
||||||
controllers.push(controller);
|
controllers.push(controller);
|
||||||
@@ -177,6 +179,7 @@ describe("relay-transport control lifecycle", () => {
|
|||||||
logger: logger as unknown as pino.Logger,
|
logger: logger as unknown as pino.Logger,
|
||||||
attachSocket: async () => {},
|
attachSocket: async () => {},
|
||||||
relayEndpoint: "relay.paseo.sh:443",
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
relayUseTls: true,
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
});
|
});
|
||||||
controllers.push(controller);
|
controllers.push(controller);
|
||||||
@@ -198,6 +201,7 @@ describe("relay-transport control lifecycle", () => {
|
|||||||
logger: logger as unknown as pino.Logger,
|
logger: logger as unknown as pino.Logger,
|
||||||
attachSocket,
|
attachSocket,
|
||||||
relayEndpoint: "relay.paseo.sh:443",
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
relayUseTls: true,
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
});
|
});
|
||||||
controllers.push(controller);
|
controllers.push(controller);
|
||||||
@@ -219,4 +223,24 @@ describe("relay-transport control lifecycle", () => {
|
|||||||
externalSessionKey: "session:clt_test",
|
externalSessionKey: "session:clt_test",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses relayUseTls for control and data socket URLs", () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const controller = startRelayTransport({
|
||||||
|
logger: logger as unknown as pino.Logger,
|
||||||
|
attachSocket: async () => {},
|
||||||
|
relayEndpoint: "[::1]:443",
|
||||||
|
relayUseTls: true,
|
||||||
|
serverId: "srv_test",
|
||||||
|
});
|
||||||
|
controllers.push(controller);
|
||||||
|
|
||||||
|
const control = MockWebSocket.instances[0];
|
||||||
|
control.open();
|
||||||
|
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||||
|
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||||
|
expect(MockWebSocket.instances[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,16 +8,14 @@ import {
|
|||||||
type Transport as RelayTransport,
|
type Transport as RelayTransport,
|
||||||
type KeyPair,
|
type KeyPair,
|
||||||
} from "@getpaseo/relay/e2ee";
|
} from "@getpaseo/relay/e2ee";
|
||||||
import {
|
import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||||
buildRelayWebSocketUrl,
|
|
||||||
shouldUseTlsForDefaultHostedRelay,
|
|
||||||
} from "../shared/daemon-endpoints.js";
|
|
||||||
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
||||||
|
|
||||||
interface RelayTransportOptions {
|
interface RelayTransportOptions {
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
|
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
|
||||||
relayEndpoint: string; // "host:port"
|
relayEndpoint: string; // "host:port"
|
||||||
|
relayUseTls: boolean;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
daemonKeyPair?: KeyPair;
|
daemonKeyPair?: KeyPair;
|
||||||
}
|
}
|
||||||
@@ -105,6 +103,7 @@ export function startRelayTransport({
|
|||||||
logger,
|
logger,
|
||||||
attachSocket,
|
attachSocket,
|
||||||
relayEndpoint,
|
relayEndpoint,
|
||||||
|
relayUseTls,
|
||||||
serverId,
|
serverId,
|
||||||
daemonKeyPair,
|
daemonKeyPair,
|
||||||
}: RelayTransportOptions): RelayTransportController {
|
}: RelayTransportOptions): RelayTransportController {
|
||||||
@@ -158,7 +157,7 @@ export function startRelayTransport({
|
|||||||
const connectionId = ++controlConnectionSeq;
|
const connectionId = ++controlConnectionSeq;
|
||||||
const url = buildRelayWebSocketUrl({
|
const url = buildRelayWebSocketUrl({
|
||||||
endpoint: relayEndpoint,
|
endpoint: relayEndpoint,
|
||||||
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
|
useTls: relayUseTls,
|
||||||
serverId,
|
serverId,
|
||||||
role: "server",
|
role: "server",
|
||||||
});
|
});
|
||||||
@@ -334,7 +333,7 @@ export function startRelayTransport({
|
|||||||
|
|
||||||
const url = buildRelayWebSocketUrl({
|
const url = buildRelayWebSocketUrl({
|
||||||
endpoint: relayEndpoint,
|
endpoint: relayEndpoint,
|
||||||
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
|
useTls: relayUseTls,
|
||||||
serverId,
|
serverId,
|
||||||
role: "server",
|
role: "server",
|
||||||
connectionId,
|
connectionId,
|
||||||
|
|||||||
@@ -40,6 +40,39 @@ describe("connection offer", () => {
|
|||||||
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer);
|
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults relay TLS to false when absent", () => {
|
||||||
|
expect(
|
||||||
|
ConnectionOfferSchema.parse({
|
||||||
|
v: 2,
|
||||||
|
serverId: "server-123",
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
relay: { endpoint: "relay.example.com:80" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
v: 2,
|
||||||
|
serverId: "server-123",
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
relay: { endpoint: "relay.example.com:80", useTls: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips relay TLS in offers without rejecting extra relay fields", () => {
|
||||||
|
const offer = ConnectionOfferSchema.parse({
|
||||||
|
v: 2,
|
||||||
|
serverId: "server-123",
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
relay: { endpoint: "relay.example.com:443", useTls: true, extra: "future" },
|
||||||
|
});
|
||||||
|
const encoded = encodeBase64UrlNoPadUtf8(JSON.stringify(offer));
|
||||||
|
|
||||||
|
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual({
|
||||||
|
v: 2,
|
||||||
|
serverId: "server-123",
|
||||||
|
daemonPublicKeyB64: "pubkey",
|
||||||
|
relay: { endpoint: "relay.example.com:443", useTls: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null when the URL has no offer fragment", () => {
|
it("returns null when the URL has no offer fragment", () => {
|
||||||
expect(parseConnectionOfferFromUrl("https://app.paseo.sh/pair")).toBeNull();
|
expect(parseConnectionOfferFromUrl("https://app.paseo.sh/pair")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const ConnectionOfferV2Schema = z.object({
|
|||||||
daemonPublicKeyB64: z.string().min(1),
|
daemonPublicKeyB64: z.string().min(1),
|
||||||
relay: z.object({
|
relay: z.object({
|
||||||
endpoint: z.string().min(1),
|
endpoint: z.string().min(1),
|
||||||
|
useTls: z.boolean().optional().default(false),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildDaemonWebSocketUrl,
|
buildDaemonWebSocketUrl,
|
||||||
buildRelayWebSocketUrl,
|
buildRelayWebSocketUrl,
|
||||||
CURRENT_RELAY_PROTOCOL_VERSION,
|
CURRENT_RELAY_PROTOCOL_VERSION,
|
||||||
|
extractHostPortFromWebSocketUrl,
|
||||||
normalizeRelayProtocolVersion,
|
normalizeRelayProtocolVersion,
|
||||||
parseConnectionUri,
|
parseConnectionUri,
|
||||||
serializeConnectionUri,
|
serializeConnectionUri,
|
||||||
@@ -173,4 +174,17 @@ describe("relay websocket URLs", () => {
|
|||||||
|
|
||||||
expect(url.protocol).toBe("wss:");
|
expect(url.protocol).toBe("wss:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("round-trips IPv6 relay endpoints with TLS enabled", () => {
|
||||||
|
const wsUrl = buildRelayWebSocketUrl({
|
||||||
|
endpoint: "[::1]:443",
|
||||||
|
useTls: true,
|
||||||
|
serverId: "srv_test",
|
||||||
|
role: "client",
|
||||||
|
});
|
||||||
|
const url = new URL(wsUrl);
|
||||||
|
|
||||||
|
expect(url.protocol).toBe("wss:");
|
||||||
|
expect(extractHostPortFromWebSocketUrl(wsUrl)).toBe("[::1]:443");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,6 +198,10 @@ export function buildRelayWebSocketUrl(params: {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Migration fallback for stored relay connections/offers that predate
|
||||||
|
* explicit relay useTls. Delete after two release cycles.
|
||||||
|
*/
|
||||||
export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean {
|
export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean {
|
||||||
try {
|
try {
|
||||||
return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT;
|
return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT;
|
||||||
|
|||||||
Reference in New Issue
Block a user