Files
elmprodvpn/selective-vpn-api/app/transportcfg/singbox_profile_eval.go

281 lines
6.9 KiB
Go

package transportcfg
import (
"fmt"
"strings"
)
type SingBoxProfileInput struct {
ID string
Mode string
Protocol string
RawConfig map[string]any
Typed map[string]any
}
type SingBoxIssue struct {
Field string
Severity string
Code string
Message string
}
type SingBoxProfileEvalDeps struct {
NormalizeMode func(mode string) (normalized string, ok bool)
CloneMapDeep func(in map[string]any) map[string]any
AsString func(v any) string
ProtocolSupported func(proto string) bool
ParsePort func(v any) (int, bool)
}
func ValidateSingBoxProfile(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) ([]SingBoxIssue, []SingBoxIssue) {
errs := make([]SingBoxIssue, 0)
warns := make([]SingBoxIssue, 0)
if strings.TrimSpace(profile.ID) == "" {
errs = append(errs, SingBoxIssue{
Field: "id",
Severity: "error",
Code: "SINGBOX_PROFILE_ID_EMPTY",
Message: "profile id is required",
})
}
mode, ok := normalizeProfileMode(profile.Mode, deps)
if !ok {
errs = append(errs, SingBoxIssue{
Field: "mode",
Severity: "error",
Code: "SINGBOX_PROFILE_MODE_INVALID",
Message: "mode must be typed|raw",
})
return errs, warns
}
if mode == "raw" {
if len(profile.RawConfig) == 0 {
errs = append(errs, SingBoxIssue{
Field: "raw_config",
Severity: "error",
Code: "SINGBOX_PROFILE_RAW_EMPTY",
Message: "raw_config is required for raw mode",
})
}
if len(profile.Typed) > 0 {
warns = append(warns, SingBoxIssue{
Field: "typed",
Severity: "warning",
Code: "SINGBOX_PROFILE_TYPED_IGNORED",
Message: "typed fields are ignored in raw mode",
})
}
return errs, warns
}
proto := strings.ToLower(strings.TrimSpace(profile.Protocol))
if !isProtocolSupported(proto, deps) {
errs = append(errs, SingBoxIssue{
Field: "protocol",
Severity: "error",
Code: "SINGBOX_PROFILE_PROTOCOL_UNSUPPORTED",
Message: "typed mode supports: vless,trojan,shadowsocks,wireguard,hysteria2,tuic",
})
}
if len(profile.Typed) == 0 {
errs = append(errs, SingBoxIssue{
Field: "typed",
Severity: "error",
Code: "SINGBOX_PROFILE_TYPED_EMPTY",
Message: "typed config is required in typed mode",
})
return errs, warns
}
server := strings.TrimSpace(asString(deps, profile.Typed["server"]))
if server == "" {
addr := strings.TrimSpace(asString(deps, profile.Typed["address"]))
if addr == "" && profile.Typed["config"] == nil {
errs = append(errs, SingBoxIssue{
Field: "typed.server",
Severity: "error",
Code: "SINGBOX_PROFILE_SERVER_REQUIRED",
Message: "typed.server (or typed.address) is required",
})
}
}
if p, ok := parsePort(deps, profile.Typed["port"]); ok {
if p <= 0 || p > 65535 {
errs = append(errs, SingBoxIssue{
Field: "typed.port",
Severity: "error",
Code: "SINGBOX_PROFILE_PORT_INVALID",
Message: "typed.port must be in range 1..65535",
})
}
}
if len(profile.RawConfig) > 0 {
warns = append(warns, SingBoxIssue{
Field: "raw_config",
Severity: "warning",
Code: "SINGBOX_PROFILE_RAW_IGNORED",
Message: "raw_config is ignored in typed mode",
})
}
return errs, warns
}
func RenderSingBoxProfileConfig(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) (map[string]any, error) {
mode, ok := normalizeProfileMode(profile.Mode, deps)
if !ok {
return nil, fmt.Errorf("unsupported profile mode %q", profile.Mode)
}
if mode == "raw" {
cfg := cloneMap(deps, profile.RawConfig)
if len(cfg) == 0 {
return nil, fmt.Errorf("raw_config is empty")
}
return cfg, nil
}
typed := cloneMap(deps, profile.Typed)
if typed == nil {
return nil, fmt.Errorf("typed config is empty")
}
if embedded, ok := typed["config"].(map[string]any); ok && len(embedded) > 0 {
return cloneMap(deps, embedded), nil
}
outbound := cloneMap(deps, typed)
if outbound == nil {
outbound = map[string]any{}
}
proto := strings.ToLower(strings.TrimSpace(profile.Protocol))
if proto == "" {
return nil, fmt.Errorf("protocol is required in typed mode")
}
outbound["type"] = proto
tag := strings.TrimSpace(asString(deps, outbound["tag"]))
if tag == "" {
tag = "proxy"
outbound["tag"] = tag
}
cfg := map[string]any{
"log": map[string]any{
"level": "warn",
},
"outbounds": []any{
outbound,
map[string]any{"type": "direct", "tag": "direct"},
},
"route": map[string]any{
"final": tag,
"auto_detect_interface": true,
},
"dns": map[string]any{
"servers": []any{
map[string]any{"type": "local", "tag": "local"},
},
"final": "local",
},
}
if v, ok := typed["dns"]; ok {
if dnsMap, ok := v.(map[string]any); ok && len(dnsMap) > 0 {
cfg["dns"] = cloneMap(deps, dnsMap)
}
}
if v, ok := typed["route"]; ok {
if routeMap, ok := v.(map[string]any); ok && len(routeMap) > 0 {
cfg["route"] = cloneMap(deps, routeMap)
}
}
if v, ok := typed["inbounds"]; ok {
switch vv := v.(type) {
case []any:
cfg["inbounds"] = vv
case map[string]any:
cfg["inbounds"] = []any{vv}
}
}
return cfg, nil
}
func NormalizeSingBoxRenderedConfig(config map[string]any, asStringFn func(v any) string) map[string]any {
if config == nil {
return nil
}
outboundsRaw, ok := config["outbounds"].([]any)
if !ok || len(outboundsRaw) == 0 {
return config
}
asString := asStringFn
if asString == nil {
asString = func(v any) string { return strings.TrimSpace(fmt.Sprint(v)) }
}
for i := range outboundsRaw {
outbound, ok := outboundsRaw[i].(map[string]any)
if !ok {
continue
}
if strings.ToLower(strings.TrimSpace(asString(outbound["type"]))) != "vless" {
continue
}
packetEncoding := strings.ToLower(strings.TrimSpace(asString(outbound["packet_encoding"])))
if packetEncoding == "" || packetEncoding == "none" {
delete(outbound, "packet_encoding")
}
flow := strings.ToLower(strings.TrimSpace(asString(outbound["flow"])))
if flow == "" || flow == "none" {
delete(outbound, "flow")
}
}
config["outbounds"] = outboundsRaw
return config
}
func normalizeProfileMode(mode string, deps SingBoxProfileEvalDeps) (string, bool) {
if deps.NormalizeMode == nil {
return "", false
}
out, ok := deps.NormalizeMode(mode)
return strings.ToLower(strings.TrimSpace(out)), ok
}
func asString(deps SingBoxProfileEvalDeps, v any) string {
if deps.AsString == nil {
return ""
}
return deps.AsString(v)
}
func cloneMap(deps SingBoxProfileEvalDeps, in map[string]any) map[string]any {
if deps.CloneMapDeep == nil {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
return deps.CloneMapDeep(in)
}
func isProtocolSupported(proto string, deps SingBoxProfileEvalDeps) bool {
if deps.ProtocolSupported == nil {
return false
}
return deps.ProtocolSupported(proto)
}
func parsePort(deps SingBoxProfileEvalDeps, v any) (int, bool) {
if deps.ParsePort == nil {
return 0, false
}
return deps.ParsePort(v)
}