// SPDX-License-Identifier: AGPL-3.0-only // Package serverpolicy validates the root-owned allowlist used by Tend's // restricted SSH receiver. It contains service names and paths, never secrets. package serverpolicy import ( "bytes" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strings" "gamertan.com/tend/internal/config" ) const SchemaVersion = 1 var servicePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`) type Policy struct { SchemaVersion int `json:"schema_version"` ConfigRoot string `json:"config_root"` IncomingRoot string `json:"incoming_root"` SharedLockFile string `json:"shared_lock_file"` Services map[string]ServicePolicy `json:"services"` } type ServicePolicy struct { Config string `json:"config"` MaxArtifactBytes int64 `json:"max_artifact_bytes"` } type CheckedService struct { Name string Config config.Config Policy ServicePolicy } func Parse(reader io.Reader) (Policy, error) { limited := io.LimitReader(reader, 1<<20+1) body, err := io.ReadAll(limited) if err != nil { return Policy{}, err } if len(body) > 1<<20 { return Policy{}, errors.New("server policy exceeds 1 MiB") } decoder := json.NewDecoder(bytes.NewReader(body)) decoder.DisallowUnknownFields() var policy Policy if err = decoder.Decode(&policy); err != nil { return Policy{}, fmt.Errorf("decode server policy: %w", err) } var trailing any if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) { return Policy{}, errors.New("server policy contains trailing data") } if err = policy.Validate(); err != nil { return Policy{}, err } return policy, nil } func Load(path string) (Policy, error) { if err := secureDirectory(filepath.Dir(path), 0); err != nil { return Policy{}, fmt.Errorf("server policy directory: %w", err) } if err := secureFile(path, 0o600); err != nil { return Policy{}, fmt.Errorf("server policy: %w", err) } file, err := os.Open(path) if err != nil { return Policy{}, err } defer file.Close() return Parse(file) } func (policy Policy) Validate() error { if policy.SchemaVersion != SchemaVersion { return fmt.Errorf("server policy schema_version must be %d", SchemaVersion) } if policy.ConfigRoot != "/etc/tend/services" { return errors.New("server policy config_root must be /etc/tend/services") } if policy.IncomingRoot != "/var/lib/tend/incoming" { return errors.New("server policy incoming_root must be /var/lib/tend/incoming") } if policy.SharedLockFile != config.SharedLockFile { return fmt.Errorf("server policy shared_lock_file must be %s", config.SharedLockFile) } if len(policy.Services) == 0 || len(policy.Services) > 128 { return errors.New("server policy must allow 1 to 128 services") } for name, service := range policy.Services { if !servicePattern.MatchString(name) { return fmt.Errorf("invalid service name %q", name) } expected := filepath.Join(policy.ConfigRoot, name+".json") if service.Config != expected { return fmt.Errorf("service %s config must be %s", name, expected) } if service.MaxArtifactBytes < 1<<20 || service.MaxArtifactBytes > 512<<20 { return fmt.Errorf("service %s artifact limit is invalid", name) } } return nil } func (policy Policy) CheckFiles() ([]CheckedService, error) { if err := policy.CheckDirectories(); err != nil { return nil, err } names := make([]string, 0, len(policy.Services)) for name := range policy.Services { names = append(names, name) } sort.Strings(names) checked := make([]CheckedService, 0, len(names)) for _, name := range names { service, err := policy.CheckService(name) if err != nil { return nil, err } checked = append(checked, service) } return checked, nil } func (policy Policy) CheckDirectories() error { if err := secureDirectory(policy.ConfigRoot, 0); err != nil { return fmt.Errorf("config root: %w", err) } if err := secureDirectory(policy.IncomingRoot, 0o700); err != nil { return fmt.Errorf("incoming root: %w", err) } if err := secureDirectory("/etc/tend/environment", 0o700); err != nil { return fmt.Errorf("environment root: %w", err) } return nil } func (policy Policy) CheckService(name string) (CheckedService, error) { entry, ok := policy.Services[name] if !ok { return CheckedService{}, errors.New("service is not allowed by server policy") } if err := secureFile(entry.Config, 0); err != nil { return CheckedService{}, fmt.Errorf("service %s config: %w", name, err) } cfg, err := config.Load(entry.Config) if err != nil { return CheckedService{}, fmt.Errorf("service %s config: %w", name, err) } if cfg.Service.Name != name { return CheckedService{}, fmt.Errorf("service %s config identity does not match", name) } if cfg.Deployment.LockFile != policy.SharedLockFile { return CheckedService{}, fmt.Errorf("service %s does not use the host-wide lock", name) } if err = secureFile(cfg.Service.EnvironmentFile, 0o600); err != nil { return CheckedService{}, fmt.Errorf("service %s environment file: %w", name, err) } if cfg.Deployment.Singleton != nil { if err = rejectEnvironmentKey(cfg.Service.EnvironmentFile, cfg.Deployment.Singleton.ListenEnv); err != nil { return CheckedService{}, fmt.Errorf("service %s environment file: %w", name, err) } } return CheckedService{Name: name, Config: cfg, Policy: entry}, nil } func CheckConfig(path string, cfg config.Config) error { expected := filepath.Join("/etc/tend/services", cfg.Service.Name+".json") if path != expected { return fmt.Errorf("production config must be %s", expected) } if err := secureDirectory("/etc/tend/services", 0); err != nil { return fmt.Errorf("config root: %w", err) } if err := secureDirectory("/etc/tend/environment", 0o700); err != nil { return fmt.Errorf("environment root: %w", err) } if err := secureFile(path, 0); err != nil { return fmt.Errorf("production config: %w", err) } if cfg.Deployment.LockFile != config.SharedLockFile { return fmt.Errorf("deployment lock must be %s", config.SharedLockFile) } if err := secureFile(cfg.Service.EnvironmentFile, 0o600); err != nil { return fmt.Errorf("environment file: %w", err) } if cfg.Deployment.Singleton != nil { if err := rejectEnvironmentKey(cfg.Service.EnvironmentFile, cfg.Deployment.Singleton.ListenEnv); err != nil { return fmt.Errorf("environment file: %w", err) } } return nil } func rejectEnvironmentKey(path, key string) error { file, err := os.Open(path) if err != nil { return err } defer file.Close() body, err := io.ReadAll(io.LimitReader(file, 1<<20+1)) if err != nil { return err } if len(body) > 1<<20 { return errors.New("environment file exceeds 1 MiB") } for _, raw := range bytes.Split(body, []byte{'\n'}) { line := strings.TrimSpace(string(raw)) if !strings.HasPrefix(line, key) { continue } remainder := strings.TrimSpace(strings.TrimPrefix(line, key)) if strings.HasPrefix(remainder, "=") { return errors.New("singleton candidate listen key must not be set in the shared environment file") } } return nil } func secureFile(path string, exactMode os.FileMode) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsRune(path, '\x00') { return errors.New("path must be clean and absolute") } if err := rejectSymlinkAncestors(filepath.Dir(path)); err != nil { return err } info, err := os.Lstat(path) if err != nil { return err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return errors.New("must be a regular non-symlink file") } if !rootOwned(info) { return errors.New("must be owned by root") } if exactMode != 0 && info.Mode().Perm() != exactMode { return fmt.Errorf("mode must be %04o", exactMode) } if exactMode == 0 && info.Mode().Perm()&0o022 != 0 { return errors.New("must not be group- or world-writable") } return nil } func secureDirectory(path string, exactMode os.FileMode) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsRune(path, '\x00') { return errors.New("path must be clean and absolute") } if err := rejectSymlinkAncestors(filepath.Dir(path)); err != nil { return err } info, err := os.Lstat(path) if err != nil { return err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return errors.New("must be a real directory") } if !rootOwned(info) { return errors.New("must be owned by root") } if exactMode != 0 && info.Mode().Perm() != exactMode { return fmt.Errorf("mode must be %04o", exactMode) } if exactMode == 0 && info.Mode().Perm()&0o022 != 0 { return errors.New("must not be group- or world-writable") } return nil } func rejectSymlinkAncestors(path string) error { current := string(filepath.Separator) for _, part := range strings.Split(strings.TrimPrefix(filepath.Clean(path), string(filepath.Separator)), string(filepath.Separator)) { if part == "" { continue } current = filepath.Join(current, part) info, err := os.Lstat(current) if err != nil { return err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("symlink or non-directory ancestor refused: %s", current) } } return nil }