Merge pull request #5 from HexBen123/feat/http-socks5-upstream

feat: 支持 HTTP 和 SOCKS5 上游节点 URI
This commit is contained in:
夏目侧耳
2026-03-14 21:59:32 +08:00
committed by GitHub
8 changed files with 208 additions and 35 deletions
+70
View File
@@ -349,6 +349,18 @@ func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound
return option.Outbound{}, fmt.Errorf("parse uri: %w", err)
}
switch strings.ToLower(parsed.Scheme) {
case "http":
opts, err := buildHTTPOptions(parsed, skipCertVerify)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeHTTP, Tag: tag, Options: &opts}, nil
case "socks5":
opts, err := buildSOCKSOptions(parsed)
if err != nil {
return option.Outbound{}, err
}
return option.Outbound{Type: C.TypeSOCKS, Tag: tag, Options: &opts}, nil
case "vless":
opts, err := buildVLESSOptions(parsed, skipCertVerify)
if err != nil {
@@ -390,6 +402,64 @@ func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound
}
}
func buildHTTPOptions(u *url.URL, skipCertVerify bool) (option.HTTPOutboundOptions, error) {
query := u.Query()
defaultPort := 80
if strings.EqualFold(query.Get("security"), "tls") {
defaultPort = 443
}
server, port, err := hostPort(u, defaultPort)
if err != nil {
return option.HTTPOutboundOptions{}, err
}
opts := option.HTTPOutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
}
if u.User != nil {
opts.Username = u.User.Username()
if password, ok := u.User.Password(); ok {
opts.Password = password
}
}
if path := u.EscapedPath(); path != "" {
opts.Path = path
}
if host := strings.TrimSpace(query.Get("host")); host != "" {
opts.Headers = badoption.HTTPHeader{"Host": {host}}
}
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
return option.HTTPOutboundOptions{}, err
} else if tlsOptions != nil {
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
}
return opts, nil
}
func buildSOCKSOptions(u *url.URL) (option.SOCKSOutboundOptions, error) {
server, port, err := hostPort(u, 1080)
if err != nil {
return option.SOCKSOutboundOptions{}, err
}
opts := option.SOCKSOutboundOptions{
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
Version: "5",
}
if u.User != nil {
opts.Username = u.User.Username()
if password, ok := u.User.Password(); ok {
opts.Password = password
}
}
if network := strings.ToLower(strings.TrimSpace(u.Query().Get("network"))); network != "" {
opts.Network = option.NetworkList(network)
}
return opts, nil
}
func buildAnyTLSOptions(u *url.URL, skipCertVerify bool) (option.AnyTLSOutboundOptions, error) {
password := ""
if u.User != nil {
+68
View File
@@ -0,0 +1,68 @@
package builder
import (
"testing"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
)
func TestBuildNodeOutboundSupportsSOCKS5(t *testing.T) {
outbound, err := buildNodeOutbound("socks-node", "socks5://demo:secret@99.144.123.135:30350", false)
if err != nil {
t.Fatalf("buildNodeOutbound returned error: %v", err)
}
if outbound.Type != C.TypeSOCKS {
t.Fatalf("outbound type = %q, want %q", outbound.Type, C.TypeSOCKS)
}
opts, ok := outbound.Options.(*option.SOCKSOutboundOptions)
if !ok {
t.Fatalf("outbound options type = %T, want *option.SOCKSOutboundOptions", outbound.Options)
}
if opts.Server != "99.144.123.135" {
t.Fatalf("server = %q, want %q", opts.Server, "99.144.123.135")
}
if opts.ServerPort != 30350 {
t.Fatalf("server port = %d, want %d", opts.ServerPort, 30350)
}
if opts.Username != "demo" {
t.Fatalf("username = %q, want %q", opts.Username, "demo")
}
if opts.Password != "secret" {
t.Fatalf("password = %q, want %q", opts.Password, "secret")
}
if opts.Version != "5" {
t.Fatalf("version = %q, want %q", opts.Version, "5")
}
}
func TestBuildNodeOutboundSupportsHTTP(t *testing.T) {
outbound, err := buildNodeOutbound("http-node", "http://alice:wonderland@example.com:8080/proxy", false)
if err != nil {
t.Fatalf("buildNodeOutbound returned error: %v", err)
}
if outbound.Type != C.TypeHTTP {
t.Fatalf("outbound type = %q, want %q", outbound.Type, C.TypeHTTP)
}
opts, ok := outbound.Options.(*option.HTTPOutboundOptions)
if !ok {
t.Fatalf("outbound options type = %T, want *option.HTTPOutboundOptions", outbound.Options)
}
if opts.Server != "example.com" {
t.Fatalf("server = %q, want %q", opts.Server, "example.com")
}
if opts.ServerPort != 8080 {
t.Fatalf("server port = %d, want %d", opts.ServerPort, 8080)
}
if opts.Username != "alice" {
t.Fatalf("username = %q, want %q", opts.Username, "alice")
}
if opts.Password != "wonderland" {
t.Fatalf("password = %q, want %q", opts.Password, "wonderland")
}
if opts.Path != "/proxy" {
t.Fatalf("path = %q, want %q", opts.Path, "/proxy")
}
}
+21 -7
View File
@@ -78,10 +78,10 @@ type MultiPortConfig struct {
// ManagementConfig controls the monitoring HTTP endpoint.
type ManagementConfig struct {
Enabled *bool `yaml:"enabled"`
Listen string `yaml:"listen"`
ProbeTarget string `yaml:"probe_target"`
Password string `yaml:"password"` // WebUI 访问密码,为空则不需要密码
Enabled *bool `yaml:"enabled"`
Listen string `yaml:"listen"`
ProbeTarget string `yaml:"probe_target"`
Password string `yaml:"password"` // WebUI 访问密码,为空则不需要密码
HealthCheckInterval time.Duration `yaml:"health_check_interval"`
}
@@ -111,6 +111,20 @@ const (
InboundProtocolMixed = "mixed"
)
var supportedProxyURISchemes = []string{
"vmess://",
"vless://",
"trojan://",
"ss://",
"ssr://",
"hysteria://",
"hysteria2://",
"hy2://",
"anytls://",
"http://",
"socks5://",
}
// NormalizeInboundProtocol normalizes inbound protocol aliases and validates the value.
func NormalizeInboundProtocol(value string) (string, error) {
protocol := strings.ToLower(strings.TrimSpace(value))
@@ -683,9 +697,9 @@ func isBase64(s string) bool {
// IsProxyURI checks if a string is a valid proxy URI
func IsProxyURI(s string) bool {
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://", "anytls://"}
for _, scheme := range schemes {
if strings.HasPrefix(strings.ToLower(s), scheme) {
lower := strings.ToLower(strings.TrimSpace(s))
for _, scheme := range supportedProxyURISchemes {
if strings.HasPrefix(lower, scheme) {
return true
}
}
+22
View File
@@ -0,0 +1,22 @@
package config
import "testing"
func TestIsProxyURIRecognizesHTTPAndSOCKS5(t *testing.T) {
tests := []struct {
name string
uri string
want bool
}{
{name: "http", uri: "http://alice:secret@example.com:8080", want: true},
{name: "socks5", uri: "socks5://alice:secret@example.com:1080", want: true},
{name: "vmess", uri: "vmess://example", want: true},
{name: "invalid", uri: "ftp://example.com", want: false},
}
for _, tt := range tests {
if got := IsProxyURI(tt.uri); got != tt.want {
t.Fatalf("%s: IsProxyURI(%q) = %v, want %v", tt.name, tt.uri, got, tt.want)
}
}
}
+4 -2
View File
@@ -495,14 +495,16 @@ func extractHostFromURI(uri string) string {
// Handle different URI schemes
lowerURI := strings.ToLower(uri)
// vmess:// vless:// trojan:// ss:// ssr:// hysteria:// hysteria2:// hy2:// anytls://
// Standard URL formats, including HTTP and SOCKS upstream proxies.
if strings.HasPrefix(lowerURI, "vmess://") ||
strings.HasPrefix(lowerURI, "vless://") ||
strings.HasPrefix(lowerURI, "trojan://") ||
strings.HasPrefix(lowerURI, "hysteria://") ||
strings.HasPrefix(lowerURI, "hysteria2://") ||
strings.HasPrefix(lowerURI, "hy2://") ||
strings.HasPrefix(lowerURI, "anytls://") {
strings.HasPrefix(lowerURI, "anytls://") ||
strings.HasPrefix(lowerURI, "http://") ||
strings.HasPrefix(lowerURI, "socks5://") {
// Standard URL format: scheme://user@host:port?params#fragment
parsed, err := url.Parse(uri)
if err != nil {
+20
View File
@@ -0,0 +1,20 @@
package geoip
import "testing"
func TestExtractHostFromURISupportsHTTPAndSOCKS5(t *testing.T) {
tests := []struct {
name string
uri string
want string
}{
{name: "http", uri: "http://alice:secret@example.com:8080", want: "example.com"},
{name: "socks5", uri: "socks5://alice:secret@99.144.123.135:30350", want: "99.144.123.135"},
}
for _, tt := range tests {
if got := extractHostFromURI(tt.uri); got != tt.want {
t.Fatalf("%s: extractHostFromURI(%q) = %q, want %q", tt.name, tt.uri, got, tt.want)
}
}
}
+2 -14
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"easy_proxies/internal/config"
"fmt"
"log"
"net/url"
@@ -175,26 +176,13 @@ func loadNodesFromLegacyFile(path string) ([]string, error) {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if isProxyURI(line) {
if config.IsProxyURI(line) {
uris = append(uris, line)
}
}
return uris, nil
}
// isProxyURI checks if a string looks like a proxy URI.
func isProxyURI(s string) bool {
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://",
"hysteria://", "hysteria2://", "hy2://", "anytls://", "http://", "socks5://"}
lower := strings.ToLower(s)
for _, scheme := range schemes {
if strings.HasPrefix(lower, scheme) {
return true
}
}
return false
}
// extractNameFromURI extracts a name from the URI fragment (#name).
func extractNameFromURI(uri string) string {
parsed, err := url.Parse(uri)
+1 -12
View File
@@ -651,24 +651,13 @@ func parseNodesFromContent(content string) ([]config.NodeConfig, error) {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if isProxyURI(line) {
if config.IsProxyURI(line) {
nodes = append(nodes, config.NodeConfig{URI: line})
}
}
return nodes, nil
}
func isProxyURI(s string) bool {
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://", "anytls://"}
lower := strings.ToLower(s)
for _, scheme := range schemes {
if strings.HasPrefix(lower, scheme) {
return true
}
}
return false
}
type defaultLogger struct{}
func (defaultLogger) Infof(format string, args ...any) {