chore: 准备发布 v1.0.0 版本

This commit is contained in:
xiamuceer-j
2026-03-02 13:13:33 +08:00
parent f57ae3c712
commit 8163e4c193
58 changed files with 19545 additions and 1 deletions
+934
View File
@@ -0,0 +1,934 @@
package builder
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
"easy_proxies/internal/config"
"easy_proxies/internal/geoip"
poolout "easy_proxies/internal/outbound/pool"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/auth"
"github.com/sagernet/sing/common/json/badoption"
)
// Build converts high level config into sing-box Options tree.
func Build(cfg *config.Config) (option.Options, error) {
baseOutbounds := make([]option.Outbound, 0, len(cfg.Nodes))
memberTags := make([]string, 0, len(cfg.Nodes))
metadata := make(map[string]poolout.MemberMeta)
var failedNodes []string
usedTags := make(map[string]int) // Track tag usage for uniqueness
// Initialize GeoIP lookup if enabled
var geoLookup *geoip.Lookup
if cfg.GeoIP.Enabled && cfg.GeoIP.DatabasePath != "" {
var err error
// Use auto-update if enabled
if cfg.GeoIP.AutoUpdateEnabled {
interval := cfg.GeoIP.AutoUpdateInterval
if interval == 0 {
interval = 24 * time.Hour // Default to 24 hours
}
geoLookup, err = geoip.NewWithAutoUpdate(cfg.GeoIP.DatabasePath, interval)
} else {
geoLookup, err = geoip.New(cfg.GeoIP.DatabasePath)
}
if err != nil {
log.Printf("⚠️ GeoIP database load failed: %v (region routing disabled)", err)
} else {
log.Printf("✅ GeoIP database loaded: %s", cfg.GeoIP.DatabasePath)
}
}
// Track nodes by region for GeoIP routing
regionMembers := make(map[string][]string)
for _, region := range geoip.AllRegions() {
regionMembers[region] = []string{}
}
for _, node := range cfg.Nodes {
baseTag := sanitizeTag(node.Name)
if baseTag == "" {
baseTag = fmt.Sprintf("node-%d", len(memberTags)+1)
}
// Ensure tag uniqueness by appending a counter if needed
tag := baseTag
if count, exists := usedTags[baseTag]; exists {
usedTags[baseTag] = count + 1
tag = fmt.Sprintf("%s-%d", baseTag, count+1)
} else {
usedTags[baseTag] = 1
}
outbound, err := buildNodeOutbound(tag, node.URI, cfg.SkipCertVerify)
if err != nil {
log.Printf("❌ Failed to build node '%s': %v (skipping)", node.Name, err)
failedNodes = append(failedNodes, node.Name)
continue
}
memberTags = append(memberTags, tag)
baseOutbounds = append(baseOutbounds, outbound)
meta := poolout.MemberMeta{
Name: node.Name,
URI: node.URI,
Mode: cfg.Mode,
}
// For multi-port and hybrid modes, use per-node port
if cfg.Mode == "multi-port" || cfg.Mode == "hybrid" {
meta.ListenAddress = cfg.MultiPort.Address
meta.Port = node.Port
} else {
meta.ListenAddress = cfg.Listener.Address
meta.Port = cfg.Listener.Port
}
// GeoIP lookup for region classification
if geoLookup != nil && geoLookup.IsEnabled() {
regionInfo := geoLookup.LookupURI(node.URI)
meta.Region = regionInfo.Code
meta.Country = regionInfo.Country
regionMembers[regionInfo.Code] = append(regionMembers[regionInfo.Code], tag)
} else {
meta.Region = geoip.RegionOther
meta.Country = "Unknown"
regionMembers[geoip.RegionOther] = append(regionMembers[geoip.RegionOther], tag)
}
metadata[tag] = meta
}
// Close GeoIP database after lookup
if geoLookup != nil {
geoLookup.Close()
}
// Check if we have at least one valid node
if len(baseOutbounds) == 0 {
return option.Options{}, fmt.Errorf("no valid nodes available (all %d nodes failed to build)", len(cfg.Nodes))
}
// Log summary
if len(failedNodes) > 0 {
log.Printf("⚠️ %d/%d nodes failed and were skipped: %v", len(failedNodes), len(cfg.Nodes), failedNodes)
}
log.Printf("✅ Successfully built %d/%d nodes", len(baseOutbounds), len(cfg.Nodes))
// Log GeoIP region distribution
if cfg.GeoIP.Enabled {
log.Println("🌍 GeoIP Region Distribution:")
for _, region := range geoip.AllRegions() {
count := len(regionMembers[region])
if count > 0 {
log.Printf(" %s %s: %d nodes", geoip.RegionEmoji(region), geoip.RegionName(region), count)
}
}
}
// Print proxy links for each node
printProxyLinks(cfg, metadata)
var (
inbounds []option.Inbound
outbounds = make([]option.Outbound, len(baseOutbounds))
route option.RouteOptions
)
copy(outbounds, baseOutbounds)
// Determine which components to enable based on mode
enablePoolInbound := cfg.Mode == "pool" || cfg.Mode == "hybrid"
enableMultiPort := cfg.Mode == "multi-port" || cfg.Mode == "hybrid"
if !enablePoolInbound && !enableMultiPort {
return option.Options{}, fmt.Errorf("unsupported mode %s", cfg.Mode)
}
// Build pool inbound (single entry point for all nodes)
if enablePoolInbound {
inbound, err := buildPoolInbound(cfg)
if err != nil {
return option.Options{}, err
}
inbounds = append(inbounds, inbound)
poolOptions := poolout.Options{
Mode: cfg.Pool.Mode,
Members: memberTags,
FailureThreshold: cfg.Pool.FailureThreshold,
BlacklistDuration: cfg.Pool.BlacklistDuration,
Metadata: metadata,
}
outbounds = append(outbounds, option.Outbound{
Type: poolout.Type,
Tag: poolout.Tag,
Options: &poolOptions,
})
route.Final = poolout.Tag
}
// Build multi-port inbounds (one port per node)
if enableMultiPort {
addr, err := parseAddr(cfg.MultiPort.Address)
if err != nil {
return option.Options{}, fmt.Errorf("parse multi-port address: %w", err)
}
for _, tag := range memberTags {
meta := metadata[tag]
perMeta := map[string]poolout.MemberMeta{tag: meta}
poolTag := fmt.Sprintf("%s-%s", poolout.Tag, tag)
perOptions := poolout.Options{
Mode: "sequential",
Members: []string{tag},
FailureThreshold: cfg.Pool.FailureThreshold,
BlacklistDuration: cfg.Pool.BlacklistDuration,
Metadata: perMeta,
}
perPool := option.Outbound{
Type: poolout.Type,
Tag: poolTag,
Options: &perOptions,
}
outbounds = append(outbounds, perPool)
inboundOptions := &option.HTTPMixedInboundOptions{
ListenOptions: option.ListenOptions{
Listen: addr,
ListenPort: meta.Port,
},
}
username := cfg.MultiPort.Username
password := cfg.MultiPort.Password
if username != "" {
inboundOptions.Users = []auth.User{{Username: username, Password: password}}
}
inboundTag := fmt.Sprintf("in-%s", tag)
inbounds = append(inbounds, option.Inbound{
Type: C.TypeHTTP,
Tag: inboundTag,
Options: inboundOptions,
})
route.Rules = append(route.Rules, option.Rule{
Type: C.RuleTypeDefault,
DefaultOptions: option.DefaultRule{
RawDefaultRule: option.RawDefaultRule{
Inbound: badoption.Listable[string]{inboundTag},
},
RuleAction: option.RuleAction{
Action: C.RuleActionTypeRoute,
RouteOptions: option.RouteActionOptions{
Outbound: poolTag,
},
},
},
})
}
}
// Build GeoIP region-based pool outbounds and routing
if cfg.GeoIP.Enabled && enablePoolInbound {
// Create pool outbound for each region that has nodes
for _, region := range geoip.AllRegions() {
members := regionMembers[region]
if len(members) == 0 {
continue
}
// Build metadata for this region's members
regionMeta := make(map[string]poolout.MemberMeta)
for _, tag := range members {
regionMeta[tag] = metadata[tag]
}
regionPoolTag := fmt.Sprintf("pool-%s", region)
regionPoolOptions := poolout.Options{
Mode: cfg.Pool.Mode,
Members: members,
FailureThreshold: cfg.Pool.FailureThreshold,
BlacklistDuration: cfg.Pool.BlacklistDuration,
Metadata: regionMeta,
}
outbounds = append(outbounds, option.Outbound{
Type: poolout.Type,
Tag: regionPoolTag,
Options: &regionPoolOptions,
})
}
// Log GeoIP routing info
geoipPort := cfg.GeoIP.Port
if geoipPort == 0 {
geoipPort = cfg.Listener.Port
}
geoipListen := cfg.GeoIP.Listen
if geoipListen == "" {
geoipListen = cfg.Listener.Address
}
log.Println("🌐 GeoIP Region Routing Enabled:")
log.Printf(" Access via: http://%s:%d/{region}", geoipListen, geoipPort)
log.Println(" Available regions: /jp, /kr, /us, /hk, /tw, /other")
log.Println(" Default (no path): all nodes pool")
}
opts := option.Options{
Log: &option.LogOptions{Level: strings.ToLower(cfg.LogLevel)},
Inbounds: inbounds,
Outbounds: outbounds,
Route: &route,
}
return opts, nil
}
func buildPoolInbound(cfg *config.Config) (option.Inbound, error) {
listenAddr, err := parseAddr(cfg.Listener.Address)
if err != nil {
return option.Inbound{}, fmt.Errorf("parse listener address: %w", err)
}
inboundOptions := &option.HTTPMixedInboundOptions{
ListenOptions: option.ListenOptions{
Listen: listenAddr,
ListenPort: cfg.Listener.Port,
},
}
if cfg.Listener.Username != "" {
inboundOptions.Users = []auth.User{{
Username: cfg.Listener.Username,
Password: cfg.Listener.Password,
}}
}
inbound := option.Inbound{
Type: C.TypeHTTP,
Tag: "http-in",
Options: inboundOptions,
}
return inbound, nil
}
func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound, error) {
parsed, err := url.Parse(rawURI)
if err != nil {
return option.Outbound{}, fmt.Errorf("parse uri: %w", err)
}
switch strings.ToLower(parsed.Scheme) {
case "vless":
opts, err := buildVLESSOptions(parsed, skipCertVerify)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeVLESS, Tag: tag, Options: &opts}, nil
case "hysteria2", "hy2":
opts, err := buildHysteria2Options(parsed, skipCertVerify)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeHysteria2, Tag: tag, Options: &opts}, nil
case "ss", "shadowsocks":
opts, err := buildShadowsocksOptions(parsed)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeShadowsocks, Tag: tag, Options: &opts}, nil
case "trojan":
opts, err := buildTrojanOptions(parsed, skipCertVerify)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeTrojan, Tag: tag, Options: &opts}, nil
case "vmess":
opts, err := buildVMessOptions(rawURI, skipCertVerify)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeVMess, Tag: tag, Options: &opts}, nil
default:
return option.Outbound{}, fmt.Errorf("unsupported scheme %q", parsed.Scheme)
}
}
func buildVLESSOptions(u *url.URL, skipCertVerify bool) (option.VLESSOutboundOptions, error) {
uuid := u.User.Username()
if uuid == "" {
return option.VLESSOutboundOptions{}, errors.New("vless uri missing uuid in userinfo")
}
server, port, err := hostPort(u, 443)
if err != nil {
return option.VLESSOutboundOptions{}, err
}
query := u.Query()
opts := option.VLESSOutboundOptions{
UUID: uuid,
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
Network: option.NetworkList(""),
}
if flow := query.Get("flow"); flow != "" {
opts.Flow = flow
}
if packetEncoding := query.Get("packetEncoding"); packetEncoding != "" {
opts.PacketEncoding = &packetEncoding
}
if transport, err := buildV2RayTransport(query); err != nil {
return option.VLESSOutboundOptions{}, err
} else if transport != nil {
opts.Transport = transport
}
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
return option.VLESSOutboundOptions{}, err
} else if tlsOptions != nil {
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
}
return opts, nil
}
func buildHysteria2Options(u *url.URL, skipCertVerify bool) (option.Hysteria2OutboundOptions, error) {
password := u.User.String()
server, port, err := hostPort(u, 443)
if err != nil {
return option.Hysteria2OutboundOptions{}, err
}
query := u.Query()
opts := option.Hysteria2OutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
Password: password,
}
if up := query.Get("upMbps"); up != "" {
opts.UpMbps = atoiDefault(up)
}
if down := query.Get("downMbps"); down != "" {
opts.DownMbps = atoiDefault(down)
}
if obfs := query.Get("obfs"); obfs != "" {
opts.Obfs = &option.Hysteria2Obfs{Type: obfs, Password: query.Get("obfs-password")}
}
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: hysteriaTLSOptions(server, query, skipCertVerify)}
return opts, nil
}
func hysteriaTLSOptions(host string, query url.Values, skipCertVerify bool) *option.OutboundTLSOptions {
tlsOptions := &option.OutboundTLSOptions{
Enabled: true,
ServerName: host,
Insecure: skipCertVerify,
}
if sni := query.Get("sni"); sni != "" {
tlsOptions.ServerName = sni
}
insecure := query.Get("insecure")
if insecure == "" {
insecure = query.Get("allowInsecure")
}
if insecure != "" {
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
}
if alpn := query.Get("alpn"); alpn != "" {
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
}
return tlsOptions
}
func buildTLSOptions(query url.Values, skipCertVerify bool) (*option.OutboundTLSOptions, error) {
security := strings.ToLower(query.Get("security"))
if security == "" || security == "none" {
return nil, nil
}
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
if sni := query.Get("sni"); sni != "" {
tlsOptions.ServerName = sni
}
insecure := query.Get("allowInsecure")
if insecure == "" {
insecure = query.Get("insecure")
}
if insecure != "" {
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
}
if alpn := query.Get("alpn"); alpn != "" {
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
}
fp := query.Get("fp")
if fp != "" {
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
}
if security == "reality" {
tlsOptions.Reality = &option.OutboundRealityOptions{Enabled: true, PublicKey: query.Get("pbk"), ShortID: query.Get("sid")}
// Reality requires uTLS; use default fingerprint if not specified
if tlsOptions.UTLS == nil {
if fp == "" {
fp = "chrome"
}
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
}
}
return tlsOptions, nil
}
func buildV2RayTransport(query url.Values) (*option.V2RayTransportOptions, error) {
transportType := strings.ToLower(query.Get("type"))
if transportType == "" || transportType == "tcp" {
return nil, nil
}
options := &option.V2RayTransportOptions{Type: transportType}
switch transportType {
case C.V2RayTransportTypeWebsocket:
wsPath := query.Get("path")
// 解析 path 中的 early data 参数,如 /path?ed=2048
if idx := strings.Index(wsPath, "?ed="); idx != -1 {
edPart := wsPath[idx+4:]
wsPath = wsPath[:idx]
// 解析 ed 值
edValue := edPart
if ampIdx := strings.Index(edPart, "&"); ampIdx != -1 {
edValue = edPart[:ampIdx]
}
if ed, err := strconv.Atoi(edValue); err == nil && ed > 0 {
options.WebsocketOptions.MaxEarlyData = uint32(ed)
options.WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol"
}
}
options.WebsocketOptions.Path = wsPath
if host := query.Get("host"); host != "" {
options.WebsocketOptions.Headers = badoption.HTTPHeader{"Host": {host}}
}
case C.V2RayTransportTypeHTTP:
options.HTTPOptions.Path = query.Get("path")
if host := query.Get("host"); host != "" {
options.HTTPOptions.Host = badoption.Listable[string]{host}
}
case C.V2RayTransportTypeGRPC:
options.GRPCOptions.ServiceName = query.Get("serviceName")
case C.V2RayTransportTypeHTTPUpgrade:
options.HTTPUpgradeOptions.Path = query.Get("path")
case "xhttp":
// XHTTP is not supported by sing-box, fallback to HTTPUpgrade
log.Printf("⚠️ XHTTP transport not supported by sing-box, falling back to HTTPUpgrade")
options.Type = C.V2RayTransportTypeHTTPUpgrade
options.HTTPUpgradeOptions.Path = query.Get("path")
if host := query.Get("host"); host != "" {
options.HTTPUpgradeOptions.Headers = badoption.HTTPHeader{"Host": {host}}
}
default:
return nil, fmt.Errorf("unsupported transport type %q", transportType)
}
return options, nil
}
func buildShadowsocksOptions(u *url.URL) (option.ShadowsocksOutboundOptions, error) {
server, port, err := hostPort(u, 8388)
if err != nil {
return option.ShadowsocksOutboundOptions{}, err
}
// Decode userinfo (base64 encoded method:password)
userInfo := u.User.String()
decoded, err := base64.RawURLEncoding.DecodeString(userInfo)
if err != nil {
// Try standard base64
decoded, err = base64.StdEncoding.DecodeString(userInfo)
if err != nil {
return option.ShadowsocksOutboundOptions{}, fmt.Errorf("decode shadowsocks userinfo: %w", err)
}
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return option.ShadowsocksOutboundOptions{}, errors.New("shadowsocks userinfo format must be method:password")
}
method := parts[0]
password := parts[1]
opts := option.ShadowsocksOutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
Method: method,
Password: password,
}
query := u.Query()
if plugin := query.Get("plugin"); plugin != "" {
opts.Plugin = plugin
opts.PluginOptions = query.Get("plugin-opts")
}
return opts, nil
}
func buildTrojanOptions(u *url.URL, skipCertVerify bool) (option.TrojanOutboundOptions, error) {
password := u.User.Username()
if password == "" {
return option.TrojanOutboundOptions{}, errors.New("trojan uri missing password in userinfo")
}
server, port, err := hostPort(u, 443)
if err != nil {
return option.TrojanOutboundOptions{}, err
}
query := u.Query()
opts := option.TrojanOutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
Password: password,
Network: option.NetworkList(""),
}
// Parse TLS options
if tlsOptions, err := buildTrojanTLSOptions(query, skipCertVerify); err != nil {
return option.TrojanOutboundOptions{}, err
} else if tlsOptions != nil {
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
}
// Parse transport options
if transport, err := buildV2RayTransport(query); err != nil {
return option.TrojanOutboundOptions{}, err
} else if transport != nil {
opts.Transport = transport
}
return opts, nil
}
// vmessJSON represents the JSON structure of a VMess URI
type vmessJSON struct {
V interface{} `json:"v"` // Version, can be string or int
PS string `json:"ps"` // Remarks/name
Add string `json:"add"` // Server address
Port interface{} `json:"port"` // Server port, can be string or int
ID string `json:"id"` // UUID
Aid interface{} `json:"aid"` // Alter ID, can be string or int
Scy string `json:"scy"` // Security/cipher
Net string `json:"net"` // Network type (tcp, ws, etc.)
Type string `json:"type"` // Header type
Host string `json:"host"` // Host header
Path string `json:"path"` // Path
TLS string `json:"tls"` // TLS (tls or empty)
SNI string `json:"sni"` // SNI
ALPN string `json:"alpn"` // ALPN
FP string `json:"fp"` // Fingerprint
}
func (v *vmessJSON) GetPort() int {
switch p := v.Port.(type) {
case float64:
return int(p)
case int:
return p
case string:
port, _ := strconv.Atoi(p)
return port
}
return 443
}
func (v *vmessJSON) GetAlterId() int {
switch a := v.Aid.(type) {
case float64:
return int(a)
case int:
return a
case string:
aid, _ := strconv.Atoi(a)
return aid
}
return 0
}
func buildVMessOptions(rawURI string, skipCertVerify bool) (option.VMessOutboundOptions, error) {
// Remove vmess:// prefix
encoded := strings.TrimPrefix(rawURI, "vmess://")
// Try to decode as base64 JSON (standard format)
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
// Try URL-safe base64
decoded, err = base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
// Try as URL format: vmess://uuid@server:port?...
return buildVMessOptionsFromURL(rawURI, skipCertVerify)
}
}
var vmess vmessJSON
if err := json.Unmarshal(decoded, &vmess); err != nil {
return option.VMessOutboundOptions{}, fmt.Errorf("parse vmess json: %w", err)
}
if vmess.Add == "" {
return option.VMessOutboundOptions{}, errors.New("vmess missing server address")
}
if vmess.ID == "" {
return option.VMessOutboundOptions{}, errors.New("vmess missing uuid")
}
port := vmess.GetPort()
if port == 0 {
port = 443
}
opts := option.VMessOutboundOptions{
ServerOptions: option.ServerOptions{
Server: vmess.Add,
ServerPort: uint16(port),
},
UUID: vmess.ID,
AlterId: vmess.GetAlterId(),
Security: vmess.Scy,
}
// Default security
if opts.Security == "" {
opts.Security = "auto"
}
// Build transport options
if vmess.Net != "" && vmess.Net != "tcp" {
transport := &option.V2RayTransportOptions{}
switch vmess.Net {
case "ws":
transport.Type = C.V2RayTransportTypeWebsocket
wsPath := vmess.Path
// Handle early data in path
if idx := strings.Index(wsPath, "?ed="); idx != -1 {
edPart := wsPath[idx+4:]
wsPath = wsPath[:idx]
edValue := edPart
if ampIdx := strings.Index(edPart, "&"); ampIdx != -1 {
edValue = edPart[:ampIdx]
}
if ed, err := strconv.Atoi(edValue); err == nil && ed > 0 {
transport.WebsocketOptions.MaxEarlyData = uint32(ed)
transport.WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol"
}
}
transport.WebsocketOptions.Path = wsPath
if vmess.Host != "" {
transport.WebsocketOptions.Headers = badoption.HTTPHeader{"Host": {vmess.Host}}
}
case "h2":
transport.Type = C.V2RayTransportTypeHTTP
transport.HTTPOptions.Path = vmess.Path
if vmess.Host != "" {
transport.HTTPOptions.Host = badoption.Listable[string]{vmess.Host}
}
case "grpc":
transport.Type = C.V2RayTransportTypeGRPC
transport.GRPCOptions.ServiceName = vmess.Path
default:
transport.Type = vmess.Net
}
opts.Transport = transport
}
// Build TLS options
if vmess.TLS == "tls" {
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
if vmess.SNI != "" {
tlsOptions.ServerName = vmess.SNI
} else if vmess.Host != "" {
tlsOptions.ServerName = vmess.Host
}
if vmess.ALPN != "" {
tlsOptions.ALPN = badoption.Listable[string](strings.Split(vmess.ALPN, ","))
}
if vmess.FP != "" {
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: vmess.FP}
}
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
}
return opts, nil
}
func buildVMessOptionsFromURL(rawURI string, skipCertVerify bool) (option.VMessOutboundOptions, error) {
parsed, err := url.Parse(rawURI)
if err != nil {
return option.VMessOutboundOptions{}, fmt.Errorf("parse vmess url: %w", err)
}
uuid := parsed.User.Username()
if uuid == "" {
return option.VMessOutboundOptions{}, errors.New("vmess uri missing uuid")
}
server, port, err := hostPort(parsed, 443)
if err != nil {
return option.VMessOutboundOptions{}, err
}
query := parsed.Query()
opts := option.VMessOutboundOptions{
ServerOptions: option.ServerOptions{
Server: server,
ServerPort: uint16(port),
},
UUID: uuid,
Security: query.Get("encryption"),
}
if opts.Security == "" {
opts.Security = "auto"
}
if aid := query.Get("alterId"); aid != "" {
opts.AlterId, _ = strconv.Atoi(aid)
}
// Build transport
if transport, err := buildV2RayTransport(query); err != nil {
return option.VMessOutboundOptions{}, err
} else if transport != nil {
opts.Transport = transport
}
// Build TLS
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
return option.VMessOutboundOptions{}, err
} else if tlsOptions != nil {
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
}
return opts, nil
}
func buildTrojanTLSOptions(query url.Values, skipCertVerify bool) (*option.OutboundTLSOptions, error) {
// Trojan always uses TLS by default
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
if sni := query.Get("sni"); sni != "" {
tlsOptions.ServerName = sni
}
if peer := query.Get("peer"); peer != "" && tlsOptions.ServerName == "" {
tlsOptions.ServerName = peer
}
insecure := query.Get("allowInsecure")
if insecure == "" {
insecure = query.Get("insecure")
}
if insecure != "" {
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
}
if alpn := query.Get("alpn"); alpn != "" {
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
}
if fp := query.Get("fp"); fp != "" {
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
}
return tlsOptions, nil
}
func hostPort(u *url.URL, defaultPort int) (string, int, error) {
host := u.Hostname()
if host == "" {
return "", 0, errors.New("missing host")
}
portStr := u.Port()
if portStr == "" {
portStr = strconv.Itoa(defaultPort)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return "", 0, fmt.Errorf("invalid port %q", portStr)
}
return host, port, nil
}
func parseAddr(value string) (*badoption.Addr, error) {
addr := strings.TrimSpace(value)
if addr == "" {
return nil, nil
}
parsed, err := netip.ParseAddr(addr)
if err != nil {
return nil, err
}
bad := badoption.Addr(parsed)
return &bad, nil
}
func sanitizeTag(name string) string {
lower := strings.ToLower(name)
lower = strings.TrimSpace(lower)
if lower == "" {
return ""
}
segments := strings.FieldsFunc(lower, func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
})
result := strings.Join(segments, "-")
result = strings.Trim(result, "-")
return result
}
func atoiDefault(value string) int {
if strings.HasSuffix(value, "mbps") {
value = strings.TrimSuffix(value, "mbps")
}
if strings.HasSuffix(value, "Mbps") {
value = strings.TrimSuffix(value, "Mbps")
}
v, _ := strconv.Atoi(value)
return v
}
// printProxyLinks prints all proxy connection information at startup
func printProxyLinks(cfg *config.Config, metadata map[string]poolout.MemberMeta) {
log.Println("")
log.Println("📡 Proxy Links:")
log.Println("═══════════════════════════════════════════════════════════════")
showPoolEntry := cfg.Mode == "pool" || cfg.Mode == "hybrid"
showMultiPort := cfg.Mode == "multi-port" || cfg.Mode == "hybrid"
if showPoolEntry {
// Pool mode: single entry point for all nodes
var auth string
if cfg.Listener.Username != "" {
auth = fmt.Sprintf("%s:%s@", cfg.Listener.Username, cfg.Listener.Password)
}
proxyURL := fmt.Sprintf("http://%s%s:%d", auth, cfg.Listener.Address, cfg.Listener.Port)
log.Printf("🌐 Pool Entry Point:")
log.Printf(" %s", proxyURL)
log.Println("")
log.Printf(" Nodes in pool (%d):", len(metadata))
for _, meta := range metadata {
log.Printf(" • %s", meta.Name)
}
if showMultiPort {
log.Println("")
}
}
if showMultiPort {
// Multi-port mode: each node has its own port
log.Printf("🔌 Multi-Port Entry Points (%d nodes):", len(cfg.Nodes))
log.Println("")
for _, node := range cfg.Nodes {
var auth string
username := node.Username
password := node.Password
if username == "" {
username = cfg.MultiPort.Username
password = cfg.MultiPort.Password
}
if username != "" {
auth = fmt.Sprintf("%s:%s@", username, password)
}
proxyURL := fmt.Sprintf("http://%s%s:%d", auth, cfg.MultiPort.Address, node.Port)
log.Printf(" [%d] %s", node.Port, node.Name)
log.Printf(" %s", proxyURL)
}
}
log.Println("═══════════════════════════════════════════════════════════════")
log.Println("")
}