312 lines
10 KiB
Go
312 lines
10 KiB
Go
// Package config defines the pages-server configuration: its shape, defaults,
|
|
// and validation. Loading (and the flag > env > file > default precedence) lives
|
|
// in load.go.
|
|
package config
|
|
|
|
import (
|
|
"encoding"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ProjectNamePattern constrains project names. It is deliberately strict: the
|
|
// name becomes a "~name" entry inside $WEBROOT, so anything that could contain
|
|
// a path separator, a "..", or a leading dot must be impossible by construction.
|
|
var ProjectNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
|
|
|
|
// AssembleMode selects how deployment directories are built from the CAS.
|
|
type AssembleMode string
|
|
|
|
const (
|
|
// AssembleAuto probes for hardlink support at startup and falls back to copy.
|
|
AssembleAuto AssembleMode = "auto"
|
|
// AssembleHardlink requires hardlinks and fails loudly if unavailable.
|
|
AssembleHardlink AssembleMode = "hardlink"
|
|
// AssembleCopy always copies. Correct but uses disk proportional to content.
|
|
AssembleCopy AssembleMode = "copy"
|
|
// AssembleNone skips on-disk assembly entirely; content is served straight
|
|
// from the CAS and $WEBROOT symlinks are not maintained.
|
|
AssembleNone AssembleMode = "none"
|
|
)
|
|
|
|
func (m AssembleMode) valid() bool {
|
|
switch m {
|
|
case AssembleAuto, AssembleHardlink, AssembleCopy, AssembleNone:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Duration wraps time.Duration so it can be written as "15m" in TOML.
|
|
type Duration time.Duration
|
|
|
|
var _ encoding.TextUnmarshaler = (*Duration)(nil)
|
|
|
|
func (d *Duration) UnmarshalText(text []byte) error {
|
|
v, err := time.ParseDuration(string(text))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*d = Duration(v)
|
|
return nil
|
|
}
|
|
|
|
// Set implements flag.Value, so the same type backs both the TOML field and the
|
|
// command-line flag.
|
|
func (d *Duration) Set(s string) error { return d.UnmarshalText([]byte(s)) }
|
|
|
|
func (d Duration) MarshalText() ([]byte, error) { return []byte(d.String()), nil }
|
|
func (d Duration) String() string { return time.Duration(d).String() }
|
|
func (d Duration) D() time.Duration { return time.Duration(d) }
|
|
|
|
// Limits bounds what a single deployment may push. Per-project overrides live
|
|
// in the projects table; these are the server-wide ceilings.
|
|
type Limits struct {
|
|
MaxFileBytes int64 `toml:"max_file_bytes"`
|
|
MaxManifestFiles int `toml:"max_manifest_files"`
|
|
MaxConcurrentUploads int `toml:"max_concurrent_uploads"`
|
|
MaxManifestBytes int64 `toml:"max_manifest_bytes"`
|
|
MaxJSONBytes int64 `toml:"max_json_bytes"`
|
|
}
|
|
|
|
// Config is the fully resolved server configuration.
|
|
type Config struct {
|
|
DataDir string `toml:"data_dir"`
|
|
Webroot string `toml:"webroot"`
|
|
|
|
Listen string `toml:"listen"` // public static-content listener
|
|
APIListen string `toml:"api_listen"` // management API listener
|
|
|
|
// SiteURL is the public origin the static listener is reachable at, as seen
|
|
// from outside — behind a reverse proxy that is a name the server itself
|
|
// never learns. It exists only so API responses can tell a CI job where its
|
|
// deployment landed; nothing about serving depends on it, so leaving it
|
|
// empty simply omits the url field.
|
|
SiteURL string `toml:"site_url"`
|
|
|
|
TrustedProxyCIDRs []string `toml:"trusted_proxy_cidrs"`
|
|
AssembleMode AssembleMode `toml:"assemble_mode"`
|
|
|
|
LogLevel string `toml:"log_level"`
|
|
LogFormat string `toml:"log_format"`
|
|
|
|
GCInterval Duration `toml:"gc_interval"`
|
|
ReconcileInterval Duration `toml:"reconcile_interval"`
|
|
ShutdownGrace Duration `toml:"shutdown_grace"`
|
|
|
|
// ReadHeaderTimeout and ReadTimeout guard both listeners. There is
|
|
// deliberately no WriteTimeout: a global write deadline on the static
|
|
// listener would kill legitimate large downloads over slow links.
|
|
ReadHeaderTimeout Duration `toml:"read_header_timeout"`
|
|
ReadTimeout Duration `toml:"read_timeout"`
|
|
IdleTimeout Duration `toml:"idle_timeout"`
|
|
|
|
Limits Limits `toml:"limits"`
|
|
|
|
// trustedNets is the parsed form of TrustedProxyCIDRs, filled by Validate.
|
|
trustedNets []netip.Prefix
|
|
}
|
|
|
|
// Default returns the baseline configuration. Every other layer (file, env,
|
|
// flags) is applied on top of this.
|
|
func Default() Config {
|
|
return Config{
|
|
DataDir: "/var/lib/pages-server",
|
|
Webroot: "/srv/www",
|
|
Listen: ":8080",
|
|
APIListen: "127.0.0.1:8081",
|
|
TrustedProxyCIDRs: []string{"127.0.0.1/32", "::1/128"},
|
|
AssembleMode: AssembleAuto,
|
|
LogLevel: "info",
|
|
LogFormat: "json",
|
|
GCInterval: Duration(15 * time.Minute),
|
|
ReconcileInterval: Duration(5 * time.Minute),
|
|
ShutdownGrace: Duration(30 * time.Second),
|
|
ReadHeaderTimeout: Duration(10 * time.Second),
|
|
ReadTimeout: Duration(5 * time.Minute),
|
|
IdleTimeout: Duration(120 * time.Second),
|
|
Limits: Limits{
|
|
MaxFileBytes: 256 << 20, // 256 MiB
|
|
MaxManifestFiles: 50000,
|
|
MaxConcurrentUploads: 32,
|
|
MaxManifestBytes: 64 << 20, // 64 MiB of manifest JSON
|
|
MaxJSONBytes: 1 << 20, // 1 MiB for ordinary API bodies
|
|
},
|
|
}
|
|
}
|
|
|
|
// DBPath is the SQLite database file.
|
|
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "pages.db") }
|
|
|
|
// CASDir holds content-addressed blobs.
|
|
func (c *Config) CASDir() string { return filepath.Join(c.DataDir, "cas") }
|
|
|
|
// DeploymentsDir holds assembled deployment trees.
|
|
func (c *Config) DeploymentsDir() string { return filepath.Join(c.DataDir, "deployments") }
|
|
|
|
// BootstrapTokenPath is where the first-run admin token is written.
|
|
func (c *Config) BootstrapTokenPath() string { return filepath.Join(c.DataDir, "bootstrap-token") }
|
|
|
|
// TrustedProxies returns the parsed trusted-proxy prefixes. Only requests whose
|
|
// direct peer falls inside one of these may have their X-Forwarded-For honoured.
|
|
func (c *Config) TrustedProxies() []netip.Prefix { return c.trustedNets }
|
|
|
|
// Validate checks the configuration and normalises it in place. It performs no
|
|
// I/O beyond stat-ing the configured directories' parents, so it is safe to run
|
|
// from --check-config without binding ports.
|
|
func (c *Config) Validate() error {
|
|
if c.DataDir == "" {
|
|
return fmt.Errorf("data_dir is required")
|
|
}
|
|
abs, err := filepath.Abs(c.DataDir)
|
|
if err != nil {
|
|
return fmt.Errorf("data_dir: %w", err)
|
|
}
|
|
c.DataDir = filepath.Clean(abs)
|
|
|
|
if c.AssembleMode != AssembleNone {
|
|
if c.Webroot == "" {
|
|
return fmt.Errorf("webroot is required unless assemble_mode is %q", AssembleNone)
|
|
}
|
|
abs, err = filepath.Abs(c.Webroot)
|
|
if err != nil {
|
|
return fmt.Errorf("webroot: %w", err)
|
|
}
|
|
c.Webroot = filepath.Clean(abs)
|
|
|
|
// $WEBROOT must not sit inside $DATA_DIR (or vice versa): the reconciler
|
|
// removes stale "~name" symlinks from the webroot, and the GC removes
|
|
// trees from the data dir. Overlapping them makes each capable of
|
|
// deleting the other's state.
|
|
if c.Webroot == c.DataDir {
|
|
return fmt.Errorf("webroot and data_dir must differ (both %q)", c.DataDir)
|
|
}
|
|
if isUnder(c.Webroot, c.DataDir) || isUnder(c.DataDir, c.Webroot) {
|
|
return fmt.Errorf("webroot %q and data_dir %q must not be nested", c.Webroot, c.DataDir)
|
|
}
|
|
}
|
|
|
|
if !c.AssembleMode.valid() {
|
|
return fmt.Errorf("assemble_mode: want auto|hardlink|copy|none, got %q", c.AssembleMode)
|
|
}
|
|
|
|
for _, spec := range []struct{ name, addr string }{
|
|
{"listen", c.Listen},
|
|
{"api_listen", c.APIListen},
|
|
} {
|
|
if spec.addr == "" {
|
|
return fmt.Errorf("%s is required", spec.name)
|
|
}
|
|
if _, _, err := net.SplitHostPort(spec.addr); err != nil {
|
|
return fmt.Errorf("%s %q: %w", spec.name, spec.addr, err)
|
|
}
|
|
}
|
|
if c.Listen == c.APIListen {
|
|
return fmt.Errorf("listen and api_listen must differ (both %q); "+
|
|
"the management API must not share an origin with served content", c.Listen)
|
|
}
|
|
|
|
if c.SiteURL != "" {
|
|
// Stored without the trailing slash so api.SiteURL can append one and
|
|
// get exactly one back.
|
|
c.SiteURL = strings.TrimRight(c.SiteURL, "/")
|
|
u, err := url.Parse(c.SiteURL)
|
|
if err != nil {
|
|
return fmt.Errorf("site_url %q: %w", c.SiteURL, err)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("site_url %q must be an absolute http:// or https:// URL", c.SiteURL)
|
|
}
|
|
if u.Host == "" {
|
|
return fmt.Errorf("site_url %q is missing a host", c.SiteURL)
|
|
}
|
|
if u.RawQuery != "" || u.Fragment != "" {
|
|
return fmt.Errorf("site_url %q must not carry a query or fragment", c.SiteURL)
|
|
}
|
|
}
|
|
|
|
c.trustedNets = c.trustedNets[:0]
|
|
for _, s := range c.TrustedProxyCIDRs {
|
|
p, err := netip.ParsePrefix(strings.TrimSpace(s))
|
|
if err != nil {
|
|
return fmt.Errorf("trusted_proxy_cidrs %q: %w", s, err)
|
|
}
|
|
c.trustedNets = append(c.trustedNets, p)
|
|
}
|
|
|
|
switch c.LogLevel {
|
|
case "debug", "info", "warn", "error":
|
|
default:
|
|
return fmt.Errorf("log_level: want debug|info|warn|error, got %q", c.LogLevel)
|
|
}
|
|
switch c.LogFormat {
|
|
case "json", "text":
|
|
default:
|
|
return fmt.Errorf("log_format: want json|text, got %q", c.LogFormat)
|
|
}
|
|
|
|
for _, spec := range []struct {
|
|
name string
|
|
d Duration
|
|
}{
|
|
{"gc_interval", c.GCInterval},
|
|
{"reconcile_interval", c.ReconcileInterval},
|
|
{"shutdown_grace", c.ShutdownGrace},
|
|
{"read_header_timeout", c.ReadHeaderTimeout},
|
|
{"read_timeout", c.ReadTimeout},
|
|
{"idle_timeout", c.IdleTimeout},
|
|
} {
|
|
if spec.d <= 0 {
|
|
return fmt.Errorf("%s must be positive, got %s", spec.name, spec.d)
|
|
}
|
|
}
|
|
|
|
if c.Limits.MaxFileBytes <= 0 {
|
|
return fmt.Errorf("limits.max_file_bytes must be positive")
|
|
}
|
|
if c.Limits.MaxManifestFiles <= 0 {
|
|
return fmt.Errorf("limits.max_manifest_files must be positive")
|
|
}
|
|
if c.Limits.MaxConcurrentUploads <= 0 {
|
|
return fmt.Errorf("limits.max_concurrent_uploads must be positive")
|
|
}
|
|
if c.Limits.MaxManifestBytes <= 0 {
|
|
return fmt.Errorf("limits.max_manifest_bytes must be positive")
|
|
}
|
|
if c.Limits.MaxJSONBytes <= 0 {
|
|
return fmt.Errorf("limits.max_json_bytes must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureDirs creates the data directory layout. Separated from Validate so
|
|
// --check-config stays read-only.
|
|
func (c *Config) EnsureDirs() error {
|
|
dirs := []string{c.DataDir, c.CASDir(), filepath.Join(c.CASDir(), "tmp"), c.DeploymentsDir()}
|
|
if c.AssembleMode != AssembleNone {
|
|
dirs = append(dirs, c.Webroot)
|
|
}
|
|
for _, d := range dirs {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
return fmt.Errorf("create %s: %w", d, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isUnder reports whether path is lexically inside base.
|
|
func isUnder(path, base string) bool {
|
|
rel, err := filepath.Rel(base, path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|