Files
deathwatch/config.go
T
emmatherock e9fe10f0f7 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).
2026-09-17 21:51:22 -03:00

354 lines
11 KiB
Go

// config.go: reading config.toml.
//
// A hand-rolled, deliberately LIMITED TOML parser. The reason for not
// using a library (BurntSushi/toml or similar) is that the environment
// this was originally compiled in had no access to the Go module proxy,
// and it also keeps the program a single .exe with no dependencies.
//
// Supports: comments (#), section headers ([something]), and
// "key = value" entries where the value is a string ("..." or '...'), a
// boolean, or an integer. Does NOT support arrays, inline tables,
// multiline strings, or dotted keys. Anything outside that subset gets
// reported with a line number instead of silently ignored: better to
// find out at startup than midway through a stream.
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
// Listens on every interface: OBS often runs on another PC on the
// LAN, so 127.0.0.1 (local only) isn't enough.
listenAddr = "0.0.0.0:47822"
// Separate port, TLS only, for the peer alone: see pin.go/tlscert.go.
// The overlay/panel/deaths NEVER go through here, on purpose.
peerListenAddr = "0.0.0.0:47823"
buildTag = "build-21-tls-peer-link"
)
type config struct {
Name string // how this player is shown; empty = character name
Mode string // "hub" or "peer"
Listen string // where the panel/overlay is served (HTTP, no TLS)
PeerListen string // hub: where it listens for the peer connection (TLS)
Hub string // peer mode: the hub's address (host:PeerListen)
Token string // optional shared secret
Invite string // peer mode: invitation code (see invite.go)
Fingerprint string // peer mode: the hub's certificate fingerprint, manual
Partner string // optional: forces co-op mode, reserving this name
Language string // interface language: "auto", "en", "es", ...
clientID string // not read from the file: see clientID()
}
func defaultConfig() config {
return config{
Mode: "hub",
Listen: listenAddr,
PeerListen: peerListenAddr,
Language: "auto",
}
}
func configPath() string {
exe, err := os.Executable()
if err != nil {
return "config.toml"
}
return filepath.Join(filepath.Dir(exe), "config.toml")
}
// tomlValue is an already-parsed value: we keep the raw text because all
// our keys are strings, but we still validate the type.
type tomlValue struct {
str string
line int
}
// parseTOML returns the root table's key map. Keys inside a section come
// out as "section.key".
func parseTOML(src string) (map[string]tomlValue, []string) {
out := map[string]tomlValue{}
var problems []string
section := ""
for i, raw := range strings.Split(src, "\n") {
lineNo := i + 1
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") {
if !strings.HasSuffix(line, "]") || strings.HasPrefix(line, "[[") {
problems = append(problems, fmt.Sprintf("line %d: section header I don't understand: %s", lineNo, line))
continue
}
section = strings.TrimSpace(line[1 : len(line)-1])
continue
}
key, rest, ok := strings.Cut(line, "=")
if !ok {
problems = append(problems, fmt.Sprintf("line %d: expected \"key = value\" and found: %s", lineNo, line))
continue
}
key = strings.TrimSpace(key)
if key == "" {
problems = append(problems, fmt.Sprintf("line %d: missing key name", lineNo))
continue
}
if section != "" {
key = section + "." + key
}
val, err := parseTOMLValue(strings.TrimSpace(rest))
if err != nil {
problems = append(problems, fmt.Sprintf("line %d (%s): %v", lineNo, key, err))
continue
}
out[strings.ToLower(key)] = tomlValue{str: val, line: lineNo}
}
return out, problems
}
func parseTOMLValue(s string) (string, error) {
if s == "" {
return "", fmt.Errorf("missing value")
}
switch s[0] {
case '"':
return parseQuoted(s, '"', true)
case '\'':
return parseQuoted(s, '\'', false)
case '[', '{':
return "", fmt.Errorf("this program doesn't support lists or inline tables")
}
// No quotes: boolean or integer. Trim a trailing comment.
if idx := strings.Index(s, "#"); idx >= 0 {
s = strings.TrimSpace(s[:idx])
}
switch strings.ToLower(s) {
case "true":
return "true", nil
case "false":
return "false", nil
}
if _, err := strconv.ParseInt(s, 10, 64); err == nil {
return s, nil
}
return "", fmt.Errorf("unquoted value that isn't a boolean or an integer: %q (missing quotes?)", s)
}
// parseQuoted reads a quoted string and discards whatever comes after it
// if it's a comment. Double quotes interpret the usual escapes; single
// quotes are literal, like in TOML.
func parseQuoted(s string, quote byte, escapes bool) (string, error) {
var sb strings.Builder
for i := 1; i < len(s); i++ {
c := s[i]
if escapes && c == '\\' {
if i+1 >= len(s) {
return "", fmt.Errorf("trailing backslash doesn't escape anything")
}
i++
switch s[i] {
case 'n':
sb.WriteByte('\n')
case 't':
sb.WriteByte('\t')
case 'r':
sb.WriteByte('\r')
case '"':
sb.WriteByte('"')
case '\\':
sb.WriteByte('\\')
default:
return "", fmt.Errorf("unsupported escape: \\%c", s[i])
}
continue
}
if c == quote {
trailing := strings.TrimSpace(s[i+1:])
if trailing != "" && !strings.HasPrefix(trailing, "#") {
return "", fmt.Errorf("extra text after the value: %q", trailing)
}
return sb.String(), nil
}
sb.WriteByte(c)
}
return "", fmt.Errorf("missing closing quote")
}
var knownKeys = map[string]bool{
"name": true, "mode": true, "listen": true, "peer_listen": true,
"hub": true, "token": true, "invite": true, "fingerprint": true,
"partner": true, "language": true,
}
func loadConfig() config {
cfg := defaultConfig()
data, err := os.ReadFile(configPath())
if err != nil {
if !os.IsNotExist(err) {
log.Printf("couldn't read config.toml (%v): continuing with the defaults", err)
}
return cfg
}
values, problems := parseTOML(string(data))
for _, p := range problems {
log.Printf("config.toml: %s", p)
}
for k, v := range values {
if !knownKeys[k] {
log.Printf("config.toml: unknown key %q (line %d), ignoring it", k, v.line)
continue
}
switch k {
case "name":
cfg.Name = v.str
case "mode":
cfg.Mode = strings.ToLower(v.str)
case "listen":
if v.str != "" {
cfg.Listen = v.str
}
case "peer_listen":
if v.str != "" {
cfg.PeerListen = v.str
}
case "hub":
cfg.Hub = v.str
case "token":
cfg.Token = v.str
case "invite":
cfg.Invite = v.str
case "fingerprint":
cfg.Fingerprint = v.str
case "partner":
cfg.Partner = v.str
case "language":
cfg.Language = v.str
}
}
if cfg.Mode != "hub" && cfg.Mode != "peer" {
log.Printf("config.toml: mode = %q is neither \"hub\" nor \"peer\", using \"hub\"", cfg.Mode)
cfg.Mode = "hub"
}
return cfg
}
const sampleConfig = `# DeathWatch - Elden Ring death counter
# Edit this file and restart the program to apply changes.
# Name shown in the overlay.
# Leave it empty to use your in-game character name.
name = ""
# "hub" = this PC serves the overlay to OBS and receives partners.
# "peer" = this PC only reads its own game and pushes to the hub.
mode = "hub"
# Address the panel and overlay listen on. Plain HTTP, meant for OBS and
# your own browser — never exposed to your co-op partner directly.
listen = "0.0.0.0:47822"
# Hub only: address for the encrypted connection to your peer (TLS,
# certificate pinned). Separate port from "listen" above ON PURPOSE: OBS's
# browser source and the panel must never see a self-signed certificate,
# so only THIS port needs exposing to your partner's network.
peer_listen = "0.0.0.0:47823"
# Peer mode: paste the invite code the hub's owner gave you. It already
# contains the hub's address, port, certificate fingerprint, and the
# shared token, so this one line replaces filling in hub/token/fingerprint
# by hand below.
invite = ""
# Peer mode, manual alternative to invite (or an override of just the
# host:port from it, e.g. if the hub guessed the wrong network interface).
# NOTE: this has to be the hub's peer_listen port (47823 by default), NOT
# its listen port (47822) — that's the single most common mistake here.
hub = ""
# Connection secret. REQUIRED for co-op, one way or another: either via
# invite above, or set by hand here together with fingerprint below (both
# together — there is no unencrypted, unpinned fallback).
# The hub generates one on first run and saves it to token.txt; it is
# also printed in the console alongside the invite code.
# The token itself never travels over the network: the hub sends a random
# challenge and the peer answers with an HMAC of it.
token = ""
# Peer mode, manual alternative to invite: the SHA-256 fingerprint of the
# hub's certificate (also printed in the hub's console). Pins the
# connection to that exact certificate — if the hub ever regenerates it
# (e.g. hub-cert.pem got deleted), this needs updating too.
fingerprint = ""
# Interface language. "auto" follows your Windows language and falls
# back to English. Any file in locales/ is a valid value, e.g. "en", "es".
language = "auto"
# Optional. Hub only: your partner's name. Setting it forces the co-op
# layout from startup, with their slot reserved before they connect.
# Leave it empty to switch to co-op automatically when they show up.
partner = ""
`
// clientID returns a stable identifier for THIS installation, saved to
// client-id.txt next to the program.
//
// It exists because the name doesn't work as a player's identity: from
// the moment we read it off the character, it changes every time they
// load a different save. With its own id, the hub keeps seeing the same
// partner even if they switch characters, reconnect, or restart the
// program.
func clientID() string {
path := "client-id.txt"
if exe, err := os.Executable(); err == nil {
path = filepath.Join(filepath.Dir(exe), "client-id.txt")
}
if data, err := os.ReadFile(path); err == nil {
if id := strings.TrimSpace(string(data)); id != "" {
return id
}
}
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// No randomness available: still return something stable for
// this run.
return fmt.Sprintf("pid-%d", os.Getpid())
}
id := hex.EncodeToString(b[:])
if err := os.WriteFile(path, []byte(id), 0644); err != nil {
log.Printf("couldn't save client-id.txt (%v): using a fresh id every startup", err)
}
return id
}
// writeSampleConfig leaves a commented config.toml the first time.
func writeSampleConfig() {
path := configPath()
if _, err := os.Stat(path); err == nil {
return
}
if err := os.WriteFile(path, []byte(sampleConfig), 0644); err != nil {
log.Printf("couldn't write the sample config.toml: %v", err)
return
}
log.Printf("left a sample config.toml next to the program")
}