diff --git a/.github/workflows/relay-binaries.yml b/.github/workflows/relay-binaries.yml
new file mode 100644
index 0000000..d4056b0
--- /dev/null
+++ b/.github/workflows/relay-binaries.yml
@@ -0,0 +1,40 @@
+name: Relay binaries
+# Builds the dependency-free Go broadcast relay for every common platform and
+# attaches it to the release, so scripts/relay-quickstart.sh can fetch it.
+on:
+ push:
+ tags: ['*']
+ workflow_dispatch:
+permissions:
+ contents: write
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ - name: Vet and build
+ working-directory: relay/go
+ run: |
+ go vet ./...
+ mkdir -p ../../out
+ for t in linux/amd64 linux/arm64 linux/arm linux/386 linux/mips linux/mipsle linux/riscv64 \
+ windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 freebsd/amd64 android/arm64; do
+ os=${t%/*}; arch=${t#*/}; ext=; [ "$os" = windows ] && ext=.exe
+ GOOS=$os GOARCH=$arch GOMIPS=softfloat GOARM=7 CGO_ENABLED=0 \
+ go build -trimpath -ldflags='-s -w' -o ../../out/htmlroom-relay-$os-$arch$ext .
+ done
+ cd ../../out && sha256sum htmlroom-relay-* > SHA256SUMS
+ - uses: actions/upload-artifact@v4
+ with:
+ name: htmlroom-relay
+ path: out/*
+ - name: Attach to release
+ if: startsWith(github.ref, 'refs/tags/')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "HTML Room $GITHUB_REF_NAME"
+ gh release upload "$GITHUB_REF_NAME" out/* --clobber
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5a6ac74..55dd57a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,6 +30,19 @@ jobs:
- run: npx playwright install --with-deps chromium
- run: npm run test:browser
- run: npm run test:pages
+ go:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ - name: Vet, format check and cross-compile the broadcast relay
+ working-directory: relay/go
+ run: |
+ test -z "$(gofmt -l .)"
+ go vet ./...
+ for t in linux/amd64 linux/mipsle windows/amd64 darwin/arm64; do GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null .; done
worker:
runs-on: ubuntu-latest
env:
@@ -52,7 +65,7 @@ jobs:
- run: |
docker run -d --name htmlroom -p 127.0.0.1:18080:8080 htmlroom:test
for i in $(seq 1 30); do curl -fs http://127.0.0.1:18080/healthz && break; sleep 1; done
- curl -fs http://127.0.0.1:18080/hr.html | grep -q '创建 HTML 房间'
+ curl -fs http://127.0.0.1:18080/hr.html | grep -q '创建加密聊天室'
installer:
runs-on: ubuntu-latest
timeout-minutes: 15
diff --git a/README.md b/README.md
index 1be0019..5307bb1 100644
--- a/README.md
+++ b/README.md
@@ -22,8 +22,8 @@
## 三步开始聊天
**第 1 步:创建房间**
-打开 [房间生成器](https://freecode100year.github.io/htmlroom/),其他设置都不用改。
-如果想多一层保险,可以点「随机生成高熵口令」设个密码(相当于门上再加一把锁)。
+打开 [房间生成器](https://freecode100year.github.io/htmlroom/),直接点「创建房间」就行,其他设置都不用改。
+如果想多一层保险,可以点「随机生成口令」设个密码(相当于门上再加一把锁)。
**第 2 步:发给朋友**
点「创建房间」后,你可以:
@@ -47,7 +47,11 @@
| 每条消息最多 **500 字** | 不联网使用(聊天时必须有网) |
| 断网后自动重连 | 找回已关闭页面里的消息 |
-创建房间时可按优先级填写 **1–3 个中继**(以逗号或空格分隔)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。
+创建房间时可按优先级填写 **1–3 个经典中继**(以逗号或空格分隔),或改用下文的[去中心化中继](#去中心化房间不依赖任何一台服务器)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。
+
+**在房间文件里直接填自己的中继:** 新创建的房间文件默认允许自定义中继。打开文件后,进房间前可以把「中继服务器」改成任意 `wss://` 地址(也可以填去中心化中继),只对这次打开有效、不改动文件。大家要连同一个中继才能互相看到消息;去中心化中继只要有一个相同即可。创建时在「中继服务器(一般不用改)」里取消勾选「允许在房间文件里自定义中继」,文件就只能连列出的中继,更严格。
+
+**想改用自己搭的中继?** 打开房间文件,展开「换一个中继服务器」,填入自己的 `wss://` 地址即可生成同一房间的新文件(密钥、口令不变,无需口令);也可在生成器页面的「已经有房间文件?换一个中继服务器」中转换旧文件。把新文件发给所有成员后,大家就在你的中继上聊天,并重新核对安全码。详见 [更换中继](docs/DEVELOPER.md#更换中继迁移到自建中继)。
多中继配置使用新版房间格式,旧文件/链接需重新生成。网页房间链接只支持站点预先允许的中继,其他组合请使用下载的房间文件。配置静态网页的多个中继见 [开发说明](docs/DEVELOPER.md#多中继房间)。
@@ -72,6 +76,28 @@
- 本项目还没有经过第三方专业机构的安全审计。
- 想要更放心,就一定要**核对安全码**。
+## 去中心化房间:不依赖任何一台服务器
+
+创建房间时,中继除了填经典的 `wss://…`,还可以填以下三种去中心化中继,最多 5 个,可以混用:
+
+| 写法 | 是什么 | 谁来提供 |
+| --- | --- | --- |
+| `bc+wss://主机/bc/` | 只负责转发的中继 | 本项目任何部署(Cloudflare、Docker、一键安装),或下面的单文件中继;任意「同一路径收到的消息转发给其他连接」的 WebSocket 服务器都兼容 |
+| `nostr+wss://…` | Nostr 中继 | 公共 Nostr 网络中的中继,或自建 |
+| `mqtt+wss://…/mqtt` | MQTT 代理(WebSocket) | mosquitto、EMQX 等,路由器、树莓派都能跑 |
+
+消息**同时发往全部中继**并自动去重:只要有一个还通,大家就能聊天,也不会出现连到不同中继的人互相看不到的情况。成员编号、在线名单都由各成员自己协商,中继只负责转发看不懂的密文;没有房间密钥的人发来的数据会被直接丢弃。旧房间文件可以用「换一个中继服务器」直接改成去中心化房间,密钥和口令不变。
+
+> 代价:没有中继替你挡住乱发数据的人(他们只能浪费流量,解不开也伪造不了消息);公共 Nostr/MQTT 中继能看到你的 IP 和收发时间,规范要求不保存临时消息,但无法保证。成员变化后请重新核对安全码。部分公共 Nostr 中继要求登录或付费,会在页面上提示被拒绝,换一个即可。
+
+### 一条命令开一个中继:不要域名、不要公网 IP、不要 root
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/Freecode100Year/htmlroom/main/scripts/relay-quickstart.sh | sh -s -- --tunnel
+```
+
+脚本下载对应平台的单文件中继(校验 SHA-256),并通过 Cloudflare 免费临时隧道得到公网地址,最后打印一行 `bc+wss://xxxx.trycloudflare.com/bc/`,把它填进房间即可。支持 Linux、macOS、FreeBSD、WSL、Termux 和 OpenWrt。Windows 可从 [Releases](https://github.com/Freecode100Year/htmlroom/releases) 下载 `htmlroom-relay-windows-amd64.exe`,安装 [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) 后运行 `htmlroom-relay-windows-amd64.exe -tunnel`。临时隧道地址每次启动都会变化;长期使用请配域名和 HTTPS,详见 [单文件中继](docs/DEVELOPER.md#单文件中继go)。
+
## 部署自己的聊天室
### Cloudflare:前端和中继一起部署(推荐)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 55334d3..1d5041b 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -9,6 +9,8 @@ Copyright (c) 2026 Freecode100Year. MIT License; see LICENSE and docs/PROVENANCE
lib/vendor/mlkem.js bundles ML-KEM-768 from @noble/post-quantum 0.7.1 and supporting @noble/hashes and @noble/curves code. The original source comments and license notices are retained.
+html-room/mesh.js bundles BIP-340 Schnorr signing over secp256k1 from @noble/curves 1.9.7 and SHA-256 from @noble/hashes 1.8.0 (npm, exact versions in package-lock.json), used only to sign Nostr events with a per-session throwaway key.
+
Copyright (c) 2024 Paul Miller (https://paulmillr.com).
Copyright (c) 2022 Paul Miller (paulmillr.com).
diff --git a/browser-tests/custom-relay.mjs b/browser-tests/custom-relay.mjs
new file mode 100644
index 0000000..66d13ba
--- /dev/null
+++ b/browser-tests/custom-relay.mjs
@@ -0,0 +1,78 @@
+// A room file made for a relay that is gone: members type their own relay into
+// the file and chat. Strict files and web links show no relay box.
+import { chromium } from 'playwright';
+import { mkdtemp, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { pathToFileURL } from 'node:url';
+import assert from 'node:assert/strict';
+import { makeConfig } from '../html-room/crypto.js';
+import { fragmentFor } from '../html-room/transport.js';
+import { render } from '../scripts/build-html-room.mjs';
+import { nodeRelay, nostrRelay } from '../test/fixtures/mesh-relays.mjs';
+
+const dir = await mkdtemp(tmpdir() + '/hr-custom-');
+const own = await nodeRelay(), nostr = await nostrRelay();
+let browser;
+try {
+ const dead = 'wss://relay-that-is-gone.invalid';
+ const config = await makeConfig(dead, 'pw', { custom: true });
+ const path = dir + '/room.html'; await writeFile(path, (await render(config, config.relays)).html);
+ browser = await chromium.launch({ headless: true });
+ const errors = [], sockets = [];
+ const open = async (relays) => {
+ const page = await browser.newPage();
+ page.on('pageerror', e => errors.push(e.message)); page.on('websocket', s => sockets.push(s.url()));
+ await page.goto(pathToFileURL(path).href);
+ assert.equal(await page.locator('#custom-relay').isVisible(), true);
+ assert.equal(await page.locator('#relay-input').inputValue(), dead);
+ await page.locator('#password').fill('pw');
+ if (relays) await page.locator('#relay-input').fill(relays);
+ await page.locator('#enter').click();
+ return page;
+ };
+ // A bad address is reported and does not count as a wrong password.
+ const probe = await open('http://nope.example');
+ await probe.locator('#status').filter({ hasText: '中继地址不对' }).waitFor();
+ assert.equal(await probe.locator('#enter').isEnabled(), true);
+ await probe.close();
+
+ // Classic: both type this project's relay.
+ const a = await open(own.url), b = await open(own.url);
+ for (const p of [a, b]) {
+ await p.locator('#relay-status').filter({ hasText: own.url }).waitFor({ timeout: 15000 });
+ await p.locator('#status').filter({ hasText: '已加密连接' }).waitFor({ timeout: 20000 });
+ }
+ await a.locator('#text').fill('文件里自己填的中继'); await a.locator('#send').click();
+ await b.locator('#messages li').filter({ hasText: '文件里自己填的中继' }).waitFor({ timeout: 15000 });
+ assert.ok(!sockets.some(u => u.includes('relay-that-is-gone')), 'the file\'s own relay was never tried');
+
+ // Decentralized: different relay lists that share one relay still meet.
+ const c = await open(`bc+${own.url}/bc/ nostr+${nostr.url}`), d = await open(`nostr+${nostr.url}`);
+ for (const p of [c, d]) await p.locator('#status').filter({ hasText: '已加密连接' }).waitFor({ timeout: 30000 });
+ await d.locator('#text').fill('只共用一个 Nostr 中继'); await d.locator('#send').click();
+ await c.locator('#messages li').filter({ hasText: '只共用一个 Nostr 中继' }).waitFor({ timeout: 20000 });
+
+ // Strict files and web links have no relay box.
+ const strict = await makeConfig(own.url, undefined), strictPath = dir + '/strict.html';
+ await writeFile(strictPath, (await render(strict, strict.relays)).html);
+ const s = await browser.newPage(); await s.goto(pathToFileURL(strictPath).href);
+ await s.locator('#status').filter({ hasText: '进入房间' }).waitFor();
+ assert.equal(await s.locator('#custom-relay').isVisible(), false);
+ const webConfig = await makeConfig(own.url, undefined, { custom: true });
+ const w = await browser.newPage(); await w.goto(`http://127.0.0.1:${own.port}/hr.html${fragmentFor(webConfig)}`);
+ await w.locator('#status').filter({ hasText: '进入房间' }).waitFor();
+ assert.equal(await w.locator('#custom-relay').isVisible(), false);
+
+ // The creation page makes custom-relay files by default, strict when unticked.
+ const maker = await browser.newPage(); await maker.goto(`http://127.0.0.1:${own.port}/`);
+ const made = async () => { await maker.locator('#mk-make').click(); await maker.locator('#mk-out').waitFor({ state: 'visible' });
+ const dl = maker.waitForEvent('download'); await maker.locator('#mk-download').click(); const f = dir + '/made.html'; await (await dl).saveAs(f);
+ return (await import('node:fs/promises')).readFile(f, 'utf8'); };
+ assert.match(await made(), /connect-src wss: /);
+ await maker.locator('#mk-advanced summary').click(); await maker.locator('#mk-custom').uncheck();
+ assert.doesNotMatch(await made(), /connect-src wss: /);
+ assert.deepEqual(errors, []);
+ console.log('PASS: custom relays typed into the file (classic and decentralized), bad address handling, strict files and web links without the box, maker default');
+} finally {
+ await browser?.close(); await Promise.all([own.stop(), nostr.stop()]); await rm(dir, { recursive: true, force: true });
+}
diff --git a/browser-tests/html-room.mjs b/browser-tests/html-room.mjs
index 973bd6a..ccc7613 100644
--- a/browser-tests/html-room.mjs
+++ b/browser-tests/html-room.mjs
@@ -19,12 +19,12 @@ try{
const pages=await Promise.all(contexts.map(c=>c.newPage()));
const errors=[],requests=[],sockets=[];
for(const p of pages){p.on('pageerror',e=>errors.push(e.message));p.on('console',m=>{if(m.type()==='error')errors.push(m.text());});p.on('request',req=>requests.push(req.url()));p.on('websocket',w=>sockets.push(w.url()));await p.goto(pathToFileURL(path).href);await p.locator('#enter').click();}
- for(const p of pages)await p.locator('#status').filter({hasText:'已建立签名混合加密连接'}).waitFor({timeout:15000});
+ for(const p of pages)await p.locator('#status').filter({hasText:'已加密连接'}).waitFor({timeout:15000});
const send=async(p,text)=>{await p.locator('#text').fill(text);await p.locator('#send').click();};
await send(pages[0],'第一端