check / check (push) Successful in 1m1s
The bot now serves an HTTP API on PORT (default 8080) beside the chat client. Every request needs the credential read at startup from the file named by API_TOKEN_FILE, sent as a bearer token; without one, every request is refused. Responses carry the security headers, bodies are capped at 64 KiB and each request's work at 10 seconds. GET /api/v1/chats lists the bot's contacts from the chat client's /_contacts command, ordered by id, and marks the contacts who deleted their chat with the bot, which the chat client keeps listing. bot.Run starts the API after set-up and stops it within 5 seconds; a listener failure ends the bot as a chat client failure does. Model: opus-5-5
208 lines
5.6 KiB
Go
208 lines
5.6 KiB
Go
// Package config loads runtime configuration from the environment (and
|
|
// an optional ./.env file) via viper.
|
|
//
|
|
// The iron rule of this package: a value that is SET but cannot be
|
|
// parsed aborts startup. It is never replaced by the default. An
|
|
// operator who writes DEBUG=yes has said something specific that this
|
|
// program does not understand, and starting anyway with the default
|
|
// turns their mistake into a silent misconfiguration that only
|
|
// surfaces much later, somewhere else. Defaults apply to values that
|
|
// are ABSENT, and to nothing else.
|
|
//
|
|
// Every parse failure found in one pass is reported together, so a
|
|
// broken deployment takes one restart to diagnose rather than several.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
// spooky action at a distance!
|
|
// this populates the environment
|
|
// from a ./.env file automatically
|
|
// for development configuration.
|
|
// .env contents should be things like
|
|
// `DEBUG=true`
|
|
// (without the backticks, of course)
|
|
_ "github.com/joho/godotenv/autoload"
|
|
)
|
|
|
|
// Environment variable names. Bare names, no prefix: this matches the
|
|
// other services and keeps a compose file readable.
|
|
const (
|
|
EnvDataDir = "DATA_DIR"
|
|
EnvDebug = "DEBUG"
|
|
EnvPort = "PORT"
|
|
EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential
|
|
)
|
|
|
|
// Defaults, for the variables that are absent.
|
|
const (
|
|
DefaultDataDir = "./data"
|
|
DefaultPort = 8080
|
|
)
|
|
|
|
// MinAPITokenLength is the fewest characters the API credential may
|
|
// have, not counting whitespace around it.
|
|
const MinAPITokenLength = 32
|
|
|
|
const maxPort = 65535
|
|
|
|
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
|
// so callers can distinguish "the operator got it wrong" from "the
|
|
// machine is broken" without string matching.
|
|
var ErrInvalidConfig = errors.New("invalid configuration")
|
|
|
|
// Config is the parsed, validated runtime configuration. Every field
|
|
// is final by the time New returns: nothing re-reads the environment
|
|
// later, so there is exactly one moment at which configuration can be
|
|
// wrong, and it is before anything starts.
|
|
type Config struct {
|
|
// DataDir holds the SimpleX Chat database: the bot's profile, its
|
|
// address and its contacts. Losing it loses the address.
|
|
DataDir string
|
|
Debug bool
|
|
|
|
// Port is the API's TCP port.
|
|
Port int
|
|
|
|
// APIToken is the credential every API request must carry, read
|
|
// from the file named by API_TOKEN_FILE. Empty when that is absent,
|
|
// and then the API refuses every request. Never log it.
|
|
APIToken string
|
|
}
|
|
|
|
// loader parses one environment into a Config, accumulating every
|
|
// failure instead of stopping at the first, so one restart surfaces the
|
|
// whole list.
|
|
type loader struct {
|
|
v *viper.Viper
|
|
errs []error
|
|
}
|
|
|
|
func (l *loader) fail(key, raw, why string) {
|
|
l.errs = append(l.errs, fmt.Errorf(
|
|
"%w: %s=%q is %s", ErrInvalidConfig, key, raw, why,
|
|
))
|
|
}
|
|
|
|
// raw returns the trimmed value of key, and whether it was set to
|
|
// anything. Whitespace-only counts as absent: it is what an empty
|
|
// compose-file entry produces, and no key here has a meaningful blank
|
|
// value.
|
|
func (l *loader) raw(key string) (string, bool) {
|
|
s := strings.TrimSpace(l.v.GetString(key))
|
|
|
|
return s, s != ""
|
|
}
|
|
|
|
func (l *loader) str(key, def string) string {
|
|
if s, ok := l.raw(key); ok {
|
|
return s
|
|
}
|
|
|
|
return def
|
|
}
|
|
|
|
// boolean accepts what strconv.ParseBool accepts (1/t/T/TRUE/true/True
|
|
// and the false equivalents) and refuses everything else. "yes" is a
|
|
// parse failure on purpose: guessing at it is how a setting ends up the
|
|
// opposite of what the operator meant.
|
|
func (l *loader) boolean(key string, def bool) bool {
|
|
s, ok := l.raw(key)
|
|
if !ok {
|
|
return def
|
|
}
|
|
|
|
b, err := strconv.ParseBool(s)
|
|
if err != nil {
|
|
l.fail(key, s, "not a boolean (use true or false)")
|
|
|
|
return def
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
// port accepts a whole number from 1 to 65535 and refuses everything
|
|
// else.
|
|
func (l *loader) port(key string, def int) int {
|
|
s, ok := l.raw(key)
|
|
if !ok {
|
|
return def
|
|
}
|
|
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 1 || n > maxPort {
|
|
l.fail(key, s, "not a port (use a whole number from 1 to 65535)")
|
|
|
|
return def
|
|
}
|
|
|
|
return n
|
|
}
|
|
|
|
// tokenFile returns the credential held in the file named by key, with
|
|
// the whitespace around it trimmed, or "" when key is absent. A file
|
|
// that cannot be read, or holds fewer than MinAPITokenLength
|
|
// characters, is a failure; the message names the file, never what it
|
|
// holds.
|
|
func (l *loader) tokenFile(key string) string {
|
|
path, ok := l.raw(key)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
b, err := os.ReadFile(path) //nolint:gosec // G304: the operator names the file.
|
|
if err != nil {
|
|
l.fail(key, path, "unreadable: "+err.Error())
|
|
|
|
return ""
|
|
}
|
|
|
|
token := strings.TrimSpace(string(b))
|
|
if utf8.RuneCountInString(token) < MinAPITokenLength {
|
|
l.fail(key, path, fmt.Sprintf("a file holding fewer than %d characters",
|
|
MinAPITokenLength))
|
|
|
|
return ""
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
// New parses and validates the environment. An error here aborts
|
|
// startup before the chat client is launched, so there is no partially
|
|
// configured running state to reason about.
|
|
func New() (*Config, error) {
|
|
v := viper.New()
|
|
v.AutomaticEnv()
|
|
|
|
return load(v)
|
|
}
|
|
|
|
// load is New's body against an explicit viper instance, so tests can
|
|
// drive it with a known environment instead of mutating the process's.
|
|
func load(v *viper.Viper) (*Config, error) {
|
|
l := &loader{v: v}
|
|
|
|
c := &Config{
|
|
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
|
Debug: l.boolean(EnvDebug, false),
|
|
Port: l.port(EnvPort, DefaultPort),
|
|
APIToken: l.tokenFile(EnvAPITokenFile),
|
|
}
|
|
|
|
if len(l.errs) > 0 {
|
|
return nil, errors.Join(l.errs...)
|
|
}
|
|
|
|
return c, nil
|
|
}
|