// SPDX-License-Identifier: AGPL-3.0-only package config import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/netip" "os" "path/filepath" "regexp" "strings" ) const SchemaVersion = 1 var ( namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`) binaryPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) packagePattern = regexp.MustCompile(`^\./[A-Za-z0-9_./-]+$`) symbolPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) unitPattern = regexp.MustCompile(`^[A-Za-z0-9_.@-]+\.service$`) ) type Config struct { SchemaVersion int `json:"schema_version"` Service Service `json:"service"` Build Build `json:"build"` Deployment Deployment `json:"deployment"` } type Service struct { Name string `json:"name"` AllowedHost string `json:"allowed_host"` } type Build struct { Package string `json:"package"` Binary string `json:"binary"` Branch string `json:"branch"` VersionSymbol string `json:"version_symbol,omitempty"` CommitSymbol string `json:"commit_symbol,omitempty"` DateSymbol string `json:"date_symbol,omitempty"` } type Deployment struct { Strategy string `json:"strategy"` Root string `json:"root"` LockFile string `json:"lock_file"` StateFile string `json:"state_file"` HealthPath string `json:"health_path"` ReadinessPath string `json:"readiness_path"` CandidateTimeoutSecs int `json:"candidate_timeout_seconds"` Smoke []Smoke `json:"smoke"` BlueGreen *BlueGreen `json:"blue_green,omitempty"` Singleton *Singleton `json:"singleton,omitempty"` } type Smoke struct { Path string `json:"path"` Contains string `json:"contains"` } type BlueGreen struct { CaddyConfig string `json:"caddy_config"` CaddyHandler string `json:"caddy_handler"` CaddyHandlerTemplate string `json:"caddy_handler_template"` BootstrapActive string `json:"bootstrap_active"` Blue Slot `json:"blue"` Green Slot `json:"green"` } type Slot struct { Unit string `json:"unit"` Address string `json:"address"` Link string `json:"link"` } type Singleton struct { Unit string `json:"unit"` Address string `json:"address"` CandidateAddress string `json:"candidate_address"` ListenEnv string `json:"listen_env"` Environment map[string]string `json:"environment,omitempty"` CurrentLink string `json:"current_link"` PreviousLink string `json:"previous_link"` } func Load(path string) (Config, error) { if !filepath.IsAbs(path) { return Config{}, errors.New("configuration path must be absolute") } info, err := os.Lstat(path) if err != nil { return Config{}, fmt.Errorf("inspect configuration: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 1<<20 { return Config{}, errors.New("configuration must be a bounded regular file, not a symlink") } b, err := os.ReadFile(path) if err != nil { return Config{}, fmt.Errorf("read configuration: %w", err) } dec := json.NewDecoder(bytes.NewReader(b)) dec.DisallowUnknownFields() var cfg Config if err := dec.Decode(&cfg); err != nil { return Config{}, fmt.Errorf("decode configuration: %w", err) } if err := requireEOF(dec); err != nil { return Config{}, err } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func requireEOF(dec *json.Decoder) error { var extra any if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { if err == nil { return errors.New("configuration contains multiple JSON values") } return fmt.Errorf("decode trailing configuration: %w", err) } return nil } func (c Config) Validate() error { if c.SchemaVersion != SchemaVersion { return fmt.Errorf("schema_version must be %d", SchemaVersion) } if !namePattern.MatchString(c.Service.Name) { return errors.New("service.name is invalid") } if c.Service.AllowedHost == "" || strings.ContainsAny(c.Service.AllowedHost, "/\\\x00\r\n\t ") { return errors.New("service.allowed_host is invalid") } if !packagePattern.MatchString(c.Build.Package) || strings.Contains(c.Build.Package, "..") { return errors.New("build.package must be a local package without traversal") } if !binaryPattern.MatchString(c.Build.Binary) { return errors.New("build.binary is invalid") } if c.Build.Branch == "" || strings.ContainsAny(c.Build.Branch, "\x00\r\n\t ~^:?*[\\") { return errors.New("build.branch is invalid") } for label, symbol := range map[string]string{ "build.version_symbol": c.Build.VersionSymbol, "build.commit_symbol": c.Build.CommitSymbol, "build.date_symbol": c.Build.DateSymbol, } { if symbol != "" && !symbolPattern.MatchString(symbol) { return fmt.Errorf("%s is invalid", label) } } d := c.Deployment if err := safeAbsolute("deployment.root", d.Root); err != nil { return err } if err := safeAbsolute("deployment.lock_file", d.LockFile); err != nil { return err } if err := safeAbsolute("deployment.state_file", d.StateFile); err != nil { return err } if filepath.Clean(d.StateFile) == filepath.Clean(d.Root) || !within(d.Root, d.StateFile) { return errors.New("deployment.state_file must be below deployment.root") } if !safeHTTPPath(d.HealthPath) || !safeHTTPPath(d.ReadinessPath) { return errors.New("health and readiness paths must be absolute HTTP paths") } if d.CandidateTimeoutSecs < 2 || d.CandidateTimeoutSecs > 300 { return errors.New("candidate_timeout_seconds must be between 2 and 300") } if len(d.Smoke) == 0 || len(d.Smoke) > 32 { return errors.New("deployment.smoke must contain 1 to 32 checks") } for i, smoke := range d.Smoke { if !safeHTTPPath(smoke.Path) || smoke.Contains == "" || len(smoke.Contains) > 4096 || strings.ContainsRune(smoke.Contains, '\x00') { return fmt.Errorf("deployment.smoke[%d] is invalid", i) } } switch d.Strategy { case "blue_green": if d.BlueGreen == nil || d.Singleton != nil { return errors.New("blue_green strategy requires only blue_green settings") } if err := validateBlueGreen(d.Root, *d.BlueGreen); err != nil { return err } case "singleton_candidate": if d.Singleton == nil || d.BlueGreen != nil { return errors.New("singleton_candidate strategy requires only singleton settings") } if err := validateSingleton(d.Root, *d.Singleton); err != nil { return err } default: return errors.New("deployment.strategy must be blue_green or singleton_candidate") } return nil } func validateBlueGreen(root string, b BlueGreen) error { if b.BootstrapActive != "blue" && b.BootstrapActive != "green" { return errors.New("blue_green.bootstrap_active must be blue or green") } for label, path := range map[string]string{"caddy_config": b.CaddyConfig, "caddy_handler": b.CaddyHandler, "caddy_handler_template": b.CaddyHandlerTemplate} { if err := safeAbsolute("deployment.blue_green."+label, path); err != nil { return err } } if filepath.Clean(b.CaddyHandler) == filepath.Clean(b.CaddyHandlerTemplate) { return errors.New("Caddy handler and template must be different files") } if err := validateSlot(root, "blue", b.Blue); err != nil { return err } if err := validateSlot(root, "green", b.Green); err != nil { return err } if b.Blue.Address == b.Green.Address || b.Blue.Unit == b.Green.Unit || b.Blue.Link == b.Green.Link { return errors.New("blue and green slots must be distinct") } return nil } func validateSlot(root, name string, slot Slot) error { if !unitPattern.MatchString(slot.Unit) { return fmt.Errorf("%s unit is invalid", name) } if err := loopbackAddress(slot.Address); err != nil { return fmt.Errorf("%s address: %w", name, err) } if err := safeAbsolute(name+" link", slot.Link); err != nil { return err } if !within(root, slot.Link) { return fmt.Errorf("%s link must be below deployment.root", name) } return nil } func validateSingleton(root string, s Singleton) error { if !unitPattern.MatchString(s.Unit) { return errors.New("singleton unit is invalid") } if err := loopbackAddress(s.Address); err != nil { return fmt.Errorf("singleton address: %w", err) } if err := loopbackAddress(s.CandidateAddress); err != nil { return fmt.Errorf("candidate address: %w", err) } if s.Address == s.CandidateAddress { return errors.New("singleton addresses must be distinct") } if !regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`).MatchString(s.ListenEnv) { return errors.New("listen_env is invalid") } for _, entry := range []struct{ name, path string }{{"current_link", s.CurrentLink}, {"previous_link", s.PreviousLink}} { if err := safeAbsolute(entry.name, entry.path); err != nil { return err } if !within(root, entry.path) { return fmt.Errorf("%s must be below deployment.root", entry.name) } } if s.CurrentLink == s.PreviousLink { return errors.New("current and previous links must differ") } for key, value := range s.Environment { if !regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`).MatchString(key) || strings.ContainsAny(value, "\x00\r\n") { return fmt.Errorf("singleton environment entry %q is invalid", key) } } return nil } func safeAbsolute(label, path string) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == string(filepath.Separator) || strings.ContainsRune(path, '\x00') { return fmt.Errorf("%s must be a clean, non-root absolute path", label) } return nil } func within(root, child string) bool { rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(child)) return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } func safeHTTPPath(path string) bool { return strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") && !strings.ContainsAny(path, "\x00\r\n?#") } func loopbackAddress(value string) error { addr, err := netip.ParseAddrPort(value) if err != nil || !addr.Addr().IsLoopback() || addr.Port() == 0 { return errors.New("must be a loopback IP and nonzero port") } return nil }