mirror of
https://github.com/orris-inc/orris.git
synced 2026-05-06 21:44:01 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
184
internal/domain/node/valueobjects/customoutbound.go
Normal file
184
internal/domain/node/valueobjects/customoutbound.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user