Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/relay-binaries.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
## 三步开始聊天

**第 1 步:创建房间**
打开 [房间生成器](https://freecode100year.github.io/htmlroom/),其他设置都不用改。
如果想多一层保险,可以点「随机生成高熵口令」设个密码(相当于门上再加一把锁)。
打开 [房间生成器](https://freecode100year.github.io/htmlroom/),直接点「创建房间」就行,其他设置都不用改。
如果想多一层保险,可以点「随机生成口令」设个密码(相当于门上再加一把锁)。

**第 2 步:发给朋友**
点「创建房间」后,你可以:
Expand All @@ -47,7 +47,11 @@
| 每条消息最多 **500 字** | 不联网使用(聊天时必须有网) |
| 断网后自动重连 | 找回已关闭页面里的消息 |

创建房间时可按优先级填写 **1–3 个中继**(以逗号或空格分隔)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。
创建房间时可按优先级填写 **1–3 个经典中继**(以逗号或空格分隔),或改用下文的[去中心化中继](#去中心化房间不依赖任何一台服务器)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。

**在房间文件里直接填自己的中继:** 新创建的房间文件默认允许自定义中继。打开文件后,进房间前可以把「中继服务器」改成任意 `wss://` 地址(也可以填去中心化中继),只对这次打开有效、不改动文件。大家要连同一个中继才能互相看到消息;去中心化中继只要有一个相同即可。创建时在「中继服务器(一般不用改)」里取消勾选「允许在房间文件里自定义中继」,文件就只能连列出的中继,更严格。

**想改用自己搭的中继?** 打开房间文件,展开「换一个中继服务器」,填入自己的 `wss://` 地址即可生成同一房间的新文件(密钥、口令不变,无需口令);也可在生成器页面的「已经有房间文件?换一个中继服务器」中转换旧文件。把新文件发给所有成员后,大家就在你的中继上聊天,并重新核对安全码。详见 [更换中继](docs/DEVELOPER.md#更换中继迁移到自建中继)。

多中继配置使用新版房间格式,旧文件/链接需重新生成。网页房间链接只支持站点预先允许的中继,其他组合请使用下载的房间文件。配置静态网页的多个中继见 [开发说明](docs/DEVELOPER.md#多中继房间)。

Expand All @@ -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:前端和中继一起部署(推荐)
Expand Down
2 changes: 2 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
78 changes: 78 additions & 0 deletions browser-tests/custom-relay.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
8 changes: 4 additions & 4 deletions browser-tests/html-room.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],'第一端 <script> 不执行');await pages[1].getByText('1:第一端 <script> 不执行',{exact:true}).waitFor({timeout:10000});
await pages[0].getByText('客户端确认 1/1',{exact:true}).waitFor({timeout:10000});
await pages[0].getByText('已送达 1/1',{exact:true}).waitFor({timeout:10000});
await send(pages[1],'第二端 双向确认');await pages[0].getByText('2:第二端 双向确认',{exact:true}).waitFor({timeout:10000});
await pages[1].getByText('客户端确认 1/1',{exact:true}).waitFor({timeout:10000});
await pages[1].getByText('已送达 1/1',{exact:true}).waitFor({timeout:10000});
assert.deepEqual(errors,[]);assert.ok(requests.every(u=>u.startsWith('file:')));assert.ok(sockets.every(u=>u===`${relay}/hr/${config.roomId}`));
for(const c of contexts)assert.deepEqual(await c.cookies(),[]);
for(const p of pages)assert.equal(await p.evaluate(async()=>({local:localStorage.length,session:sessionStorage.length,dbs:(await indexedDB.databases()).length})).then(x=>JSON.stringify(x)),JSON.stringify({local:0,session:0,dbs:0}));
Expand All @@ -46,6 +46,6 @@ try{
tlsServer=spawn(process.execPath,['server/server.mjs'],{env:{...process.env,HOST:'127.0.0.1',PORT:String(tlsPort),TLS_CERT:cert,TLS_KEY:key},stdio:'ignore'});
await new Promise((resolve,reject)=>{const timer=setTimeout(resolve,700);tlsServer.once('exit',()=>{clearTimeout(timer);reject(Error('TLS fixture server exited'));});});
const tlsConfig=await makeConfig(tlsRelay),tlsHtml=await render(tlsConfig,[tlsRelay]);const tlsPath=dir+'/tls.html';await writeFile(tlsPath,tlsHtml.html,{mode:0o600});
const tp=await contexts[0].newPage();await tp.goto(pathToFileURL(tlsPath).href);await tp.locator('#enter').click();await tp.locator('#status').filter({hasText:'证书信任问题'}).waitFor({timeout:7000});await tp.close();
const tp=await contexts[0].newPage();await tp.goto(pathToFileURL(tlsPath).href);await tp.locator('#enter').click();await tp.locator('#status').filter({hasText:'证书'}).waitFor({timeout:7000});await tp.close();
console.log(JSON.stringify({browser:browser.version(),checks:11,result:'pass',fileBytes:r.bytes,scriptHash:r.scriptHash,consoleErrors:errors.length,httpExternalRequests:requests.filter(u=>!u.startsWith('file:')).length}));
}finally{await browser?.close();server?.kill('SIGTERM');tlsServer?.kill('SIGTERM');await rm(dir,{recursive:true,force:true});}
8 changes: 4 additions & 4 deletions browser-tests/htmlroom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ try{
assert.equal(config.version,2);assert.equal(config.encrypted,false);assert.ok(config.master);
await maker.goto(fragment);await maker.waitForFunction(()=>location.hash.length===0);assert.equal(new URL(maker.url()).hash.length,0);await maker.locator('#enter').click();
await file.goto(pathToFileURL(path).href);await file.locator('#enter').click();
for(const p of [maker,file])await p.locator('#status').filter({hasText:'已建立签名混合加密连接'}).waitFor({timeout:12000});
await maker.locator('#text').fill('网页到文件');await maker.locator('#send').click();await file.getByText('1:网页到文件',{exact:true}).waitFor({timeout:8000});await maker.getByText('客户端确认 1/1',{exact:true}).waitFor({timeout:8000});
await file.locator('#text').fill('文件到网页');await file.locator('#send').click();await maker.getByText('2:文件到网页',{exact:true}).waitFor({timeout:8000});await file.getByText('客户端确认 1/1',{exact:true}).waitFor({timeout:8000});
for(const p of [maker,file])await p.locator('#status').filter({hasText:'已加密连接'}).waitFor({timeout:12000});
await maker.locator('#text').fill('网页到文件');await maker.locator('#send').click();await file.getByText('1:网页到文件',{exact:true}).waitFor({timeout:8000});await maker.getByText('已送达 1/1',{exact:true}).waitFor({timeout:8000});
await file.locator('#text').fill('文件到网页');await file.locator('#send').click();await maker.getByText('2:文件到网页',{exact:true}).waitFor({timeout:8000});await file.getByText('已送达 1/1',{exact:true}).waitFor({timeout:8000});
assert.deepEqual(errors,[]);assert.ok(requests.every(u=>(u.startsWith(base+'/')||u.startsWith('file:'))&&!u.includes('#')&&!u.includes(config.master)));
for(const c of [a,b])assert.deepEqual(await c.cookies(),[]);
for(const p of [maker,file])assert.deepEqual(await p.evaluate(async()=>[localStorage.length,sessionStorage.length,(await indexedDB.databases()).length]),[0,0,0]);
const bad=await a.newPage();let connected=0;bad.on('websocket',()=>connected++);await bad.goto(base+'/hr.html#hr1=invalid');await bad.getByText(/房间链接无效/).waitFor();assert.equal(connected,0);
const bad=await a.newPage();let connected=0;bad.on('websocket',()=>connected++);await bad.goto(base+'/hr.html#hr1=invalid');await bad.getByText(/房间链接打不开/).waitFor();assert.equal(connected,0);
const wrapped=await a.newPage();await wrapped.goto(base+'/hr.html');await wrapped.locator('#mk-gen').click();assert.equal((await wrapped.locator('#mk-pass').inputValue()).length,64);await wrapped.locator('#mk-make').click();await wrapped.locator('#mk-out').waitFor({state:'visible'});
const encoded=new URL(await wrapped.locator('#mk-link').inputValue()).hash.slice(5);const protectedConfig=JSON.parse(Buffer.from(encoded,'base64url').toString());assert.equal(protectedConfig.encrypted,true);assert.equal('master'in protectedConfig,false);
console.log(JSON.stringify({suite:'integrated hosted maker/Fragment + file interop',checks:9,result:'pass',browser:browser.version(),consoleErrors:0,thirdPartyRequests:0}));
Expand Down
Loading
Loading