191 lines
6.2 KiB
Go
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
|
|
}
|