WASTE_CONFIG.turnSecret was shipped in plaintext config.js and used to compute coturn HMAC credentials client-side in browser.ts. Anyone reading the PWA's JS could read the secret and mint unlimited long-lived TURN credentials, turning the relay into an open proxy. The anchor now mints short-lived (1h) credentials server-side via a new GET /turn-credentials endpoint (-turn-secret flag), mirroring what the daemon already does. The browser fetches credentials instead of holding the secret. Daemon mode was unaffected (already server-side). Docs updated to drop turnSecret from config.js examples, document the new nginx route, and instruct anyone with an old config.js to rotate the coturn secret since it was previously exposed. 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)
Prebuilt binaries
Every tagged release publishes cross-compiled binaries — no Go toolchain required. Grab them from the repo's Releases page:
https://repo.explewd.com/explewd/waste-go/releases
| File | What it is | Run it on |
|---|---|---|
waste-daemon-<os>-<arch> |
The peer process — your identity, mesh connections, file shares | Each friend's own machine |
waste-anchor-<os>-<arch> |
The signaling relay (no message content ever passes through it) | One server you control (VPS) |
<os> is linux, darwin (macOS), or windows; <arch> is amd64 or arm64 (Windows builds are amd64 only). Windows binaries have a .exe suffix.
Linux / macOS:
curl -LO https://repo.explewd.com/explewd/waste-go/releases/download/<tag>/waste-daemon-linux-amd64
chmod +x waste-daemon-linux-amd64
./waste-daemon-linux-amd64 -alias yourname -data-dir ~/.waste --join 'waste:eyJ...'
macOS Gatekeeper: unsigned binaries downloaded from a browser get a quarantine flag and macOS will refuse to run them ("cannot be opened because the developer cannot be verified"). Clear it once after downloading:
xattr -d com.apple.quarantine waste-daemon-darwin-arm64(or right-click → Open the first time, which prompts for an override).
Windows: download the .exe, then run it from PowerShell or cmd.exe:
.\waste-daemon-windows-amd64.exe -alias yourname -data-dir C:\waste --join "waste:eyJ..."
SmartScreen may warn about an unrecognized publisher on first run — these binaries aren't code-signed. Click "More info" → "Run anyway".
Once the daemon is running, drive it with the TUI (./cmd/tui built locally, or the web UI in daemon mode) — the daemon itself has no UI of its own, it just exposes the local IPC API on port 17337.
The waste-anchor binary is for whoever is hosting a network — see Hosting on a VPS below for the full anchor + web UI setup. For a quick local anchor (e.g. testing on a LAN), just run:
./waste-anchor-linux-amd64 -bind 0.0.0.0:8080
No desktop app build in the current release. A native Wails desktop app (
cmd/app/) exists in the source tree, but the CI step that packages it for releases was broken (wrong output path) until this fix — earlier tagged releases only have daemon/anchor binaries. The next tag will includewaste-linux-amd64. Until then, build it yourself with./build-app.sh(see Desktop app (Wails) below).
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 these locations:
Location 1 — WebSocket signaling (/ws)
- Location:
/ws - Forward hostname/IP:
127.0.0.1 - Forward port:
8080 - Enable: WebSockets Support
Location 1b — TURN credentials (/turn-credentials, only if using TURN — see step 4)
- Location:
/turn-credentials - Forward hostname/IP:
127.0.0.1 - Forward port:
8080 - Plain HTTP, no WebSockets toggle needed
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)/turn-credentials→ anchor process (plain HTTP; only needed if using TURN)/*→ 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
Start the anchor with the same secret, so it can mint short-lived credentials on your behalf:
./waste-anchor -bind 0.0.0.0:8080 -turn-secret YOUR_SECRET_HERE
This enables GET /turn-credentials on the anchor, which returns a fresh {username, credential} pair (1-hour TTL) computed from the shared secret — the secret itself never leaves the server.
Update config.js to tell browsers about the TURN server (no secret here — only the public relay address):
window.WASTE_CONFIG = {
signalURL: 'wss://your-domain.com/ws',
turnURL: 'turn:your-domain.com:3478',
}
Security note: earlier versions of this doc had you put
turnSecretdirectly inconfig.js. Don't — anyone reading the PWA's JS bundle could read it and mint unlimited, long-lived TURN credentials, turning your relay into an open proxy for anyone. The browser now calls the anchor's/turn-credentialsendpoint instead and only ever sees a credential that expires in an hour. If you have an oldconfig.jswithturnSecretset, remove it and rotate the coturn secret (static-auth-secretinturnserver.confand the anchor's-turn-secretflag) since the old one was exposed.
The browser adapter calls signalURL with /ws swapped for /turn-credentials to find the anchor's endpoint by default; set turnCredentialsURL explicitly in WASTE_CONFIG if the anchor is reachable at a different path. If turnURL is set but the credentials endpoint is unreachable, the browser falls back to STUN-only.
You'll also need nginx to route the new path to the anchor, alongside /ws (see step 3):
/turn-credentials→ anchor process (plain HTTP, no WebSocket upgrade needed)
Daemon mode TURN: pass -turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE when starting the daemon. This is unaffected by the above — the daemon computes credentials itself server-side and never exposes the secret, same as the anchor now does 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 · Ctrl+N — cycle networks · 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)/join <name>— join a new network at runtime (the-networkflag is optional; start idle and/jointo connect)/net <n|name>— switch active network by index or name/react <emoji>— react to the last message (use actual emoji characters, e.g./react 👍)/react <n> <emoji>— react to message[n](line numbers shown next to each message)
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":"send_reaction","network_id":"...","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
{"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":"reaction","network_id":"...","peer_id":"<64-hex>","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
{"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.