config.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. package config
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "net"
  8. "os"
  9. "strconv"
  10. "strings"
  11. "time"
  12. )
  13. const maxConfigBytes = 1 << 20
  14. // Config contains the process-level settings shared by the HTTP and storage
  15. // layers. Environment variables override values loaded from VOCAT_CONFIG.
  16. type Config struct {
  17. Address string
  18. DatabasePath string
  19. AdminUsername string
  20. AdminPassword string
  21. SessionTTL time.Duration
  22. SecureCookies bool
  23. ShutdownTimeout time.Duration
  24. MaxRequestBodyBytes int64
  25. }
  26. type fileConfig struct {
  27. Address *string `json:"address"`
  28. DatabasePath *string `json:"database_path"`
  29. AdminUsername *string `json:"admin_username"`
  30. AdminPassword *string `json:"admin_password"`
  31. SessionTTL *string `json:"session_ttl"`
  32. SecureCookies *bool `json:"secure_cookies"`
  33. ShutdownTimeout *string `json:"shutdown_timeout"`
  34. MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"`
  35. }
  36. // Default returns a configuration suitable for a first local deployment.
  37. // Operators should replace the bootstrap password through
  38. // VOCAT_ADMIN_PASSWORD before exposing the service.
  39. func Default() Config {
  40. return Config{
  41. Address: "0.0.0.0:7575",
  42. DatabasePath: "./data/vocat.db",
  43. AdminUsername: "admin",
  44. AdminPassword: "admin",
  45. SessionTTL: 24 * time.Hour,
  46. SecureCookies: false,
  47. ShutdownTimeout: 10 * time.Second,
  48. MaxRequestBodyBytes: 1 << 20,
  49. }
  50. }
  51. // Load reads an optional strict JSON file selected by VOCAT_CONFIG and then
  52. // applies VOCAT_* environment overrides.
  53. func Load() (Config, error) {
  54. cfg := Default()
  55. if path := strings.TrimSpace(os.Getenv("VOCAT_CONFIG")); path != "" {
  56. fileValues, err := loadFile(path)
  57. if err != nil {
  58. return Config{}, err
  59. }
  60. if err := applyFile(&cfg, fileValues); err != nil {
  61. return Config{}, fmt.Errorf("load config %q: %w", path, err)
  62. }
  63. }
  64. if err := applyEnvironment(&cfg); err != nil {
  65. return Config{}, err
  66. }
  67. if err := cfg.Validate(); err != nil {
  68. return Config{}, err
  69. }
  70. return cfg, nil
  71. }
  72. func loadFile(path string) (fileConfig, error) {
  73. file, err := os.Open(path)
  74. if err != nil {
  75. return fileConfig{}, fmt.Errorf("open config %q: %w", path, err)
  76. }
  77. defer file.Close()
  78. info, err := file.Stat()
  79. if err != nil {
  80. return fileConfig{}, fmt.Errorf("stat config %q: %w", path, err)
  81. }
  82. if info.Size() > maxConfigBytes {
  83. return fileConfig{}, fmt.Errorf("config %q exceeds %d bytes", path, maxConfigBytes)
  84. }
  85. decoder := json.NewDecoder(io.LimitReader(file, maxConfigBytes))
  86. decoder.DisallowUnknownFields()
  87. var values fileConfig
  88. if err := decoder.Decode(&values); err != nil {
  89. return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err)
  90. }
  91. var trailing any
  92. if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
  93. if err == nil {
  94. err = errors.New("multiple JSON values")
  95. }
  96. return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err)
  97. }
  98. return values, nil
  99. }
  100. func applyFile(cfg *Config, values fileConfig) error {
  101. if values.Address != nil {
  102. cfg.Address = *values.Address
  103. }
  104. if values.DatabasePath != nil {
  105. cfg.DatabasePath = *values.DatabasePath
  106. }
  107. if values.AdminUsername != nil {
  108. cfg.AdminUsername = *values.AdminUsername
  109. }
  110. if values.AdminPassword != nil {
  111. cfg.AdminPassword = *values.AdminPassword
  112. }
  113. if values.SessionTTL != nil {
  114. duration, err := time.ParseDuration(*values.SessionTTL)
  115. if err != nil {
  116. return fmt.Errorf("session_ttl: %w", err)
  117. }
  118. cfg.SessionTTL = duration
  119. }
  120. if values.SecureCookies != nil {
  121. cfg.SecureCookies = *values.SecureCookies
  122. }
  123. if values.ShutdownTimeout != nil {
  124. duration, err := time.ParseDuration(*values.ShutdownTimeout)
  125. if err != nil {
  126. return fmt.Errorf("shutdown_timeout: %w", err)
  127. }
  128. cfg.ShutdownTimeout = duration
  129. }
  130. if values.MaxRequestBodyBytes != nil {
  131. cfg.MaxRequestBodyBytes = *values.MaxRequestBodyBytes
  132. }
  133. return nil
  134. }
  135. func applyEnvironment(cfg *Config) error {
  136. applyString := func(name string, target *string) {
  137. if value, ok := os.LookupEnv(name); ok {
  138. *target = value
  139. }
  140. }
  141. applyString("VOCAT_ADDR", &cfg.Address)
  142. applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath)
  143. applyString("VOCAT_ADMIN_USERNAME", &cfg.AdminUsername)
  144. applyString("VOCAT_ADMIN_PASSWORD", &cfg.AdminPassword)
  145. if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok {
  146. duration, err := time.ParseDuration(value)
  147. if err != nil {
  148. return fmt.Errorf("VOCAT_SESSION_TTL: %w", err)
  149. }
  150. cfg.SessionTTL = duration
  151. }
  152. if value, ok := os.LookupEnv("VOCAT_SECURE_COOKIES"); ok {
  153. secure, err := strconv.ParseBool(value)
  154. if err != nil {
  155. return fmt.Errorf("VOCAT_SECURE_COOKIES: %w", err)
  156. }
  157. cfg.SecureCookies = secure
  158. }
  159. if value, ok := os.LookupEnv("VOCAT_SHUTDOWN_TIMEOUT"); ok {
  160. duration, err := time.ParseDuration(value)
  161. if err != nil {
  162. return fmt.Errorf("VOCAT_SHUTDOWN_TIMEOUT: %w", err)
  163. }
  164. cfg.ShutdownTimeout = duration
  165. }
  166. if value, ok := os.LookupEnv("VOCAT_MAX_REQUEST_BODY_BYTES"); ok {
  167. size, err := strconv.ParseInt(value, 10, 64)
  168. if err != nil {
  169. return fmt.Errorf("VOCAT_MAX_REQUEST_BODY_BYTES: %w", err)
  170. }
  171. cfg.MaxRequestBodyBytes = size
  172. }
  173. return nil
  174. }
  175. // Validate rejects settings that would make the server unusable or weaken its
  176. // basic request limits.
  177. func (cfg Config) Validate() error {
  178. host, portText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
  179. if err != nil {
  180. return fmt.Errorf("address: %w", err)
  181. }
  182. _ = host
  183. port, err := strconv.Atoi(portText)
  184. if err != nil || port < 1 || port > 65535 {
  185. return fmt.Errorf("address: invalid TCP port %q", portText)
  186. }
  187. if strings.TrimSpace(cfg.DatabasePath) == "" {
  188. return errors.New("database_path must not be empty")
  189. }
  190. username := strings.TrimSpace(cfg.AdminUsername)
  191. if username == "" || len(username) > 64 {
  192. return errors.New("admin_username must contain between 1 and 64 characters")
  193. }
  194. if strings.ContainsAny(username, "\r\n\t") {
  195. return errors.New("admin_username must not contain control whitespace")
  196. }
  197. if cfg.AdminPassword == "" {
  198. return errors.New("admin_password must not be empty")
  199. }
  200. if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour {
  201. return errors.New("session_ttl must be between 5m and 720h")
  202. }
  203. if cfg.ShutdownTimeout <= 0 || cfg.ShutdownTimeout > 5*time.Minute {
  204. return errors.New("shutdown_timeout must be between 1ns and 5m")
  205. }
  206. if cfg.MaxRequestBodyBytes < 1024 || cfg.MaxRequestBodyBytes > 10<<20 {
  207. return errors.New("max_request_body_bytes must be between 1024 and 10485760")
  208. }
  209. return nil
  210. }
  211. // UsesDefaultCredentials reports whether the documented bootstrap credentials
  212. // are still active.
  213. func (cfg Config) UsesDefaultCredentials() bool {
  214. return cfg.AdminUsername == "admin" && cfg.AdminPassword == "admin"
  215. }