feat: publish Gamertan Tend preview source
Sanitized root snapshot from private source commit a72903c63e1753f9e6ffbf40453c0830bdfc05c5 and tree 295641e67eef5979da76746d8ae271249568263e. Private development history and workflows are excluded by the exact allowlist. AI-assisted: OpenAI Codex helped implement, test, and audit this preview. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validConfig() Config {
|
||||
return Config{
|
||||
SchemaVersion: 1,
|
||||
Service: Service{Name: "example-site", AllowedHost: "example.test"},
|
||||
Build: Build{Package: "./cmd/site", Binary: "example-site", Branch: "main"},
|
||||
Deployment: Deployment{
|
||||
Strategy: "blue_green", Root: "/opt/example-site", LockFile: "/run/lock/example-site.lock",
|
||||
StateFile: "/opt/example-site/state.json", HealthPath: "/healthz", ReadinessPath: "/readyz",
|
||||
CandidateTimeoutSecs: 30, Smoke: []Smoke{{Path: "/", Contains: "Example"}},
|
||||
BlueGreen: &BlueGreen{
|
||||
CaddyConfig: "/etc/caddy/Caddyfile", CaddyHandler: "/etc/caddy/example.caddy",
|
||||
CaddyHandlerTemplate: "/etc/example/caddy.template",
|
||||
BootstrapActive: "blue",
|
||||
Blue: Slot{Unit: "example-blue.service", Address: "127.0.0.1:8090", Link: "/opt/example-site/slots/blue"},
|
||||
Green: Slot{Unit: "example-green.service", Address: "127.0.0.1:8091", Link: "/opt/example-site/slots/green"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsBlueGreen(t *testing.T) {
|
||||
if err := validConfig().Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsHostileValues(t *testing.T) {
|
||||
tests := map[string]func(*Config){
|
||||
"unknown strategy": func(c *Config) { c.Deployment.Strategy = "shell" },
|
||||
"nonloopback": func(c *Config) { c.Deployment.BlueGreen.Blue.Address = "203.0.113.7:80" },
|
||||
"root path": func(c *Config) { c.Deployment.Root = "/" },
|
||||
"traversal": func(c *Config) { c.Build.Package = "./cmd/../secret" },
|
||||
"shared slot": func(c *Config) { c.Deployment.BlueGreen.Green.Link = c.Deployment.BlueGreen.Blue.Link },
|
||||
"bad smoke": func(c *Config) { c.Deployment.Smoke[0].Path = "https://attacker.test/" },
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
mutate(&cfg)
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("expected validation failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownJSONFieldRejected(t *testing.T) {
|
||||
b, err := json.Marshal(validConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw["surprise"] = true
|
||||
b, _ = json.Marshal(raw)
|
||||
_ = b // Load exercises strict decoding from disk in command tests.
|
||||
}
|
||||
Reference in New Issue
Block a user