package main import ( "bytes" "encoding/json" "errors" "fmt" "net" "net/url" "os" "path/filepath" "strings" ) type startupConfig struct { Listen string `json:"listen"` DispatcherID string `json:"dispatcher_id"` DispatcherSecret string `json:"dispatcher_secret"` BrokerURL string `json:"broker_url"` Snapshots string `json:"snapshots"` Feedback string `json:"feedback_dir"` TLSCert string `json:"tls_cert,omitempty"` TLSKey string `json:"tls_key,omitempty"` } func loadStartup(path string) (startupConfig, error) { var c startupConfig info, err := os.Stat(path) if err != nil { return c, fmt.Errorf("read startup config: %w", err) } if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { return c, errors.New("startup config must be a private regular file") } raw, err := os.ReadFile(path) if err != nil { return c, fmt.Errorf("read startup config: %w", err) } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err = dec.Decode(&c); err != nil || !json.Valid(raw) { return c, errors.New("invalid startup configuration") } if c.Listen == "" { c.Listen = "0.0.0.0:8089" } if _, _, err = net.SplitHostPort(c.Listen); err != nil { return c, errors.New("listen must specify host and port") } if c.DispatcherID == "" || c.DispatcherSecret == "" || strings.ContainsAny(c.DispatcherID+c.DispatcherSecret, "\r\n") || c.Snapshots == "" || c.Feedback == "" { return c, errors.New("dispatcher identity, secret, snapshots and feedback_dir are required") } address, err := url.Parse(c.BrokerURL) if err != nil || (address.Scheme != "amqp" && address.Scheme != "amqps") || address.Host == "" || !strings.HasPrefix(strings.TrimPrefix(address.Path, "/"), "saas-mock-") { return c, errors.New("broker_url must use a dedicated saas-mock-* vhost") } base, err := filepath.Abs(filepath.Dir(path)) if err != nil { return c, err } resolve := func(p string) string { if p == "" { return "" } if !filepath.IsAbs(p) { p = filepath.Join(base, p) } return filepath.Clean(p) } c.Snapshots = resolve(c.Snapshots) c.Feedback = resolve(c.Feedback) c.TLSCert = resolve(c.TLSCert) c.TLSKey = resolve(c.TLSKey) // Resolve existing path components so aliases cannot place output in an input directory. realPath := func(p string) (string, error) { var suffix []string for { real, e := filepath.EvalSymlinks(p) if e == nil { for i := len(suffix) - 1; i >= 0; i-- { real = filepath.Join(real, suffix[i]) } return real, nil } if !os.IsNotExist(e) { return "", e } parent := filepath.Dir(p) if parent == p { return "", e } suffix = append(suffix, filepath.Base(p)) p = parent } } c.Snapshots, err = realPath(c.Snapshots) if err != nil { return c, fmt.Errorf("snapshot path: %w", err) } c.Feedback, err = realPath(c.Feedback) if err != nil { return c, fmt.Errorf("feedback path: %w", err) } overlaps := func(a, b string) bool { rel, e := filepath.Rel(a, b) return e == nil && (rel == "." || !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") } if overlaps(c.Snapshots, c.Feedback) || overlaps(c.Feedback, c.Snapshots) || overlaps(c.Feedback, resolve(filepath.Base(path))) { return c, errors.New("feedback directory must be separate from configuration inputs") } if (c.TLSCert == "") != (c.TLSKey == "") { return c, errors.New("tls_cert and tls_key must be provided together") } if c.TLSCert != "" && (overlaps(c.Feedback, c.TLSCert) || overlaps(c.Feedback, c.TLSKey)) { return c, errors.New("feedback directory overlaps TLS inputs") } return c, nil }