check / check (push) Successful in 1m1s
POST /api/v1/reports stays unauthenticated but is bounded. Each client address, as the trusted-proxy logic resolves it, may send REPORTS_PER_MINUTE reports a minute (default 60), counted by go-chi/httprate over a sliding minute; past that it gets 429 with Retry-After. reportbuf refuses a report that would take the report files past DATA_DIR_MAX_BYTES (default 1 GiB) with ErrFull, answered with 507; the count starts from the files already in DATA_DIR, and reports not yet written count at their uncompressed size. CORS adds nothing unless CORS_ALLOWED_ORIGINS lists origins. A limit that is not a positive number stops the server from starting. Model: opus-5-5
151 lines
4.2 KiB
Go
151 lines
4.2 KiB
Go
// Package config loads application configuration from
|
|
// environment variables, .env files, and config files.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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. It covers the RFC1918
|
|
// ranges (to match nginx.conf) plus IPv4 and IPv6 loopback,
|
|
// because the reverse proxy shares the container and reaches
|
|
// the backend over loopback.
|
|
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")
|
|
|
|
// 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.
|
|
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) {
|
|
log.Error("config file malformed", "error", err)
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
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: viper.GetBool("DEBUG"),
|
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
|
Port: viper.GetInt("PORT"),
|
|
ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"),
|
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
|
TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
|
|
log: log,
|
|
params: ¶ms,
|
|
}
|
|
|
|
// viper reads a value that is not a number as 0, so this also
|
|
// catches a mistyped setting.
|
|
if s.ReportsPerMinute <= 0 {
|
|
return nil, fmt.Errorf("REPORTS_PER_MINUTE %q: %w",
|
|
viper.GetString("REPORTS_PER_MINUTE"), errNotPositive)
|
|
}
|
|
|
|
if s.DataDirMaxBytes <= 0 {
|
|
return nil, fmt.Errorf("DATA_DIR_MAX_BYTES %q: %w",
|
|
viper.GetString("DATA_DIR_MAX_BYTES"), errNotPositive)
|
|
}
|
|
|
|
if s.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
s.log = params.Logger.Get()
|
|
}
|
|
|
|
return s, 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
|
|
}
|