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:
Mohamed Boudra
2026-05-06 17:25:16 +08:00
committed by GitHub
parent 6c319d05dd
commit 2e45650f22
33 changed files with 397 additions and 17 deletions
+1
View File
@@ -178,6 +178,7 @@ export default function PairScanScreen() {
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
},
{ serverId: offer.serverId },
@@ -137,6 +137,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
useTls: parsedOffer.relay.useTls,
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
},
{ serverId: parsedOffer.serverId },
@@ -198,6 +198,7 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
relay: {
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
useTls: input?.relay?.useTls ?? false,
},
};
}
@@ -1616,6 +1617,43 @@ describe("HostRuntimeStore", () => {
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 () => {
const store = new HostRuntimeStore({
deps: {
+8 -2
View File
@@ -476,7 +476,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
...base,
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId: host.serverId,
}),
e2ee: {
@@ -1414,21 +1414,25 @@ export class HostRuntimeStore {
async upsertRelayConnection(input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
}): Promise<HostProfile> {
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
const useTls = input.useTls ?? false;
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
if (!daemonPublicKeyB64) {
throw new Error("daemonPublicKeyB64 is required");
}
const explicitUseTls = input.useTls !== undefined;
return this.upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection: {
id: `relay:${relayEndpoint}`,
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
...(explicitUseTls ? { useTls } : {}),
daemonPublicKeyB64,
},
});
@@ -1438,6 +1442,7 @@ export class HostRuntimeStore {
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
});
@@ -1983,6 +1988,7 @@ export interface HostMutations {
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
}) => Promise<HostProfile>;
@@ -27,4 +27,48 @@ describe("normalizeStoredHostProfile", () => {
});
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",
});
});
});
+6 -2
View File
@@ -22,6 +22,7 @@ export interface RelayHostConnection {
id: string;
type: "relay";
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
}
@@ -73,6 +74,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
if (left.type === "relay" && right.type === "relay") {
return (
left.relayEndpoint === right.relayEndpoint &&
left.useTls === right.useTls &&
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
);
}
@@ -243,7 +245,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
if (!record) {
return null;
}
const type = typeof record.type === "string" ? record.type : null;
const type = record.type;
if (type === "directTcp") {
try {
const endpoint = normalizeLoopbackToLocalhost(
@@ -277,10 +279,12 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
).trim();
if (!daemonPublicKeyB64) return null;
const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined;
return {
id: `relay:${relayEndpoint}`,
id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
...(useTls !== undefined ? { useTls } : {}),
daemonPublicKeyB64,
};
} catch {
@@ -145,6 +145,37 @@ describe("test-daemon-connection connectToDaemon", () => {
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 () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(
@@ -110,7 +110,7 @@ export async function buildClientConfig(
...base,
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },