Files
luotianyi 6aae7b65ea feat(proxypool): 站点级出口代理池(Xray / 订阅 / 端点,粘性选路与健康探测)
- proxypool: 站点级出口绑定 proxies = [条目名, "sub:订阅名"],粘性选路、
  失败切换、后台健康探测;支持 Xray 分享链接 / 订阅 / 裸 ip:port 与 socks5 端点,
  托管 Xray 核心(可自动下载);站点未绑定时回退 [proxy] 主/备线路
- upstream: RouteSource 抽象 + routesFor/poolReport;求解与请求共用同一出口
- admin: 代理池面板与维护动作;overview 暴露池状态
2026-10-08 00:19:39 +08:00

518 lines
14 KiB
Go

// Package proxypool manages the egress endpoints used to reach the upstream
// sites: Xray share links driven by a managed Xray core, plus plain
// http/https/socks5 endpoints. Selection is sticky per site and health is
// probed in the background.
package proxypool
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// Node is a parsed proxy node.
type Node struct {
Kind string // "xray" (share link) or "url" (plain endpoint)
Scheme string // vless / vmess / trojan / ss / http / https / socks5
Display string // host:port, for the console
Link string // original input line
URL string // Kind == "url": ready-to-use proxy URL
Outbound map[string]any // Kind == "xray": Xray outbound object (tag added later)
}
// ParseNode parses one line: an Xray share link (vless/vmess/trojan/ss) or a
// plain endpoint. Accepted plain forms:
//
// scheme://user:pass@host:port
// user:pass@host:port (scheme = defaultScheme)
// host:port (scheme = defaultScheme)
// host:port:user:pass (scheme = defaultScheme)
func ParseNode(line, defaultScheme string) (*Node, error) {
line = strings.TrimSpace(line)
if line == "" {
return nil, errors.New("empty node")
}
switch {
case strings.HasPrefix(line, "vless://"):
return parseVless(line)
case strings.HasPrefix(line, "vmess://"):
return parseVmess(line)
case strings.HasPrefix(line, "trojan://"):
return parseTrojan(line)
case strings.HasPrefix(line, "ss://"):
return parseShadowsocks(line)
default:
return parsePlainEndpoint(line, defaultScheme)
}
}
func parseVless(raw string) (*Node, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("vless: %w", err)
}
id := u.User.Username()
host, port := u.Hostname(), u.Port()
if id == "" || host == "" || port == "" {
return nil, errors.New("vless: missing uuid / host / port")
}
q := u.Query()
user := map[string]any{"id": id, "encryption": "none"}
if flow := q.Get("flow"); flow != "" {
user["flow"] = flow
}
out := map[string]any{
"protocol": "vless",
"settings": map[string]any{
"vnext": []any{map[string]any{
"address": host, "port": portInt(port), "users": []any{user},
}},
},
"streamSettings": streamSettings(firstNonEmptyStr(q.Get("type"), "tcp"), q.Get("security"), q),
}
return &Node{Kind: "xray", Scheme: "vless", Display: net.JoinHostPort(host, port), Link: raw, Outbound: out}, nil
}
func parseVmess(raw string) (*Node, error) {
body := strings.TrimPrefix(raw, "vmess://")
if i := strings.IndexAny(body, "#"); i >= 0 {
body = body[:i]
}
dec, err := b64Decode(body)
if err != nil {
return nil, fmt.Errorf("vmess: invalid base64 payload: %w", err)
}
var v struct {
Add string `json:"add"`
Port any `json:"port"`
ID string `json:"id"`
Aid any `json:"aid"`
Scy string `json:"scy"`
Net string `json:"net"`
Type string `json:"type"`
Host string `json:"host"`
Path string `json:"path"`
TLS string `json:"tls"`
SNI string `json:"sni"`
FP string `json:"fp"`
ALPN string `json:"alpn"`
}
if err := json.Unmarshal([]byte(dec), &v); err != nil {
return nil, fmt.Errorf("vmess: bad json: %w", err)
}
port := toInt(v.Port)
if v.Add == "" || port == 0 || v.ID == "" {
return nil, errors.New("vmess: missing add / port / id")
}
security := strings.TrimSpace(v.Scy)
if security == "" {
security = "auto"
}
out := map[string]any{
"protocol": "vmess",
"settings": map[string]any{
"vnext": []any{map[string]any{
"address": v.Add, "port": port,
"users": []any{map[string]any{"id": v.ID, "alterId": toInt(v.Aid), "security": security}},
}},
},
}
network := strings.ToLower(strings.TrimSpace(v.Net))
if network == "" {
network = "tcp"
}
ss := map[string]any{"network": network}
switch network {
case "ws":
ws := map[string]any{}
if v.Path != "" {
ws["path"] = v.Path
}
if v.Host != "" {
ws["headers"] = map[string]any{"Host": v.Host}
}
ss["wsSettings"] = ws
case "h2":
h2 := map[string]any{}
if v.Path != "" {
h2["path"] = v.Path
}
if v.Host != "" {
h2["host"] = []any{v.Host}
}
ss["h2Settings"] = h2
case "grpc":
g := map[string]any{}
if v.Path != "" {
g["serviceName"] = v.Path
}
ss["grpcSettings"] = g
case "tcp":
if strings.EqualFold(v.Type, "http") {
req := map[string]any{"path": []any{firstNonEmptyStr(v.Path, "/")}}
if v.Host != "" {
req["headers"] = map[string]any{"Host": []any{v.Host}}
}
ss["tcpSettings"] = map[string]any{"header": map[string]any{"type": "http", "request": req}}
}
case "httpupgrade", "xhttp":
sub := map[string]any{}
if v.Path != "" {
sub["path"] = v.Path
}
if v.Host != "" {
sub["host"] = v.Host
}
ss[network+"Settings"] = sub
}
if strings.EqualFold(v.TLS, "tls") {
ss["security"] = "tls"
t := map[string]any{}
if sni := firstNonEmptyStr(v.SNI, v.Host); sni != "" {
t["serverName"] = sni
}
if v.FP != "" {
t["fingerprint"] = v.FP
}
if v.ALPN != "" {
t["alpn"] = strings.Split(v.ALPN, ",")
}
ss["tlsSettings"] = t
}
out["streamSettings"] = ss
return &Node{Kind: "xray", Scheme: "vmess", Display: net.JoinHostPort(v.Add, strconv.Itoa(port)), Link: raw, Outbound: out}, nil
}
func parseTrojan(raw string) (*Node, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("trojan: %w", err)
}
pass := u.User.Username()
host, port := u.Hostname(), u.Port()
if pass == "" || host == "" || port == "" {
return nil, errors.New("trojan: missing password / host / port")
}
q := u.Query()
security := q.Get("security")
if security == "" {
security = "tls"
}
out := map[string]any{
"protocol": "trojan",
"settings": map[string]any{
"servers": []any{map[string]any{"address": host, "port": portInt(port), "password": pass}},
},
"streamSettings": streamSettings(firstNonEmptyStr(q.Get("type"), "tcp"), security, q),
}
return &Node{Kind: "xray", Scheme: "trojan", Display: net.JoinHostPort(host, port), Link: raw, Outbound: out}, nil
}
func parseShadowsocks(raw string) (*Node, error) {
body := strings.TrimPrefix(raw, "ss://")
if i := strings.Index(body, "#"); i >= 0 {
body = body[:i]
}
var method, pass, hostport string
if at := strings.LastIndex(body, "@"); at >= 0 {
userinfo, hp := body[:at], body[at+1:]
if i := strings.IndexAny(hp, "?"); i >= 0 {
hp = hp[:i]
}
if strings.Contains(userinfo, ":") {
// plain method:password (uncommon)
} else {
dec, err := b64Decode(userinfo)
if err != nil {
return nil, errors.New("ss: cannot decode credentials")
}
userinfo = dec
}
mp := strings.SplitN(userinfo, ":", 2)
if len(mp) != 2 || mp[0] == "" || mp[1] == "" {
return nil, errors.New("ss: bad credentials")
}
method, pass, hostport = mp[0], mp[1], hp
} else {
// legacy: ss://base64(method:password@host:port)
dec, err := b64Decode(body)
if err != nil {
return nil, errors.New("ss: invalid base64 payload")
}
at := strings.LastIndex(dec, "@")
if at < 0 {
return nil, errors.New("ss: missing @host")
}
mp := strings.SplitN(dec[:at], ":", 2)
if len(mp) != 2 {
return nil, errors.New("ss: bad credentials")
}
method, pass, hostport = mp[0], mp[1], dec[at+1:]
}
host, port, err := splitHostPort(hostport)
if err != nil {
return nil, fmt.Errorf("ss: %w", err)
}
out := map[string]any{
"protocol": "shadowsocks",
"settings": map[string]any{
"servers": []any{map[string]any{"address": host, "port": port, "method": method, "password": pass}},
},
}
return &Node{Kind: "xray", Scheme: "ss", Display: net.JoinHostPort(host, strconv.Itoa(port)), Link: raw, Outbound: out}, nil
}
func parsePlainEndpoint(line, defaultScheme string) (*Node, error) {
if defaultScheme = strings.ToLower(strings.TrimSpace(defaultScheme)); defaultScheme == "" {
defaultScheme = "http"
}
build := func(scheme, host, port, user, pass string) (*Node, error) {
scheme = normalizeScheme(scheme)
if scheme == "" {
return nil, fmt.Errorf("unsupported proxy scheme %q (http/https/socks5)", scheme)
}
if host == "" || port == "" {
return nil, errors.New("missing host or port")
}
u := &url.URL{Scheme: scheme, Host: net.JoinHostPort(host, port)}
if user != "" {
u.User = url.UserPassword(user, pass)
}
return &Node{
Kind: "url", Scheme: scheme,
Display: net.JoinHostPort(host, port),
Link: line, URL: u.String(),
}, nil
}
switch {
case strings.Contains(line, "://"):
u, err := url.Parse(line)
if err != nil {
return nil, fmt.Errorf("bad proxy url: %w", err)
}
user, pass := "", ""
if u.User != nil {
user = u.User.Username()
pass, _ = u.User.Password()
}
return build(u.Scheme, u.Hostname(), u.Port(), user, pass)
case strings.Contains(line, "@"):
at := strings.LastIndex(line, "@")
userinfo, hostport := line[:at], line[at+1:]
mp := strings.SplitN(userinfo, ":", 2)
user, pass := mp[0], ""
if len(mp) == 2 {
pass = mp[1]
}
host, port, err := splitHostPort(hostport)
if err != nil {
return nil, err
}
return build(defaultScheme, host, strconv.Itoa(port), user, pass)
default:
parts := strings.Split(line, ":")
switch len(parts) {
case 2:
return build(defaultScheme, parts[0], parts[1], "", "")
case 4:
// ip:port:user:pass
return build(defaultScheme, parts[0], parts[1], parts[2], parts[3])
default:
return nil, fmt.Errorf("unrecognized endpoint %q", line)
}
}
}
// ParseSubscriptionBody turns a subscription response into node lines. The
// body may be plain text (one node per line) or a base64 blob of that text.
func ParseSubscriptionBody(raw []byte) []string {
s := strings.TrimSpace(string(raw))
if s == "" {
return nil
}
if !strings.Contains(s, "://") {
if dec, err := b64Decode(strings.ReplaceAll(s, "\n", "")); err == nil {
s = dec
}
}
out := make([]string, 0, 16)
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out
}
// ── helpers ──────────────────────────────────────────────────────
// streamSettings builds Xray streamSettings from share-link query params.
func streamSettings(network, security string, q url.Values) map[string]any {
network = strings.ToLower(strings.TrimSpace(network))
if network == "" {
network = "tcp"
}
ss := map[string]any{"network": network}
switch network {
case "ws":
ws := map[string]any{}
if p := q.Get("path"); p != "" {
ws["path"] = p
}
if h := q.Get("host"); h != "" {
ws["headers"] = map[string]any{"Host": h}
}
ss["wsSettings"] = ws
case "grpc":
g := map[string]any{}
if s := q.Get("serviceName"); s != "" {
g["serviceName"] = s
}
ss["grpcSettings"] = g
case "httpupgrade":
h := map[string]any{}
if p := q.Get("path"); p != "" {
h["path"] = p
}
if hh := q.Get("host"); hh != "" {
h["host"] = hh
}
ss["httpupgradeSettings"] = h
case "xhttp":
x := map[string]any{}
if p := q.Get("path"); p != "" {
x["path"] = p
}
if hh := q.Get("host"); hh != "" {
x["host"] = hh
}
if m := q.Get("mode"); m != "" {
x["mode"] = m
}
ss["xhttpSettings"] = x
case "tcp":
if strings.EqualFold(q.Get("headerType"), "http") {
req := map[string]any{}
if h := q.Get("host"); h != "" {
req["headers"] = map[string]any{"Host": []any{h}}
}
if p := q.Get("path"); p != "" {
req["path"] = []any{p}
}
ss["tcpSettings"] = map[string]any{"header": map[string]any{"type": "http", "request": req}}
}
}
switch strings.ToLower(security) {
case "tls":
t := map[string]any{}
if sni := firstNonEmptyStr(q.Get("sni"), q.Get("host")); sni != "" {
t["serverName"] = sni
}
if fp := q.Get("fp"); fp != "" {
t["fingerprint"] = fp
}
if alpn := q.Get("alpn"); alpn != "" {
t["alpn"] = strings.Split(alpn, ",")
}
ss["security"] = "tls"
ss["tlsSettings"] = t
case "reality":
r := map[string]any{}
if sni := q.Get("sni"); sni != "" {
r["serverName"] = sni
}
if fp := q.Get("fp"); fp != "" {
r["fingerprint"] = fp
}
if pbk := q.Get("pbk"); pbk != "" {
r["publicKey"] = pbk
}
if sid := q.Get("sid"); sid != "" {
r["shortId"] = sid
}
if spx := q.Get("spx"); spx != "" {
r["spiderX"] = spx
}
ss["security"] = "reality"
ss["realitySettings"] = r
}
return ss
}
func normalizeScheme(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "http":
return "http"
case "https":
return "https"
case "socks", "socks5", "socks5h":
return "socks5"
default:
return ""
}
}
func b64Decode(s string) (string, error) {
s = strings.TrimSpace(s)
trimmed := strings.TrimRight(s, "=")
for _, enc := range []*base64.Encoding{
base64.RawURLEncoding, base64.URLEncoding,
base64.RawStdEncoding, base64.StdEncoding,
} {
if b, err := enc.DecodeString(trimmed); err == nil {
return string(b), nil
}
}
return "", errors.New("invalid base64")
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func portInt(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
func toInt(v any) int {
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(x))
return n
default:
return 0
}
}
func splitHostPort(hostport string) (string, int, error) {
host, port, err := net.SplitHostPort(strings.TrimSpace(hostport))
if err != nil {
return "", 0, fmt.Errorf("bad host:port %q", hostport)
}
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "", 0, fmt.Errorf("bad port in %q", hostport)
}
return host, n, nil
}