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>
This commit is contained in:
+66
-3
@@ -78,7 +78,8 @@ try {
|
||||
// First start: identity setup wizard
|
||||
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
|
||||
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page.getByLabel('Name').fill('alice');
|
||||
await page.getByLabel('Name', { exact: true }).fill('alice');
|
||||
assert.ok(await page.getByLabel('Use this name as my username').isChecked());
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
@@ -119,7 +120,7 @@ try {
|
||||
await page.getByTitle('Add a server').click();
|
||||
await page.getByLabel('Address').fill(host);
|
||||
await page.getByLabel('Port').fill(port);
|
||||
await page.getByLabel('Username').fill('alice');
|
||||
assert.equal(await page.getByLabel('Username').inputValue(), 'alice', 'username suggested from the identity');
|
||||
await page.getByLabel('Label').fill('Test Server');
|
||||
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||
await page.getByText(/Connected/).first().waitFor();
|
||||
@@ -331,8 +332,28 @@ try {
|
||||
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
|
||||
await page.getByRole('radio', { name: 'Dark' }).click();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
|
||||
// Hints are off by default: the title under the pointer is set aside while hovered, and back afterwards
|
||||
const hintTarget = page.locator('.rail').getByRole('button', { name: 'Add a server' });
|
||||
const titleOf = () => page.locator('.rail .tile.add').first().getAttribute('title');
|
||||
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
||||
assert.equal(await page.getByLabel('Show hints when the pointer rests').isChecked(), false);
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: color schemes');
|
||||
await hintTarget.hover();
|
||||
assert.equal(await titleOf(), null, 'no tooltip while hints are off');
|
||||
await page.mouse.move(700, 400);
|
||||
assert.equal(await titleOf(), 'Add a server');
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
||||
await page.getByLabel('Show hints when the pointer rests').check();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await hintTarget.hover();
|
||||
assert.equal(await titleOf(), 'Add a server', 'tooltip kept while hints are on');
|
||||
await page.mouse.move(700, 400);
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
||||
await page.getByLabel('Show hints when the pointer rests').uncheck();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: color schemes, hints off by default and can be turned on');
|
||||
|
||||
// Layouts like the desktop client: classic (channels right of chat), stacked (channels above)
|
||||
const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!);
|
||||
@@ -571,6 +592,15 @@ try {
|
||||
await srvInfo.waitFor({ state: 'detached' });
|
||||
console.log('ok: server information dialog');
|
||||
|
||||
// Toolbar: description editor and settings
|
||||
await page.locator('.me').getByRole('button', { name: 'Change your description' }).click();
|
||||
await page.locator('.panel .rich').waitFor();
|
||||
await page.locator('.panel').getByRole('button', { name: 'Cancel' }).click();
|
||||
await page.locator('.me').getByRole('button', { name: 'Configure' }).click();
|
||||
await page.getByRole('dialog', { name: 'Settings' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: toolbar opens the description editor and settings');
|
||||
|
||||
// Information: own connection shows client details and the full certificate
|
||||
await page.locator('.sidebar .user.self').click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Information' }).click();
|
||||
@@ -629,6 +659,29 @@ try {
|
||||
assert.equal(await page.locator('.me .who span').textContent(), 'Root');
|
||||
console.log('ok: single click shows description without joining');
|
||||
|
||||
// Description marker: only on channels that have one. Resting on the row previews, clicking the marker opens the panel
|
||||
const marker = page.locator('.channel', { hasText: 'Lobby' }).getByRole('button', { name: 'Show description of Lobby' });
|
||||
assert.equal(await page.locator('.channel', { hasText: 'Games' }).locator('.desc').count(), 0);
|
||||
await page.locator('.panel').getByRole('button', { name: 'Close' }).click();
|
||||
await page.locator('.channel .name', { hasText: 'Games' }).hover();
|
||||
await page.waitForTimeout(2000);
|
||||
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview for a channel without description');
|
||||
for (const spot of [page.locator('.channel .name', { hasText: 'Lobby' }), marker]) {
|
||||
await spot.hover();
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview before 1 second');
|
||||
await page.locator('.desc-pop .html', { hasText: 'Welcome to the lobby' }).waitFor({ timeout: 3000 });
|
||||
// Opens at the pointer
|
||||
const at = (await spot.boundingBox())!;
|
||||
const pop = (await page.locator('.desc-pop').boundingBox())!;
|
||||
assert.ok(Math.abs(pop.x - (at.x + at.width / 2 + 12)) < 3 && Math.abs(pop.y - (at.y + at.height / 2 + 12)) < 3, 'preview at the pointer');
|
||||
await page.mouse.move(700, 400);
|
||||
await page.locator('.desc-pop').waitFor({ state: 'detached' });
|
||||
}
|
||||
await marker.click();
|
||||
await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor();
|
||||
console.log('ok: description previews on hover, marker opens it on click');
|
||||
|
||||
// Side chat sends to that channel while staying in Root
|
||||
const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!;
|
||||
await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click();
|
||||
@@ -760,6 +813,16 @@ try {
|
||||
await page.getByRole('button', { name: 'Delete channel' }).click();
|
||||
await bobSees('deleted', () => !chByName('Team Room'));
|
||||
console.log('ok: channel deleted');
|
||||
|
||||
// Toolbar: expand and collapse the whole tree
|
||||
await page.locator('.me').getByRole('button', { name: 'Expand all channels' }).click();
|
||||
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor();
|
||||
await page.locator('.me').getByRole('button', { name: 'Collapse all channels' }).click();
|
||||
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor({ state: 'detached' });
|
||||
// Channels with people in them stay open
|
||||
assert.notEqual(await page.locator('.me .who span').textContent(), 'Root');
|
||||
await page.locator('.sidebar .row.user.self').waitFor();
|
||||
console.log('ok: toolbar expands and collapses all channels, occupied ones stay open');
|
||||
}
|
||||
|
||||
// Without an upload host, images are sent inline within Mumble's image limit
|
||||
|
||||
@@ -81,7 +81,7 @@ const page = await app.firstWindow();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page.getByLabel('Name').fill('kibi');
|
||||
await page.getByLabel('Name', { exact: true }).fill('kibi');
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// A bare browser window for the web E2E: no preload, so the page runs as it would in a browser.
|
||||
const { app, BrowserWindow, session } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
session.defaultSession.setPermissionRequestHandler((_wc, _permission, cb) => cb(true));
|
||||
// Electron would open a save dialog; a browser saves to the downloads folder
|
||||
session.defaultSession.on('will-download', (_e, item) => {
|
||||
const file = path.join(process.env.MUMH5_DOWNLOADS, item.getFilename());
|
||||
item.setSavePath(file);
|
||||
item.once('done', (_ev, state) => { if (state === 'completed') fs.writeFileSync(file + '.done', ''); });
|
||||
});
|
||||
const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false } });
|
||||
win.loadURL(process.env.MUMH5_WEB_URL);
|
||||
});
|
||||
app.on('window-all-closed', () => app.quit());
|
||||
@@ -0,0 +1,168 @@
|
||||
// Drives the browser build through the web proxy against a real Mumble server.
|
||||
// npm run build:web && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:web
|
||||
// The page runs in a plain Chromium window (Electron without the preload), served by the bundled proxy.
|
||||
import { _electron as electron } from 'playwright-core';
|
||||
import electronPath from 'electron';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, existsSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCodec } from '../../src/core/proto.ts';
|
||||
import { decodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
|
||||
import { MumbleClient } from '../../src/core/client.ts';
|
||||
import type { Transport } from '../../src/core/transport.ts';
|
||||
import { openTls } from '../../electron/tls-transport.ts';
|
||||
import { generateIdentity } from '../../electron/identity.ts';
|
||||
|
||||
const target = process.env.MUMBLE_TEST_HOST;
|
||||
if (!target) {
|
||||
console.log('MUMBLE_TEST_HOST not set, skipping');
|
||||
process.exit(0);
|
||||
}
|
||||
const [host, port] = target.split(':');
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
function headless(username: string): Promise<MumbleClient> {
|
||||
const id = generateIdentity(username);
|
||||
const client = new MumbleClient(createCodec());
|
||||
const t: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
|
||||
const conn = openTls(host, Number(port), id.certPem, id.keyPem, {
|
||||
onSecure: () => {}, onData: c => t.onData?.(c), onClose: r => t.onClose?.(r)
|
||||
});
|
||||
client.connect(t, { username, os: 'test' });
|
||||
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
|
||||
}
|
||||
|
||||
function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
|
||||
return Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timed out waiting for: ${label}`)), ms))]);
|
||||
}
|
||||
|
||||
// The bundled proxy, as it would be deployed: one allowed server, serving dist-web
|
||||
const proxyPort = 18000 + Math.floor(Math.random() * 1000);
|
||||
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], {
|
||||
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server` }, stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
await within(new Promise<void>((res, rej) => {
|
||||
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
|
||||
proxy.on('exit', code => rej(new Error(`proxy exited with ${code}`)));
|
||||
}), 'proxy start');
|
||||
|
||||
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
|
||||
const app = await electron.launch({
|
||||
executablePath: electronPath as unknown as string,
|
||||
args: [path.join(root, 'test/e2e/web-shell.cjs'), `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
||||
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||
env: { ...env, MUMH5_WEB_URL: `http://127.0.0.1:${proxyPort}/`, MUMH5_DOWNLOADS: downloads } as Record<string, string>
|
||||
});
|
||||
const bob = await headless('bob');
|
||||
const name = `webalice${Date.now() % 100000}`;
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
page.on('console', m => { if (m.type() === 'error') console.log('[page]', m.text()); });
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
assert.equal(await page.evaluate(() => 'mumh5Native' in window), false, 'no desktop API in the page');
|
||||
|
||||
// Identity: created through the proxy, kept in the browser
|
||||
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
|
||||
await page.getByText(/passed to the proxy at/).waitFor();
|
||||
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('alice');
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
// Backup: downloaded as a .p12 file
|
||||
await page.getByLabel(/^Backup password/).fill('backup-pass');
|
||||
await page.getByLabel('Repeat password').fill('backup-pass');
|
||||
await page.getByRole('button', { name: 'Save backup...' }).click();
|
||||
await page.getByText('Backup saved to your downloads as alice.p12').waitFor();
|
||||
const saved = path.join(downloads, 'alice.p12');
|
||||
for (let i = 0; i < 50 && !existsSync(saved + '.done'); i++) await new Promise(r => setTimeout(r, 100));
|
||||
assert.ok(existsSync(saved + '.done') && statSync(saved).size > 1000, 'backup file downloaded');
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
|
||||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.identities')!));
|
||||
assert.equal(stored.identities.length, 1);
|
||||
assert.equal(stored.setupDone, true);
|
||||
console.log('ok: identity created through the proxy and backed up');
|
||||
|
||||
// Desktop-only features are not offered
|
||||
assert.equal(await page.getByTitle('Browse public servers').count(), 0);
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
||||
assert.ok(await page.getByLabel('Link previews').locator('option[value=direct]').isDisabled());
|
||||
await page.getByRole('tab', { name: 'Identities' }).click();
|
||||
await page.getByText('alice', { exact: true }).first().waitFor();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: public list and direct previews are desktop only');
|
||||
|
||||
// The connect dialog offers the proxy's servers
|
||||
await page.getByTitle('Add a server').click();
|
||||
const pick = page.getByLabel('Server', { exact: true });
|
||||
await pick.waitFor();
|
||||
await page.waitForFunction(v => (document.getElementById('c-host') as HTMLSelectElement)?.value === v, `${host}:${port}`);
|
||||
assert.equal(await page.getByLabel('Label').inputValue(), 'Test Server');
|
||||
await page.getByLabel('Username').fill(name);
|
||||
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||
await page.getByText(/Connected/).first().waitFor();
|
||||
await page.locator('.sidebar').getByText('bob').waitFor();
|
||||
console.log('ok: connected through the proxy, sees bob');
|
||||
|
||||
// Text both ways
|
||||
const got = new Promise<string>(res => bob.on('text', m => res(m.html)));
|
||||
await page.getByLabel('Message').fill('hello from the browser');
|
||||
await page.keyboard.press('Enter');
|
||||
assert.match(await within(got, 'text from the page'), /hello from the browser/);
|
||||
bob.sendText({ channels: [0] }, 'hello browser');
|
||||
await page.locator('.msg', { hasText: 'hello browser' }).waitFor();
|
||||
console.log('ok: text in both directions');
|
||||
|
||||
// Voice goes through the TCP tunnel
|
||||
const session = () => [...bob.users.values()].find(u => u.name === name)!.session;
|
||||
const stream = new Promise<VoicePacket[]>(res => {
|
||||
const packets: VoicePacket[] = [];
|
||||
const off = bob.on('voice', raw => {
|
||||
const p = decodeVoice(raw);
|
||||
if (!p || p.session !== session()) return;
|
||||
packets.push(p);
|
||||
if (packets.length >= 20) { off(); res(packets); }
|
||||
});
|
||||
});
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('radio', { name: /Always on/ }).click();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
const packets = await within(stream, 'voice packets from the page', 20000);
|
||||
assert.ok(packets.every(p => p.opus.length > 0));
|
||||
console.log(`ok: page sends Opus voice through the proxy (${packets[0].opus.length} bytes per packet)`);
|
||||
|
||||
await page.locator('.me').getByRole('button', { name: 'Server information' }).click();
|
||||
await page.getByRole('dialog', { name: 'Server information' }).getByText('Through the TCP connection').waitFor();
|
||||
await page.getByRole('dialog', { name: 'Server information' }).getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||
console.log('ok: server information shows voice over TCP');
|
||||
|
||||
// Own information: with the proxy on this machine the server sees our own address, shown as such
|
||||
await page.locator('.sidebar .row.user.self').click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Information' }).click();
|
||||
const own = page.getByRole('dialog', { name: /^Information:/ });
|
||||
await own.getByText('Address', { exact: true }).waitFor();
|
||||
assert.equal(await own.getByText('Seen by the server').count(), 0);
|
||||
await own.getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||
console.log('ok: own information shows the address the server sees');
|
||||
|
||||
// The identity survives a reload and the server recognizes the same certificate
|
||||
const hash = () => [...bob.users.values()].find(u => u.name === name)?.hash;
|
||||
const before = hash();
|
||||
assert.ok(before, 'bob sees the certificate hash');
|
||||
await page.reload();
|
||||
await page.locator('.rail .tile[title^="Test Server"]').click();
|
||||
await page.getByText(/Connected/).first().waitFor();
|
||||
const deadline = Date.now() + 5000;
|
||||
while (hash() !== before && Date.now() < deadline) await new Promise(r => setTimeout(r, 100));
|
||||
assert.equal(hash(), before, 'same certificate after a reload');
|
||||
console.log('ok: identity kept across a reload');
|
||||
|
||||
console.log('WEB E2E PASSED');
|
||||
} finally {
|
||||
bob.disconnect();
|
||||
await app.close().catch(() => {});
|
||||
proxy.kill();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// The web proxy: allowlist, identity endpoints and the WebSocket bridge. The bridge tests need a
|
||||
// real Mumble server and are skipped unless MUMBLE_TEST_HOST is set (see server.test.ts).
|
||||
import { test } from 'node:test';
|
||||
import net from 'node:net';
|
||||
import assert from 'node:assert/strict';
|
||||
import { proxyLine } from '../electron/tls-transport.ts';
|
||||
import { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import { WebSocketTransport } from '../src/core/ws-transport.ts';
|
||||
import { createCodec } from '../src/core/proto.ts';
|
||||
import { MumbleClient } from '../src/core/client.ts';
|
||||
|
||||
const target = process.env.MUMBLE_TEST_HOST;
|
||||
|
||||
async function withProxy(config: Partial<ProxyConfig>, fn: (base: string) => Promise<void>): Promise<void> {
|
||||
const proxy = await startProxy({ ...defaults, port: 0, origins: ['*'], ...config });
|
||||
try { await fn(`http://127.0.0.1:${proxy.port}`); } finally { await proxy.close(); }
|
||||
}
|
||||
|
||||
const post = (base: string, route: string, body: unknown, headers: Record<string, string> = {}) =>
|
||||
fetch(`${base}/api/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
|
||||
|
||||
test('server list parsing', () => {
|
||||
assert.deepEqual(parseServers('Mumble.Example.com, voice.example.org:1234=Friends ,[::1]:5=Local'), [
|
||||
{ host: 'mumble.example.com', port: 64738, label: 'mumble.example.com' },
|
||||
{ host: 'voice.example.org', port: 1234, label: 'Friends' },
|
||||
{ host: '::1', port: 5, label: 'Local' }
|
||||
]);
|
||||
assert.deepEqual(parseServers(''), []);
|
||||
});
|
||||
|
||||
test('private address detection', () => {
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.1.1', '172.20.0.1', '169.254.1.1', '100.64.0.1', '::1', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1']) assert.ok(isPrivateAddress(a), a);
|
||||
for (const a of ['8.8.8.8', '172.32.0.1', '2001:4860:4860::8888', '::ffff:8.8.8.8']) assert.ok(!isPrivateAddress(a), a);
|
||||
});
|
||||
|
||||
test('refuses to start without allowed servers', async () => {
|
||||
await assert.rejects(startProxy({ ...defaults, port: 0 }), /No servers allowed/);
|
||||
});
|
||||
|
||||
test('config lists the allowed servers', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org=Friends') }, async base => {
|
||||
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false });
|
||||
});
|
||||
});
|
||||
|
||||
test('identity create, describe, export and import round trip', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org') }, async base => {
|
||||
const created = await (await post(base, 'identity/create', { name: 'web user', email: '' })).json();
|
||||
assert.match(created.certPem, /BEGIN CERTIFICATE/);
|
||||
assert.match(created.keyPem, /PRIVATE KEY/);
|
||||
assert.match(created.cert.subject, /CN=web user/);
|
||||
assert.equal(created.commonName, 'web user');
|
||||
|
||||
const { p12 } = await (await post(base, 'identity/export', { certPem: created.certPem, keyPem: created.keyPem, password: 'secret-pass', name: 'web user' })).json();
|
||||
const wrong = await post(base, 'identity/import', { p12, password: 'nope' });
|
||||
assert.equal(wrong.status, 400);
|
||||
const imported = await (await post(base, 'identity/import', { p12, password: 'secret-pass' })).json();
|
||||
assert.equal(imported.fingerprint, created.fingerprint);
|
||||
|
||||
const der = created.certPem.replace(/-----[^-]+-----|\s/g, '');
|
||||
const [described] = await (await post(base, 'certs/describe', { ders: [der] })).json();
|
||||
assert.equal(described.fingerprint256, created.cert.fingerprint256);
|
||||
});
|
||||
});
|
||||
|
||||
test('requests from other origins are refused', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
|
||||
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });
|
||||
assert.equal(cross.status, 403);
|
||||
const same = await post(base, 'certs/describe', { ders: [] }, { Origin: base });
|
||||
assert.equal(same.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
test('static files are served without leaving the folder', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org'), staticDir: 'test' }, async base => {
|
||||
const ok = await fetch(`${base}/proxy.test.ts`);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await fetch(`${base}/..%2Fpackage.json`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/%2e%2e/package.json`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('behind a reverse proxy, limits count the forwarded client address', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org'), trustProxy: true, maxPerAddress: 1 }, async base => {
|
||||
const open = (forwarded: string) => new Promise<string>(resolve => {
|
||||
const ws = new WsClient(`${base.replace(/^http/, 'ws')}/api/connect`, { headers: { 'X-Forwarded-For': forwarded } });
|
||||
ws.on('open', () => resolve('open'));
|
||||
ws.on('unexpected-response', (_req, res) => resolve(String(res.statusCode)));
|
||||
ws.on('error', () => resolve('error'));
|
||||
});
|
||||
assert.equal(await open('203.0.113.1'), 'open');
|
||||
assert.equal(await open('203.0.113.2'), 'open');
|
||||
// A made-up first entry does not hide the address the reverse proxy saw
|
||||
assert.equal(await open('198.51.100.9, 203.0.113.1'), '503');
|
||||
});
|
||||
});
|
||||
|
||||
test('PROXY protocol lines', () => {
|
||||
assert.equal(proxyLine('203.0.113.7', 40000, '127.0.0.1', 64738), 'PROXY TCP4 203.0.113.7 127.0.0.1 40000 64738\r\n');
|
||||
assert.equal(proxyLine('::ffff:203.0.113.7', 40000, '::ffff:10.0.0.2', 64738), 'PROXY TCP4 203.0.113.7 10.0.0.2 40000 64738\r\n');
|
||||
assert.equal(proxyLine('2001:db8::7', 40000, '::1', 64738), 'PROXY TCP6 2001:db8::7 ::1 40000 64738\r\n');
|
||||
assert.equal(proxyLine('203.0.113.7', 40000, '::1', 64738), 'PROXY TCP6 ::ffff:203.0.113.7 ::1 40000 64738\r\n');
|
||||
assert.equal(proxyLine('2001:db8::7', 40000, '127.0.0.1', 64738), 'PROXY UNKNOWN\r\n');
|
||||
});
|
||||
|
||||
test('the client address is announced to the server before TLS when asked to', async () => {
|
||||
// Stands in for go-mmproxy: reads the first bytes of the connection
|
||||
let gotFirst!: (s: string) => void;
|
||||
const first = new Promise<string>(resolve => { gotFirst = resolve; });
|
||||
const upstream = net.createServer(s => s.once('data', d => { gotFirst(d.toString('latin1')); s.destroy(); }));
|
||||
await new Promise<void>(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const port = (upstream.address() as net.AddressInfo).port;
|
||||
try {
|
||||
await withProxy({ servers: parseServers(`127.0.0.1:${port}`), sendProxy: true, trustProxy: true }, async base => {
|
||||
const id = await identity(base, 'a');
|
||||
const ws = new WsClient(wsUrl(base), { headers: { 'X-Forwarded-For': '203.0.113.7' } });
|
||||
ws.on('open', () => ws.send(JSON.stringify({ host: '127.0.0.1', port, ...id })));
|
||||
ws.on('error', () => {});
|
||||
assert.match(await first, new RegExp(`^PROXY TCP4 203\\.0\\.113\\.7 127\\.0\\.0\\.1 \\d+ ${port}\\r\\n`));
|
||||
ws.terminate();
|
||||
});
|
||||
} finally {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
const identity = async (base: string, name: string) => (await post(base, 'identity/create', { name })).json() as Promise<{ certPem: string; keyPem: string }>;
|
||||
const wsUrl = (base: string) => `${base.replace(/^http/, 'ws')}/api/connect`;
|
||||
|
||||
test('servers off the allowlist are refused', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org') }, async base => {
|
||||
const t = new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: 64738 }, await identity(base, 'a'));
|
||||
await assert.rejects(t.secure, /does not allow connecting to 127\.0\.0\.1:64738/);
|
||||
});
|
||||
});
|
||||
|
||||
test('private addresses are refused when any server is allowed', async () => {
|
||||
await withProxy({ allowAny: true }, async base => {
|
||||
const id = await identity(base, 'a');
|
||||
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: 64738 }, id).secure, /private addresses/);
|
||||
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: 'localhost', port: 64738 }, id).secure, /private addresses/);
|
||||
});
|
||||
});
|
||||
|
||||
test('a listed server on a private address is reachable when any server is allowed', async () => {
|
||||
// Refuses the TLS handshake, which is enough to see the connection was attempted
|
||||
const local = net.createServer(s => s.destroy());
|
||||
await new Promise<void>(resolve => local.listen(0, '127.0.0.1', resolve));
|
||||
const port = (local.address() as net.AddressInfo).port;
|
||||
try {
|
||||
await withProxy({ allowAny: true, servers: parseServers(`127.0.0.1:${port},localhost:${port}`) }, async base => {
|
||||
const id = await identity(base, 'a');
|
||||
for (const host of ['127.0.0.1', 'localhost']) {
|
||||
const err = await new WebSocketTransport(wsUrl(base), { host, port }, id).secure.then(() => null, e => e as Error);
|
||||
assert.ok(err && !/private addresses|does not allow/.test(err.message), `${host}: ${err?.message}`);
|
||||
}
|
||||
// Other private addresses stay blocked
|
||||
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: port + 1 }, id).secure, /private addresses/);
|
||||
});
|
||||
} finally {
|
||||
local.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('two clients talk through the proxy', { skip: !target }, async () => {
|
||||
const [host, port = '64738'] = target!.split(':');
|
||||
await withProxy({ servers: parseServers(target!) }, async base => {
|
||||
const connect = async (name: string) => {
|
||||
const transport = new WebSocketTransport(wsUrl(base), { host, port: Number(port) }, await identity(base, name));
|
||||
const info = await transport.secure;
|
||||
assert.match(info.fingerprint, /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/);
|
||||
assert.ok(info.chain.length >= 1);
|
||||
assert.equal(info.clientAddress, '127.0.0.1');
|
||||
const client = new MumbleClient(createCodec());
|
||||
client.connect(transport, { username: name, os: 'test' });
|
||||
await new Promise<void>((resolve, reject) => { client.on('synced', () => resolve()); client.on('close', reject); });
|
||||
return client;
|
||||
};
|
||||
const a = await connect(`proxy-a-${Date.now() % 100000}`);
|
||||
const b = await connect(`proxy-b-${Date.now() % 100000}`);
|
||||
const got = new Promise<string>(resolve => b.on('text', m => resolve(m.html)));
|
||||
a.sendText({ channels: [0] }, 'hello through the proxy');
|
||||
assert.equal(await got, 'hello through the proxy');
|
||||
// No UDP in a browser: voice stays on the TCP tunnel
|
||||
assert.equal(a.udpOk, false);
|
||||
a.disconnect();
|
||||
b.disconnect();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user