Files
mumh5/electron/tls-transport.ts
kibiandClaude Opus 5.5 82972438fe Add the browser version, a toolbar, description previews and hints
Browser version
- Self-hosted proxy (server/) that serves the web build and bridges WebSocket
  connections to Mumble servers over TLS, with a server allowlist or allow-any mode
- WebSocket transport and a browser platform layer; identities live in the browser
- npm run build:web, dev:web, proxy and test:e2e:web; proxy unit tests

Interface
- Toolbar next to mute and deafen: description editor, settings, expand or collapse all
- Channels with a description show a marker; resting on the row previews it
- Collapse all keeps channels with people open
- Hints (title tooltips) can be switched on in a new Accessibility settings tab
- New identities can set the username suggested when connecting
- Own user information tells the client address from the one the server sees

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:47:10 +02:00

79 lines
3.8 KiB
TypeScript

import tls from 'node:tls';
import net from 'node:net';
import { describeCert, type CertDetails } from './certs.ts';
export interface TlsHandlers {
onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[]; address: string; port: number }): void;
onData(chunk: Uint8Array): void;
onClose(reason: string): void;
}
// Opens a TLS connection to a Mumble server. Most servers use self-signed certificates,
// so verification is left to the caller (trust on first use via the sha256 fingerprint).
export interface TlsOptions {
// Custom DNS resolution (the web proxy uses it to refuse private addresses)
lookup?: net.LookupFunction;
// Announce this client address with a PROXY protocol v1 line before TLS starts, for a
// receiver such as go-mmproxy that passes it on to a server without PROXY support
proxyClient?: string;
}
// PROXY protocol v1 line. Source and destination must be the same family; an IPv4 client is
// written as a mapped address towards an IPv6 destination, anything else is announced as unknown.
export function proxyLine(client: string, clientPort: number, dest: string, destPort: number): string {
const d = dest.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
const c = client.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
if (net.isIPv4(c) && net.isIPv4(d)) return `PROXY TCP4 ${c} ${d} ${clientPort} ${destPort}\r\n`;
if (net.isIPv6(d) && net.isIP(c)) return `PROXY TCP6 ${net.isIPv4(c) ? `::ffff:${c}` : c} ${d} ${clientPort} ${destPort}\r\n`;
return 'PROXY UNKNOWN\r\n';
}
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers, opts: TlsOptions = {}) {
let closeReason = 'Connection closed';
const servername = /^[\d.:]+$/.test(host) ? undefined : host;
let socket: tls.TLSSocket;
if (opts.proxyClient) {
// The PROXY line goes first, in the clear; TLS then runs over the same connection
const raw = net.connect({ host, port, lookup: opts.lookup });
raw.once('connect', () => raw.write(proxyLine(opts.proxyClient!, raw.localPort ?? 0, raw.remoteAddress ?? '', raw.remotePort ?? port)));
raw.on('error', (e: Error) => { closeReason = e.message; socket.destroy(); });
socket = tls.connect({ socket: raw, cert, key, rejectUnauthorized: false, servername });
} else {
socket = tls.connect({ host, port, cert, key, lookup: opts.lookup, rejectUnauthorized: false, servername });
}
socket.setNoDelay(true);
socket.setKeepAlive(true, 30000);
socket.setTimeout(20000, () => {
closeReason = 'Connection timed out';
socket.destroy();
});
socket.on('secureConnect', () => {
socket.setTimeout(0);
// Leaf first, then the issuers the server sent (the root links to itself)
const chain: CertDetails[] = [];
let cur = socket.getPeerCertificate(true) as tls.DetailedPeerCertificate | undefined;
const seen = new Set<string>();
while (cur?.raw && !seen.has(cur.fingerprint256) && chain.length < 8) {
seen.add(cur.fingerprint256);
chain.push(describeCert(cur.raw));
cur = cur.issuerCertificate;
}
h.onSecure({
chain,
// The server's actual IP, so UDP voice goes to the same machine as the TCP connection
address: socket.remoteAddress ?? host,
port: socket.remotePort ?? port,
fingerprint: chain[0]?.fingerprint256 ?? '',
authorized: socket.authorized,
authError: socket.authorizationError ? String(socket.authorizationError) : null
});
});
socket.on('data', (chunk: Buffer) => h.onData(new Uint8Array(chunk)));
socket.on('error', (e: Error) => { closeReason = e.message; });
socket.on('close', () => h.onClose(closeReason));
return {
send(bytes: Uint8Array) { if (!socket.destroyed) socket.write(bytes); },
close() { closeReason = 'Disconnected'; socket.destroy(); }
};
}