Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/metacubex/sing v0.5.7
github.com/metacubex/sing-vmess v0.2.5
github.com/redis/go-redis/v9 v9.21.0
github.com/refraction-networking/utls v1.6.7
github.com/shadowsocks/go-shadowsocks2 v0.1.5
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
Expand All @@ -41,6 +44,7 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
Expand All @@ -55,6 +59,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/uuid/v5 v5.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
Expand Down
10 changes: 10 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
Expand Down Expand Up @@ -80,6 +82,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down Expand Up @@ -128,6 +132,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand All @@ -154,6 +162,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
Expand Down
5 changes: 4 additions & 1 deletion backend/internal/application/egress/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func fetchProxySubscription(ctx context.Context, value string, viaProxy string)
}
}
request.Header.Set("Accept", "text/plain, text/*;q=0.9, */*;q=0.1")
request.Header.Set("User-Agent", "grok2api-egress-subscription/1")
request.Header.Set("User-Agent", "Clash.Meta")
response, err := client.Do(request)
if err != nil {
return nil, err
Expand Down Expand Up @@ -368,6 +368,9 @@ func parseProxySubscription(value string) ([]subscriptionEntry, int, error) {
return entries, decodedSkipped, nil
}
}
if entries, clashSkipped, matched := parseClashSubscription(value); matched && len(entries) > 0 {
return entries, clashSkipped, nil
}
return nil, skipped, errors.New("订阅中没有可用的代理节点")
}

Expand Down
289 changes: 289 additions & 0 deletions backend/internal/application/egress/subscription_clash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
package egress

import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"

"gopkg.in/yaml.v3"
)

type clashSubscription struct {
Proxies []yaml.Node `yaml:"proxies"`
}

type clashProxy struct {
Type string `yaml:"type"`
Server string `yaml:"server"`
Port clashInteger `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Cipher string `yaml:"cipher"`
UUID string `yaml:"uuid"`
AlterID clashInteger `yaml:"alterId"`
Network string `yaml:"network"`
TLS bool `yaml:"tls"`
ServerName string `yaml:"servername"`
SNI string `yaml:"sni"`
Flow string `yaml:"flow"`
ALPN clashStringList `yaml:"alpn"`
SkipCertVerify bool `yaml:"skip-cert-verify"`
ClientFingerprint string `yaml:"client-fingerprint"`
Plugin string `yaml:"plugin"`
WSOptions clashWebSocketOption `yaml:"ws-opts"`
RealityOptions clashRealityOptions `yaml:"reality-opts"`
}

type clashWebSocketOption struct {
Path string `yaml:"path"`
Headers map[string]string `yaml:"headers"`
}

type clashRealityOptions struct {
PublicKey string `yaml:"public-key"`
ShortID string `yaml:"short-id"`
}

type clashInteger int

func (value *clashInteger) UnmarshalYAML(node *yaml.Node) error {
if node == nil || node.Kind != yaml.ScalarNode {
return errors.New("Clash 整数字段格式无效")
}
parsed, err := strconv.ParseInt(strings.TrimSpace(node.Value), 10, 32)
if err != nil {
return errors.New("Clash 整数字段格式无效")
}
*value = clashInteger(parsed)
return nil
}

type clashStringList []string

func (value *clashStringList) UnmarshalYAML(node *yaml.Node) error {
if node == nil {
return errors.New("Clash 字符串列表格式无效")
}
if node.Kind == yaml.AliasNode {
if node.Alias == nil {
return errors.New("Clash 字符串列表格式无效")
}
node = node.Alias
}
var items []string
switch node.Kind {
case yaml.ScalarNode:
items = strings.Split(node.Value, ",")
case yaml.SequenceNode:
if err := node.Decode(&items); err != nil {
return errors.New("Clash 字符串列表格式无效")
}
default:
return errors.New("Clash 字符串列表格式无效")
}
normalized := make([]string, 0, len(items))
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
normalized = append(normalized, item)
}
}
*value = normalized
return nil
}

type clashVMessShare struct {
Version string `json:"v"`
Address string `json:"add"`
Port string `json:"port"`
UUID string `json:"id"`
AlterID string `json:"aid"`
Cipher string `json:"scy"`
Network string `json:"net"`
TLS string `json:"tls,omitempty"`
ServerName string `json:"sni,omitempty"`
ALPN string `json:"alpn,omitempty"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
AllowInsecure bool `json:"allowInsecure,omitempty"`
}

func parseClashSubscription(value string) ([]subscriptionEntry, int, bool) {
var document clashSubscription
if err := yaml.Unmarshal([]byte(strings.TrimPrefix(value, "\ufeff")), &document); err != nil || document.Proxies == nil {
return nil, 0, false
}
if len(document.Proxies) > maxSubscriptionEntries {
return nil, len(document.Proxies), true
}
lines := make([]string, 0, len(document.Proxies))
skipped := 0
for index := range document.Proxies {
var proxy clashProxy
if err := document.Proxies[index].Decode(&proxy); err != nil {
skipped++
continue
}
line, err := clashProxyURL(proxy)
if err != nil {
skipped++
continue
}
lines = append(lines, line)
}
entries, lineSkipped := parseProxyLines(strings.Join(lines, "\n"))
return entries, skipped + lineSkipped, true
}

