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
27 changes: 25 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@ jobs:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts
- run: npm test
- name: npm test
shell: bash
run: npm test 2>&1 | tee test-output.txt; exit ${PIPESTATUS[0]}
# Failing test names and errors as an annotation, readable from the API
# without downloading logs.
- name: Report failing tests
if: failure()
shell: bash
run: |
msg=$(grep -nE "^not ok|^# Subtest|^ +(error|expected|actual|location|code):" test-output.txt | grep -B1 -A6 "not ok" | head -80 | sed 's/%/%25/g' | awk '{printf "%s%%0A", $0}')
echo "::error title=npm test failures::${msg}"
- run: npm run build:check
browser:
runs-on: ubuntu-latest
Expand All @@ -30,6 +40,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 +75,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
85 changes: 81 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,40 @@
- 🔒 **上锁的信**:你们发的每句话都会先“锁”起来再送出去。负责传话的服务器只能看到一堆乱码,看不到内容。
- 💨 **说完就散**:本项目不会持久保存聊天记录。关掉网页,这个页面里的聊天内容就没了;对方仍可复制或截图。

### 一张图看懂

整个项目就是一个 `index.html`:怎么打开它,它就扮演什么角色。

```mermaid
flowchart TD
F["index.html(一个文件)<br/>同时是网页和服务器"]
F --> A["空白文件 · 浏览器<br/>创建器(可离线)"]
F --> B["房间文件 · 浏览器<br/>加密聊天室"]
F --> C["node index.html<br/>中继服务器"]
A --> G["生成房间文件<br/>新钥匙,可设口令"]
G -- 发给朋友 --> B
B -. "↻ 每个房间文件也能再生新房间" .-> G
B <--> R["中继(只转发密文)<br/>wss:// 经典 · bc+ / nostr+ / mqtt+ 去中心化"]
C --> R

classDef file fill:#F1EFE8,stroke:#5F5E5A,color:#2C2C2A
classDef browser fill:#EEEDFE,stroke:#534AB7,color:#26215C
classDef relay fill:#E1F5EE,stroke:#0F6E56,color:#04342C
class F file
class A,B,G browser
class C,R relay
```

- **浏览器打开空白文件**:创建器,断网也能生成房间文件。
- **浏览器打开房间文件**:加密聊天室;房间文件里也带着创建器,可以继续生成新房间。
- **`node index.html`**:中继服务器;空白文件运行时还会提供创建页面,房间文件运行时不公开自己。
- 聊天要经过中继,中继只看得到密文。

## 三步开始聊天

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

**第 2 步:发给朋友**
点「创建房间」后,你可以:
Expand All @@ -38,16 +67,42 @@

> 💡 每点一次「创建房间」都会生成一个**全新的**房间,就算密码一样,也进不了以前的房间。

## 不联网也能创建:一个文件就是整个网站

- **每个房间文件都能再生一个新房间。** 打开任意 `index.html`,展开「创建一个新房间」,就能生成钥匙不同、可设新口令的新房间文件。全程在文件里完成,**不需要联网,也不依赖任何网站**;生成的文件和官方构建逐字节一致。
- **离线创建器。** 网站本身就是一个不带房间的 `index.html`。在网站底部点「下载离线版」保存下来,以后断网也能用它创建房间。
- 只有聊天时需要连上中继。

## 房间文件同时是服务器

同一个 `index.html`,用浏览器打开是聊天室;装了 [Node.js](https://nodejs.org/)(18 或更新)的电脑上运行它,就是中继服务器,什么都不用安装:

```sh
node index.html # 本机中继 + 创建页面 http://127.0.0.1:8080/
node index.html --tunnel # 再加一个免费公网地址(需要 cloudflared)
node index.html --help # 全部选项
```

- 空白创建器运行时,同时提供创建页面和中继,**一个文件就是一个完整的网站**。
- 房间文件运行时只当中继,**不会把自己当网页公开**,以免泄露房间钥匙。
- 支持经典房间(`wss://…`)和去中心化房间(`bc+wss://…/bc/`)。其他设备需要 `wss://`:加 `--tunnel`,或用 `--tls-cert`/`--tls-key`。
- 如果提示 `Unknown file extension ".html"`,说明文件所在目录上层有声明 `"type": "module"` 的 package.json:换个目录,或复制成 `relay.cjs` 再运行 `node relay.cjs`。
- 只运行你信任或核验过(`node scripts/verify-room-file.mjs 文件`)的文件:运行等于在电脑上执行文件里的代码。

## 能做什么,不能做什么

| ✅ 可以 | ❌ 不可以 |
| --- | --- |
| 发文字消息 | 发图片、文件、语音 |
| 一个房间最多 **25 人** | 保存聊天记录 |
| 一个房间最多 **50 人**(写死在代码里) | 保存聊天记录 |
| 每条消息最多 **500 字** | 不联网使用(聊天时必须有网) |
| 断网后自动重连 | 找回已关闭页面里的消息 |

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

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

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

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

Expand All @@ -72,6 +127,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 show no relay box; links opened on the site do.
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(), true, 'the hosted creator can reach typed relays too');

// 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 without the box, links on the site with it, maker default');
} finally {
await browser?.close(); await Promise.all([own.stop(), nostr.stop()]); await rm(dir, { recursive: true, force: true });
}
Loading
Loading