// 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") }