-turn-url and -turn-secret flags on the daemon; credentials generated using coturn use-auth-secret HMAC-SHA1 scheme (same as browser mode). ICEServers field on mesh.Mesh threads extra ICE servers through to every PeerConnection created by the anchor client. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
waste-go
A modern reimagining of 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
# 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):
./waste-anchor -bind 127.0.0.1:8080
Or use the helper script which handles background execution and logging:
./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:
./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
# 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):
./deploy-web.sh
Create a /var/www/waste-web/config.js on the VPS (not in git — this is host-specific):
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:
nohup npx serve -s ~/waste-www -l 1337 &
Or use serve-web.sh which handles PID tracking and restart:
./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: returnindex.htmlfor 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:
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):
openssl rand -hex 32
Enable and start:
systemctl enable coturn
systemctl start coturn
Update config.js to tell browsers about the TURN server:
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
turnURLandturnSecretfromWASTE_CONFIGand adds the TURN server to the WebRTCICEServerslist 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_sharevia IPC to manage share roots. Share configuration is stored inshares.jsonnext toidentity.jsonin the data directory and survives restarts.networks: ["*"]makes a share visible on all networks; omit to scope it to specific network IDs. The legacyset_share_dirsingle-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)
# 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:
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)
# 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
./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):
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
# → {"type":"invite_generated","invite":"waste:eyJ..."}
Bob joins using the invite:
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 for the full protocol addendum.
Terminal UI
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:
{"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:
{"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.