From b64969c3dce0d03fea0a6f321ff18455fb374ba9 Mon Sep 17 00:00:00 2001 From: orris-inc Date: Thu, 12 Feb 2026 15:53:37 +0800 Subject: [PATCH] feat: add custom outbound support for route configuration Allow users to define custom sing-box outbound configurations (custom_xxx) in route rules, in addition to existing preset types and node references. - Add CustomOutbound value object with validation (tag, protocol, server, port, settings) - Extend OutboundType to support custom_xxx references - Add custom outbounds to RouteConfig with referential integrity validation - Add CustomOutboundDTO and DTO conversion functions - Flatten custom outbounds into agent outbounds list for sing-box consumption - Agent responses only include custom outbounds in the outbounds array, not in route - Add persistence support via RouteConfigJSON/CustomOutboundJSON in mapper --- internal/application/node/dto/agentdto.go | 223 +++++++++++++++++- internal/application/node/dto/hubdto.go | 11 +- .../node/valueobjects/customoutbound.go | 184 +++++++++++++++ .../domain/node/valueobjects/outboundtype.go | 26 +- .../domain/node/valueobjects/routeconfig.go | 108 ++++++++- .../persistence/mappers/nodemapper.go | 45 +++- 6 files changed, 577 insertions(+), 20 deletions(-) create mode 100644 internal/domain/node/valueobjects/customoutbound.go diff --git a/internal/application/node/dto/agentdto.go b/internal/application/node/dto/agentdto.go index 0d25420..f1b4a31 100644 --- a/internal/application/node/dto/agentdto.go +++ b/internal/application/node/dto/agentdto.go @@ -75,8 +75,19 @@ type NodeConfigResponse struct { // RouteConfigDTO represents the routing configuration for sing-box type RouteConfigDTO struct { - Rules []RouteRuleDTO `json:"rules,omitempty"` // Ordered list of routing rules - Final string `json:"final"` // Default outbound when no rules match (direct/block/proxy/node_xxx) + Rules []RouteRuleDTO `json:"rules,omitempty"` // Ordered list of routing rules + Final string `json:"final"` // Default outbound when no rules match (direct/block/proxy/node_xxx/custom_xxx) + CustomOutbounds []CustomOutboundDTO `json:"custom_outbounds,omitempty"` // User-defined outbound configurations +} + +// CustomOutboundDTO represents a user-defined sing-box outbound configuration. +// Route rules reference these via custom_xxx tags. +type CustomOutboundDTO struct { + Tag string `json:"tag"` // Unique identifier, must start with "custom_" + Type string `json:"type"` // Protocol type (shadowsocks, trojan, vless, vmess, hysteria2, tuic, anytls, socks, http) + Server string `json:"server"` // Server hostname or IP address + Port int `json:"server_port"` // Server port number + Settings map[string]any `json:"settings,omitempty"` // Protocol-specific configuration (password, uuid, method, tls, transport, etc.) } // RouteRuleDTO represents a single routing rule, compatible with sing-box route rule @@ -418,11 +429,20 @@ func ToNodeConfigResponse(n *node.Node, referencedNodes []*node.Node, serverKeyF // Convert route configuration if present if n.RouteConfig() != nil { config.Route = ToRouteConfigDTO(n.RouteConfig()) + // Agent only needs route rules and final action; custom outbound + // definitions are already flattened into the top-level outbounds list. + config.Route.CustomOutbounds = nil + + // Merge custom outbounds into the outbounds list + customDTOs := CustomOutboundsToAgentDTOs(n.RouteConfig()) + if len(customDTOs) > 0 { + config.Outbounds = append(config.Outbounds, customDTOs...) + } } // Convert referenced nodes to outbounds if len(referencedNodes) > 0 { - config.Outbounds = ToOutboundDTOs(referencedNodes, serverKeyFunc) + config.Outbounds = append(config.Outbounds, ToOutboundDTOs(referencedNodes, serverKeyFunc)...) } return config @@ -439,10 +459,21 @@ func ToRouteConfigDTO(rc *vo.RouteConfig) *RouteConfigDTO { rules = append(rules, ToRouteRuleDTO(&rule)) } - return &RouteConfigDTO{ + dto := &RouteConfigDTO{ Rules: rules, Final: rc.FinalAction().String(), } + + // Convert custom outbounds + if rc.HasCustomOutbounds() { + customOutbounds := rc.CustomOutbounds() + dto.CustomOutbounds = make([]CustomOutboundDTO, 0, len(customOutbounds)) + for _, co := range customOutbounds { + dto.CustomOutbounds = append(dto.CustomOutbounds, customOutboundToDTO(&co)) + } + } + + return dto } // ToRouteRuleDTO converts domain RouteRule to DTO @@ -828,6 +859,21 @@ func FromRouteConfigDTO(dto *RouteConfigDTO) (*vo.RouteConfig, error) { } } + // Convert and set custom outbounds + if len(dto.CustomOutbounds) > 0 { + customOutbounds := make([]vo.CustomOutbound, 0, len(dto.CustomOutbounds)) + for i, coDTO := range dto.CustomOutbounds { + co, err := customOutboundFromDTO(&coDTO) + if err != nil { + return nil, fmt.Errorf("invalid custom outbound at index %d: %w", i, err) + } + customOutbounds = append(customOutbounds, *co) + } + if err := config.SetCustomOutbounds(customOutbounds); err != nil { + return nil, fmt.Errorf("failed to set custom outbounds: %w", err) + } + } + return config, nil } @@ -911,6 +957,175 @@ func FromRouteRuleDTO(dto *RouteRuleDTO) (*vo.RouteRule, error) { return rule, nil } +// customOutboundToDTO converts domain CustomOutbound to CustomOutboundDTO +func customOutboundToDTO(co *vo.CustomOutbound) CustomOutboundDTO { + return CustomOutboundDTO{ + Tag: co.Tag(), + Type: co.Protocol(), + Server: co.Server(), + Port: int(co.Port()), + Settings: co.Settings(), + } +} + +// customOutboundFromDTO converts CustomOutboundDTO to domain CustomOutbound +func customOutboundFromDTO(dto *CustomOutboundDTO) (*vo.CustomOutbound, error) { + if dto == nil { + return nil, nil + } + port := dto.Port + if port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid port number: %d (must be 1-65535)", port) + } + return vo.NewCustomOutbound(dto.Tag, dto.Type, dto.Server, uint16(port), dto.Settings) +} + +// CustomOutboundsToAgentDTOs converts custom outbounds from a RouteConfig into OutboundDTOs +// for the agent sync protocol. The settings map is flattened into OutboundDTO fields +// using JSON marshal/unmarshal for automatic field mapping. +func CustomOutboundsToAgentDTOs(rc *vo.RouteConfig) []OutboundDTO { + if rc == nil || !rc.HasCustomOutbounds() { + return nil + } + + customOutbounds := rc.CustomOutbounds() + dtos := make([]OutboundDTO, 0, len(customOutbounds)) + for _, co := range customOutbounds { + dto := customOutboundToAgentDTO(&co) + dtos = append(dtos, dto) + } + return dtos +} + +// customOutboundToAgentDTO converts a domain CustomOutbound to an OutboundDTO +// for the agent sync protocol by mapping settings to typed fields. +func customOutboundToAgentDTO(co *vo.CustomOutbound) OutboundDTO { + dto := OutboundDTO{ + Tag: co.Tag(), + Type: co.Protocol(), + Server: co.Server(), + Port: int(co.Port()), + } + + s := co.Settings() + if s == nil { + return dto + } + + // Common fields + dto.Password = getStringFromSettings(s, "password") + dto.UUID = getStringFromSettings(s, "uuid") + dto.Method = getStringFromSettings(s, "method") + dto.Plugin = getStringFromSettings(s, "plugin") + dto.PluginOpts = getStringFromSettings(s, "plugin_opts") + dto.VLESSFlow = getStringFromSettings(s, "flow") + dto.VMessAlterID = getIntFromSettings(s, "alter_id") + dto.VMessSecurity = getStringFromSettings(s, "security") + dto.Hysteria2Obfs = getStringFromSettings(s, "obfs") + dto.Hysteria2ObfsPassword = getStringFromSettings(s, "obfs_password") + dto.Hysteria2UpMbps = getIntPtrFromSettings(s, "up_mbps") + dto.Hysteria2DownMbps = getIntPtrFromSettings(s, "down_mbps") + dto.TUICCongestionControl = getStringFromSettings(s, "congestion_control") + dto.TUICUDPRelayMode = getStringFromSettings(s, "udp_relay_mode") + dto.AnyTLSFingerprint = getStringFromSettings(s, "anytls_fingerprint") + dto.AnyTLSIdleSessionCheckInterval = getStringFromSettings(s, "anytls_idle_session_check_interval") + dto.AnyTLSIdleSessionTimeout = getStringFromSettings(s, "anytls_idle_session_timeout") + dto.AnyTLSMinIdleSession = getIntFromSettings(s, "anytls_min_idle_session") + + // TLS configuration + if tlsMap, ok := s["tls"].(map[string]any); ok { + dto.TLS = &OutboundTLSDTO{ + Enabled: getBoolFromSettings(tlsMap, "enabled"), + ServerName: getStringFromSettings(tlsMap, "server_name"), + Insecure: getBoolFromSettings(tlsMap, "insecure"), + DisableSNI: getBoolFromSettings(tlsMap, "disable_sni"), + } + if alpn, ok := tlsMap["alpn"].([]any); ok { + for _, a := range alpn { + if str, ok := a.(string); ok { + dto.TLS.ALPN = append(dto.TLS.ALPN, str) + } + } + } + // Reality configuration + if realityMap, ok := tlsMap["reality"].(map[string]any); ok { + dto.TLS.Reality = &OutboundRealityDTO{ + Enabled: getBoolFromSettings(realityMap, "enabled"), + PublicKey: getStringFromSettings(realityMap, "public_key"), + ShortID: getStringFromSettings(realityMap, "short_id"), + } + } + } + + // Transport configuration + if transportMap, ok := s["transport"].(map[string]any); ok { + dto.Transport = &OutboundTransportDTO{ + Type: getStringFromSettings(transportMap, "type"), + Path: getStringFromSettings(transportMap, "path"), + ServiceName: getStringFromSettings(transportMap, "service_name"), + } + if headers, ok := transportMap["headers"].(map[string]any); ok { + dto.Transport.Headers = make(map[string]string, len(headers)) + for k, v := range headers { + if str, ok := v.(string); ok { + dto.Transport.Headers[k] = str + } + } + } + } + + return dto +} + +// Settings extraction helpers + +func getStringFromSettings(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getIntFromSettings(m map[string]any, key string) int { + switch v := m[key].(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + default: + return 0 + } +} + +func getIntPtrFromSettings(m map[string]any, key string) *int { + val, ok := m[key] + if !ok { + return nil + } + // Only return non-nil for actual numeric types; ignore non-numeric values + switch v := val.(type) { + case float64: + i := int(v) + return &i + case int: + return &v + case int64: + i := int(v) + return &i + default: + return nil + } +} + +func getBoolFromSettings(m map[string]any, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} + // generatePasswordForEncryptionMethod generates password based on encryption method type // SS2022 methods use base64-encoded fixed-length keys // Traditional SS methods use hex-encoded keys (backward compatible) diff --git a/internal/application/node/dto/hubdto.go b/internal/application/node/dto/hubdto.go index f9ab14a..9523583 100644 --- a/internal/application/node/dto/hubdto.go +++ b/internal/application/node/dto/hubdto.go @@ -274,11 +274,20 @@ func ToNodeConfigData(n *node.Node, referencedNodes []*node.Node, serverKeyFunc // Convert route configuration if present if n.RouteConfig() != nil { config.Route = ToRouteConfigDTO(n.RouteConfig()) + // Agent only needs route rules and final action; custom outbound + // definitions are already flattened into the top-level outbounds list. + config.Route.CustomOutbounds = nil + + // Merge custom outbounds into the outbounds list + customDTOs := CustomOutboundsToAgentDTOs(n.RouteConfig()) + if len(customDTOs) > 0 { + config.Outbounds = append(config.Outbounds, customDTOs...) + } } // Convert referenced nodes to outbounds if len(referencedNodes) > 0 { - config.Outbounds = ToOutboundDTOs(referencedNodes, serverKeyFunc) + config.Outbounds = append(config.Outbounds, ToOutboundDTOs(referencedNodes, serverKeyFunc)...) } return config diff --git a/internal/domain/node/valueobjects/customoutbound.go b/internal/domain/node/valueobjects/customoutbound.go new file mode 100644 index 0000000..3cf7fa5 --- /dev/null +++ b/internal/domain/node/valueobjects/customoutbound.go @@ -0,0 +1,184 @@ +package valueobjects + +import ( + "fmt" + "net" + "reflect" + "regexp" + "strings" +) + +// customOutboundTagSuffixPattern validates the suffix part of a custom outbound tag. +// Only allows alphanumeric characters, hyphens, and underscores. +var customOutboundTagSuffixPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +// hostnamePattern validates a DNS hostname (RFC 952/1123). +// Each label: starts/ends with alphanumeric, may contain hyphens, max 63 chars. +var hostnamePattern = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) + +// isSupportedCustomProtocol checks if a protocol is a supported sing-box outbound type. +func isSupportedCustomProtocol(protocol string) bool { + switch protocol { + case "shadowsocks", "trojan", "vless", "vmess", "hysteria2", "tuic", "anytls", "socks", "http": + return true + default: + return false + } +} + +// CustomOutbound represents a user-defined sing-box outbound configuration. +// It stores the full protocol configuration for outbound connections +// that are not tied to any registered system node. +type CustomOutbound struct { + tag string // Unique identifier, must start with "custom_" + protocol string // sing-box outbound type (shadowsocks, trojan, etc.) + server string // Server address (IP or hostname) + port uint16 // Server port + settings map[string]any // Protocol-specific configuration (password, uuid, method, tls, transport, etc.) +} + +// NewCustomOutbound creates a new CustomOutbound with validation. +// The settings map is deep-copied to prevent external mutation. +func NewCustomOutbound(tag, protocol, server string, port uint16, settings map[string]any) (*CustomOutbound, error) { + co := &CustomOutbound{ + tag: tag, + protocol: protocol, + server: server, + port: port, + settings: deepCopyMap(settings), + } + if err := co.Validate(); err != nil { + return nil, err + } + return co, nil +} + +// Tag returns the custom outbound tag +func (co *CustomOutbound) Tag() string { return co.tag } + +// Protocol returns the protocol type +func (co *CustomOutbound) Protocol() string { return co.protocol } + +// Server returns the server address +func (co *CustomOutbound) Server() string { return co.server } + +// Port returns the server port +func (co *CustomOutbound) Port() uint16 { return co.port } + +// Settings returns a deep copy of the protocol-specific settings +func (co *CustomOutbound) Settings() map[string]any { + if co.settings == nil { + return nil + } + return deepCopyMap(co.settings) +} + +// deepCopyMap performs a deep copy of a map[string]any, handling nested maps and slices. +func deepCopyMap(m map[string]any) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + result[k] = deepCopyValue(v) + } + return result +} + +// deepCopySlice performs a deep copy of a []any, handling nested maps and slices. +func deepCopySlice(s []any) []any { + result := make([]any, len(s)) + for i, v := range s { + result[i] = deepCopyValue(v) + } + return result +} + +// deepCopyValue performs a deep copy of a single value from a JSON-unmarshaled structure. +func deepCopyValue(v any) any { + switch val := v.(type) { + case map[string]any: + return deepCopyMap(val) + case []any: + return deepCopySlice(val) + default: + return v + } +} + +// Validate validates the custom outbound configuration +func (co *CustomOutbound) Validate() error { + // Validate tag format + if !strings.HasPrefix(co.tag, customOutboundPrefix) { + return fmt.Errorf("custom outbound tag must start with '%s', got: %s", customOutboundPrefix, co.tag) + } + suffix := co.tag[len(customOutboundPrefix):] + if suffix == "" { + return fmt.Errorf("custom outbound tag must have content after '%s' prefix", customOutboundPrefix) + } + if len(suffix) > 64 { + return fmt.Errorf("custom outbound tag suffix too long: max 64 characters") + } + if !customOutboundTagSuffixPattern.MatchString(suffix) { + return fmt.Errorf("custom outbound tag suffix contains invalid characters: %s (only alphanumeric, hyphens, and underscores allowed)", suffix) + } + + // Validate protocol + if !isSupportedCustomProtocol(co.protocol) { + return fmt.Errorf("unsupported custom outbound protocol: %s", co.protocol) + } + + // Validate server (must be non-empty, valid IP or hostname) + if co.server == "" { + return fmt.Errorf("custom outbound server address is required") + } + if len(co.server) > 253 { + return fmt.Errorf("custom outbound server address too long: max 253 characters") + } + if net.ParseIP(co.server) == nil { + // Not an IP, validate as RFC-compliant hostname using whitelist pattern + if !hostnamePattern.MatchString(co.server) { + return fmt.Errorf("invalid custom outbound server address: %s (must be a valid IP or hostname)", co.server) + } + } + + // Validate port + if co.port == 0 { + return fmt.Errorf("custom outbound port must be between 1 and 65535") + } + + // Validate settings size to prevent DoS via oversized configurations + if len(co.settings) > 50 { + return fmt.Errorf("custom outbound settings has too many keys: %d (max 50)", len(co.settings)) + } + + return nil +} + +// Equals compares two CustomOutbound instances for equality, including settings. +func (co *CustomOutbound) Equals(other *CustomOutbound) bool { + if co == nil && other == nil { + return true + } + if co == nil || other == nil { + return false + } + return co.tag == other.tag && + co.protocol == other.protocol && + co.server == other.server && + co.port == other.port && + reflect.DeepEqual(co.settings, other.settings) +} + +// IsSupportedCustomProtocol checks if a protocol string is a supported custom outbound protocol. +func IsSupportedCustomProtocol(protocol string) bool { + return isSupportedCustomProtocol(protocol) +} + +// ReconstructCustomOutbound reconstructs a CustomOutbound from persistence data without validation. +func ReconstructCustomOutbound(tag, protocol, server string, port uint16, settings map[string]any) *CustomOutbound { + return &CustomOutbound{ + tag: tag, + protocol: protocol, + server: server, + port: port, + settings: settings, + } +} diff --git a/internal/domain/node/valueobjects/outboundtype.go b/internal/domain/node/valueobjects/outboundtype.go index 9159183..e6ad669 100644 --- a/internal/domain/node/valueobjects/outboundtype.go +++ b/internal/domain/node/valueobjects/outboundtype.go @@ -10,6 +10,7 @@ import ( // Supports: // - Preset types: "direct", "block", "proxy" // - Node reference: "node_xxx" (routes traffic through the specified node) +// - Custom outbound: "custom_xxx" (routes traffic through a user-defined outbound) type OutboundType string const ( @@ -24,6 +25,9 @@ const ( // nodeSIDPrefix is the prefix for node SID references const nodeSIDPrefix = "node_" +// customOutboundPrefix is the prefix for custom outbound references +const customOutboundPrefix = "custom_" + // IsPresetType checks if this is a built-in outbound type (direct/block/proxy) func (o OutboundType) IsPresetType() bool { switch o { @@ -40,9 +44,23 @@ func (o OutboundType) IsNodeReference() bool { return strings.HasPrefix(s, nodeSIDPrefix) && len(s) > len(nodeSIDPrefix) } -// IsValid checks if the outbound type is valid (either preset type or node reference) +// IsCustomOutbound checks if this outbound references a custom outbound (custom_xxx format) +func (o OutboundType) IsCustomOutbound() bool { + s := string(o) + return strings.HasPrefix(s, customOutboundPrefix) && len(s) > len(customOutboundPrefix) +} + +// CustomOutboundTag returns the custom outbound tag if this is a custom outbound reference, empty string otherwise +func (o OutboundType) CustomOutboundTag() string { + if o.IsCustomOutbound() { + return string(o) + } + return "" +} + +// IsValid checks if the outbound type is valid (preset type, node reference, or custom outbound) func (o OutboundType) IsValid() bool { - return o.IsPresetType() || o.IsNodeReference() + return o.IsPresetType() || o.IsNodeReference() || o.IsCustomOutbound() } // NodeSID returns the node SID if this is a node reference, empty string otherwise @@ -59,11 +77,11 @@ func (o OutboundType) String() string { } // ParseOutboundType parses a string to OutboundType -// Accepts preset types (direct/block/proxy) and node SID references (node_xxx) +// Accepts preset types (direct/block/proxy), node SID references (node_xxx), or custom outbound references (custom_xxx) func ParseOutboundType(s string) (OutboundType, error) { o := OutboundType(s) if !o.IsValid() { - return "", fmt.Errorf("invalid outbound type: %s (must be 'direct', 'block', 'proxy', or node SID like 'node_xxx')", s) + return "", fmt.Errorf("invalid outbound type: %s (must be 'direct', 'block', 'proxy', node SID like 'node_xxx', or custom outbound like 'custom_xxx')", s) } return o, nil } diff --git a/internal/domain/node/valueobjects/routeconfig.go b/internal/domain/node/valueobjects/routeconfig.go index 6f2fedb..5e7550d 100644 --- a/internal/domain/node/valueobjects/routeconfig.go +++ b/internal/domain/node/valueobjects/routeconfig.go @@ -6,8 +6,9 @@ import "fmt" // It specifies how traffic should be routed based on matching rules. // Compatible with sing-box route configuration. type RouteConfig struct { - rules []RouteRule // Ordered list of routing rules - finalAction OutboundType // Default action when no rules match + rules []RouteRule // Ordered list of routing rules + finalAction OutboundType // Default action when no rules match + customOutbounds []CustomOutbound // User-defined outbound configurations referenced by route rules via custom_xxx tags } // NewRouteConfig creates a new route configuration @@ -65,6 +66,59 @@ func (c *RouteConfig) SetFinalAction(action OutboundType) error { return nil } +// CustomOutbounds returns a copy of the custom outbounds +func (c *RouteConfig) CustomOutbounds() []CustomOutbound { + if c.customOutbounds == nil { + return nil + } + result := make([]CustomOutbound, len(c.customOutbounds)) + copy(result, c.customOutbounds) + return result +} + +// maxCustomOutbounds is the maximum number of custom outbounds per route config +const maxCustomOutbounds = 20 + +// SetCustomOutbounds replaces all custom outbounds after validation. +// Validates each outbound and ensures tag uniqueness. +func (c *RouteConfig) SetCustomOutbounds(outbounds []CustomOutbound) error { + if len(outbounds) > maxCustomOutbounds { + return fmt.Errorf("too many custom outbounds: %d (max %d)", len(outbounds), maxCustomOutbounds) + } + // Validate each outbound and check tag uniqueness + seen := make(map[string]bool, len(outbounds)) + for i, co := range outbounds { + if err := co.Validate(); err != nil { + return fmt.Errorf("invalid custom outbound at index %d: %w", i, err) + } + if seen[co.Tag()] { + return fmt.Errorf("duplicate custom outbound tag: %s", co.Tag()) + } + seen[co.Tag()] = true + } + // Defensive copy to prevent caller from modifying internal state + cp := make([]CustomOutbound, len(outbounds)) + copy(cp, outbounds) + c.customOutbounds = cp + return nil +} + +// HasCustomOutbounds checks if the route config has custom outbounds +func (c *RouteConfig) HasCustomOutbounds() bool { + return len(c.customOutbounds) > 0 +} + +// GetCustomOutboundByTag returns a copy of a custom outbound by its tag, or nil if not found +func (c *RouteConfig) GetCustomOutboundByTag(tag string) *CustomOutbound { + for i := range c.customOutbounds { + if c.customOutbounds[i].Tag() == tag { + co := c.customOutbounds[i] // value copy + return &co + } + } + return nil +} + // Validate validates the route configuration func (c *RouteConfig) Validate() error { if !c.finalAction.IsValid() { @@ -75,6 +129,36 @@ func (c *RouteConfig) Validate() error { return fmt.Errorf("invalid rule at index %d: %w", i, err) } } + + // Validate custom outbounds: uniqueness and individual validity + customTags := make(map[string]bool, len(c.customOutbounds)) + for i, co := range c.customOutbounds { + if err := co.Validate(); err != nil { + return fmt.Errorf("invalid custom outbound at index %d: %w", i, err) + } + if customTags[co.Tag()] { + return fmt.Errorf("duplicate custom outbound tag: %s", co.Tag()) + } + customTags[co.Tag()] = true + } + + // Validate that all custom outbound references in rules have corresponding definitions + for i, rule := range c.rules { + if rule.outbound.IsCustomOutbound() { + tag := rule.outbound.CustomOutboundTag() + if !customTags[tag] { + return fmt.Errorf("rule at index %d references undefined custom outbound: %s", i, tag) + } + } + } + // Also check finalAction + if c.finalAction.IsCustomOutbound() { + tag := c.finalAction.CustomOutboundTag() + if !customTags[tag] { + return fmt.Errorf("final action references undefined custom outbound: %s", tag) + } + } + return nil } @@ -107,11 +191,20 @@ func (c *RouteConfig) Equals(other *RouteConfig) bool { return false } } + if len(c.customOutbounds) != len(other.customOutbounds) { + return false + } + for i, co := range c.customOutbounds { + if !co.Equals(&other.customOutbounds[i]) { + return false + } + } return true } // GetReferencedNodeSIDs returns all unique node SIDs referenced in outbound rules. // This includes both rule outbounds and finalAction if they reference nodes. +// Custom outbound references (custom_xxx) are excluded. func (c *RouteConfig) GetReferencedNodeSIDs() []string { if c == nil { return nil @@ -120,7 +213,7 @@ func (c *RouteConfig) GetReferencedNodeSIDs() []string { seen := make(map[string]bool) var sids []string - // Check rules + // Check rules (skip custom outbound references) for _, rule := range c.rules { if rule.outbound.IsNodeReference() { sid := rule.outbound.NodeSID() @@ -131,7 +224,7 @@ func (c *RouteConfig) GetReferencedNodeSIDs() []string { } } - // Check finalAction + // Check finalAction (skip custom outbound references) if c.finalAction.IsNodeReference() { sid := c.finalAction.NodeSID() if !seen[sid] { @@ -158,10 +251,11 @@ func (c *RouteConfig) HasNodeReferences() bool { } // ReconstructRouteConfig reconstructs a RouteConfig from persistence data -func ReconstructRouteConfig(rules []RouteRule, finalAction OutboundType) *RouteConfig { +func ReconstructRouteConfig(rules []RouteRule, finalAction OutboundType, customOutbounds []CustomOutbound) *RouteConfig { return &RouteConfig{ - rules: rules, - finalAction: finalAction, + rules: rules, + finalAction: finalAction, + customOutbounds: customOutbounds, } } diff --git a/internal/infrastructure/persistence/mappers/nodemapper.go b/internal/infrastructure/persistence/mappers/nodemapper.go index 691dec1..bd71f98 100644 --- a/internal/infrastructure/persistence/mappers/nodemapper.go +++ b/internal/infrastructure/persistence/mappers/nodemapper.go @@ -49,8 +49,18 @@ type ShadowsocksConfigData struct { // RouteConfigJSON represents the JSON structure for RouteConfig persistence type RouteConfigJSON struct { - Rules []RouteRuleJSON `json:"rules,omitempty"` - FinalAction string `json:"final_action"` + Rules []RouteRuleJSON `json:"rules,omitempty"` + FinalAction string `json:"final_action"` + CustomOutbounds []CustomOutboundJSON `json:"custom_outbounds,omitempty"` +} + +// CustomOutboundJSON represents the JSON structure for a custom outbound in persistence +type CustomOutboundJSON struct { + Tag string `json:"tag"` + Type string `json:"type"` + Server string `json:"server"` + Port uint16 `json:"server_port"` + Settings map[string]any `json:"settings,omitempty"` } // RouteRuleJSON represents the JSON structure for a single routing rule @@ -383,10 +393,27 @@ func routeConfigToJSON(rc *vo.RouteConfig) *RouteConfigJSON { }) } - return &RouteConfigJSON{ + rcJSON := &RouteConfigJSON{ Rules: rules, FinalAction: rc.FinalAction().String(), } + + // Serialize custom outbounds + if rc.HasCustomOutbounds() { + customOutbounds := rc.CustomOutbounds() + rcJSON.CustomOutbounds = make([]CustomOutboundJSON, 0, len(customOutbounds)) + for _, co := range customOutbounds { + rcJSON.CustomOutbounds = append(rcJSON.CustomOutbounds, CustomOutboundJSON{ + Tag: co.Tag(), + Type: co.Protocol(), + Server: co.Server(), + Port: co.Port(), + Settings: co.Settings(), + }) + } + } + + return rcJSON } // routeConfigFromJSON converts JSON structure to domain RouteConfig @@ -427,5 +454,15 @@ func routeConfigFromJSON(rcJSON *RouteConfigJSON) *vo.RouteConfig { rules = append(rules, *rule) } - return vo.ReconstructRouteConfig(rules, finalAction) + // Deserialize custom outbounds + var customOutbounds []vo.CustomOutbound + if len(rcJSON.CustomOutbounds) > 0 { + customOutbounds = make([]vo.CustomOutbound, 0, len(rcJSON.CustomOutbounds)) + for _, coJSON := range rcJSON.CustomOutbounds { + co := vo.ReconstructCustomOutbound(coJSON.Tag, coJSON.Type, coJSON.Server, coJSON.Port, coJSON.Settings) + customOutbounds = append(customOutbounds, *co) + } + } + + return vo.ReconstructRouteConfig(rules, finalAction, customOutbounds) }