feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type outboundTagSource struct {
|
||||
Tag string
|
||||
Source string
|
||||
}
|
||||
|
||||
func collectAdditionalOutboundTagSources(path string, rawOutbounds []map[string]any) ([]outboundTagSource, error) {
|
||||
sources := collectRawOutboundTagSources(rawOutbounds)
|
||||
customSources, err := collectCustomConfigOutboundTagSources(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, customSources...)
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func collectRawOutboundTagSources(outbounds []map[string]any) []outboundTagSource {
|
||||
if len(outbounds) == 0 {
|
||||
return nil
|
||||
}
|
||||
sources := make([]outboundTagSource, 0, len(outbounds))
|
||||
for i, outbound := range outbounds {
|
||||
tag, _ := outbound["tag"].(string)
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, outboundTagSource{Tag: tag, Source: fmt.Sprintf("kernel.custom_outbound[%d]", i)})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func collectCustomConfigOutboundTagSources(path string) ([]outboundTagSource, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read custom config %q: %w", path, err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decoded := make(map[string]any)
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as JSON: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := yaml.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as YAML: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
items, ok := toSliceOfMaps(decoded["outbounds"])
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
sources := make([]outboundTagSource, 0, len(items))
|
||||
for i, item := range items {
|
||||
tag, _ := item["tag"].(string)
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, outboundTagSource{Tag: tag, Source: fmt.Sprintf("kernel.custom_config.outbounds[%d]", i)})
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func toSliceOfMaps(v any) ([]map[string]any, bool) {
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
switch items := v.(type) {
|
||||
case []any:
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
mapped, ok := toMap(item)
|
||||
if ok {
|
||||
result = append(result, mapped)
|
||||
}
|
||||
}
|
||||
return result, len(result) > 0
|
||||
case []map[string]any:
|
||||
return items, len(items) > 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func toMap(v any) (map[string]any, bool) {
|
||||
switch mapped := v.(type) {
|
||||
case map[string]any:
|
||||
return mapped, true
|
||||
case map[any]any:
|
||||
result := make(map[string]any, len(mapped))
|
||||
for k, val := range mapped {
|
||||
result[fmt.Sprint(k)] = val
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func validateOutboundTagCollisions(structured []OutboundConfig, additional []outboundTagSource) error {
|
||||
seen := make(map[string]string, len(additional))
|
||||
for _, source := range additional {
|
||||
key := strings.ToLower(strings.TrimSpace(source.Tag))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if prev, exists := seen[key]; exists {
|
||||
return fmt.Errorf("%s.tag duplicates %q from %s", source.Source, source.Tag, prev)
|
||||
}
|
||||
seen[key] = source.Source
|
||||
}
|
||||
for i, outbound := range structured {
|
||||
key := strings.ToLower(strings.TrimSpace(outbound.Tag))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if prev, exists := seen[key]; exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].tag duplicates %q from %s", i, outbound.Tag, prev)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func additionalTagNames(sources []outboundTagSource) []string {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
tags := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
tags = append(tags, source.Tag)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestNodeSpecFromPanelValidated_AllowsTargetsFromCustomConfigOutbounds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
customConfigPath := filepath.Join(dir, "custom.json")
|
||||
if err := os.WriteFile(customConfigPath, []byte(`{"outbounds":[{"tag":"wg-exit","protocol":"wireguard"}]}`), 0o644); err != nil {
|
||||
t.Fatalf("write custom config: %v", err)
|
||||
}
|
||||
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "proxy",
|
||||
Protocol: "socks",
|
||||
ProxyTag: "wg-exit",
|
||||
Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
|
||||
}},
|
||||
CustomRouteRules: []panel.CustomRouteRule{{
|
||||
Action: panel.RouteAction{Type: "route", Target: "wg-exit"},
|
||||
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox", CustomConfig: customConfigPath})
|
||||
if err != nil {
|
||||
t.Fatalf("expected custom_config outbound tags to be available, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSpecFromPanelValidated_RejectsDuplicateTagsAcrossSources(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
customConfigPath := filepath.Join(dir, "custom.yaml")
|
||||
if err := os.WriteFile(customConfigPath, []byte("outbounds:\n - tag: warp\n protocol: wireguard\n"), 0o644); err != nil {
|
||||
t.Fatalf("write custom config: %v", err)
|
||||
}
|
||||
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "warp",
|
||||
Protocol: "socks",
|
||||
Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
|
||||
}},
|
||||
}, config.KernelConfig{
|
||||
Type: "singbox",
|
||||
CustomConfig: customConfigPath,
|
||||
CustomOutbound: []map[string]any{{
|
||||
"tag": "proxy",
|
||||
"protocol": "socks",
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `custom_outbounds[0].tag duplicates "warp" from kernel.custom_config.outbounds[0]`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundTagCollisions_RejectsDuplicateRawSources(t *testing.T) {
|
||||
err := validateOutboundTagCollisions(nil, []outboundTagSource{
|
||||
{Tag: "warp", Source: "kernel.custom_outbound[0]"},
|
||||
{Tag: "warp", Source: "kernel.custom_config.outbounds[0]"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `kernel.custom_config.outbounds[0].tag duplicates "warp" from kernel.custom_outbound[0]`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
func OutboundSupportMatrix() map[string]KernelOutboundSupport {
|
||||
return map[string]KernelOutboundSupport{
|
||||
"xray": {
|
||||
Protocols: []string{
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"shadowsocks",
|
||||
"socks",
|
||||
"http",
|
||||
"wireguard",
|
||||
},
|
||||
Features: []string{
|
||||
"tag",
|
||||
"protocol",
|
||||
"settings",
|
||||
"proxy_tag",
|
||||
},
|
||||
},
|
||||
"singbox": {
|
||||
Protocols: []string{
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"shadowsocks",
|
||||
"socks",
|
||||
"http",
|
||||
"wireguard",
|
||||
"tuic",
|
||||
"hysteria2",
|
||||
"anytls",
|
||||
"naive",
|
||||
"mieru",
|
||||
},
|
||||
Features: []string{
|
||||
"tag",
|
||||
"protocol",
|
||||
"settings",
|
||||
"proxy_tag",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type KernelOutboundSupport struct {
|
||||
Protocols []string
|
||||
Features []string
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateCustomOutbounds(outbounds []OutboundConfig) error {
|
||||
return ValidateCustomOutboundsForKernel(outbounds, "", nil)
|
||||
}
|
||||
|
||||
func ValidateCustomOutboundsForKernel(outbounds []OutboundConfig, kernelType string, additionalTags []string) error {
|
||||
if len(outbounds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
kernelType = strings.ToLower(strings.TrimSpace(kernelType))
|
||||
allowedProtocols := map[string]struct{}{}
|
||||
if kernelType != "" {
|
||||
support, ok := OutboundSupportMatrix()[kernelType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported kernel type %q", kernelType)
|
||||
}
|
||||
for _, protocol := range support.Protocols {
|
||||
allowedProtocols[strings.ToLower(protocol)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(outbounds))
|
||||
availableTags := make(map[string]struct{}, len(outbounds)+len(additionalTags)+2)
|
||||
availableTags["direct"] = struct{}{}
|
||||
availableTags["block"] = struct{}{}
|
||||
for _, tag := range additionalTags {
|
||||
tag = strings.ToLower(strings.TrimSpace(tag))
|
||||
if tag != "" {
|
||||
availableTags[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for i, outbound := range outbounds {
|
||||
tag := strings.TrimSpace(outbound.Tag)
|
||||
protocol := strings.ToLower(strings.TrimSpace(outbound.Protocol))
|
||||
if tag == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].tag is required", i)
|
||||
}
|
||||
if protocol == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].protocol is required", i)
|
||||
}
|
||||
if kernelType != "" {
|
||||
if _, ok := allowedProtocols[protocol]; !ok {
|
||||
return fmt.Errorf("custom_outbounds[%d].protocol %q is not supported by kernel %q", i, outbound.Protocol, kernelType)
|
||||
}
|
||||
}
|
||||
if err := validateOutboundSettings(i, outbound.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
key := strings.ToLower(tag)
|
||||
if _, exists := seen[key]; exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].tag duplicates %q", i, tag)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
availableTags[key] = struct{}{}
|
||||
}
|
||||
|
||||
for i, outbound := range outbounds {
|
||||
if outbound.ProxyTag == "" {
|
||||
continue
|
||||
}
|
||||
proxyTag := strings.ToLower(strings.TrimSpace(outbound.ProxyTag))
|
||||
if proxyTag == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].proxy_tag must not be blank", i)
|
||||
}
|
||||
if proxyTag == strings.ToLower(strings.TrimSpace(outbound.Tag)) {
|
||||
return fmt.Errorf("custom_outbounds[%d].proxy_tag must not reference itself", i)
|
||||
}
|
||||
if _, exists := availableTags[proxyTag]; !exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].proxy_tag references unknown outbound %q", i, outbound.ProxyTag)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutboundSettings(index int, settings map[string]any) error {
|
||||
if len(settings) == 0 {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings is required", index)
|
||||
}
|
||||
for _, reservedKey := range []string{"tag", "protocol", "proxy_tag", "proxyTag"} {
|
||||
if _, exists := settings[reservedKey]; exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.%s is reserved", index, reservedKey)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"server", "uuid", "password", "method", "cipher", "private_key", "secret_key", "privateKey", "secretKey"} {
|
||||
if value, ok := settings[field]; ok {
|
||||
if strings.TrimSpace(fmt.Sprintf("%v", value)) == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.%s must not be blank", index, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok := settings["server_port"]; ok {
|
||||
if _, err := asPort(value); err != nil {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.server_port %w", index, err)
|
||||
}
|
||||
}
|
||||
if value, ok := settings["serverPort"]; ok {
|
||||
if _, err := asPort(value); err != nil {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.serverPort %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asPort(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
if v < 1 || v > 65535 {
|
||||
return 0, fmt.Errorf("must be between 1 and 65535")
|
||||
}
|
||||
return v, nil
|
||||
case int32:
|
||||
return asPort(int(v))
|
||||
case int64:
|
||||
return asPort(int(v))
|
||||
case float64:
|
||||
if v != float64(int(v)) {
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
return asPort(int(v))
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
return asPort(parsed)
|
||||
default:
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCustomOutbounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outbounds []OutboundConfig
|
||||
kernel string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid chain",
|
||||
outbounds: []OutboundConfig{
|
||||
{Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"}},
|
||||
{Tag: "proxy", Protocol: "socks", ProxyTag: "warp", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
},
|
||||
kernel: "singbox",
|
||||
},
|
||||
{
|
||||
name: "valid native vmess settings preserved",
|
||||
outbounds: []OutboundConfig{{Tag: "vmess-native", Protocol: "vmess", Settings: map[string]any{"vnext": []any{map[string]any{"address": "1.1.1.1", "port": 443}}}}},
|
||||
kernel: "xray",
|
||||
},
|
||||
{
|
||||
name: "missing tag",
|
||||
outbounds: []OutboundConfig{{Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].tag is required",
|
||||
},
|
||||
{
|
||||
name: "missing protocol",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].protocol is required",
|
||||
},
|
||||
{
|
||||
name: "duplicate tag",
|
||||
outbounds: []OutboundConfig{
|
||||
{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
{Tag: "proxy", Protocol: "http", Settings: map[string]any{"server": "3.3.3.3", "server_port": 8080}},
|
||||
},
|
||||
wantErr: `custom_outbounds[1].tag duplicates "proxy"`,
|
||||
},
|
||||
{
|
||||
name: "self proxy tag",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "proxy", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].proxy_tag must not reference itself",
|
||||
},
|
||||
{
|
||||
name: "unknown proxy tag",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: `custom_outbounds[0].proxy_tag references unknown outbound "missing"`,
|
||||
},
|
||||
{
|
||||
name: "built-in proxy tag allowed",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "direct", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
},
|
||||
{
|
||||
name: "unsupported protocol for kernel",
|
||||
outbounds: []OutboundConfig{{Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443}}},
|
||||
kernel: "xray",
|
||||
wantErr: `custom_outbounds[0].protocol "hysteria2" is not supported by kernel "xray"`,
|
||||
},
|
||||
{
|
||||
name: "missing settings",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks"}},
|
||||
wantErr: "custom_outbounds[0].settings is required",
|
||||
},
|
||||
{
|
||||
name: "reserved snake case key in settings",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"proxy_tag": "bad", "server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].settings.proxy_tag is reserved",
|
||||
},
|
||||
{
|
||||
name: "reserved camel case key in settings",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"proxyTag": "bad", "server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].settings.proxyTag is reserved",
|
||||
},
|
||||
{
|
||||
name: "invalid server_port",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 70000}}},
|
||||
wantErr: "custom_outbounds[0].settings.server_port must be between 1 and 65535",
|
||||
},
|
||||
{
|
||||
name: "invalid camel case serverPort",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "serverPort": 70000}}},
|
||||
wantErr: "custom_outbounds[0].settings.serverPort must be between 1 and 65535",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCustomOutboundsForKernel(tc.outbounds, tc.kernel, nil)
|
||||
if tc.wantErr == "" && err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package model
|
||||
|
||||
type CustomRouteRule struct {
|
||||
Name string
|
||||
Disabled bool
|
||||
Match RouteMatch
|
||||
Action RouteAction
|
||||
}
|
||||
|
||||
type RouteMatch struct {
|
||||
Domains []string
|
||||
DomainSuffixes []string
|
||||
IPCIDRs []string
|
||||
Ports []string
|
||||
Networks []string
|
||||
SourceCIDRs []string
|
||||
SourcePorts []string
|
||||
}
|
||||
|
||||
type RouteAction struct {
|
||||
Type string
|
||||
Target string
|
||||
}
|
||||
|
||||
func cloneCustomRouteRules(src []CustomRouteRule) []CustomRouteRule {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]CustomRouteRule, 0, len(src))
|
||||
for _, rule := range src {
|
||||
out = append(out, CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: cloneRouteMatch(rule.Match),
|
||||
Action: cloneRouteAction(rule.Action),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRouteMatch(src RouteMatch) RouteMatch {
|
||||
return RouteMatch{
|
||||
Domains: cloneStringSlice(src.Domains),
|
||||
DomainSuffixes: cloneStringSlice(src.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(src.IPCIDRs),
|
||||
Ports: cloneStringSlice(src.Ports),
|
||||
Networks: cloneStringSlice(src.Networks),
|
||||
SourceCIDRs: cloneStringSlice(src.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(src.SourcePorts),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRouteAction(src RouteAction) RouteAction {
|
||||
return RouteAction{
|
||||
Type: src.Type,
|
||||
Target: src.Target,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
func RouteSupportMatrix() map[string]KernelRouteSupport {
|
||||
return map[string]KernelRouteSupport{
|
||||
"xray": {
|
||||
Matchers: []string{
|
||||
"domains",
|
||||
"domain_suffixes",
|
||||
"ip_cidrs",
|
||||
"ports",
|
||||
"networks",
|
||||
"source_cidrs",
|
||||
"source_ports",
|
||||
},
|
||||
Actions: []string{"block", "direct", "route"},
|
||||
},
|
||||
"singbox": {
|
||||
Matchers: []string{
|
||||
"domains",
|
||||
"domain_suffixes",
|
||||
"ip_cidrs",
|
||||
"ports",
|
||||
"networks",
|
||||
"source_cidrs",
|
||||
"source_ports",
|
||||
},
|
||||
Actions: []string{"block", "direct", "route"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type KernelRouteSupport struct {
|
||||
Matchers []string
|
||||
Actions []string
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateCustomRouteRules(rules []CustomRouteRule, kernelType string, availableTags map[string]struct{}) error {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
kernelType = strings.ToLower(strings.TrimSpace(kernelType))
|
||||
matcherSupport := map[string]struct{}{}
|
||||
actionSupport := map[string]struct{}{}
|
||||
if kernelType != "" {
|
||||
support, ok := RouteSupportMatrix()[kernelType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported kernel type %q", kernelType)
|
||||
}
|
||||
for _, matcher := range support.Matchers {
|
||||
matcherSupport[strings.ToLower(matcher)] = struct{}{}
|
||||
}
|
||||
for _, action := range support.Actions {
|
||||
actionSupport[strings.ToLower(action)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for i, rule := range rules {
|
||||
if rule.Disabled {
|
||||
continue
|
||||
}
|
||||
if !hasRouteMatch(rule.Match) {
|
||||
return fmt.Errorf("custom_route_rules[%d].match is required", i)
|
||||
}
|
||||
if err := ensureRouteMatcherSupported(i, kernelType, matcherSupport, rule.Match); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePortSpecs(rule.Match.Ports, fmt.Sprintf("custom_route_rules[%d].match.ports", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePortSpecs(rule.Match.SourcePorts, fmt.Sprintf("custom_route_rules[%d].match.source_ports", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNetworks(rule.Match.Networks, fmt.Sprintf("custom_route_rules[%d].match.networks", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionType := strings.ToLower(strings.TrimSpace(rule.Action.Type))
|
||||
if actionType == "" {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.type is required", i)
|
||||
}
|
||||
if kernelType != "" {
|
||||
if _, ok := actionSupport[actionType]; !ok {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.type %q is not supported by kernel %q", i, rule.Action.Type, kernelType)
|
||||
}
|
||||
}
|
||||
target := strings.TrimSpace(rule.Action.Target)
|
||||
switch actionType {
|
||||
case "route":
|
||||
if target == "" {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.target is required when action.type is route", i)
|
||||
}
|
||||
if availableTags != nil {
|
||||
if _, ok := availableTags[strings.ToLower(target)]; !ok {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.target references unknown outbound %q", i, rule.Action.Target)
|
||||
}
|
||||
}
|
||||
case "direct", "block":
|
||||
if target != "" {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.target is only allowed when action.type is route", i)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("custom_route_rules[%d].action.type %q is not supported", i, rule.Action.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasRouteMatch(match RouteMatch) bool {
|
||||
for _, values := range [][]string{
|
||||
match.Domains,
|
||||
match.DomainSuffixes,
|
||||
match.IPCIDRs,
|
||||
match.Ports,
|
||||
match.Networks,
|
||||
match.SourceCIDRs,
|
||||
match.SourcePorts,
|
||||
} {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureRouteMatcherSupported(index int, kernelType string, matcherSupport map[string]struct{}, match RouteMatch) error {
|
||||
if kernelType == "" {
|
||||
return nil
|
||||
}
|
||||
checks := map[string][]string{
|
||||
"domains": match.Domains,
|
||||
"domain_suffixes": match.DomainSuffixes,
|
||||
"ip_cidrs": match.IPCIDRs,
|
||||
"ports": match.Ports,
|
||||
"networks": match.Networks,
|
||||
"source_cidrs": match.SourceCIDRs,
|
||||
"source_ports": match.SourcePorts,
|
||||
}
|
||||
for matcher, values := range checks {
|
||||
if !hasAnyNonBlank(values) {
|
||||
continue
|
||||
}
|
||||
if _, ok := matcherSupport[matcher]; !ok {
|
||||
return fmt.Errorf("custom_route_rules[%d].match.%s is not supported by kernel %q", index, matcher, kernelType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAnyNonBlank(values []string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateNetworks(values []string, field string) error {
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if value != "tcp" && value != "udp" {
|
||||
return fmt.Errorf("%s contains unsupported network %q", field, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePortSpecs(values []string, field string) error {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(value, "-") || strings.Contains(value, ":") {
|
||||
delimiter := "-"
|
||||
if strings.Contains(value, ":") {
|
||||
delimiter = ":"
|
||||
}
|
||||
parts := strings.Split(value, delimiter)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("%s contains invalid port range %q", field, value)
|
||||
}
|
||||
start, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || start < 1 || start > 65535 {
|
||||
return fmt.Errorf("%s contains invalid port range %q", field, value)
|
||||
}
|
||||
end, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || end < 1 || end > 65535 || end < start {
|
||||
return fmt.Errorf("%s contains invalid port range %q", field, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("%s contains invalid port %q", field, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCustomRouteRules(t *testing.T) {
|
||||
available := map[string]struct{}{"direct": {}, "block": {}, "warp": {}, "proxy": {}}
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []CustomRouteRule
|
||||
kernel string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid route target",
|
||||
rules: []CustomRouteRule{{
|
||||
Match: RouteMatch{DomainSuffixes: []string{"example.com"}, Ports: []string{"443"}, Networks: []string{"tcp"}},
|
||||
Action: RouteAction{Type: "route", Target: "warp"},
|
||||
}},
|
||||
kernel: "singbox",
|
||||
},
|
||||
{
|
||||
name: "missing match",
|
||||
rules: []CustomRouteRule{{Action: RouteAction{Type: "direct"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: "custom_route_rules[0].match is required",
|
||||
},
|
||||
{
|
||||
name: "unknown target",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "route", Target: "missing"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].action.target references unknown outbound "missing"`,
|
||||
},
|
||||
{
|
||||
name: "route target required",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "route"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: "custom_route_rules[0].action.target is required when action.type is route",
|
||||
},
|
||||
{
|
||||
name: "direct target forbidden",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "direct", Target: "warp"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: "custom_route_rules[0].action.target is only allowed when action.type is route",
|
||||
},
|
||||
{
|
||||
name: "unsupported action",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "dns"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].action.type "dns" is not supported by kernel "singbox"`,
|
||||
},
|
||||
{
|
||||
name: "invalid port range",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{Ports: []string{"2000-1000"}}, Action: RouteAction{Type: "direct"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].match.ports contains invalid port range "2000-1000"`,
|
||||
},
|
||||
{
|
||||
name: "invalid network",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{Networks: []string{"icmp"}}, Action: RouteAction{Type: "direct"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].match.networks contains unsupported network "icmp"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCustomRouteRules(tc.rules, tc.kernel, available)
|
||||
if tc.wantErr == "" && err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,27 @@ func NodeSpecFromPanel(nc *panel.NodeConfig) *NodeSpec {
|
||||
})
|
||||
}
|
||||
|
||||
customRouteRules := make([]CustomRouteRule, 0, len(nc.CustomRouteRules))
|
||||
for _, rule := range nc.CustomRouteRules {
|
||||
customRouteRules = append(customRouteRules, CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: RouteMatch{
|
||||
Domains: cloneStringSlice(rule.Match.Domains),
|
||||
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
|
||||
Ports: cloneStringSlice(rule.Match.Ports),
|
||||
Networks: cloneStringSlice(rule.Match.Networks),
|
||||
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
|
||||
},
|
||||
Action: RouteAction{
|
||||
Type: rule.Action.Type,
|
||||
Target: rule.Action.Target,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &NodeSpec{
|
||||
Protocol: nc.Protocol,
|
||||
ListenIP: nc.ListenIP,
|
||||
@@ -76,6 +97,7 @@ func NodeSpecFromPanel(nc *panel.NodeConfig) *NodeSpec {
|
||||
KernelLogLevel: nc.KernelLogLevel,
|
||||
CustomOutbounds: outbounds,
|
||||
CustomRoutes: cloneMapSlice(nc.CustomRoutes),
|
||||
CustomRouteRules: customRouteRules,
|
||||
CertConfig: certCfg,
|
||||
AutoTLS: nc.AutoTLS,
|
||||
Domain: nc.Domain,
|
||||
@@ -103,6 +125,14 @@ func NodeSpecFromPanel(nc *panel.NodeConfig) *NodeSpec {
|
||||
}
|
||||
}
|
||||
|
||||
func NodeSpecFromPanelValidated(nc *panel.NodeConfig, kcfg config.KernelConfig) (*NodeSpec, error) {
|
||||
spec := NodeSpecFromPanel(nc)
|
||||
if err := ValidateNodeSpec(spec, kcfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func UserSpecsFromPanel(users []panel.User) []UserSpec {
|
||||
if users == nil {
|
||||
return nil
|
||||
@@ -174,6 +204,27 @@ func (n *NodeSpec) ToPanel() *panel.NodeConfig {
|
||||
})
|
||||
}
|
||||
|
||||
customRouteRules := make([]panel.CustomRouteRule, 0, len(n.CustomRouteRules))
|
||||
for _, rule := range n.CustomRouteRules {
|
||||
customRouteRules = append(customRouteRules, panel.CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: panel.RouteMatch{
|
||||
Domains: cloneStringSlice(rule.Match.Domains),
|
||||
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
|
||||
Ports: cloneStringSlice(rule.Match.Ports),
|
||||
Networks: cloneStringSlice(rule.Match.Networks),
|
||||
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
|
||||
},
|
||||
Action: panel.RouteAction{
|
||||
Type: rule.Action.Type,
|
||||
Target: rule.Action.Target,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &panel.NodeConfig{
|
||||
Protocol: n.Protocol,
|
||||
ListenIP: n.ListenIP,
|
||||
@@ -185,6 +236,7 @@ func (n *NodeSpec) ToPanel() *panel.NodeConfig {
|
||||
KernelLogLevel: n.KernelLogLevel,
|
||||
CustomOutbounds: outbounds,
|
||||
CustomRoutes: cloneMapSlice(n.CustomRoutes),
|
||||
CustomRouteRules: customRouteRules,
|
||||
CertConfig: certCfg,
|
||||
AutoTLS: n.AutoTLS,
|
||||
Domain: n.Domain,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestNodeSpecFromPanelValidated(t *testing.T) {
|
||||
t.Run("valid custom outbounds and route targets", func(t *testing.T) {
|
||||
node, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{
|
||||
{Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"}},
|
||||
{Tag: "proxy", Protocol: "socks", ProxyTag: "warp", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
},
|
||||
CustomRouteRules: []panel.CustomRouteRule{{
|
||||
Action: panel.RouteAction{Type: "route", Target: "proxy"},
|
||||
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err != nil {
|
||||
t.Fatalf("NodeSpecFromPanelValidated: %v", err)
|
||||
}
|
||||
if node == nil || len(node.CustomOutbounds) != 2 {
|
||||
t.Fatalf("unexpected node spec: %#v", node)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid custom outbounds", func(t *testing.T) {
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject unsupported protocol for kernel", func(t *testing.T) {
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "xray"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `protocol "hysteria2" is not supported by kernel "xray"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject unknown route target", func(t *testing.T) {
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"},
|
||||
}},
|
||||
CustomRouteRules: []panel.CustomRouteRule{{
|
||||
Action: panel.RouteAction{Type: "route", Target: "missing"},
|
||||
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `custom_route_rules[0].action.target references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,24 @@ func NodeSpecFromStandalone(cfg *config.Config) *NodeSpec {
|
||||
})
|
||||
}
|
||||
|
||||
customRouteRules := make([]CustomRouteRule, 0, len(sc.Node.CustomRouteRules))
|
||||
for _, rule := range sc.Node.CustomRouteRules {
|
||||
customRouteRules = append(customRouteRules, CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: RouteMatch{
|
||||
Domains: cloneStringSlice(rule.Match.Domains),
|
||||
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
|
||||
Ports: cloneStringSlice(rule.Match.Ports),
|
||||
Networks: cloneStringSlice(rule.Match.Networks),
|
||||
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
|
||||
},
|
||||
Action: RouteAction{Type: rule.Action.Type, Target: rule.Action.Target},
|
||||
})
|
||||
}
|
||||
|
||||
var multiplex *MultiplexConfig
|
||||
if sc.Node.Multiplex != nil {
|
||||
multiplex = &MultiplexConfig{
|
||||
@@ -44,6 +62,7 @@ func NodeSpecFromStandalone(cfg *config.Config) *NodeSpec {
|
||||
Network: sc.Node.Network,
|
||||
NetworkSettings: cloneAnyMap(sc.Node.NetworkSettings),
|
||||
Routes: routes,
|
||||
CustomRouteRules: customRouteRules,
|
||||
KernelType: cfg.Kernel.Type,
|
||||
KernelLogLevel: cfg.Kernel.LogLevel,
|
||||
Cipher: sc.Node.Cipher,
|
||||
@@ -70,6 +89,14 @@ func NodeSpecFromStandalone(cfg *config.Config) *NodeSpec {
|
||||
}
|
||||
}
|
||||
|
||||
func NodeSpecFromStandaloneValidated(cfg *config.Config) (*NodeSpec, error) {
|
||||
spec := NodeSpecFromStandalone(cfg)
|
||||
if err := ValidateNodeSpec(spec, cfg.Kernel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func UserSpecsFromStandalone(cfg *config.Config) []UserSpec {
|
||||
if cfg.Standalone == nil {
|
||||
return nil
|
||||
|
||||
@@ -12,18 +12,18 @@ func TestNodeSpecFromStandalone(t *testing.T) {
|
||||
Standalone: &config.StandaloneConfig{
|
||||
Enabled: true,
|
||||
Node: config.StandaloneNodeConfig{
|
||||
Protocol: "vless",
|
||||
ListenIP: "::",
|
||||
ServerPort: 8443,
|
||||
Network: "ws",
|
||||
NetworkSettings: map[string]any{"path": "/ws"},
|
||||
Protocol: "vless",
|
||||
ListenIP: "::",
|
||||
ServerPort: 8443,
|
||||
Network: "ws",
|
||||
NetworkSettings: map[string]any{"path": "/ws"},
|
||||
TLS: 1,
|
||||
Host: "node.example.com",
|
||||
ServerName: "node.example.com",
|
||||
AcceptProxyProtocol: true,
|
||||
PaddingScheme: "stop=8\n0=30-30",
|
||||
Routes: []config.StandaloneRouteRule{{ID: 1, Match: []string{"example.com"}, Action: "direct"}},
|
||||
Multiplex: &config.StandaloneMultiplexConfig{Enabled: true, Protocol: "smux", MaxConnections: 4},
|
||||
Routes: []config.StandaloneRouteRule{{ID: 1, Match: []string{"example.com"}, Action: "direct"}},
|
||||
Multiplex: &config.StandaloneMultiplexConfig{Enabled: true, Protocol: "smux", MaxConnections: 4},
|
||||
},
|
||||
Users: []config.StandaloneUser{{ID: 7, UUID: "11111111-1111-1111-1111-111111111111", SpeedLimit: 50, DeviceLimit: 2}},
|
||||
},
|
||||
|
||||
@@ -10,13 +10,14 @@ type NodeSpec struct {
|
||||
NetworkSettings map[string]any
|
||||
Routes []RouteRule
|
||||
|
||||
KernelType string
|
||||
KernelLogLevel string
|
||||
CustomOutbounds []OutboundConfig
|
||||
CustomRoutes []map[string]any
|
||||
CertConfig *config.CertConfig
|
||||
AutoTLS bool
|
||||
Domain string
|
||||
KernelType string
|
||||
KernelLogLevel string
|
||||
CustomOutbounds []OutboundConfig
|
||||
CustomRoutes []map[string]any
|
||||
CustomRouteRules []CustomRouteRule
|
||||
CertConfig *config.CertConfig
|
||||
AutoTLS bool
|
||||
Domain string
|
||||
|
||||
Cipher string
|
||||
Plugin string
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
)
|
||||
|
||||
func ValidateNodeSpec(n *NodeSpec, kcfg config.KernelConfig) error {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
effectiveKernelType := strings.TrimSpace(kcfg.Type)
|
||||
if effectiveKernelType == "" {
|
||||
effectiveKernelType = strings.TrimSpace(n.KernelType)
|
||||
}
|
||||
kernelType, err := normalizeKernelType(effectiveKernelType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize kernel type: %w", err)
|
||||
}
|
||||
|
||||
additionalOutboundSources, err := collectAdditionalOutboundTagSources(kcfg.CustomConfig, kcfg.CustomOutbound)
|
||||
if err != nil {
|
||||
return fmt.Errorf("collect additional outbound tags: %w", err)
|
||||
}
|
||||
if err := validateOutboundTagCollisions(n.CustomOutbounds, additionalOutboundSources); err != nil {
|
||||
return fmt.Errorf("validate outbound tags: %w", err)
|
||||
}
|
||||
additionalTags := additionalTagNames(additionalOutboundSources)
|
||||
availableTags := buildAvailableOutboundTags(n.CustomOutbounds, additionalTags)
|
||||
if err := ValidateCustomOutboundsForKernel(n.CustomOutbounds, kernelType, additionalTags); err != nil {
|
||||
return fmt.Errorf("validate custom outbounds: %w", err)
|
||||
}
|
||||
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
|
||||
return fmt.Errorf("validate custom route rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeKernelType(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "singbox", "sing-box":
|
||||
return "singbox", nil
|
||||
case "xray":
|
||||
return "xray", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported kernel type %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func buildAvailableOutboundTags(structured []OutboundConfig, rawTags []string) map[string]struct{} {
|
||||
available := map[string]struct{}{
|
||||
"direct": {},
|
||||
"block": {},
|
||||
}
|
||||
for _, outbound := range structured {
|
||||
tag := strings.ToLower(strings.TrimSpace(outbound.Tag))
|
||||
if tag != "" {
|
||||
available[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, tag := range rawTags {
|
||||
tag = strings.ToLower(strings.TrimSpace(tag))
|
||||
if tag != "" {
|
||||
available[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
return available
|
||||
}
|
||||
Reference in New Issue
Block a user