Multi-network: - Left panel now shows Networks section above Rooms - /join <name> joins a new network at runtime; -network flag now optional - ctrl+n cycles between joined networks; /net <n|name> switches by number or name - All state (rooms, peers, messages) scoped per network via netData struct - Messages keyed by "netId:room" (same as web store) Reactions: - /react <emoji> — react to the last message in the current room - /react <n> <emoji> — react to message number n - Reactions displayed as a dimmed line below each message: 👍 2 ❤️ 1 - EvtReaction handler updates in-place and refreshes viewport History: - EvtHistoryLoaded now handled: historical messages prepended, deduped by mid, sorted by time UX: - Each message prefixed with [n] line number so /react targets are unambiguous - -network flag is now optional (start idle, /join to connect) - Status bar hint updated with new commands 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).
Daemon mode TURN: pass -turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE when starting the daemon. The same coturn use-auth-secret HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode.
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.
Mobile: the web UI ships a PWA manifest (manifest.json) so iOS and Android users can pin it to their home screen via "Add to Home Screen" in Safari or Chrome. It runs as a standalone app with no browser chrome. No app store, no native install required.
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.
Transfer resume (daemon mode)
When a file transfer is interrupted mid-stream — peer disconnects, network drops — the partially-received data is kept on disk. A .meta sidecar is written alongside each in-progress .tmp file recording the file name, size, and SHA-256.
When the peer reconnects and re-offers the same file, the daemon finds the matching partial by SHA-256, sends back a file-accept with a non-zero resume_offset, and the sender seeks to that byte position before streaming. The receiver appends to the existing file and verifies the full SHA-256 at the end.
Corrupted transfers (all bytes received but hash mismatch) are removed. Interrupted transfers are kept indefinitely until either successfully completed or the .tmp/.meta files are manually deleted.
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.
Daemon download directory
Received files are saved into a per-network subdirectory so networks stay isolated. By default this is inside the data directory:
~/.waste/downloads-<network-id>/
Pass -download-dir at startup to use a different base:
go run ./cmd/daemon -download-dir ~/Downloads ...
Files then land in ~/Downloads/downloads-<network-id>/. The path can also be changed at runtime via the set_download_dir IPC command (takes effect for the next incoming transfer on that network). The current path is reported in every state_snapshot event under networks[].download_dir.
Desktop app (Wails)
cmd/app/ is a Wails v2 shell that packages the React frontend and the daemon logic into a single native binary for Windows, macOS, and Linux. No Rust, no Electron — just Go + the OS webview.
The daemon runs embedded in the same process. The webview connects to ws://127.0.0.1:17338 exactly as it does in browser-daemon mode, so the frontend code is unchanged. Identity and stores use the OS config directory (~/Library/Application Support/waste on macOS, ~/.config/waste on Linux, %APPDATA%\waste on Windows).
Prerequisites
- Go 1.24+
- Node.js 20+
- Wails CLI:
go install github.com/wailsapp/wails/v2/cmd/wails@latest - Linux platform deps:
sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev
Build
# Production build → dist/waste-linux-amd64 (or waste.exe / waste.app)
./build-app.sh
# Dev mode (hot-reload; start Vite in another terminal: cd web && npm run dev)
./build-app.sh dev
# Build without system tray (no libayatana-appindicator3-dev needed)
CGO_ENABLED=1 WAILS_TAGS=notray ./build-app.sh
build-app.sh builds the React frontend, copies web/dist/ into cmd/app/frontend/dist/ for embedding, then runs wails build. The result is a single self-contained binary.
What the desktop app provides
- Single binary — daemon logic is embedded, no subprocess to manage
- System tray (Linux/Windows) — hide to tray, reopen from tray menu, Quit
- macOS — hides to Dock on window close; full menu-bar tray is future work
- OS notifications — native popup on new message or completed file transfer (uses the browser
NotificationAPI via Wails events; the app will request permission on first notification) - Data directory — OS-appropriate config dir (
~/.config/waste,~/Library/Application Support/waste,%APPDATA%\waste)
CI / automated builds
.gitea/workflows/build.yml runs on every v* tag push:
- Server binaries (daemon + anchor): cross-compiled for Linux amd64/arm64, macOS amd64/arm64, Windows amd64 — no CGo, no special runner needed
- Desktop app: Linux amd64 on the default runner (installs GTK + WebKit + appindicator deps automatically)
- Releases: all artifacts attached to the Gitea release
To add macOS or Windows desktop builds, add self-hosted Gitea runners on those platforms and mirror the desktop-linux job.
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
Slash commands: /room <name> — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a * prefix in the sidebar.
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","network_ids":["abc123"]} // network-scoped
{"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"}
{"type":"create_room","room":"dev"}
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
{"type":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."}
Events:
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>","networks":[{"network_id":"...","network_name":"friends","share_dir":"...","download_dir":"..."}]}
{"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":"room_created","network_id":"...","room":"dev"}
{"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.