Files
go-sip/internal/config/dispatcher_file.go
T

191 lines
6.2 KiB
Go

package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"strings"
"sync"
"time"
"unicode/utf8"
"git.ipao.vip/rogee/go-sip/contracts"
"github.com/santhosh-tekuri/jsonschema/v6"
)
type dispatcherFile struct {
SchemaVersion string `json:"schema_version"`
DispatcherID string `json:"dispatcher_id"`
OSS struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
ObjectPrefix string `json:"object_prefix"`
AccessKeyIDEnv string `json:"access_key_id_env"`
AccessKeySecretEnv string `json:"access_key_secret_env"`
} `json:"oss"`
}
var dispatcherFileSchema = sync.OnceValues(func() (*jsonschema.Schema, error) {
data, err := contracts.Files.ReadFile("upstream/" + contracts.SourceCommit + "/dispatcher-config.schema.json")
if err != nil {
return nil, err
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data))
if err != nil {
return nil, err
}
compiler := jsonschema.NewCompiler()
compiler.AssertFormat()
const resource = "https://go-sip.local/dispatcher-config.json"
if err := compiler.AddResource(resource, doc); err != nil {
return nil, err
}
return compiler.Compile(resource)
})
// LoadDispatcherFile applies an entirely validated configuration atomically.
// Only credentials explicitly referenced by this file are read from the
// environment; missing/invalid files never fall back to legacy OSS settings.
func (c *Config) LoadDispatcherFile(filename string) error {
if c == nil || filename == "" {
return errors.New("dispatcher configuration and --config file are required")
}
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("open dispatcher configuration: %w", err)
}
const maxBytes = 64 << 10
raw, readErr := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err := errors.Join(readErr, file.Close()); err != nil {
return fmt.Errorf("read dispatcher configuration: %w", err)
}
if len(raw) > maxBytes || !utf8.Valid(raw) {
return errors.New("dispatcher configuration must be valid UTF-8 JSON of at most 64 KiB")
}
check := json.NewDecoder(bytes.NewReader(raw))
if err := uniqueJSONKeys(check, 0); err != nil {
return fmt.Errorf("dispatcher configuration JSON: %w", err)
}
if _, err := check.Token(); !errors.Is(err, io.EOF) {
return errors.New("dispatcher configuration must contain one JSON object")
}
var input dispatcherFile
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
return fmt.Errorf("decode dispatcher configuration: %w", err)
}
schema, err := dispatcherFileSchema()
if err != nil {
return fmt.Errorf("compile dispatcher configuration schema: %w", err)
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("parse dispatcher configuration: %w", err)
}
if err := schema.Validate(document); err != nil {
var failure *jsonschema.ValidationError
if errors.As(err, &failure) {
for len(failure.Causes) > 0 {
failure = failure.Causes[0]
}
// Report the violated field and rule, never its potentially sensitive value.
return fmt.Errorf("dispatcher configuration schema violation at /%s (%T)", strings.Join(failure.InstanceLocation, "/"), failure.ErrorKind)
}
return errors.New("dispatcher configuration schema validation failed")
}
endpoint, err := url.Parse(input.OSS.Endpoint)
if err != nil || endpoint.Hostname() == "" || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
return errors.New("oss.endpoint must be an HTTP(S) service URL without credentials, query or fragment")
}
for name, value := range map[string]string{"region": input.OSS.Region, "bucket": input.OSS.Bucket, "object_prefix": input.OSS.ObjectPrefix} {
if value == "" || strings.TrimSpace(value) != value {
return fmt.Errorf("oss.%s must be nonempty and have no surrounding whitespace", name)
}
}
prefix := input.OSS.ObjectPrefix
if path.IsAbs(prefix) || path.Clean(prefix) != prefix || prefix == "." || prefix == ".." || strings.HasPrefix(prefix, "../") || strings.Contains(prefix, `\`) {
return errors.New("oss.object_prefix must be a relative object prefix without traversal")
}
key, err := fileCredential("access_key_id_env", input.OSS.AccessKeyIDEnv)
if err != nil {
return err
}
secret, err := fileCredential("access_key_secret_env", input.OSS.AccessKeySecretEnv)
if err != nil {
return err
}
updated := *c
updated.DispatcherID = input.DispatcherID
updated.OSSEndpoint = input.OSS.Endpoint
updated.OSSRegion = input.OSS.Region
updated.OSSBucket = input.OSS.Bucket
updated.OSSKeyPrefix = prefix
updated.OSSAccessKeyID = key
updated.OSSAccessKeySecret = secret
updated.OSSGrantTTL = 15 * time.Minute
*c = updated
return nil
}
func fileCredential(field, reference string) (string, error) {
value, exists := os.LookupEnv(reference)
if !exists || strings.TrimSpace(value) == "" {
return "", fmt.Errorf("credential referenced by oss.%s is unavailable", field)
}
return value, nil
}
// encoding/json accepts repeated object keys. Inspect its token stream before
// typed decoding so duplicate settings cannot silently override earlier values.
func uniqueJSONKeys(decoder *json.Decoder, depth int) error {
if depth > 16 {
return errors.New("configuration nesting is too deep")
}
token, err := decoder.Token()
if err != nil {
return err
}
delimiter, compound := token.(json.Delim)
if !compound {
return nil
}
switch delimiter {
case '{':
seen := make(map[string]bool)
for decoder.More() {
keyToken, err := decoder.Token()
if err != nil {
return err
}
key, ok := keyToken.(string)
if !ok {
return errors.New("configuration object key must be a string")
}
if seen[key] {
return fmt.Errorf("duplicate configuration field %q", key)
}
seen[key] = true
if err := uniqueJSONKeys(decoder, depth+1); err != nil {
return err
}
}
case '[':
for decoder.More() {
if err := uniqueJSONKeys(decoder, depth+1); err != nil {
return err
}
}
default:
return errors.New("unexpected configuration JSON delimiter")
}
_, err = decoder.Token()
return err
}