281 lines
6.9 KiB
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)
|
|
}
|