platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
280
selective-vpn-api/app/transportcfg/singbox_profile_eval.go
Normal file
280
selective-vpn-api/app/transportcfg/singbox_profile_eval.go
Normal file
@@ -0,0 +1,280 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user