Files

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
}