// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini package main import ( "errors" "fmt" "log/slog" "net" "strconv" ) // Environment variable names const ( envDBPath = "DB_PATH" envHTTPAddr = "HTTP_ADDR" envHTTPPort = "HTTP_PORT" envLogLevel = "LOG_LEVEL" ) // Default values for the optional environment variables const ( defaultHTTPAddr = "0.0.0.0" defaultHTTPPort = 80 defaultLogLevel = slog.LevelInfo ) // errConfig marks configuration errors (exit code 2) var errConfig = errors.New("configuration error") // Config is the fully validated runtime configuration type Config struct { DBPath string HTTPAddr string HTTPPort int LogLevel slog.Level } // ListenAddress returns the host:port pair the HTTP server binds to func (c Config) ListenAddress() string { return net.JoinHostPort(c.HTTPAddr, strconv.Itoa(c.HTTPPort)) } // ListenNetwork returns "tcp4" for IPv4 addresses (so 0.0.0.0 binds IPv4 only) // and "tcp" for IPv6 addresses (so :: binds dual-stack where the OS allows it) func (c Config) ListenNetwork() string { if ip := net.ParseIP(c.HTTPAddr); ip != nil && ip.To4() != nil { return "tcp4" } return "tcp" } // configErrorf builds an error wrapping errConfig func configErrorf(format string, args ...any) error { return fmt.Errorf("%w: "+format, append([]any{errConfig}, args...)...) } // LoadConfig reads and validates the configuration from the given environment lookup function // Variables are validated in the order DB_PATH, HTTP_ADDR, HTTP_PORT, LOG_LEVEL; the first error wins func LoadConfig(lookupEnv func(string) (string, bool)) (Config, error) { cfg := Config{ HTTPAddr: defaultHTTPAddr, HTTPPort: defaultHTTPPort, LogLevel: defaultLogLevel, } // DB_PATH is mandatory v, ok := lookupEnv(envDBPath) if !ok { return Config{}, configErrorf("%s is required", envDBPath) } if v == "" { return Config{}, configErrorf("%s must not be empty", envDBPath) } cfg.DBPath = v // HTTP_ADDR must be a literal IPv4 or IPv6 address if v, ok := lookupEnv(envHTTPAddr); ok { if v == "" { return Config{}, configErrorf("%s must not be empty", envHTTPAddr) } if net.ParseIP(v) == nil { return Config{}, configErrorf("invalid %s %q: must be an IPv4 or IPv6 address", envHTTPAddr, v) } cfg.HTTPAddr = v } // HTTP_PORT must be a decimal integer in 1-65535 if v, ok := lookupEnv(envHTTPPort); ok { if v == "" { return Config{}, configErrorf("%s must not be empty", envHTTPPort) } port, err := parsePort(v) if err != nil { return Config{}, configErrorf("invalid %s %q: must be an integer between 1 and 65535", envHTTPPort, v) } cfg.HTTPPort = port } // LOG_LEVEL must be one of the five supported names if v, ok := lookupEnv(envLogLevel); ok { if v == "" { return Config{}, configErrorf("%s must not be empty", envLogLevel) } level, err := ParseLogLevel(v) if err != nil { return Config{}, configErrorf("invalid %s %q: must be one of ERROR, WARN, INFO, DEBUG, TRACE", envLogLevel, v) } cfg.LogLevel = level } return cfg, nil } // parsePort accepts only ASCII decimal digits (no sign, no whitespace) in the range 1-65535 func parsePort(s string) (int, error) { if s == "" { return 0, errors.New("empty port") } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return 0, errors.New("port is not a decimal integer") } } port, err := strconv.Atoi(s) if err != nil { return 0, err } if port < 1 || port > 65535 { return 0, errors.New("port out of range") } return port, nil }