| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- package config
- import (
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "os"
- "strconv"
- "strings"
- "time"
- )
- const maxConfigBytes = 1 << 20
- // Config contains the process-level settings shared by the HTTP and storage
- // layers. Environment variables override values loaded from VOCAT_CONFIG.
- type Config struct {
- Address string
- DatabasePath string
- AdminUsername string
- AdminPassword string
- SessionTTL time.Duration
- SecureCookies bool
- ShutdownTimeout time.Duration
- MaxRequestBodyBytes int64
- }
- type fileConfig struct {
- Address *string `json:"address"`
- DatabasePath *string `json:"database_path"`
- AdminUsername *string `json:"admin_username"`
- AdminPassword *string `json:"admin_password"`
- SessionTTL *string `json:"session_ttl"`
- SecureCookies *bool `json:"secure_cookies"`
- ShutdownTimeout *string `json:"shutdown_timeout"`
- MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"`
- }
- // Default returns a configuration suitable for a first local deployment.
- // Operators should replace the bootstrap password through
- // VOCAT_ADMIN_PASSWORD before exposing the service.
- func Default() Config {
- return Config{
- Address: "0.0.0.0:7575",
- DatabasePath: "./data/vocat.db",
- AdminUsername: "admin",
- AdminPassword: "admin",
- SessionTTL: 24 * time.Hour,
- SecureCookies: false,
- ShutdownTimeout: 10 * time.Second,
- MaxRequestBodyBytes: 1 << 20,
- }
- }
- // Load reads an optional strict JSON file selected by VOCAT_CONFIG and then
- // applies VOCAT_* environment overrides.
- func Load() (Config, error) {
- cfg := Default()
- if path := strings.TrimSpace(os.Getenv("VOCAT_CONFIG")); path != "" {
- fileValues, err := loadFile(path)
- if err != nil {
- return Config{}, err
- }
- if err := applyFile(&cfg, fileValues); err != nil {
- return Config{}, fmt.Errorf("load config %q: %w", path, err)
- }
- }
- if err := applyEnvironment(&cfg); err != nil {
- return Config{}, err
- }
- if err := cfg.Validate(); err != nil {
- return Config{}, err
- }
- return cfg, nil
- }
- func loadFile(path string) (fileConfig, error) {
- file, err := os.Open(path)
- if err != nil {
- return fileConfig{}, fmt.Errorf("open config %q: %w", path, err)
- }
- defer file.Close()
- info, err := file.Stat()
- if err != nil {
- return fileConfig{}, fmt.Errorf("stat config %q: %w", path, err)
- }
- if info.Size() > maxConfigBytes {
- return fileConfig{}, fmt.Errorf("config %q exceeds %d bytes", path, maxConfigBytes)
- }
- decoder := json.NewDecoder(io.LimitReader(file, maxConfigBytes))
- decoder.DisallowUnknownFields()
- var values fileConfig
- if err := decoder.Decode(&values); err != nil {
- return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err)
- }
- var trailing any
- if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
- if err == nil {
- err = errors.New("multiple JSON values")
- }
- return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err)
- }
- return values, nil
- }
- func applyFile(cfg *Config, values fileConfig) error {
- if values.Address != nil {
- cfg.Address = *values.Address
- }
- if values.DatabasePath != nil {
- cfg.DatabasePath = *values.DatabasePath
- }
- if values.AdminUsername != nil {
- cfg.AdminUsername = *values.AdminUsername
- }
- if values.AdminPassword != nil {
- cfg.AdminPassword = *values.AdminPassword
- }
- if values.SessionTTL != nil {
- duration, err := time.ParseDuration(*values.SessionTTL)
- if err != nil {
- return fmt.Errorf("session_ttl: %w", err)
- }
- cfg.SessionTTL = duration
- }
- if values.SecureCookies != nil {
- cfg.SecureCookies = *values.SecureCookies
- }
- if values.ShutdownTimeout != nil {
- duration, err := time.ParseDuration(*values.ShutdownTimeout)
- if err != nil {
- return fmt.Errorf("shutdown_timeout: %w", err)
- }
- cfg.ShutdownTimeout = duration
- }
- if values.MaxRequestBodyBytes != nil {
- cfg.MaxRequestBodyBytes = *values.MaxRequestBodyBytes
- }
- return nil
- }
- func applyEnvironment(cfg *Config) error {
- applyString := func(name string, target *string) {
- if value, ok := os.LookupEnv(name); ok {
- *target = value
- }
- }
- applyString("VOCAT_ADDR", &cfg.Address)
- applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath)
- applyString("VOCAT_ADMIN_USERNAME", &cfg.AdminUsername)
- applyString("VOCAT_ADMIN_PASSWORD", &cfg.AdminPassword)
- if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok {
- duration, err := time.ParseDuration(value)
- if err != nil {
- return fmt.Errorf("VOCAT_SESSION_TTL: %w", err)
- }
- cfg.SessionTTL = duration
- }
- if value, ok := os.LookupEnv("VOCAT_SECURE_COOKIES"); ok {
- secure, err := strconv.ParseBool(value)
- if err != nil {
- return fmt.Errorf("VOCAT_SECURE_COOKIES: %w", err)
- }
- cfg.SecureCookies = secure
- }
- if value, ok := os.LookupEnv("VOCAT_SHUTDOWN_TIMEOUT"); ok {
- duration, err := time.ParseDuration(value)
- if err != nil {
- return fmt.Errorf("VOCAT_SHUTDOWN_TIMEOUT: %w", err)
- }
- cfg.ShutdownTimeout = duration
- }
- if value, ok := os.LookupEnv("VOCAT_MAX_REQUEST_BODY_BYTES"); ok {
- size, err := strconv.ParseInt(value, 10, 64)
- if err != nil {
- return fmt.Errorf("VOCAT_MAX_REQUEST_BODY_BYTES: %w", err)
- }
- cfg.MaxRequestBodyBytes = size
- }
- return nil
- }
- // Validate rejects settings that would make the server unusable or weaken its
- // basic request limits.
- func (cfg Config) Validate() error {
- host, portText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
- if err != nil {
- return fmt.Errorf("address: %w", err)
- }
- _ = host
- port, err := strconv.Atoi(portText)
- if err != nil || port < 1 || port > 65535 {
- return fmt.Errorf("address: invalid TCP port %q", portText)
- }
- if strings.TrimSpace(cfg.DatabasePath) == "" {
- return errors.New("database_path must not be empty")
- }
- username := strings.TrimSpace(cfg.AdminUsername)
- if username == "" || len(username) > 64 {
- return errors.New("admin_username must contain between 1 and 64 characters")
- }
- if strings.ContainsAny(username, "\r\n\t") {
- return errors.New("admin_username must not contain control whitespace")
- }
- if cfg.AdminPassword == "" {
- return errors.New("admin_password must not be empty")
- }
- if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour {
- return errors.New("session_ttl must be between 5m and 720h")
- }
- if cfg.ShutdownTimeout <= 0 || cfg.ShutdownTimeout > 5*time.Minute {
- return errors.New("shutdown_timeout must be between 1ns and 5m")
- }
- if cfg.MaxRequestBodyBytes < 1024 || cfg.MaxRequestBodyBytes > 10<<20 {
- return errors.New("max_request_body_bytes must be between 1024 and 10485760")
- }
- return nil
- }
- // UsesDefaultCredentials reports whether the documented bootstrap credentials
- // are still active.
- func (cfg Config) UsesDefaultCredentials() bool {
- return cfg.AdminUsername == "admin" && cfg.AdminPassword == "admin"
- }
|