Add TLS + certificate pinning for the peer link

The co-op link was authenticated (HMAC token, never sent over the wire)
but not encrypted. The hub now generates a self-signed cert on first run;
the peer pins its exact fingerprint (no CA involved — there isn't one for
a Tailscale/LAN address), delivered via a single invite-code paste that
also carries the token, replacing today's separate IP+token copy.

The peer link moves to its own TLS-only port (peer_listen, 47823) so the
plain overlay/panel port (47822, OBS-facing) never needs to be exposed
alongside it — today, opening the overlay port to a remote partner also
exposes /deaths and the panel to anyone.

Mandatory pinning, no insecure fallback: a half-configured peer (some but
not all of hub/token/fingerprint, or a broken invite) fails loudly at
startup rather than connecting unpinned. An unconfigured peer still runs
fine as a local-only overlay, same as before.

New: tlscert.go (cert generation/persistence), pin.go (fingerprint
pinning), invite.go (invite-code encode/decode, host auto-detection),
each with tests. main.go/config.go/duo.go/ws.go carry the wiring for
this — the dual listener, new config keys, and the TLS-aware WebSocket
dial — and were rewritten in English in the process, per the project's
new English-only code convention (see CLAUDE.md).
This commit is contained in:
emmatherock committed 2026-09-17 21:51:22 -03:00
1 parent e1f7e6f529
commit e9fe10f0f7
12 files changed
+1198 -465

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
// invite.go: the one-paste invitation code a hub prints and a peer pastes
// into their config, replacing separately copying an IP and a token.
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"strings"
)
type inviteCode struct {
Host string `json:"host"`
Port string `json:"port"`
Fingerprint string `json:"fingerprint"`
Token string `json:"token"`
}
func encodeInvite(c inviteCode) string {
b, _ := json.Marshal(c) // a struct of plain strings: Marshal can't fail
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeInvite(s string) (inviteCode, error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(s))
if err != nil {
return inviteCode{}, fmt.Errorf("invite code isn't valid base64: %w", err)
}
var c inviteCode
if err := json.Unmarshal(raw, &c); err != nil {
return inviteCode{}, fmt.Errorf("invite code doesn't decode to a valid invite: %w", err)
}
if c.Host == "" || c.Port == "" || c.Token == "" {
return inviteCode{}, fmt.Errorf("invite code is missing a host, port, or token")
}
if !validFingerprint(c.Fingerprint) {
return inviteCode{}, fmt.Errorf("invite code's fingerprint doesn't look like a SHA-256 hex digest")
}
return c, nil
}
// tailscaleCGNAT is the address range Tailscale assigns its clients from.
var tailscaleCGNAT = mustParseCIDR("100.64.0.0/10")
func mustParseCIDR(s string) *net.IPNet {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic(err) // a hardcoded literal: only fails if this code is wrong
}
return n
}
// pickBestHost picks the address a co-op partner is most likely to be
// able to reach: a Tailscale address over a plain private-LAN one (a VPN
// like ZeroTier or WireGuard usually also hands out a private-range
// address, so this still prefers it over nothing) over nothing at all.
// It's a guess, not a guarantee — the hub operator can always override it
// with the "hub" config key if it's wrong.
func pickBestHost(addrs []string) string {
var private string
for _, a := range addrs {
ip := net.ParseIP(a)
if ip == nil {
continue
}
if tailscaleCGNAT.Contains(ip) {
return a
}
if private == "" && ip.IsPrivate() {
private = a
}
}
return private
}
// candidateIPv4s lists this machine's non-loopback IPv4 addresses, for
// pickBestHost to choose from and for logging the full list so a human
// can pick a different one if the guess is wrong.
func candidateIPv4s() []string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return nil
}
var out []string
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok || ipNet.IP.IsLoopback() {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil {
continue
}
out = append(out, ip4.String())
}
return out
}