func clashProxyURL(proxy clashProxy) (string, error) {
server, err := clashProxyServer(proxy.Server, int(proxy.Port))
if err != nil {
return "", err
}
proxyType := strings.ToLower(strings.TrimSpace(proxy.Type))
switch proxyType {
case "http":
scheme := "http"
if proxy.TLS {
scheme = "https"
}
return clashStandardProxyURL(scheme, server, proxy.Username, proxy.Password), nil
case "socks5":
if proxy.TLS {
return "", errors.New("暂不支持 TLS SOCKS5")
}
return clashStandardProxyURL("socks5", server, proxy.Username, proxy.Password), nil
case "ss":
if strings.TrimSpace(proxy.Plugin) != "" {
return "", errors.New("暂不支持 Shadowsocks plugin")
}
method := strings.ToLower(strings.TrimSpace(proxy.Cipher))
if method == "" || proxy.Password == "" {
return "", errors.New("Shadowsocks cipher/password 不能为空")
}
credential := base64.RawURLEncoding.EncodeToString([]byte(method + ":" + proxy.Password))
return "ss://" + credential + "@" + server, nil
case "trojan":
if proxy.Password == "" {
return "", errors.New("Trojan password 不能为空")
}
query, err := clashTunnelQuery(proxy, "tls")
if err != nil {
return "", err
}
return (&url.URL{Scheme: "trojan", User: url.User(proxy.Password), Host: server, RawQuery: query.Encode()}).String(), nil
case "vless":
if proxy.UUID == "" {
return "", errors.New("VLESS UUID 不能为空")
}
security := "none"
if proxy.RealityOptions.PublicKey != "" || proxy.RealityOptions.ShortID != "" {
security = "reality"
} else if proxy.TLS {
security = "tls"
}
query, err := clashTunnelQuery(proxy, security)
if err != nil {
return "", err
}
query.Set("encryption", "none")
if flow := strings.TrimSpace(proxy.Flow); flow != "" {
query.Set("flow", flow)
}
if security == "reality" {
query.Set("pbk", proxy.RealityOptions.PublicKey)
query.Set("sid", proxy.RealityOptions.ShortID)
query.Set("fp", firstNonEmptySubscription(proxy.ClientFingerprint, "chrome"))
}
return (&url.URL{Scheme: "vless", User: url.User(proxy.UUID), Host: server, RawQuery: query.Encode()}).String(), nil
case "vmess":
return clashVMessURL(proxy)
default:
return "", fmt.Errorf("暂不支持 Clash 代理类型 %q", proxyType)
}
}

func clashProxyServer(host string, port int) (string, error) {
host = strings.Trim(strings.TrimSpace(host), "[]")
if host == "" || port < 1 || port > 65535 {
return "", errors.New("Clash 代理服务器地址无效")
}
return net.JoinHostPort(host, strconv.Itoa(port)), nil
}

func clashStandardProxyURL(scheme, server, username, password string) string {
value := &url.URL{Scheme: scheme, Host: server}
if username != "" || password != "" {
value.User = url.UserPassword(username, password)
}
return value.String()
}

func clashTunnelQuery(proxy clashProxy, security string) (url.Values, error) {
transport := strings.ToLower(strings.TrimSpace(proxy.Network))
if transport == "" {
transport = "tcp"
}
if transport == "websocket" {
transport = "ws"
}
query := make(url.Values)
query.Set("type", transport)
query.Set("security", security)
query.Set("sni", firstNonEmptySubscription(proxy.ServerName, proxy.SNI, proxy.Server))
if proxy.SkipCertVerify {
query.Set("allowInsecure", "1")
}
if len(proxy.ALPN) != 0 {
query.Set("alpn", strings.Join(proxy.ALPN, ","))
}
if transport == "ws" {
query.Set("path", firstNonEmptySubscription(proxy.WSOptions.Path, "/"))
query.Set("host", firstNonEmptySubscription(proxy.WSOptions.Headers["Host"], proxy.WSOptions.Headers["host"], proxy.ServerName, proxy.SNI, proxy.Server))
}
return query, nil
}

func clashVMessURL(proxy clashProxy) (string, error) {
if proxy.UUID == "" {
return "", errors.New("VMess UUID 不能为空")
}
network := strings.ToLower(strings.TrimSpace(proxy.Network))
if network == "" {
network = "tcp"
}
if network == "websocket" {
network = "ws"
}
share := clashVMessShare{
Version: "2", Address: proxy.Server, Port: strconv.Itoa(int(proxy.Port)), UUID: proxy.UUID,
AlterID: strconv.Itoa(int(proxy.AlterID)), Cipher: firstNonEmptySubscription(proxy.Cipher, "auto"), Network: network,
ServerName: firstNonEmptySubscription(proxy.ServerName, proxy.SNI, proxy.Server), ALPN: strings.Join(proxy.ALPN, ","),
AllowInsecure: proxy.SkipCertVerify,
}
if proxy.TLS {
share.TLS = "tls"
}
if network == "ws" {
share.Path = firstNonEmptySubscription(proxy.WSOptions.Path, "/")
share.Host = firstNonEmptySubscription(proxy.WSOptions.Headers["Host"], proxy.WSOptions.Headers["host"], proxy.ServerName, proxy.SNI, proxy.Server)
}
encoded, err := json.Marshal(share)
if err != nil {
return "", err
}
return "vmess://" + base64.RawStdEncoding.EncodeToString(encoded), nil
}

func firstNonEmptySubscription(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
Loading
Loading