check / check (push) Successful in 3m40s
fx wrote its own steps of starting and stopping as plain text to stderr. It now logs them with its own slog event logger through the backend's logger, so off a terminal every line from start to stop is JSON. A config file that is found but cannot be read now makes config.New return the error instead of panicking. A test runs the server as a child process and checks that all it writes is JSON, on a normal start and stop and with such a config file. The backend logs its name, version and architecture once at start. The health check's uptime keys are now uptime_seconds and uptime_human, and its type and method take the names GO_HTTP_SERVER_CONVENTIONS.md gives. Its path, content type, status and code are unchanged. Model: opus-5-5
218 lines
6.2 KiB
Go
218 lines
6.2 KiB
Go
// Package config loads application configuration from
|
|
// environment variables, .env files, and config files.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/netwatch/internal/globals"
|
|
"sneak.berlin/go/netwatch/internal/logger"
|
|
|
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
|
"github.com/spf13/viper"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
// defaultTrustedProxies lists the networks whose forwarded
|
|
// headers are honoured by default: IPv4 and IPv6 loopback,
|
|
// for a reverse proxy on the same host, and the RFC1918
|
|
// ranges. The container image does not use it:
|
|
// bin/entrypoint.sh gives the server 127.0.0.1/32, since
|
|
// nginx is its only client there.
|
|
const defaultTrustedProxies = "127.0.0.1/32,::1/128," +
|
|
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
|
|
// Default limits on stored reports; backend/README.md gives the
|
|
// reasons for these values.
|
|
const (
|
|
defaultReportsPerMinute = 60
|
|
defaultDataDirMaxBytes = 1 << 30 // 1 GiB
|
|
)
|
|
|
|
var (
|
|
errNotPositive = errors.New("must be a positive whole number")
|
|
errNotOrigin = errors.New(
|
|
"must be an origin, scheme://host with an optional port",
|
|
)
|
|
errNotPort = errors.New("must be a port number, 1 to 65535")
|
|
errNotBool = errors.New("must be true or false")
|
|
errNotIP = errors.New("must be an IP address, or empty")
|
|
)
|
|
|
|
// Params defines the dependencies for Config.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Globals *globals.Globals
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Config holds the resolved application configuration.
|
|
type Config struct {
|
|
BindAddress string
|
|
CORSAllowedOrigins []string
|
|
DataDir string
|
|
DataDirMaxBytes int64
|
|
Debug bool
|
|
MetricsPassword string
|
|
MetricsUsername string
|
|
Port int
|
|
ReportsPerMinute int
|
|
SentryDSN string
|
|
TrustedProxies []string
|
|
log *slog.Logger
|
|
params *Params
|
|
}
|
|
|
|
// New loads configuration from env, .env files, and config
|
|
// files, returning a fully resolved Config. It fails, with an error
|
|
// naming the setting, on a value the server cannot use, and on a
|
|
// config file it finds but cannot read.
|
|
func New(
|
|
_ fx.Lifecycle,
|
|
params Params,
|
|
) (*Config, error) {
|
|
log := params.Logger.Get()
|
|
name := params.Globals.Appname
|
|
|
|
viper.SetConfigName(name)
|
|
viper.SetConfigType("yaml")
|
|
viper.AddConfigPath("/etc/" + name)
|
|
viper.AddConfigPath("$HOME/.config/" + name)
|
|
|
|
viper.AutomaticEnv()
|
|
|
|
// An empty CORS_ALLOWED_ORIGINS allows no other origin.
|
|
viper.SetDefault("CORS_ALLOWED_ORIGINS", "")
|
|
viper.SetDefault("DATA_DIR", "./data/reports")
|
|
viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes)
|
|
viper.SetDefault("DEBUG", "false")
|
|
// An empty BIND_ADDRESS listens on every interface.
|
|
viper.SetDefault("BIND_ADDRESS", "")
|
|
viper.SetDefault("PORT", "8080")
|
|
viper.SetDefault("REPORTS_PER_MINUTE", defaultReportsPerMinute)
|
|
viper.SetDefault("SENTRY_DSN", "")
|
|
viper.SetDefault("METRICS_USERNAME", "")
|
|
viper.SetDefault("METRICS_PASSWORD", "")
|
|
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
|
|
|
|
err := viper.ReadInConfig()
|
|
if err != nil {
|
|
var notFound viper.ConfigFileNotFoundError
|
|
if !errors.As(err, ¬Found) {
|
|
return nil, fmt.Errorf("config file %s: %w",
|
|
viper.ConfigFileUsed(), err)
|
|
}
|
|
}
|
|
|
|
// Read with strconv: viper's GetInt and GetBool would read a value
|
|
// they cannot parse as 0 or false instead of failing.
|
|
port, err := strconv.Atoi(viper.GetString("PORT"))
|
|
if err != nil || port < 1 || port > math.MaxUint16 {
|
|
return nil, fmt.Errorf("PORT %q: %w",
|
|
viper.GetString("PORT"), errNotPort)
|
|
}
|
|
|
|
debug, err := strconv.ParseBool(viper.GetString("DEBUG"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DEBUG %q: %w",
|
|
viper.GetString("DEBUG"), errNotBool)
|
|
}
|
|
|
|
s := &Config{
|
|
BindAddress: viper.GetString("BIND_ADDRESS"),
|
|
CORSAllowedOrigins: splitList(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
|
DataDir: viper.GetString("DATA_DIR"),
|
|
DataDirMaxBytes: viper.GetInt64("DATA_DIR_MAX_BYTES"),
|
|
Debug: debug,
|
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
|
Port: port,
|
|
ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"),
|
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
|
TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
|
|
log: log,
|
|
params: ¶ms,
|
|
}
|
|
|
|
err = s.check()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if s.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
s.log = params.Logger.Get()
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// check fails with an error naming the first setting here whose value
|
|
// the server cannot use. New checks PORT and DEBUG as it reads them,
|
|
// and the middleware checks TRUSTED_PROXIES as it parses it.
|
|
func (s *Config) check() error {
|
|
// viper reads a value that is not a number as 0, so this also
|
|
// catches a mistyped setting.
|
|
if s.ReportsPerMinute <= 0 {
|
|
return fmt.Errorf("REPORTS_PER_MINUTE %q: %w",
|
|
viper.GetString("REPORTS_PER_MINUTE"), errNotPositive)
|
|
}
|
|
|
|
if s.DataDirMaxBytes <= 0 {
|
|
return fmt.Errorf("DATA_DIR_MAX_BYTES %q: %w",
|
|
viper.GetString("DATA_DIR_MAX_BYTES"), errNotPositive)
|
|
}
|
|
|
|
if s.BindAddress != "" {
|
|
_, err := netip.ParseAddr(s.BindAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("BIND_ADDRESS %q: %w", s.BindAddress, errNotIP)
|
|
}
|
|
}
|
|
|
|
return checkOrigins(s.CORSAllowedOrigins)
|
|
}
|
|
|
|
// checkOrigins fails on the first CORS_ALLOWED_ORIGINS entry that is
|
|
// not a plain origin, scheme://host with an optional port, as browsers
|
|
// send it; anything more, such as a trailing "/", would match no page.
|
|
// go-chi/cors reads a "*" anywhere in an entry as a wildcard, so no
|
|
// entry may contain one.
|
|
func checkOrigins(origins []string) error {
|
|
for _, origin := range origins {
|
|
u, err := url.Parse(origin)
|
|
if err != nil || u.Scheme == "" || u.Host == "" ||
|
|
strings.Contains(origin, "*") ||
|
|
origin != u.Scheme+"://"+u.Host {
|
|
return fmt.Errorf("CORS_ALLOWED_ORIGINS %q: %w",
|
|
origin, errNotOrigin)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// splitList turns a comma-separated setting into a trimmed
|
|
// slice, dropping empty entries.
|
|
func splitList(raw string) []string {
|
|
parts := strings.Split(raw, ",")
|
|
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|