- Signed invites: waste: URI gains inviter+sig fields (Ed25519); hello carries the invite so receiving peers can verify against known keys - RequireInvite per-network flag: rejects peers without valid signed invite - Hash-based hang links: #waste:base64 fragment pre-fills join form without server-side leakage of network name - Multi-share: shares.json (daemon) + waste_shares localStorage (browser); IPC add_share/remove_share/list_shares commands - EXTENSIONS.md: addendum documenting all waste-go protocol deviations from YAW/2; all extensions are additive and backward compatible Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
375 lines
14 KiB
Markdown
375 lines
14 KiB
Markdown
# waste-go
|
|
|
|
A modern reimagining of [WASTE](https://en.wikipedia.org/wiki/WASTE) — decentralized,
|
|
friend-to-friend encrypted mesh networking with chat and file sharing. Written in Go.
|
|
|
|
## Project layout
|
|
|
|
```
|
|
waste-go/
|
|
├── cmd/
|
|
│ ├── daemon/ The peer process — run one on each friend's machine
|
|
│ ├── anchor/ WebSocket signaling server — run this on your VPS
|
|
│ └── tui/ Bubble Tea terminal UI (connects to a running daemon)
|
|
└── internal/
|
|
├── proto/ All wire types (shared by daemon and anchor)
|
|
├── crypto/ Ed25519 identity, nacl/box signaling, ChaCha20-Poly1305
|
|
├── mesh/ Connected peer state + DataChannel helpers
|
|
├── anchor/ Anchor client — WebRTC signaling via the anchor server
|
|
└── ipc/ Local JSON API (UI talks to daemon here, port 17337)
|
|
```
|
|
|
|
---
|
|
|
|
## Hosting on a VPS
|
|
|
|
You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager.
|
|
|
|
### 1. Build and run the anchor
|
|
|
|
```bash
|
|
# On your local machine — cross-compile for Linux
|
|
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
|
|
|
|
# Copy to VPS
|
|
scp bin/waste-anchor user@your-vps:~/waste-anchor
|
|
```
|
|
|
|
On the VPS, run the anchor and keep it alive (systemd, screen, whatever you use):
|
|
|
|
```bash
|
|
./waste-anchor -bind 127.0.0.1:8080
|
|
```
|
|
|
|
Or use the helper script which handles background execution and logging:
|
|
|
|
```bash
|
|
./setup-anchor.sh --bg # start in background, logs to waste-anchor.log
|
|
./setup-anchor.sh --stop # stop it
|
|
```
|
|
|
|
To cross-compile and redeploy the anchor binary from your local machine:
|
|
|
|
```bash
|
|
./deploy-daemon.sh
|
|
```
|
|
|
|
This kills the existing anchor, uploads the new binary, and restarts it.
|
|
|
|
The anchor listens locally on port 8080 — Nginx Proxy Manager will expose it over TLS.
|
|
|
|
### 2. Build and upload the web UI
|
|
|
|
```bash
|
|
# On your local machine
|
|
cd web
|
|
npm install
|
|
npm run build
|
|
# Produces web/dist/
|
|
|
|
# Copy to VPS
|
|
rsync -az web/dist/ user@your-vps:~/waste-www/
|
|
```
|
|
|
|
Or use the deploy script (builds + rsyncs in one step):
|
|
|
|
```bash
|
|
./deploy-web.sh
|
|
```
|
|
|
|
Create a `/var/www/waste-web/config.js` on the VPS (not in git — this is host-specific):
|
|
|
|
```js
|
|
window.WASTE_CONFIG = {
|
|
signalURL: 'wss://your-domain.com/ws',
|
|
}
|
|
```
|
|
|
|
This tells the browser where to connect for signaling. Without it the join form shows a blank signal server field and the user must fill it in manually.
|
|
|
|
### 3. Nginx Proxy Manager setup
|
|
|
|
Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need two locations:
|
|
|
|
**Location 1 — WebSocket signaling (`/ws`)**
|
|
- Location: `/ws`
|
|
- Forward hostname/IP: `127.0.0.1`
|
|
- Forward port: `8080`
|
|
- Enable: WebSockets Support
|
|
|
|
**Location 2 — Web UI (catch-all)**
|
|
- Location: `/`
|
|
- Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`)
|
|
- Enable the SPA fallback so unknown paths return `index.html` — this is required for invite links to work
|
|
|
|
If NPM doesn't support static file serving directly, run a small static server on a spare port and proxy `/` to it:
|
|
|
|
```bash
|
|
nohup npx serve -s ~/waste-www -l 1337 &
|
|
```
|
|
|
|
Or use `serve-web.sh` which handles PID tracking and restart:
|
|
|
|
```bash
|
|
./serve-web.sh # kills existing instance, starts fresh, logs to waste-www.log
|
|
```
|
|
|
|
The key requirements:
|
|
|
|
- `/ws` → anchor process (WebSocket, keep-alive)
|
|
- `/*` → static file server (SPA fallback: return `index.html` for unknown paths)
|
|
|
|
### 4. TURN relay (optional, fixes mobile / CGNAT)
|
|
|
|
WebRTC hole-punching fails when both peers are behind symmetric NAT — common on mobile data and some ISPs. A TURN relay fixes this. It runs directly on the VPS, not through Nginx Proxy Manager.
|
|
|
|
**Firewall:** open UDP 3478 (and optionally TCP 3478) on the Hetzner firewall. No NPM config needed — coturn speaks its own protocol.
|
|
|
|
**Install coturn:**
|
|
|
|
```bash
|
|
apt install coturn
|
|
```
|
|
|
|
**`/etc/turnserver.conf`:**
|
|
|
|
```
|
|
listening-port=3478
|
|
fingerprint
|
|
use-auth-secret
|
|
static-auth-secret=YOUR_SECRET_HERE
|
|
realm=your-domain.com
|
|
no-tcp-relay
|
|
```
|
|
|
|
Generate your own secret (do not reuse the example above):
|
|
|
|
```bash
|
|
openssl rand -hex 32
|
|
```
|
|
|
|
Enable and start:
|
|
|
|
```bash
|
|
systemctl enable coturn
|
|
systemctl start coturn
|
|
```
|
|
|
|
**Update `config.js`** to tell browsers about the TURN server:
|
|
|
|
```js
|
|
window.WASTE_CONFIG = {
|
|
signalURL: 'wss://your-domain.com/ws',
|
|
turnURL: 'turn:your-domain.com:3478',
|
|
turnSecret: 'YOUR_SECRET_HERE',
|
|
}
|
|
```
|
|
|
|
The `use-auth-secret` mode generates short-lived TURN credentials from the shared secret — no user database required. The relay only sees opaque DTLS-encrypted blobs.
|
|
|
|
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
|
|
|
|
---
|
|
|
|
## How it works: daemon vs browser mode
|
|
|
|
There are two ways to use the web UI.
|
|
|
|
### Browser mode (for anyone with just a URL)
|
|
|
|
When the web UI is served from a non-localhost origin — or locally with `config.js` setting `signalURL` — it runs entirely in the browser. No daemon, no install. Crypto (Ed25519/X25519) runs via libsodium compiled to WebAssembly. The identity seed is stored in `localStorage` and persists across sessions.
|
|
|
|
A user visits your domain, enters their name and a network name, and joins. Invite links (`waste:…` or `?n=name&a=wss://…`) pre-fill the join form.
|
|
|
|
**Identity note:** browser mode uses the master identity directly (same keypair on all networks, compatible with yaw2). The daemon derives a separate keypair per network via HKDF. A browser user and a daemon user on the same network will see each other and can chat — they just appear as different peers even if they're the same person.
|
|
|
|
**Session persistence:** the last-used network name, alias, and anchor URL are saved to `localStorage`. On reload, the browser automatically rejoins the saved network — no login screen. To leave a network or switch identity, click the **⏻** button in the top-left of the sidebar. You'll be asked whether to also clear the identity keypair (export a backup first if you want to keep it).
|
|
|
|
### Daemon mode (for users running the daemon locally)
|
|
|
|
`launch-web.sh` starts the Go daemon and the Vite dev server. The web UI connects to the local daemon over WebSocket IPC (`ws://127.0.0.1:17338`). The daemon handles all crypto and connects to the anchor.
|
|
|
|
When the web UI is loaded from `localhost` without a `config.js`, it defaults to daemon mode. A "Switch to browser mode" button is available in the join screen if the daemon is not running.
|
|
|
|
---
|
|
|
|
## File sharing (browser mode)
|
|
|
|
File transfer runs peer-to-peer over WebRTC DataChannels — files never touch the anchor or any server.
|
|
|
|
### Sharing a folder
|
|
|
|
In the sidebar under **Sharing**, click **+ Share folder** to pick a local directory. The selected files become available for peers to browse and download. A checkbox lets you control whether subfolders are included (default: yes).
|
|
|
|
Multiple folders can be shared — each appears in the list with a ↺ re-pick button (to restore after a page reload) and a ✕ remove button. The share list is saved in `localStorage` so it survives reloads; you'll be prompted to re-pick any folder whose files were lost on reload.
|
|
|
|
> Your browser will show a warning along the lines of "really upload X files?" when you pick a folder. This is a built-in browser security prompt — **no files are uploaded anywhere.** Files are transferred directly to a peer only when they explicitly request one via the file browser.
|
|
|
|
### Browsing a peer's files
|
|
|
|
Hover over a peer in the sidebar to reveal action buttons. Click **⊞** to request their file list. A panel opens on the right showing their shared files. Folders appear first and are clickable — navigate into them with a breadcrumb trail at the top. Sort by name or size; search to filter across all files in the current directory. Click **↓** next to any file to download it directly from that peer.
|
|
|
|
### Sending a file directly
|
|
|
|
Hover over a peer and click **📎** to open a file picker. The selected file is pushed immediately to that peer — they don't need to be sharing anything. The recipient's browser auto-downloads the file on arrival.
|
|
|
|
> In daemon mode, use `add_share` / `remove_share` via IPC to manage share roots. Share configuration is stored in `shares.json` next to `identity.json` in the data directory and survives restarts. `networks: ["*"]` makes a share visible on all networks; omit to scope it to specific network IDs. The legacy `set_share_dir` single-dir command still works alongside it.
|
|
|
|
---
|
|
|
|
## Local development
|
|
|
|
### Prerequisites
|
|
|
|
- Go 1.24+ — https://go.dev/dl/
|
|
- Node.js 20+
|
|
|
|
### Quick start (three peers in one terminal session)
|
|
|
|
```bash
|
|
# Terminal 1 — local anchor
|
|
go run ./cmd/anchor -bind 127.0.0.1:17339
|
|
|
|
# Terminal 2 — peer A
|
|
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
|
|
|
|
# Terminal 3 — peer B
|
|
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
|
|
```
|
|
|
|
Join both to a network:
|
|
|
|
```bash
|
|
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337
|
|
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341
|
|
```
|
|
|
|
### Web UI (daemon mode)
|
|
|
|
```bash
|
|
# Requires a running daemon on port 17337
|
|
./launch-web.sh
|
|
|
|
# Or with a custom alias and network:
|
|
ALIAS=alice NETWORK=friends ./launch-web.sh
|
|
```
|
|
|
|
### Automated test
|
|
|
|
```bash
|
|
./test-network.sh
|
|
```
|
|
|
|
Boots anchor + three peers, joins them to a network, sends group messages and DMs, verifies SQLite persistence.
|
|
|
|
---
|
|
|
|
## Onboarding a new peer
|
|
|
|
Alice generates an invite (TUI: `Ctrl+I`, or via IPC):
|
|
|
|
```bash
|
|
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
|
|
# → {"type":"invite_generated","invite":"waste:eyJ..."}
|
|
```
|
|
|
|
Bob joins using the invite:
|
|
|
|
```bash
|
|
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJ...'
|
|
go run ./cmd/tui --join 'waste:eyJ...'
|
|
```
|
|
|
|
The invite encodes the anchor URL and network name. Sharing it only lets the recipient join the same network — the anchor never sees plaintext messages.
|
|
|
|
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
|
|
|
### Signed invites and invite-only networks (waste-go extension)
|
|
|
|
Invites generated by `generate_invite` are **cryptographically signed** by the generating peer. The `waste:` payload carries an `inviter` field (Ed25519 public key) and a `sig` field (signature over anchor + network + inviter). When Bob joins, the invite is forwarded in the `hello` message so Alice can verify it.
|
|
|
|
To enable invite-only enforcement on a network, pass `require_invite: true` in the `join_network` command. Peers presenting no invite, an unsigned invite, or an invite signed by an unknown peer are rejected.
|
|
|
|
### "Come hang" hang links
|
|
|
|
The 🔗 button in the web UI copies a **hash-based hang link**:
|
|
|
|
```
|
|
https://your-domain.com/#waste:eyJ...
|
|
```
|
|
|
|
The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks.
|
|
|
|
See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum.
|
|
|
|
---
|
|
|
|
## Terminal UI
|
|
|
|
```bash
|
|
go run ./cmd/tui -network friends
|
|
```
|
|
|
|
| Flag | Default | Description |
|
|
|---|---|---|
|
|
| `-network` | *(required unless -join)* | Network name to join on startup |
|
|
| `-join` | — | `waste:` invite string |
|
|
| `-ipc` | `17337` | Daemon IPC port |
|
|
|
|
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
|
|
|
---
|
|
|
|
## IPC protocol
|
|
|
|
Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
|
|
|
**Commands:**
|
|
```jsonc
|
|
{"type":"join_network","network_name":"friends"}
|
|
{"type":"send_message","room":"general","body":"hi"}
|
|
{"type":"send_message","to":"<64-hex>","body":"hey"} // DM
|
|
{"type":"generate_invite"}
|
|
{"type":"get_state"}
|
|
{"type":"get_file_list"}
|
|
{"type":"get_file_list","peer_id":"<64-hex>"}
|
|
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
|
|
{"type":"add_share","path":"/home/alice/Music"} // global share
|
|
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
|
|
{"type":"remove_share","path":"/home/alice/Music"}
|
|
{"type":"list_shares"}
|
|
{"type":"export_identity","passphrase":"..."}
|
|
{"type":"import_identity","passphrase":"...","backup":"..."}
|
|
```
|
|
|
|
**Events:**
|
|
```jsonc
|
|
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>"}
|
|
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
|
|
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
|
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
|
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
|
|
{"type":"network_joined","network_id":"...","network_name":"friends"}
|
|
{"type":"invite_generated","invite":"waste:<base64>"}
|
|
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
|
|
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
|
|
{"type":"identity_exported","backup":"..."}
|
|
{"type":"error","error_message":"..."}
|
|
```
|
|
|
|
---
|
|
|
|
## Crypto
|
|
|
|
| Purpose | Algorithm |
|
|
|---|---|
|
|
| Identity | Ed25519 |
|
|
| Signaling (2.0) | XSalsa20-Poly1305, X25519 keys derived from Ed25519 |
|
|
| Signaling (2.1) | XSalsa20-Poly1305, ephemeral X25519 per session (forward secrecy) |
|
|
| Transport | WebRTC DataChannels (DTLS+SCTP via pion/webrtc) |
|
|
| File integrity | SHA-256 |
|
|
|
|
### Forward-secret signaling (YAW/2.1)
|
|
|
|
Each peer generates a fresh X25519 keypair per session and broadcasts the public half in a signed `ekey` message before sending an offer. The `esk` is zeroed when the session ends. A 2.0 peer ignores `ekey` and the offerer falls back to static-key sealing after 2 s — so 2.1↔2.0 sessions work, just without forward secrecy.
|