// 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)) }