feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl

This commit is contained in:
xboard
2026-04-16 23:06:58 +08:00
parent 598b6b531b
commit c5bcbc3299
52 changed files with 6676 additions and 1348 deletions
+155
View File
@@ -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
}
+81
View File
@@ -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)
}
}
+50
View File
@@ -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
}
+139
View File
@@ -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)
}
}
})
}
}
+58
View File
@@ -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,
}
}
+35
View File
@@ -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
}
+178
View File
@@ -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)
}
}
})
}
}
+52
View File
@@ -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,
+84
View File
@@ -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)
}
})
}
+27
View File
@@ -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
+7 -7
View File
@@ -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}},
},
+8 -7
View File
@@ -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
+71
View File
@@ -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
}