Files

147 lines
4.8 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"`
Server serverSettings `yaml:"server"`
Registration registrationSettings `yaml:"registration"`
Provider providerSettings `yaml:"provider"`
Worker workerSettings `yaml:"worker"`
}
type serverSettings struct {
ListenAddress *string `yaml:"listen_address"`
TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs"`
}
type registrationSettings struct {
Attempts *int `yaml:"attempts"`
WindowSeconds *int `yaml:"window_seconds"`
}
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 {
listenAddress := "127.0.0.1:8080"
return runtimeSettings{
SchemaVersion: 1,
Server: serverSettings{ListenAddress: &listenAddress},
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.Server.ListenAddress != nil {
listenAddress := strings.TrimSpace(*settings.Server.ListenAddress)
if listenAddress != *settings.Server.ListenAddress {
return runtimeSettings{}, errors.New("portal settings server.listen_address must not contain surrounding whitespace")
}
if err := validateListenAddress(listenAddress); err != nil {
return runtimeSettings{}, err
}
}
if (settings.Registration.Attempts == nil) != (settings.Registration.WindowSeconds == nil) {
return runtimeSettings{}, errors.New("portal settings registration attempts and window_seconds must be configured together")
}
if settings.Registration.Attempts != nil && (*settings.Registration.Attempts <= 0 || *settings.Registration.WindowSeconds <= 0) {
return runtimeSettings{}, errors.New("portal settings registration values must be positive")
}
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
}