check / check (push) Successful in 1m13s
The bot now serves an HTTP API on `PORT` (default 8080) beside the chat client, whose WebSocket stays on 127.0.0.1 inside the container. Every request needs `Authorization: Bearer` with the credential from the file named by `API_TOKEN_FILE`, compared in constant time; with no credential configured every request is refused, `OPTIONS *` included. `GET /api/v1/chats` lists the bot's chats. Responses carry the security headers from the repository policies; bodies, requests and the server are time- and size-bounded. The chat client stops only after the API has finished its requests. Disclosures: `contact_deleted` is an extra field; 404 and 405 answer in JSON; requests net/http cannot parse are refused by net/http without the security headers; three gosec findings are suppressed as false positives. 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
|
|
}
|