185 lines
5.9 KiB
Go
185 lines
5.9 KiB
Go
package model
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
MaxSingBoxOutboundBytes = 64 << 10
|
|
MaxSingBoxOutboundDepth = 24
|
|
MaxSingBoxOutboundNodes = 4096
|
|
MaxSingBoxOutboundArrayItems = 1024
|
|
MaxSingBoxOutboundObjectFields = 256
|
|
)
|
|
|
|
var singBoxTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
|
|
|
|
// These outbound types either do not make a remote connection or can compose
|
|
// arbitrary local routing. Other types are checked by the installed sing-box
|
|
// binary, so new remote protocols remain usable without a linkport release.
|
|
var singBoxLocalOutboundTypes = map[string]bool{
|
|
"bridge": true, "block": true, "cache_file": true, "direct": true,
|
|
"dns": true, "echo": true, "loopback": true, "redirect": true,
|
|
"selector": true, "tailscale": true, "tor": true, "tun": true,
|
|
"urltest": true,
|
|
}
|
|
|
|
var singBoxForbiddenKeys = map[string]bool{
|
|
"bind_interface": true, "data_directory": true,
|
|
"domain_resolver": true, "executable_path": true, "extra_args": true,
|
|
"inet4_bind_address": true, "inet6_bind_address": true,
|
|
"interface_name": true, "netns": true, "plugin": true,
|
|
"plugin_opts": true, "protect_path": true, "routing_mark": true,
|
|
"system_interface": true,
|
|
}
|
|
|
|
var singBoxFullConfigKeys = map[string]bool{
|
|
"$schema": true, "certificate": true, "certificate_providers": true,
|
|
"dns": true, "endpoints": true, "experimental": true,
|
|
"http_clients": true, "inbounds": true, "log": true,
|
|
"network_namespaces": true, "ntp": true, "outbounds": true,
|
|
"route": true, "services": true,
|
|
}
|
|
|
|
var singBoxReservedKeys = map[string]bool{
|
|
"__proto__": true, "constructor": true, "prototype": true,
|
|
}
|
|
|
|
func extractSingBoxOutbound(object map[string]any) (map[string]any, error) {
|
|
raw, present := object["outbounds"]
|
|
if !present {
|
|
return object, nil
|
|
}
|
|
entries, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, errors.New("sing-box full configuration has no usable remote outbound")
|
|
}
|
|
candidates := make([]map[string]any, 0, len(entries))
|
|
byTag := map[string]map[string]any{}
|
|
for _, entry := range entries {
|
|
candidate, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
typ, ok := candidate["type"].(string)
|
|
if !ok || singBoxLocalOutboundTypes[typ] {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate)
|
|
if tag, ok := candidate["tag"].(string); ok {
|
|
byTag[tag] = candidate
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return nil, errors.New("sing-box full configuration has no usable remote outbound")
|
|
}
|
|
if route, ok := object["route"].(map[string]any); ok {
|
|
if final, ok := route["final"].(string); ok {
|
|
if candidate, exists := byTag[final]; exists {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
}
|
|
return candidates[0], nil
|
|
}
|
|
|
|
// ValidateSingBoxOutbound accepts one outbound object or extracts one remote
|
|
// outbound from a full sing-box service configuration. Local file, process and
|
|
// host-routing knobs are forbidden because sing-box runs with the same OS
|
|
// identity as linkport.
|
|
func ValidateSingBoxOutbound(raw string) (canonical string, outboundType string, err error) {
|
|
content := []byte(strings.TrimSpace(raw))
|
|
if len(content) == 0 {
|
|
return "", "", errors.New("sing-box outbound JSON is required")
|
|
}
|
|
if len(content) > MaxSingBoxOutboundBytes {
|
|
return "", "", errors.New("sing-box outbound JSON is too large")
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
|
decoder.UseNumber()
|
|
var object map[string]any
|
|
if err = decoder.Decode(&object); err != nil || object == nil {
|
|
return "", "", errors.New("invalid sing-box outbound JSON")
|
|
}
|
|
var trailing any
|
|
if trailingErr := decoder.Decode(&trailing); trailingErr != io.EOF {
|
|
return "", "", errors.New("sing-box outbound must contain one JSON object")
|
|
}
|
|
object, err = extractSingBoxOutbound(object)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
for key := range object {
|
|
if singBoxFullConfigKeys[strings.ToLower(key)] {
|
|
return "", "", errors.New("sing-box outbound contains a full configuration field")
|
|
}
|
|
}
|
|
outboundType, _ = object["type"].(string)
|
|
if !singBoxTypePattern.MatchString(outboundType) || singBoxLocalOutboundTypes[outboundType] {
|
|
return "", "", errors.New("unsupported sing-box outbound type")
|
|
}
|
|
type pendingValue struct {
|
|
value any
|
|
depth int
|
|
}
|
|
stack := []pendingValue{{value: object}}
|
|
nodes := 0
|
|
for len(stack) > 0 {
|
|
current := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
nodes++
|
|
if nodes > MaxSingBoxOutboundNodes || current.depth > MaxSingBoxOutboundDepth {
|
|
return "", "", errors.New("sing-box outbound JSON is too complex")
|
|
}
|
|
switch value := current.value.(type) {
|
|
case map[string]any:
|
|
if len(value) > MaxSingBoxOutboundObjectFields {
|
|
return "", "", errors.New("sing-box outbound JSON is too complex")
|
|
}
|
|
for key, child := range value {
|
|
lowerKey := strings.ToLower(key)
|
|
if singBoxReservedKeys[lowerKey] || singBoxKeyHasControl(key) {
|
|
return "", "", errors.New("sing-box outbound contains an invalid object key")
|
|
}
|
|
if lowerKey == "detour" || strings.HasSuffix(lowerKey, "_detour") {
|
|
return "", "", errors.New("sing-box outbound cannot reference another outbound")
|
|
}
|
|
if singBoxForbiddenKeys[lowerKey] || strings.HasSuffix(lowerKey, "_path") {
|
|
return "", "", errors.New("sing-box outbound contains a forbidden local capability")
|
|
}
|
|
stack = append(stack, pendingValue{value: child, depth: current.depth + 1})
|
|
}
|
|
case []any:
|
|
if len(value) > MaxSingBoxOutboundArrayItems {
|
|
return "", "", errors.New("sing-box outbound JSON is too complex")
|
|
}
|
|
for _, child := range value {
|
|
stack = append(stack, pendingValue{value: child, depth: current.depth + 1})
|
|
}
|
|
}
|
|
}
|
|
delete(object, "tag")
|
|
encoded, err := json.Marshal(object)
|
|
if err != nil {
|
|
return "", "", errors.New("invalid sing-box outbound JSON")
|
|
}
|
|
if len(encoded) > MaxSingBoxOutboundBytes {
|
|
return "", "", errors.New("sing-box outbound JSON is too large")
|
|
}
|
|
return string(encoded), outboundType, nil
|
|
}
|
|
|
|
func singBoxKeyHasControl(key string) bool {
|
|
for _, value := range key {
|
|
if value < 0x20 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|