Files
chorus/internal/config/file.go
T

118 lines
3.5 KiB
Go

package config
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/goccy/go-yaml"
)
const maxSettingsBytes = 1 << 20
type runtimeSettings struct {
SchemaVersion int `yaml:"schema_version"`
Provider providerSettings `yaml:"provider"`
Worker workerSettings `yaml:"worker"`
}
type providerSettings struct {
HTTPTimeoutSeconds int `yaml:"http_timeout_seconds"`
MaxResponseBytes int `yaml:"max_response_bytes"`
AllowedPorts []uint16 `yaml:"allowed_ports"`
}
type workerSettings struct {
LeaseSeconds int `yaml:"lease_seconds"`
PollMilliseconds int `yaml:"poll_milliseconds"`
}
func defaultRuntimeSettings() runtimeSettings {
return runtimeSettings{
SchemaVersion: 1,
Provider: providerSettings{
HTTPTimeoutSeconds: 45,
MaxResponseBytes: 32 << 20,
AllowedPorts: []uint16{80, 443},
},
Worker: workerSettings{LeaseSeconds: 60, PollMilliseconds: 250},
}
}
func loadRuntimeSettings(path string) (runtimeSettings, error) {
if strings.TrimSpace(path) == "" {
return runtimeSettings{}, errors.New("portal --config is required")
}
extension := strings.ToLower(filepath.Ext(path))
if extension != ".yml" && extension != ".yaml" {
return runtimeSettings{}, errors.New("portal settings file must use .yml or .yaml")
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return runtimeSettings{}, errors.New("portal settings file does not exist")
}
if info.Size() > maxSettingsBytes {
return runtimeSettings{}, errors.New("portal settings file exceeds 1 MiB")
}
body, err := os.ReadFile(path)
if err != nil {
return runtimeSettings{}, errors.New("read portal settings file")
}
var settings runtimeSettings
decoder := yaml.NewDecoder(bytes.NewReader(body), yaml.Strict())
if err := decoder.Decode(&settings); err != nil {
return runtimeSettings{}, fmt.Errorf("portal settings file is invalid: %w", err)
}
if err := rejectAdditionalDocument(decoder); err != nil {
return runtimeSettings{}, err
}
if settings.SchemaVersion != 1 {
return runtimeSettings{}, errors.New("portal settings schema_version must be 1")
}
if settings.Provider.HTTPTimeoutSeconds <= 0 || settings.Provider.MaxResponseBytes <= 0 || settings.Worker.LeaseSeconds <= 0 || settings.Worker.PollMilliseconds <= 0 {
return runtimeSettings{}, errors.New("portal settings values must be positive")
}
ports, err := normalizePorts(settings.Provider.AllowedPorts)
if err != nil {
return runtimeSettings{}, err
}
settings.Provider.AllowedPorts = ports
if settings.Worker.LeaseSeconds <= settings.Provider.HTTPTimeoutSeconds+5 {
return runtimeSettings{}, errors.New("worker lease must exceed provider HTTP timeout by more than 5 seconds")
}
return settings, nil
}
func rejectAdditionalDocument(decoder *yaml.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err == io.EOF {
return nil
} else if err != nil {
return fmt.Errorf("portal settings file is invalid: %w", err)
}
return errors.New("portal settings file must contain one YAML document")
}
func normalizePorts(values []uint16) ([]uint16, error) {
if len(values) == 0 {
return nil, errors.New("provider allowed_ports must not be empty")
}
ports := make([]uint16, 0, len(values))
seen := make(map[uint16]struct{}, len(values))
for _, port := range values {
if port == 0 {
return nil, errors.New("provider allowed_ports contains an invalid port")
}
if _, exists := seen[port]; exists {
continue
}
seen[port] = struct{}{}
ports = append(ports, port)
}
return ports, nil
}