Export the reviewed allowlisted snapshot from private source commit 07c1655921f21ee5e4fc4d85639d199e8867b17d. This records the Docker Compose activation, schema-compatible rollback, and stateful migration resource findings from Observatory Preview 19 dogfooding. AI-Assisted: OpenAI Codex Signed-off-by: Cole Speelman <crspeelman@gmail.com>
301 lines
9.1 KiB
Go
301 lines
9.1 KiB
Go
// 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
|
|
}
|