diff --git a/.gitignore b/.gitignore index 2aa68c9..95533f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ dist-web/ +dist-proxy/ dist-electron/ release/ *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 0640877..543a0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Added +- Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README. - Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files. - Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed. - Per-person volume slider (0 to 300%) directly in the user context menu. Double-click resets it to 100%. diff --git a/CLAUDE.md b/CLAUDE.md index 503fa0a..9759383 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ ## Project overview -mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. A browser build (through a self-hosted WebSocket proxy with a server allowlist) is planned. +mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. There is also a browser build that connects through a self-hosted WebSocket proxy with a server allowlist (`server/`). File sharing uploads to a f0ckm instance (`../f0ckm`, `src/chat_upload_handler.mjs` there) with an upload-only key; links are posted in chat. @@ -22,6 +22,10 @@ npm test # unit tests; server tests too when MUMBLE_TEST_HOST is s npm run build # renderer to dist/, main+preload to dist-electron/ (esbuild) npm run test:e2e # drives the built app (needs npm run build first) npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts) +npm run build:web # browser build to dist-web/, proxy bundle to dist-proxy/proxy.mjs +npm run dev:web # browser build with hot reload: starts the proxy (any server allowed) and Vite +npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required) +npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first) npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/ ``` @@ -38,7 +42,9 @@ A reused test server keeps registrations and channels from earlier runs; tests m ## Architecture - `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox). +- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. - `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here. +- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`. - `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text. - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store). diff --git a/README.md b/README.md index e1be241..0124713 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,81 @@ Installers are written to `release/`. The Windows installer also builds on Linux --- +## Browser version + +mumh5 also runs in a browser. Browsers cannot open the TLS connection Mumble uses or present a client certificate, so a small proxy you host yourself does that part: it serves the web app and bridges each browser connection to a Mumble server. + +```bash +npm ci +npm run build:web # web app to dist-web/, proxy to dist-proxy/proxy.mjs +MUMH5_SERVERS="mumble.example.com=My server" node dist-proxy/proxy.mjs +``` + +Then open `http://127.0.0.1:8080`. For development, `npm run dev:web` starts the proxy and a hot-reloading page together, with any server allowed. To deploy, copy `dist-web/` and `dist-proxy/` next to each other on the server (Node 22 or newer, no `node_modules` needed) and put a reverse proxy with HTTPS in front that forwards WebSocket upgrades. Browsers only allow the microphone on HTTPS pages (or on localhost). + +| Variable | Default | Meaning | +| --- | --- | --- | +| `MUMH5_SERVERS` | none | Mumble servers people may connect to: `host[:port][=Label]`, comma-separated. Required unless `MUMH5_ALLOW_ANY=1` | +| `MUMH5_ALLOW_ANY` | off | Allow any server on the public internet. Private and loopback addresses stay blocked, except servers listed in `MUMH5_SERVERS` (for a Mumble server on the same machine: `MUMH5_SERVERS=localhost`), or all of them with `MUMH5_ALLOW_PRIVATE=1` | +| `MUMH5_PORT`, `MUMH5_BIND` | `8080`, `127.0.0.1` | Where the proxy listens | +| `MUMH5_ORIGINS` | same host | Origins allowed to use the API, comma-separated, when the page is hosted elsewhere | +| `MUMH5_TRUST_PROXY` | off | Take client addresses from `X-Forwarded-For` (set this behind a reverse proxy) | +| `MUMH5_SEND_PROXY` | off | Announce each visitor's address to the server with the PROXY protocol (see below). Breaks connections to a plain Mumble server | +| `MUMH5_STATIC` | `../dist-web` | Folder with the web build | +| `MUMH5_MAX_CONNECTIONS`, `MUMH5_MAX_PER_ADDRESS` | `200`, `8` | Connection limits, in total and per client address | + +Example for nginx. `Host` must be passed on unchanged, because the proxy only accepts requests whose origin matches it. `X-Forwarded-For` carries the visitor's address; start the proxy with `MUMH5_TRUST_PROXY=1` so its per-address limits use it. + +```nginx +map $http_upgrade $connection_upgrade { default upgrade; '' close; } + +server { + listen 443 ssl; + server_name voice.example.com; + # ssl_certificate and ssl_certificate_key go here + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + proxy_buffering off; + } +} +``` + +By default the Mumble server sees every browser user coming from the proxy's address, and that is the address other people and admins get in a user's information. No nginx setting changes that. Bans by IP on the Mumble server would then hit all users of the proxy; ban by certificate instead. In your own information mumh5 shows the address the proxy saw for you, next to the one the server sees. + +#### Showing the Mumble server the visitor's real address + +Mumble does not understand the PROXY protocol ([mumble#4769](https://github.com/mumble-voip/mumble/issues/4769)), so the only way is to make the connection to it really come from the visitor's address. [go-mmproxy](https://github.com/path-network/go-mmproxy) does this on Linux: it accepts a connection that starts with a PROXY line and opens the connection to the server with that address as the source. It has to run on the same machine as the Mumble server, as root or with `CAP_NET_ADMIN`, with routing rules that send the server's replies back to it: + +```bash +# on the Mumble server's machine +ip rule add from 127.0.0.1/8 iif lo table 123 +ip route add local 0.0.0.0/0 dev lo table 123 +ip -6 rule add from ::1/128 iif lo table 123 +ip -6 route add local ::/0 dev lo table 123 +go-mmproxy -l 127.0.0.1:64750 -4 127.0.0.1:64738 -6 "[::1]:64738" + +# the mumh5 proxy then connects to go-mmproxy instead of Mumble +MUMH5_SEND_PROXY=1 MUMH5_TRUST_PROXY=1 MUMH5_SERVERS="127.0.0.1:64750=My server" node dist-proxy/proxy.mjs +``` + +The routing rules and options are go-mmproxy's; check its README for your system. mumh5's side (sending the PROXY line) is covered by tests, the go-mmproxy setup itself has not been tested with mumh5. People on the desktop client keep connecting to Mumble directly. + +What is different from the desktop app: + +- **The proxy is trusted.** Your certificate and its private key are kept in the browser's storage and sent to the proxy on every connect, and everything you send, including the server password, passes through it. Whoever runs the proxy could act as you. Use a proxy you run yourself or trust. +- **Voice goes over TCP.** Browsers have no UDP sockets, so voice uses Mumble's TCP tunnel through the proxy. It works, with more delay on lossy connections. +- **Clearing the browser's data removes your identity.** Download a backup; the same `.p12` file works in the desktop app and in Mumble. +- **Not available:** the public server browser, link previews fetched by your own computer, the tray icon, importing the desktop Mumble certificate automatically. +- Voice needs a browser with WebCodecs audio. It is tested in Chromium only. + ## File sharing with f0ckm Mumble can only carry small inline images. mumh5 can share any file by uploading it to a f0ckm instance (a self-hosted imageboard by the same author) and posting the link. Other mumh5 users get inline players; desktop Mumble users get a normal link plus a small preview. @@ -167,7 +242,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim ## Privacy and security -- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. +- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. (The browser version is different, see above.) - **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you. - **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube players are off by default and click-to-play in privacy mode when enabled. - **Strict content security policy** in the app, and all message HTML is sanitized. @@ -179,8 +254,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim - System-wide push to talk - Whisper and shout - Rich chat between mumh5 users: replies, reactions, edits, typing indicators -- Video and screen sharing between mumh5 users -- A browser version, through a small self-hosted WebSocket proxy +- Screen sharing between mumh5 users in a channel, with sound (one application or the whole system, also on Linux through PipeWire). Anyone can start a stream; no extra key, your Mumble certificate is your identity. Streams go directly between clients with WebRTC, set up through the Mumble server, with an optional self-hosted relay for many viewers - Release builds for all platforms --- @@ -201,6 +275,7 @@ docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \ MUMBLE_TEST_HOST=localhost:64739 npm test npm run build MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e +npm run build:web && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:web # browser build through the proxy ``` On Linux the end-to-end test forces X11, so it can run on a virtual display: `Xvfb :99 & DISPLAY=:99 npm run test:e2e`. See [CLAUDE.md](CLAUDE.md) for the architecture and project conventions. diff --git a/docs/screenshots/main.png b/docs/screenshots/main.png index 2ab9231..ae72e88 100644 Binary files a/docs/screenshots/main.png and b/docs/screenshots/main.png differ diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png index eef06eb..1d616e7 100644 Binary files a/docs/screenshots/mobile.png and b/docs/screenshots/mobile.png differ diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png index 09b1d81..64b93f3 100644 Binary files a/docs/screenshots/profile.png and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/settings-appearance.png b/docs/screenshots/settings-appearance.png index cfbb518..0e464e3 100644 Binary files a/docs/screenshots/settings-appearance.png and b/docs/screenshots/settings-appearance.png differ diff --git a/docs/screenshots/theme-frost.png b/docs/screenshots/theme-frost.png index dcfa445..623b319 100644 Binary files a/docs/screenshots/theme-frost.png and b/docs/screenshots/theme-frost.png differ diff --git a/docs/screenshots/theme-light.png b/docs/screenshots/theme-light.png index 1e30c40..27aa522 100644 Binary files a/docs/screenshots/theme-light.png and b/docs/screenshots/theme-light.png differ diff --git a/docs/screenshots/theme-midnight.png b/docs/screenshots/theme-midnight.png index 9056b28..b6681c9 100644 Binary files a/docs/screenshots/theme-midnight.png and b/docs/screenshots/theme-midnight.png differ diff --git a/docs/screenshots/theme-win95.png b/docs/screenshots/theme-win95.png index 7686f2d..6fc906b 100644 Binary files a/docs/screenshots/theme-win95.png and b/docs/screenshots/theme-win95.png differ diff --git a/docs/screenshots/voice-settings.png b/docs/screenshots/voice-settings.png index 4bbc3c8..67c55c4 100644 Binary files a/docs/screenshots/voice-settings.png and b/docs/screenshots/voice-settings.png differ diff --git a/electron/tls-transport.ts b/electron/tls-transport.ts index d1c93ac..107cf67 100644 --- a/electron/tls-transport.ts +++ b/electron/tls-transport.ts @@ -1,4 +1,5 @@ import tls from 'node:tls'; +import net from 'node:net'; import { describeCert, type CertDetails } from './certs.ts'; export interface TlsHandlers { @@ -9,13 +10,37 @@ export interface TlsHandlers { // 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 function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers) { +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 socket = tls.connect({ - host, port, cert, key, - rejectUnauthorized: false, - servername: /^[\d.:]+$/.test(host) ? undefined : host - }); + 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, () => { diff --git a/package-lock.json b/package-lock.json index 4bcc2fe..ce52b6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@tsconfig/svelte": "^5.0.8", "@types/node": "^26.6.3", "@types/node-forge": "^1.3.14", + "@types/ws": "^8.18.2", "electron": "^44.5.1", "electron-builder": "^26.15.3", "esbuild": "^0.28.2", @@ -26,7 +27,8 @@ "svelte": "^5.57.1", "svelte-check": "^4.7.6", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.1", + "ws": "^8.22.0" } }, "node_modules/@babel/helper-string-parser": { @@ -1513,6 +1515,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", @@ -5736,6 +5748,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 890a4fd..0504d72 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "scripts": { "dev": "node scripts/dev.mjs", "build": "vite build && node scripts/build-electron.mjs", - "build:web": "vite build --mode web --outDir dist-web", + "build:web": "vite build --mode web --outDir dist-web && node scripts/build-proxy.mjs", + "dev:web": "node scripts/dev-web.mjs", + "proxy": "node server/main.ts", "check": "svelte-check --tsconfig ./tsconfig.json", "test": "node --test test/*.test.ts", "dist": "npm run build && electron-builder", @@ -17,6 +19,7 @@ "dist:win": "npm run build && electron-builder --win", "dist:mac": "npm run build && electron-builder --mac", "test:e2e": "node test/e2e/app.e2e.ts", + "test:e2e:web": "node test/e2e/web.e2e.ts", "proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js", "screenshots": "node test/e2e/screenshots.ts" }, @@ -90,6 +93,7 @@ "@tsconfig/svelte": "^5.0.8", "@types/node": "^26.6.3", "@types/node-forge": "^1.3.14", + "@types/ws": "^8.18.2", "electron": "^44.5.1", "electron-builder": "^26.15.3", "esbuild": "^0.28.2", @@ -98,7 +102,8 @@ "svelte": "^5.57.1", "svelte-check": "^4.7.6", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.1", + "ws": "^8.22.0" }, "dependencies": { "dompurify": "^3.4.16", diff --git a/scripts/build-proxy.mjs b/scripts/build-proxy.mjs new file mode 100644 index 0000000..f4d2315 --- /dev/null +++ b/scripts/build-proxy.mjs @@ -0,0 +1,15 @@ +import * as esbuild from 'esbuild'; + +// Bundles the web proxy to one file, dist-proxy/proxy.mjs, that runs with plain Node. +await esbuild.build({ + entryPoints: { proxy: 'server/main.ts' }, + outdir: 'dist-proxy', + outExtension: { '.js': '.mjs' }, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + // Bundled CommonJS dependencies still call require() + banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" }, + logLevel: 'info' +}); diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs new file mode 100644 index 0000000..a1f599b --- /dev/null +++ b/scripts/dev-web.mjs @@ -0,0 +1,19 @@ +import { spawn } from 'node:child_process'; +import { createServer } from 'vite'; + +// The browser build for development: the web proxy on 127.0.0.1:8080 and the Vite dev server, +// which forwards /api to it (vite.config.ts). Without MUMH5_SERVERS the proxy allows any +// server, private addresses included; it only listens on this machine. +const env = { ...process.env, MUMH5_PORT: '8080', MUMH5_BIND: '127.0.0.1' }; +if (!env.MUMH5_SERVERS && !env.MUMH5_ALLOW_ANY) Object.assign(env, { MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1' }); + +const proxy = spawn(process.execPath, ['server/main.ts'], { stdio: 'inherit', env }); +proxy.on('exit', code => { console.error(`The proxy stopped (exit code ${code}). Is port 8080 in use?`); process.exit(1); }); + +const server = await createServer({ mode: 'web' }); +await server.listen(); +server.printUrls(); + +const stop = () => { proxy.removeAllListeners('exit'); proxy.kill(); server.close().finally(() => process.exit(0)); }; +process.on('SIGINT', stop); +process.on('SIGTERM', stop); diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000..ddde361 --- /dev/null +++ b/server/main.ts @@ -0,0 +1,19 @@ +// Starts the web proxy from environment variables (see the README, "Browser version"). +import path from 'node:path'; +import { existsSync } from 'node:fs'; +import { configFromEnv, startProxy } from './proxy.ts'; + +const config = configFromEnv(process.env); +// The web build sits next to this script's folder, both in the repository and when bundled +config.staticDir ??= [path.resolve(import.meta.dirname, '../dist-web')].find(d => existsSync(path.join(d, 'index.html'))) ?? null; + +try { + const { port } = await startProxy(config); + console.log(`mumh5 proxy listening on http://${config.bind}:${port}`); + console.log(config.allowAny ? `Allowed servers: any${config.allowPrivate ? ', private addresses included' : ' public address'}` : `Allowed servers: ${config.servers.map(s => `${s.host}:${s.port}`).join(', ')}`); + if (config.sendProxy) console.log('Announcing client addresses with the PROXY protocol; the allowed servers must expect it'); + console.log(config.staticDir ? `Serving the web app from ${config.staticDir}` : 'No web build found (npm run build:web); serving the API only'); +} catch (e) { + console.error((e as Error).message); + process.exit(1); +} diff --git a/server/proxy.ts b/server/proxy.ts new file mode 100644 index 0000000..1e872ae --- /dev/null +++ b/server/proxy.ts @@ -0,0 +1,318 @@ +// Web proxy for the browser build: serves the web app and bridges WebSocket connections to +// Mumble servers over TLS. Browsers cannot open TLS sockets or present a client certificate, +// so the proxy does both. It only connects to the servers on its allowlist. +import http from 'node:http'; +import dns from 'node:dns'; +import net from 'node:net'; +import path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { WebSocketServer, type WebSocket } from 'ws'; +import { openTls } from '../electron/tls-transport.ts'; +import { generateIdentity, identityFromP12, identityToP12, certCommonName } from '../electron/identity.ts'; +import { describeCert } from '../electron/certs.ts'; + +export interface AllowedServer { host: string; port: number; label: string } + +export interface ProxyConfig { + port: number; + bind: string; + servers: AllowedServer[]; + // Connect anywhere on the public internet instead of only to `servers` + allowAny: boolean; + // With allowAny: also allow loopback and private addresses + allowPrivate: boolean; + // Allowed Origin headers; empty means the origin must match the Host header, '*' allows all + origins: string[]; + // Take the client address from X-Forwarded-For (behind a reverse proxy) + trustProxy: boolean; + // Start every server connection with a PROXY protocol line naming the client's address. + // Only for servers behind something that understands it (go-mmproxy); plain Mumble does not. + sendProxy: boolean; + staticDir: string | null; + maxConnections: number; + maxPerAddress: number; +} + +export const defaults: ProxyConfig = { + port: 8080, bind: '127.0.0.1', servers: [], allowAny: false, allowPrivate: false, origins: [], + trustProxy: false, sendProxy: false, staticDir: null, maxConnections: 200, maxPerAddress: 8 +}; + +// "host", "host:port", "[v6]:port", each optionally followed by "=Label" +export function parseServers(list: string): AllowedServer[] { + return list.split(',').map(s => s.trim()).filter(Boolean).map(entry => { + const eq = entry.indexOf('='); + const addr = eq === -1 ? entry : entry.slice(0, eq); + const label = eq === -1 ? '' : entry.slice(eq + 1).trim(); + const m = /^\[([^\]]+)\](?::(\d+))?$/.exec(addr) ?? /^([^:]+)(?::(\d+))?$/.exec(addr); + if (!m) throw new Error(`Invalid server "${entry}"`); + const host = m[1].trim().toLowerCase(); + return { host, port: Number(m[2] ?? 64738), label: label || host }; + }); +} + +export function configFromEnv(env: NodeJS.ProcessEnv): ProxyConfig { + const on = (v?: string) => v === '1' || v === 'true'; + return { + ...defaults, + port: Number(env.MUMH5_PORT ?? defaults.port), + bind: env.MUMH5_BIND ?? defaults.bind, + servers: parseServers(env.MUMH5_SERVERS ?? ''), + allowAny: on(env.MUMH5_ALLOW_ANY), + allowPrivate: on(env.MUMH5_ALLOW_PRIVATE), + origins: (env.MUMH5_ORIGINS ?? '').split(',').map(s => s.trim()).filter(Boolean), + trustProxy: on(env.MUMH5_TRUST_PROXY), + sendProxy: on(env.MUMH5_SEND_PROXY), + staticDir: env.MUMH5_STATIC ?? null, + maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections), + maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress) + }; +} + +// Loopback, private, link-local and other addresses that are not on the public internet +export function isPrivateAddress(address: string): boolean { + if (net.isIPv4(address)) { + const [a, b] = address.split('.').map(Number); + return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224; + } + const v6 = address.toLowerCase(); + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6); + if (mapped) return isPrivateAddress(mapped[1]); + return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || /^ff/.test(v6); +} + +// DNS lookup that fails for private addresses, so a public name cannot point the proxy inward +const publicLookup: net.LookupFunction = (hostname, options, callback) => { + // With `all` the result is a list of addresses, otherwise one address string + (dns.lookup as Function)(hostname, options, (err: Error | null, result: string | dns.LookupAddress[], family?: number) => { + if (err) return (callback as Function)(err); + const list = Array.isArray(result) ? result : [{ address: result, family }]; + if (list.some(r => isPrivateAddress(r.address))) return (callback as Function)(new Error('This proxy does not connect to private addresses')); + (callback as Function)(null, result, family); + }); +}; + +const TYPES: Record = { + '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.jpg': 'image/jpeg', + '.webp': 'image/webp', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.wasm': 'application/wasm' +}; + +const MAX_BODY = 256 * 1024; +// Mumble frames are at most 8 MiB; a WebSocket message carries a chunk of that stream +const MAX_WS_MESSAGE = 8 * 1024 * 1024 + 64; +const MAX_BUFFERED = 32 * 1024 * 1024; + +class HttpError extends Error { + status: number; + constructor(status: number, message: string) { super(message); this.status = status; } +} + +export async function startProxy(config: ProxyConfig): Promise<{ port: number; close(): Promise }> { + if (!config.allowAny && !config.servers.length) { + throw new Error('No servers allowed. Set MUMH5_SERVERS=host[:port][=Label],... or MUMH5_ALLOW_ANY=1.'); + } + const staticDir = config.staticDir ? path.resolve(config.staticDir) : null; + const perAddress = new Map(); + // Identity requests per address in the current minute; key generation is the costly part + const identityUse = new Map(); + const sweep = setInterval(() => identityUse.clear(), 60000); + sweep.unref(); + + const addressOf = (req: http.IncomingMessage): string => { + // The last entry is the one our own reverse proxy added; earlier ones come from the client and can be made up + const fwd = config.trustProxy ? String(req.headers['x-forwarded-for'] ?? '').split(',').at(-1)!.trim() : ''; + return fwd || req.socket.remoteAddress || 'unknown'; + }; + + const originOk = (req: http.IncomingMessage): boolean => { + if (config.origins.includes('*')) return true; + const origin = req.headers.origin; + if (!origin) return false; + if (config.origins.length) return config.origins.includes(origin); + try { return new URL(origin).host === req.headers.host; } catch { return false; } + }; + + const listed = (host: string, port: number): boolean => config.servers.some(s => s.host === host.toLowerCase() && s.port === port); + const allowed = (host: string, port: number): boolean => config.allowAny || listed(host, port); + + async function readJson(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > MAX_BODY) throw new HttpError(413, 'Request too large'); + chunks.push(chunk as Buffer); + } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'Invalid JSON'); } + } + + const withCert = (id: { certPem: string; keyPem: string; fingerprint: string }) => + ({ ...id, cert: describeCert(id.certPem), commonName: certCommonName(id.certPem) }); + + async function api(req: http.IncomingMessage, route: string): Promise { + if (route === 'config' && req.method === 'GET') { + return { servers: config.servers, any: config.allowAny }; + } + if (req.method !== 'POST') throw new HttpError(404, 'Not found'); + if (!originOk(req)) throw new HttpError(403, 'Origin not allowed'); + const body = await readJson(req); + if (route.startsWith('identity/')) { + const addr = addressOf(req); + const used = (identityUse.get(addr) ?? 0) + 1; + identityUse.set(addr, used); + if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute'); + } + switch (route) { + // Nothing is stored here: the browser keeps its identities and sends one along when it connects + case 'identity/create': + return withCert(generateIdentity(String(body.name ?? '').slice(0, 128), String(body.email ?? '').slice(0, 128))); + case 'identity/import': + return withCert(identityFromP12(new Uint8Array(Buffer.from(String(body.p12 ?? ''), 'base64')), String(body.password ?? ''))); + case 'identity/export': { + const id = { certPem: String(body.certPem), keyPem: String(body.keyPem), fingerprint: '' }; + return { p12: Buffer.from(identityToP12(id, String(body.password ?? ''), String(body.name ?? 'Identity').slice(0, 128))).toString('base64') }; + } + case 'certs/describe': + return (Array.isArray(body.ders) ? body.ders.slice(0, 8) : []).flatMap((d: unknown) => { + try { return [describeCert(Buffer.from(String(d), 'base64'))]; } catch { return []; } + }); + } + throw new HttpError(404, 'Not found'); + } + + async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): Promise { + if (!staticDir || (req.method !== 'GET' && req.method !== 'HEAD')) throw new HttpError(404, 'Not found'); + let rel: string; + try { rel = decodeURIComponent(pathname); } catch { throw new HttpError(400, 'Bad path'); } + let file = path.join(staticDir, path.normalize('/' + rel)); + if (file !== staticDir && !file.startsWith(staticDir + path.sep)) throw new HttpError(404, 'Not found'); + let data: Buffer; + try { + if ((await fs.stat(file)).isDirectory()) file = path.join(file, 'index.html'); + data = await fs.readFile(file); + } catch { + throw new HttpError(404, 'Not found'); + } + const html = file.endsWith('.html'); + res.writeHead(200, { + 'Content-Type': TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', + 'Content-Length': data.length, + // Built assets carry a content hash in their name; the page itself must stay fresh + 'Cache-Control': html ? 'no-cache' : 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY' + }); + res.end(req.method === 'HEAD' ? undefined : data); + } + + const server = http.createServer((req, res) => { + const pathname = new URL(req.url ?? '/', 'http://x').pathname; + const work = pathname.startsWith('/api/') + ? api(req, pathname.slice(5)).then(result => { + const data = Buffer.from(JSON.stringify(result)); + res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); + res.end(data); + }) + : serveStatic(req, res, pathname); + work.catch((e: Error) => { + const status = e instanceof HttpError ? e.status : 400; + if (res.headersSent) return res.destroy(); + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ error: e.message })); + }); + }); + + const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_MESSAGE, perMessageDeflate: false }); + + server.on('upgrade', (req, socket, head) => { + const refuse = (status: string) => { socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); socket.destroy(); }; + if (new URL(req.url ?? '/', 'http://x').pathname !== '/api/connect') return refuse('404 Not Found'); + if (!originOk(req)) return refuse('403 Forbidden'); + const addr = addressOf(req); + if (wss.clients.size >= config.maxConnections || (perAddress.get(addr) ?? 0) >= config.maxPerAddress) return refuse('503 Service Unavailable'); + wss.handleUpgrade(req, socket, head, ws => bridge(ws, addr)); + }); + + // One WebSocket is one Mumble connection. The first message names the server and carries the + // client certificate; after that binary messages are the raw Mumble TCP stream in both directions. + function bridge(ws: WebSocket, addr: string): void { + perAddress.set(addr, (perAddress.get(addr) ?? 0) + 1); + const clientAddress = addr.replace(/^::ffff:/, ''); + let conn: ReturnType | null = null; + let done = false; + const finish = (reason: string) => { + if (done) return; + done = true; + clearTimeout(hello); + conn?.close(); + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type: 'close', reason })); + ws.close(1000); + } + }; + const hello = setTimeout(() => finish('No connection request received'), 10000); + + ws.on('message', (data, isBinary) => { + if (conn) { + if (isBinary) conn.send(data as Buffer); + return; + } + if (isBinary) return finish('Expected a connection request'); + let req: any; + try { req = JSON.parse(data.toString()); } catch { return finish('Invalid connection request'); } + const host = String(req.host ?? '').trim(); + const port = Number(req.port) || 64738; + if (!host || host.length > 255 || port < 1 || port > 65535 || !allowed(host, port)) { + return finish(`This proxy does not allow connecting to ${host}:${port}`); + } + // Servers the operator listed are trusted as they are, also on localhost or a private network + const guard = config.allowAny && !config.allowPrivate && !listed(host, port); + if (guard && net.isIP(host) && isPrivateAddress(host)) return finish('This proxy does not connect to private addresses'); + clearTimeout(hello); + conn = openTls(host, port, String(req.certPem ?? ''), String(req.keyPem ?? ''), { + onSecure: info => { + if (ws.readyState !== ws.OPEN) return; + // The server's address stays here; the browser has no use for it without UDP + ws.send(JSON.stringify({ type: 'secure', info: { + fingerprint: info.fingerprint, authorized: info.authorized, authError: info.authError, chain: info.chain, + // Mumble only ever sees this proxy's address; tell the browser its own so the app can show it + clientAddress + } })); + }, + onData: chunk => { + if (ws.readyState !== ws.OPEN) return; + // A browser that stopped reading must not grow our memory without bound + if (ws.bufferedAmount > MAX_BUFFERED) return finish('Connection too slow'); + ws.send(chunk, { binary: true }); + }, + onClose: reason => finish(reason) + }, { lookup: guard ? publicLookup : undefined, proxyClient: config.sendProxy ? clientAddress : undefined }); + }); + ws.on('close', () => { + done = true; + clearTimeout(hello); + conn?.close(); + const left = (perAddress.get(addr) ?? 1) - 1; + if (left > 0) perAddress.set(addr, left); else perAddress.delete(addr); + }); + ws.on('error', () => ws.terminate()); + } + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.port, config.bind, resolve); + }); + return { + port: (server.address() as net.AddressInfo).port, + close: () => new Promise(resolve => { + clearInterval(sweep); + for (const ws of wss.clients) ws.terminate(); + wss.close(); + server.close(() => resolve()); + server.closeAllConnections(); + }) + }; +} diff --git a/src/core/transport.ts b/src/core/transport.ts index b0f8b22..400ef63 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -51,4 +51,7 @@ export interface ServerCertInfo { authorized: boolean; // true if it chains to a trusted CA authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT chain: CertDetails[]; // leaf first + // Browser build only: this client's address as the web proxy saw it. The Mumble server + // sees the proxy's address instead and cannot be told the original one. + clientAddress?: string; } diff --git a/src/core/ws-transport.ts b/src/core/ws-transport.ts new file mode 100644 index 0000000..c3a7b3c --- /dev/null +++ b/src/core/ws-transport.ts @@ -0,0 +1,50 @@ +import type { Transport, ServerCertInfo } from './transport.ts'; + +// Transport through the mumh5 web proxy: one WebSocket per Mumble connection. The proxy opens +// the TLS socket and presents the client certificate, since a browser can do neither. +// `secure` resolves once the proxy has finished the TLS handshake, before any Mumble data is +// sent, so the caller can check the server certificate before sending a password. +// There is no UDP here; voice goes through the TCP tunnel. +export class WebSocketTransport implements Transport { + onData: ((chunk: Uint8Array) => void) | null = null; + onClose: ((reason: string) => void) | null = null; + readonly secure: Promise; + private ws: WebSocket; + + constructor(url: string, target: { host: string; port: number }, identity: { certPem: string; keyPem: string }) { + let resolveSecure!: (i: ServerCertInfo) => void; + let rejectSecure!: (e: Error) => void; + this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; }); + // Callers that never await `secure` still get onClose + this.secure.catch(() => {}); + let reason = ''; + let opened = false; + const ws = new WebSocket(url); + this.ws = ws; + ws.binaryType = 'arraybuffer'; + ws.onopen = () => { + opened = true; + ws.send(JSON.stringify({ host: target.host, port: target.port, certPem: identity.certPem, keyPem: identity.keyPem })); + }; + ws.onmessage = e => { + if (typeof e.data !== 'string') return this.onData?.(new Uint8Array(e.data as ArrayBuffer)); + let msg: any; + try { msg = JSON.parse(e.data); } catch { return; } + if (msg.type === 'secure') resolveSecure(msg.info); + else if (msg.type === 'close') reason = String(msg.reason ?? ''); + }; + ws.onclose = () => { + reason ||= opened ? 'Connection closed' : 'Could not reach the proxy'; + rejectSecure(new Error(reason)); + this.onClose?.(reason); + }; + } + + send(bytes: Uint8Array): void { + if (this.ws.readyState === WebSocket.OPEN) this.ws.send(bytes as Uint8Array); + } + + close(): void { + this.ws.close(); + } +} diff --git a/src/lib/hints.ts b/src/lib/hints.ts new file mode 100644 index 0000000..8b54b4b --- /dev/null +++ b/src/lib/hints.ts @@ -0,0 +1,33 @@ +import { store } from './settings.svelte.ts'; + +// Hints are the browser tooltips from title attributes. With the setting off, the title of +// whatever is under the pointer is set aside while it is hovered and put back afterwards, so +// the attributes stay in place for screen readers and everything else that reads them. +export function startHints(): void { + let held: { el: Element; title: string; labelled: boolean }[] = []; + + const restore = () => { + for (const h of held) { + // Leave it alone if the app set a new title in the meantime + if (!h.el.hasAttribute('title')) h.el.setAttribute('title', h.title); + if (h.labelled) h.el.removeAttribute('aria-label'); + } + held = []; + }; + + document.addEventListener('mouseover', e => { + restore(); + if (store.settings.hints) return; + // A title on any ancestor would show too + for (let el = e.target as Element | null; el; el = el.parentElement) { + const title = el.getAttribute('title'); + if (!title) continue; + // Keep the accessible name of icon buttons that are only named by their title + const labelled = !el.hasAttribute('aria-label') && !el.textContent?.trim(); + if (labelled) el.setAttribute('aria-label', title); + el.removeAttribute('title'); + held.push({ el, title, labelled }); + } + }, true); + document.addEventListener('mouseleave', restore); +} diff --git a/src/lib/icons.ts b/src/lib/icons.ts index 06c56b5..4035bd5 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -6,6 +6,7 @@ export const ICON_PATHS: Record = { 'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18', settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', plus: 'M12 5v14M5 12h14', + filter: 'M3 4h18l-7 8v7l-4 2v-9z', info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01', 'arrow-right': 'M5 12h14M13 6l6 6-6 6', paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5', diff --git a/src/lib/native.ts b/src/lib/native.ts index d0c1fd7..eddfde7 100644 --- a/src/lib/native.ts +++ b/src/lib/native.ts @@ -1,8 +1,27 @@ import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts'; +import { webApi, openWebTransport } from './web.svelte.ts'; -interface NativeApi { +// What both the desktop app and the browser build (through the web proxy) provide +export interface PlatformApi { platformInfo(): Promise<{ os: string; osVersion: string }>; describeCerts(ders: Uint8Array[]): Promise; + identities: { + list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; + create(name: string, email: string): Promise; + importP12(bytes: Uint8Array, password: string, name: string): Promise; + findMumble(): Promise<{ name: string } | null>; + importMumble(name: string): Promise; + // Where the backup was saved, or null when cancelled + exportP12(id: string, password: string): Promise; + setDefault(id: string): Promise; + rename(id: string, name: string): Promise; + remove(id: string): Promise; + finishSetup(): Promise; + }; +} + +// The desktop app's preload API +interface NativeApi extends PlatformApi { updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void; onTrayAction(fn: (action: string) => void): void; linkPreview(url: string): Promise; @@ -10,18 +29,6 @@ interface NativeApi { pingServer(host: string, port: number): Promise; onContextMenu(fn: (params: ContextParams) => void): void; editAction(action: string, arg?: unknown): void; - identities: { - list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; - create(name: string, email: string): Promise; - importP12(bytes: Uint8Array, password: string, name: string): Promise; - findMumble(): Promise<{ name: string } | null>; - importMumble(name: string): Promise; - exportP12(id: string, password: string): Promise; - setDefault(id: string): Promise; - rename(id: string, name: string): Promise; - remove(id: string): Promise; - finishSetup(): Promise; - }; open(connId: string, host: string, port: number, identityId?: string): Promise; send(connId: string, bytes: Uint8Array): void; close(connId: string): void; @@ -71,7 +78,18 @@ declare global { interface Window { mumh5Native?: NativeApi } } -export const native: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null; +// Desktop-only features (tray, UDP voice, public list, direct link previews) check `desktop` +export const desktop: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null; +// The browser build (vite --mode web) talks to the web proxy instead +export const isWeb = !desktop && import.meta.env?.MODE === 'web'; +export const native: PlatformApi | null = desktop ?? (isWeb ? webApi : null); + +// A connection to a Mumble server: TLS in the desktop app, the proxy's WebSocket in the browser +export async function openTransport(host: string, port: number, identityId?: string): Promise<(Transport & { secure: Promise }) | null> { + if (desktop) return new ElectronTransport(desktop, host, port, identityId); + if (isWeb) return openWebTransport(host, port, identityId); + return null; +} // Transport backed by a TLS socket in the Electron main process. // `secure` resolves once the TLS handshake is done, before any Mumble data is sent, diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 086cd15..6335215 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -1,4 +1,4 @@ -import { native, type LinkPreviewData } from './native.ts'; +import { desktop, type LinkPreviewData } from './native.ts'; import { store } from './settings.svelte.ts'; // One request per URL and mode per app run @@ -6,9 +6,12 @@ const cache = new Map>(); // The mode in effect: previews through the upload host need its key, without one they are off. // Never falls back to fetching directly, that would show linked sites the user's IP. +// Fetching directly needs the desktop app (a web page cannot read other sites). export function previewMode(): 'host' | 'direct' | 'off' { const { linkPreviews, uploadHost, uploadKey } = store.settings; - return linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews; + if (linkPreviews === 'host' && !(uploadHost && uploadKey)) return 'off'; + if (linkPreviews === 'direct' && !desktop) return 'off'; + return linkPreviews; } export function linkPreview(url: string): Promise { @@ -19,7 +22,7 @@ export function linkPreview(url: string): Promise { let p = cache.get(id); if (!p) { p = mode === 'direct' - ? (native?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null) + ? (desktop?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null) : fetch(`${uploadHost.replace(/\/+$/, '')}/api/chat/preview?url=${encodeURIComponent(url)}`, { headers: { 'X-API-Key': uploadKey } }) .then(r => r.json()) .then(d => (d?.success ? d as LinkPreviewData : null)) diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 066b4e5..c5d7873 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -1,6 +1,6 @@ import { createCodec } from '../core/proto.ts'; import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts'; -import { native, ElectronTransport } from './native.ts'; +import { native, openTransport } from './native.ts'; import type { ServerCertInfo } from '../core/transport.ts'; import { store, type SavedServer } from './settings.svelte.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; @@ -155,9 +155,10 @@ export class Session { this.expanded[id] = !this.isExpanded(id); } + // Collapsing goes back to the default: channels with people in them stay open setAllExpanded(open: boolean): void { const all: Record = {}; - for (const id of this.client?.channels.keys() ?? []) all[id] = open; + if (open) for (const id of this.client?.channels.keys() ?? []) all[id] = true; this.expanded = all; } @@ -224,13 +225,16 @@ export class Session { this.status = 'connecting'; if (!native) { - this.fail('Connecting needs the desktop app for now. The web version will connect through a proxy.'); + this.fail('Connecting needs the desktop app or the web build served by a mumh5 proxy.'); return; } - const transport = new ElectronTransport(native, server.host, server.port, server.identityId); + let transport; let info; try { + transport = (await openTransport(server.host, server.port, server.identityId))!; + // Superseded while the identity was being prepared + if (attempt !== this.attempt) return transport.close(); info = await transport.secure; } catch (e) { if (attempt === this.attempt) this.fail((e as Error).message); diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index a84c09c..00b862c 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -24,6 +24,7 @@ export interface Settings { theme: ThemeId; publicListUrl: string; // where the public server list comes from arrangement: Arrangement; + hints: boolean; // tooltips (title attributes) when resting the pointer on buttons } // Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name @@ -50,7 +51,8 @@ const defaults: Settings = { linkPreviews: 'host', theme: 'dark', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', - arrangement: 'side' + arrangement: 'side', + hints: false }; class SettingsStore { @@ -58,9 +60,17 @@ class SettingsStore { servers = $state(loadList('mumh5.servers')); // Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use) pins = $state>(load('mumh5.pins', {})); + // Username to suggest when connecting with an identity, keyed by identity id + identityNames = $state>(load('mumh5.identityNames', {})); // Local nicknames for other users, only visible to us; keyed by certificate hash (or name) nicknames = $state>(load('mumh5.nicknames', {})); + setIdentityName(id: string, name: string) { + if (name.trim()) this.identityNames[id] = name.trim(); + else delete this.identityNames[id]; + save('mumh5.identityNames', $state.snapshot(this.identityNames)); + } + saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); } upsertServer(s: SavedServer) { diff --git a/src/lib/text-menu.ts b/src/lib/text-menu.ts index f8e341f..1faacd2 100644 --- a/src/lib/text-menu.ts +++ b/src/lib/text-menu.ts @@ -1,11 +1,11 @@ -import { native } from './native.ts'; +import { desktop } from './native.ts'; import { menu, type MenuItem } from './menu.svelte.ts'; // Right-click menu for text fields, messages, links and images. Elements with their own menu // (users, channels, servers) call preventDefault, so Electron does not report those. export function startTextMenu(): void { - if (!native) return; - const api = native; + if (!desktop) return; + const api = desktop; api.onContextMenu(p => { // Edit commands act on the focused element; keep it focused through the menu diff --git a/src/lib/tray.svelte.ts b/src/lib/tray.svelte.ts index 3c8cd0a..f639089 100644 --- a/src/lib/tray.svelte.ts +++ b/src/lib/tray.svelte.ts @@ -1,4 +1,4 @@ -import { native } from './native.ts'; +import { desktop } from './native.ts'; import { ICON_PATHS } from './icons.ts'; import { session, sessions } from './session.svelte.ts'; import { voice, type TransmitMode } from './audio/voice.svelte.ts'; @@ -40,8 +40,8 @@ function drawIcon(look: TrayLook): string { // Mirrors voice state into the system tray and handles its menu export function startTray(): void { - if (!native) return; - const api = native; + if (!desktop) return; + const api = desktop; $effect.root(() => { $effect(() => { diff --git a/src/lib/ui.svelte.ts b/src/lib/ui.svelte.ts index d74da50..9dc8a07 100644 --- a/src/lib/ui.svelte.ts +++ b/src/lib/ui.svelte.ts @@ -25,9 +25,11 @@ class Ui { wizard = $state<'setup' | 'add' | null>(null); // Quick audio popover from right-clicking mute (input) or deafen (output) quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null); - settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice'); + settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>('voice'); publicServers = $state(false); serverInfo = $state(false); + // Set by the toolbar to open the own profile with the description editor + editDescription = $state(false); // Add-server dialog, optionally prefilled (from the public list) addServer = $state<{ host?: string; port?: number; label?: string } | null>(null); // Channel dialog: create a subchannel of `parent`, or edit channel `id` diff --git a/src/lib/web.svelte.ts b/src/lib/web.svelte.ts new file mode 100644 index 0000000..810d551 --- /dev/null +++ b/src/lib/web.svelte.ts @@ -0,0 +1,153 @@ +// The browser build: what the desktop app does in its main process goes through the mumh5 web +// proxy instead (server/proxy.ts). Identities are kept in this browser and sent to the proxy +// when connecting, because the proxy presents the certificate to the Mumble server. +import type { CertDetails } from '../core/transport.ts'; +import { WebSocketTransport } from '../core/ws-transport.ts'; +import type { IdentitySummary, PlatformApi } from './native.ts'; + +interface StoredIdentity { + id: string; + name: string; + certPem: string; + keyPem: string; + fingerprint: string; + cert: CertDetails; + createdAt: string; +} +interface StoreData { identities: StoredIdentity[]; defaultId: string | null; setupDone: boolean } + +const KEY = 'mumh5.identities'; + +// The proxy normally serves the page; VITE_PROXY_URL points a separately hosted page at it +const base = (() => { + const fixed = import.meta.env?.VITE_PROXY_URL as string | undefined; + if (fixed) return fixed.replace(/\/*$/, '/'); + return typeof location !== 'undefined' ? new URL('.', location.href).href : ''; +})(); + +async function call(route: string, body?: unknown): Promise { + let res: Response; + try { + res = await fetch(`${base}api/${route}`, body === undefined ? undefined + : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + } catch { + throw new Error('Could not reach the proxy.'); + } + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.error ?? `The proxy answered ${res.status}.`); + return data as T; +} + +const toBase64 = (bytes: Uint8Array) => { let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s); }; +const fromBase64 = (b64: string) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); + +function read(): StoreData { + try { + const d = JSON.parse(localStorage.getItem(KEY) ?? 'null'); + if (d && Array.isArray(d.identities)) return d; + } catch { /* storage unavailable or damaged */ } + return { identities: [], defaultId: null, setupDone: false }; +} + +function write(d: StoreData): void { + // Unlike settings, losing this silently would lose the user's registrations + try { localStorage.setItem(KEY, JSON.stringify(d)); } catch { throw new Error('This browser does not allow storing the identity (private window or storage disabled).'); } +} + +const summary = (d: StoreData, i: StoredIdentity): IdentitySummary => + ({ id: i.id, name: i.name, fingerprint: i.fingerprint, isDefault: i.id === d.defaultId, cert: i.cert }); + +type Fresh = { certPem: string; keyPem: string; fingerprint: string; cert: CertDetails; commonName: string }; + +function add(fresh: Fresh, name: string): IdentitySummary { + const d = read(); + const existing = d.identities.find(i => i.fingerprint === fresh.fingerprint); + if (existing) throw new Error(`This certificate is already stored as "${existing.name}".`); + const stored: StoredIdentity = { + id: crypto.randomUUID(), name: name.trim() || fresh.commonName || 'Identity', certPem: fresh.certPem, keyPem: fresh.keyPem, + fingerprint: fresh.fingerprint, cert: fresh.cert, createdAt: new Date().toISOString() + }; + d.identities.push(stored); + d.defaultId ??= stored.id; + write(d); + return summary(d, stored); +} + +// The identity to connect with: the requested one, else the default, else a new one +async function identityFor(id?: string): Promise { + let d = read(); + if (!d.identities.length) { + add(await call('identity/create', { name: 'mumh5 user', email: '' }), 'My identity'); + d = read(); + } + return d.identities.find(i => i.id === id) ?? d.identities.find(i => i.id === d.defaultId) ?? d.identities[0]; +} + +function browserVersion(): string { + const m = /(Firefox|Edg|Chrome|Version)\/(\d+)/.exec(navigator.userAgent); + if (!m) return ''; + return `${m[1] === 'Edg' ? 'Edge' : m[1] === 'Version' ? 'Safari' : m[1]} ${m[2]}`; +} + +export const webApi: PlatformApi = { + platformInfo: async () => ({ os: 'Web', osVersion: browserVersion() }), + describeCerts: ders => call('certs/describe', { ders: ders.map(toBase64) }), + identities: { + list: async () => { const d = read(); return { identities: d.identities.map(i => summary(d, i)), setupDone: d.setupDone }; }, + create: async (name, email) => add(await call('identity/create', { name, email }), name), + importP12: async (bytes, password, name) => add(await call('identity/import', { p12: toBase64(bytes), password }), name), + // The desktop client's settings are out of a browser's reach + findMumble: async () => null, + importMumble: async () => { throw new Error('Not available in the browser. Export the certificate from Mumble and import the file.'); }, + // Returns the file name; the browser decides where downloads go + exportP12: async (id, password) => { + const i = read().identities.find(x => x.id === id); + if (!i) throw new Error('Identity not found.'); + const { p12 } = await call<{ p12: string }>('identity/export', { certPem: i.certPem, keyPem: i.keyPem, password, name: i.name }); + const file = `${i.name.replace(/[^\w.-]+/g, '_') || 'identity'}.p12`; + const url = URL.createObjectURL(new Blob([fromBase64(p12)], { type: 'application/x-pkcs12' })); + const a = document.createElement('a'); + a.href = url; + a.download = file; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + return `your downloads as ${file}`; + }, + setDefault: async id => { const d = read(); if (d.identities.some(i => i.id === id)) d.defaultId = id; write(d); }, + rename: async (id, name) => { const d = read(); const i = d.identities.find(x => x.id === id); if (i && name.trim()) i.name = name.trim(); write(d); }, + remove: async id => { + const d = read(); + d.identities = d.identities.filter(i => i.id !== id); + if (d.defaultId === id) d.defaultId = d.identities[0]?.id ?? null; + write(d); + }, + finishSetup: async () => { const d = read(); d.setupDone = true; write(d); } + } +}; + +export async function openWebTransport(host: string, port: number, identityId?: string): Promise { + const identity = await identityFor(identityId); + return new WebSocketTransport(`${base.replace(/^http/, 'ws')}api/connect`, { host, port }, identity); +} + +// Which servers the proxy connects to; the connect dialog offers these +export interface ProxyServer { host: string; port: number; label: string } +class ProxyInfo { + servers = $state([]); + any = $state(false); + loaded = $state(false); + error = $state(''); + + async load(): Promise { + try { + const c = await call<{ servers: ProxyServer[]; any: boolean }>('config'); + this.servers = c.servers; + this.any = c.any; + this.error = ''; + } catch (e) { + this.error = (e as Error).message; + } + this.loaded = true; + } +} +export const proxyInfo = new ProxyInfo(); diff --git a/src/main.ts b/src/main.ts index e2c8b9c..4d0ac2f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,13 @@ import App from './App.svelte'; import './themes.css'; import { startTray } from './lib/tray.svelte.ts'; import { startTextMenu } from './lib/text-menu.ts'; +import { startHints } from './lib/hints.ts'; +import { isWeb } from './lib/native.ts'; +import { proxyInfo } from './lib/web.svelte.ts'; + +if (isWeb) proxyInfo.load(); mount(App, { target: document.getElementById('app')! }); startTray(); startTextMenu(); +startHints(); diff --git a/src/ui/ChannelNode.svelte b/src/ui/ChannelNode.svelte index d672b43..a288377 100644 --- a/src/ui/ChannelNode.svelte +++ b/src/ui/ChannelNode.svelte @@ -10,6 +10,7 @@ import { store } from '../lib/settings.svelte.ts'; import { drag } from '../lib/drag.svelte.ts'; import { sounds } from '../lib/audio/sounds.svelte.ts'; + import { renderIncoming } from '../lib/html.ts'; import type { Channel } from '../core/client.ts'; let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props(); @@ -22,6 +23,39 @@ const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0); const total = $derived(session.subtreeUserCount(channel.id)); + // Resting on a channel that has a description for 1 second opens a quick preview next to its row + const hasDescription = $derived(!!(channel.description || channel.descriptionHash)); + const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : ''); + let preview = $state<{ x: number; y: number } | null>(null); + let hoverTimer: ReturnType | undefined; + // Where the pointer is while it rests on the row; the preview opens there + let pointer = { x: 0, y: 0 }; + function trackPointer(e: MouseEvent) { + pointer = { x: e.clientX, y: e.clientY }; + } + function hoverRow(e: MouseEvent) { + if (!hasDescription) return; + trackPointer(e); + clearTimeout(hoverTimer); + hoverTimer = setTimeout(() => { + // Long descriptions are only announced by hash; the text is fetched now + session.loadDescription(channel.id); + // Just below and right of the pointer, kept inside the window + preview = { x: Math.max(8, Math.min(pointer.x + 12, window.innerWidth - 348)), y: Math.max(8, Math.min(pointer.y + 12, window.innerHeight - 340)) }; + }, 1000); + } + // Short grace period, so the pointer can move from the row into the preview + function leaveRow() { + clearTimeout(hoverTimer); + hoverTimer = setTimeout(() => (preview = null), 250); + } + function showDescription() { + clearTimeout(hoverTimer); + preview = null; + session.showChannel(channel.id, 'info'); + ui.panelOpen = true; + } + // Single click previews, double click acts. The single action waits briefly so a // double click does not also flash the preview. let timer: ReturnType | undefined; @@ -69,7 +103,8 @@ oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation" ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }} ondragleave={() => (over = false)} - ondrop={onDrop}> + ondrop={onDrop} + onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}> {#if children.length || users.length} + {#if hasDescription} + + {/if} + {#if preview} + + {/if} {#if users.length && expanded}
    @@ -144,6 +190,15 @@ .caret.collapsed svg { transform: rotate(-90deg); } .name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; } .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .desc { flex: none; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; } + .desc span { width: 9px; height: 9px; background: var(--warn); } + .desc:hover span { outline: 2px solid var(--warn); outline-offset: 1px; } + .desc-pop { position: fixed; z-index: 40; width: 340px; max-width: calc(100vw - 16px); max-height: 320px; overflow: auto; padding: 10px 12px; + background: var(--bg-2); border: 1px solid var(--line); color: var(--text); font-size: 14px; box-shadow: 0 8px 32px rgb(0 0 0 / 0.4); } + .desc-pop strong { display: block; margin-bottom: 6px; } + .desc-pop p { margin: 0; color: var(--text-dim); } + .desc-pop .html { overflow-wrap: anywhere; user-select: text; } + .desc-pop .html :global(img) { max-width: 100%; } .count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; } .badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; } .user { gap: 8px; text-align: left; font-size: 14px; user-select: none; } diff --git a/src/ui/ConnectDialog.svelte b/src/ui/ConnectDialog.svelte index 160e3a5..5401a7f 100644 --- a/src/ui/ConnectDialog.svelte +++ b/src/ui/ConnectDialog.svelte @@ -4,6 +4,8 @@ import { sessions } from '../lib/session.svelte.ts'; import { identities } from '../lib/identities.svelte.ts'; import { makeServerIcon } from '../lib/upload.ts'; + import { isWeb } from '../lib/native.ts'; + import { proxyInfo } from '../lib/web.svelte.ts'; // prefill: values for a new server, e.g. picked from the public list let { server, prefill, onclose }: { server: SavedServer | null; prefill?: { host?: string; port?: number; label?: string }; onclose: () => void } = $props(); @@ -15,14 +17,41 @@ let label = $state(initial?.label ?? pre.label ?? ''); let host = $state(initial?.host ?? pre.host ?? ''); let port = $state(initial?.port ?? pre.port ?? 64738); - // New servers start with the name you used last - let username = $state(initial?.username ?? store.servers.at(-1)?.username ?? ''); let password = $state(initial?.password ?? ''); let identityId = $state(initial?.identityId ?? ''); + // The name chosen for an identity when it was created, if any + const identityName = (id: string) => store.identityNames[identities.byId(id || undefined)?.id ?? ''] ?? ''; + // New servers start with the identity's name, else the name you used last + const firstName = initial?.username ?? (identityName('') || store.servers.at(-1)?.username || ''); + let username = $state(firstName); + // Picking another identity swaps the suggestion, unless the name was typed by hand + let suggested = firstName; + function identityChanged() { + const next = identityName(identityId); + if (next && (!username.trim() || username === suggested)) username = suggested = next; + } let icon = $state(initial?.icon ?? ''); let iconError = $state(''); let iconPicker: HTMLInputElement; + // In the browser the proxy decides which servers can be reached; offer those instead of free text + const listed = $derived(isWeb && !proxyInfo.any ? proxyInfo.servers : null); + const pick = $derived(listed ? `${host}:${port}` : ''); + function choose(value: string) { + const s = listed?.find(x => `${x.host}:${x.port}` === value); + if (!s) return; + // Keep a label the user typed; replace one that came from the previous choice + if (!label.trim() || listed?.some(x => x.label === label)) label = s.label; + host = s.host; + port = s.port; + } + // Refreshed per dialog, so a changed allowlist shows up without a reload + if (isWeb) proxyInfo.load(); + // A new server starts on the first one the proxy offers + $effect(() => { + if (!initial && listed?.length && !listed.some(x => x.host === host && x.port === port)) choose(`${listed[0].host}:${listed[0].port}`); + }); + async function pickIcon() { const file = iconPicker.files?.[0]; iconPicker.value = ''; @@ -62,18 +91,27 @@
    - -
    - - -
    + {#if listed} + + +

    This proxy connects to the servers listed here.

    + {:else} + +
    + + +
    + {/if} + {#if isWeb && proxyInfo.error}

    {proxyInfo.error}

    {/if} {#if identities.list.length > 1} - {#each identities.list as i (i.id)}{/each} @@ -104,6 +142,7 @@ .icon-row { display: flex; align-items: center; gap: 8px; } .preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; } .preview img { width: 100%; height: 100%; object-fit: cover; display: block; } + .note { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); } .err { color: var(--danger); font-size: 13px; margin: 6px 0 0; } .opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); } diff --git a/src/ui/IdentityList.svelte b/src/ui/IdentityList.svelte index 9df9019..6e07e1f 100644 --- a/src/ui/IdentityList.svelte +++ b/src/ui/IdentityList.svelte @@ -1,5 +1,5 @@