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:
1 parent
e1f7e6f529
commit
e9fe10f0f7
12 files changed
+1198
-465
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user