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).
99 lines
2.7 KiB
Go
99 lines
2.7 KiB
Go
// 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
|
|
}
|