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
This commit is contained in:
orris-inc
2026-02-12 15:53:37 +08:00
parent 1bed713a96
commit b64969c3dc
6 changed files with 577 additions and 20 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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)
}