The Go module moves from backend/ to the root (go.mod, go.sum, .golangci.yml, cmd/, internal/), the page's source to web/, and nginx's two files to configs/. backend/ is gone: its LICENSE and .dockerignore are deleted, and its .gitignore entries, README and scripts are folded into the root ones. script/build builds the server binary after the frontend; script/run and script/clean move up from backend/script/; make test, lint, fmt and fmt-check do what the backend's did. Vite's root is web/ and it still builds into dist/. go.mod ignores node_modules/, whose npm packages can ship .go files. script/bootstrap no longer installs gcc, which only the host Go tests needed. Model: opus-5-5
This commit was merged in pull request #127.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
// 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; 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")
|
||||
|
||||
errMetricsCredentials = errors.New(
|
||||
"METRICS_USERNAME and METRICS_PASSWORD must be set together, " +
|
||||
"or neither",
|
||||
)
|
||||
errMetricsUsernameColon = errors.New(
|
||||
"METRICS_USERNAME must not contain \":\", " +
|
||||
"which basic auth cannot carry in a user name",
|
||||
)
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// The server records and serves metrics only with both set, so
|
||||
// one alone is a mistake that would otherwise go unnoticed.
|
||||
if (s.MetricsUsername == "") != (s.MetricsPassword == "") {
|
||||
return errMetricsCredentials
|
||||
}
|
||||
|
||||
// Basic auth splits the credentials at the first ":", so with one
|
||||
// in the user name every request to /metrics would get 401.
|
||||
if strings.Contains(s.MetricsUsername, ":") {
|
||||
return errMetricsUsernameColon
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user