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

+56
View File
@@ -0,0 +1,56 @@
package main
import "testing"
func validTestFingerprint() string {
certDER, _, _ := generateHubCert()
return certFingerprint(certDER)
}
func TestInviteRoundtrip(t *testing.T) {
fp := validTestFingerprint()
want := inviteCode{Host: "100.101.102.103", Port: "47823", Fingerprint: fp, Token: "sekrit"}
got, err := decodeInvite(encodeInvite(want))
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("roundtrip mismatch: got %+v, want %+v", got, want)
}
}
func TestDecodeInviteRejectsMalformed(t *testing.T) {
fp := validTestFingerprint()
cases := map[string]string{
"not base64 at all": "!!! not base64 !!!",
"base64 but not json": "aGVsbG8gd29ybGQ", // "hello world"
"missing token": encodeInvite(inviteCode{Host: "h", Port: "1", Fingerprint: fp}),
"missing host": encodeInvite(inviteCode{Port: "1", Fingerprint: fp, Token: "t"}),
"bad fingerprint": encodeInvite(inviteCode{Host: "h", Port: "1", Fingerprint: "not-a-fingerprint", Token: "t"}),
}
for name, code := range cases {
if _, err := decodeInvite(code); err == nil {
t.Errorf("%s: expected an error, got none", name)
}
}
}
func TestPickBestHost(t *testing.T) {
cases := []struct {
name string
addrs []string
want string
}{
{"tailscale preferred over LAN", []string{"192.168.1.5", "100.101.102.103"}, "100.101.102.103"},
{"LAN only", []string{"192.168.1.5"}, "192.168.1.5"},
{"nothing usable", []string{"8.8.8.8"}, ""},
{"empty", nil, ""},
}
for _, c := range cases {
if got := pickBestHost(c.addrs); got != c.want {
t.Errorf("%s: pickBestHost(%v) = %q, want %q", c.name, c.addrs, got, c.want)
}
}
}