Release 1.9.0: add VLESS, VMess and Trojan via Xray-core.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// Link is a normalized VLESS / VMess / Trojan share link.
|
||||
type Link struct {
|
||||
Protocol config.Protocol
|
||||
Raw string
|
||||
Remark string
|
||||
Address string
|
||||
Port int
|
||||
UUID string // vless/vmess id, or trojan password
|
||||
AlterID int
|
||||
Security string // encryption for vmess (auto/aes-128-gcm/…) or vless encryption
|
||||
Flow string
|
||||
Network string // tcp/ws/grpc/h2/httpupgrade/xhttp/splithttp
|
||||
Type string // header type for tcp
|
||||
Host string // ws/http host header
|
||||
Path string
|
||||
TLS string // none/tls/reality
|
||||
SNI string
|
||||
ALPN string
|
||||
FP string
|
||||
PBK string // reality public key
|
||||
SID string // reality shortId
|
||||
SPX string // reality spiderX
|
||||
ServiceName string // grpc
|
||||
Mode string // grpc multi/gun
|
||||
AllowInsecure bool
|
||||
PacketEncoding string // xudp/packetaddr for vless
|
||||
}
|
||||
|
||||
// Detect reports whether raw is a vless/vmess/trojan share link.
|
||||
func Detect(raw string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
return strings.HasPrefix(lower, "vless://") ||
|
||||
strings.HasPrefix(lower, "vmess://") ||
|
||||
strings.HasPrefix(lower, "trojan://")
|
||||
}
|
||||
|
||||
// DetectProtocol returns the specific protocol or "".
|
||||
func DetectProtocol(raw string) config.Protocol {
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "vless://"):
|
||||
return config.ProtocolVLESS
|
||||
case strings.HasPrefix(lower, "vmess://"):
|
||||
return config.ProtocolVMess
|
||||
case strings.HasPrefix(lower, "trojan://"):
|
||||
return config.ProtocolTrojan
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeShareLink parses and returns a canonical share URI + remark.
|
||||
func NormalizeShareLink(raw string) (normalized string, remark string, err error) {
|
||||
link, err := Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return link.Raw, link.Remark, nil
|
||||
}
|
||||
|
||||
// Parse accepts vless://, vmess://, trojan:// share links.
|
||||
func Parse(raw string) (Link, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Link{}, fmt.Errorf("пустая ссылка")
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "vless://"):
|
||||
return parseVLESS(raw)
|
||||
case strings.HasPrefix(lower, "vmess://"):
|
||||
return parseVMess(raw)
|
||||
case strings.HasPrefix(lower, "trojan://"):
|
||||
return parseTrojan(raw)
|
||||
default:
|
||||
return Link{}, fmt.Errorf("ожидалась ссылка vless://, vmess:// или trojan://")
|
||||
}
|
||||
}
|
||||
|
||||
// HostPort extracts server host/port for ping.
|
||||
func HostPort(raw string) (host, port string, err error) {
|
||||
link, err := Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if link.Address == "" {
|
||||
return "", "", fmt.Errorf("нет хоста")
|
||||
}
|
||||
p := strconv.Itoa(link.Port)
|
||||
if link.Port <= 0 {
|
||||
p = "443"
|
||||
}
|
||||
return link.Address, p, nil
|
||||
}
|
||||
|
||||
func parseVLESS(raw string) (Link, error) {
|
||||
remark := ""
|
||||
body := raw
|
||||
if i := strings.IndexByte(body, '#'); i >= 0 {
|
||||
remark, _ = url.QueryUnescape(strings.TrimSpace(body[i+1:]))
|
||||
body = body[:i]
|
||||
}
|
||||
u, err := url.Parse(body)
|
||||
if err != nil {
|
||||
return Link{}, fmt.Errorf("parse vless: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := 443
|
||||
if p := u.Port(); p != "" {
|
||||
port, _ = strconv.Atoi(p)
|
||||
}
|
||||
uuid := ""
|
||||
if u.User != nil {
|
||||
uuid = u.User.Username()
|
||||
}
|
||||
if host == "" || uuid == "" {
|
||||
return Link{}, fmt.Errorf("vless: нужен uuid@host:port")
|
||||
}
|
||||
q := u.Query()
|
||||
link := Link{
|
||||
Protocol: config.ProtocolVLESS,
|
||||
Raw: rebuildURI("vless", uuid, "", host, port, q, remark),
|
||||
Remark: remark,
|
||||
Address: host,
|
||||
Port: port,
|
||||
UUID: uuid,
|
||||
Security: firstNonEmpty(q.Get("encryption"), "none"),
|
||||
Flow: q.Get("flow"),
|
||||
Network: firstNonEmpty(q.Get("type"), q.Get("network"), "tcp"),
|
||||
Type: q.Get("headerType"),
|
||||
Host: firstNonEmpty(q.Get("host"), q.Get("authority")),
|
||||
Path: firstNonEmpty(q.Get("path"), q.Get("serviceName")),
|
||||
TLS: firstNonEmpty(q.Get("security"), "none"),
|
||||
SNI: firstNonEmpty(q.Get("sni"), q.Get("serverName")),
|
||||
ALPN: q.Get("alpn"),
|
||||
FP: firstNonEmpty(q.Get("fp"), q.Get("fingerprint")),
|
||||
PBK: firstNonEmpty(q.Get("pbk"), q.Get("publicKey")),
|
||||
SID: firstNonEmpty(q.Get("sid"), q.Get("shortId")),
|
||||
SPX: firstNonEmpty(q.Get("spx"), q.Get("spiderX")),
|
||||
ServiceName: firstNonEmpty(q.Get("serviceName"), q.Get("path")),
|
||||
Mode: q.Get("mode"),
|
||||
AllowInsecure: truthy(q.Get("allowInsecure")) || truthy(q.Get("insecure")),
|
||||
PacketEncoding: q.Get("packetEncoding"),
|
||||
}
|
||||
if link.Network == "grpc" && link.ServiceName == "" {
|
||||
link.ServiceName = link.Path
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func parseTrojan(raw string) (Link, error) {
|
||||
remark := ""
|
||||
body := raw
|
||||
if i := strings.IndexByte(body, '#'); i >= 0 {
|
||||
remark, _ = url.QueryUnescape(strings.TrimSpace(body[i+1:]))
|
||||
body = body[:i]
|
||||
}
|
||||
u, err := url.Parse(body)
|
||||
if err != nil {
|
||||
return Link{}, fmt.Errorf("parse trojan: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := 443
|
||||
if p := u.Port(); p != "" {
|
||||
port, _ = strconv.Atoi(p)
|
||||
}
|
||||
password := ""
|
||||
if u.User != nil {
|
||||
password = u.User.Username()
|
||||
if p, ok := u.User.Password(); ok && p != "" {
|
||||
password = password + ":" + p
|
||||
}
|
||||
}
|
||||
if host == "" || password == "" {
|
||||
return Link{}, fmt.Errorf("trojan: нужен password@host:port")
|
||||
}
|
||||
q := u.Query()
|
||||
tls := firstNonEmpty(q.Get("security"), "tls")
|
||||
if tls == "" || tls == "none" {
|
||||
tls = "tls"
|
||||
}
|
||||
link := Link{
|
||||
Protocol: config.ProtocolTrojan,
|
||||
Raw: rebuildURI("trojan", password, "", host, port, q, remark),
|
||||
Remark: remark,
|
||||
Address: host,
|
||||
Port: port,
|
||||
UUID: password,
|
||||
Network: firstNonEmpty(q.Get("type"), q.Get("network"), "tcp"),
|
||||
Type: q.Get("headerType"),
|
||||
Host: firstNonEmpty(q.Get("host"), q.Get("authority")),
|
||||
Path: q.Get("path"),
|
||||
TLS: tls,
|
||||
SNI: firstNonEmpty(q.Get("sni"), q.Get("peer"), q.Get("serverName")),
|
||||
ALPN: q.Get("alpn"),
|
||||
FP: firstNonEmpty(q.Get("fp"), q.Get("fingerprint")),
|
||||
PBK: firstNonEmpty(q.Get("pbk"), q.Get("publicKey")),
|
||||
SID: firstNonEmpty(q.Get("sid"), q.Get("shortId")),
|
||||
SPX: firstNonEmpty(q.Get("spx"), q.Get("spiderX")),
|
||||
ServiceName: firstNonEmpty(q.Get("serviceName"), q.Get("path")),
|
||||
Mode: q.Get("mode"),
|
||||
AllowInsecure: truthy(q.Get("allowInsecure")) || truthy(q.Get("insecure")),
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
type vmessShare struct {
|
||||
V any `json:"v"`
|
||||
PS string `json:"ps"`
|
||||
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"`
|
||||
ALPN string `json:"alpn"`
|
||||
FP string `json:"fp"`
|
||||
}
|
||||
|
||||
func parseVMess(raw string) (Link, error) {
|
||||
body := strings.TrimSpace(raw)
|
||||
if i := strings.Index(strings.ToLower(body), "vmess://"); i >= 0 {
|
||||
body = body[i+len("vmess://"):]
|
||||
}
|
||||
if i := strings.IndexByte(body, '#'); i >= 0 {
|
||||
body = body[:i]
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
decoded, err := decodeBase64Flexible(body)
|
||||
if err != nil {
|
||||
return Link{}, fmt.Errorf("vmess base64: %w", err)
|
||||
}
|
||||
var m vmessShare
|
||||
if err := json.Unmarshal([]byte(decoded), &m); err != nil {
|
||||
return Link{}, fmt.Errorf("vmess json: %w", err)
|
||||
}
|
||||
port := anyToInt(m.Port, 443)
|
||||
aid := anyToInt(m.Aid, 0)
|
||||
if m.Add == "" || m.ID == "" {
|
||||
return Link{}, fmt.Errorf("vmess: нет add/id")
|
||||
}
|
||||
tls := strings.ToLower(strings.TrimSpace(m.TLS))
|
||||
if tls == "1" || tls == "true" {
|
||||
tls = "tls"
|
||||
}
|
||||
if tls == "" {
|
||||
tls = "none"
|
||||
}
|
||||
link := Link{
|
||||
Protocol: config.ProtocolVMess,
|
||||
Remark: m.PS,
|
||||
Address: m.Add,
|
||||
Port: port,
|
||||
UUID: m.ID,
|
||||
AlterID: aid,
|
||||
Security: firstNonEmpty(m.Scy, "auto"),
|
||||
Network: firstNonEmpty(m.Net, "tcp"),
|
||||
Type: m.Type,
|
||||
Host: m.Host,
|
||||
Path: m.Path,
|
||||
TLS: tls,
|
||||
SNI: firstNonEmpty(m.SNI, m.Host),
|
||||
ALPN: m.ALPN,
|
||||
FP: m.FP,
|
||||
}
|
||||
// Rebuild canonical vmess:// for storage.
|
||||
out, _ := json.Marshal(map[string]any{
|
||||
"v": "2", "ps": link.Remark, "add": link.Address, "port": link.Port,
|
||||
"id": link.UUID, "aid": link.AlterID, "scy": link.Security, "net": link.Network,
|
||||
"type": link.Type, "host": link.Host, "path": link.Path, "tls": link.TLS,
|
||||
"sni": link.SNI, "alpn": link.ALPN, "fp": link.FP,
|
||||
})
|
||||
link.Raw = "vmess://" + base64.StdEncoding.EncodeToString(out)
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func rebuildURI(scheme, user, pass, host string, port int, q url.Values, remark string) string {
|
||||
u := &url.URL{Scheme: scheme, Host: net.JoinHostPort(host, strconv.Itoa(port))}
|
||||
if pass != "" {
|
||||
u.User = url.UserPassword(user, pass)
|
||||
} else {
|
||||
u.User = url.User(user)
|
||||
}
|
||||
if len(q) > 0 {
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
s := u.String()
|
||||
if remark != "" {
|
||||
s += "#" + url.PathEscape(remark)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func decodeBase64Flexible(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ReplaceAll(s, "-", "+")
|
||||
s = strings.ReplaceAll(s, "_", "/")
|
||||
switch len(s) % 4 {
|
||||
case 2:
|
||||
s += "=="
|
||||
case 3:
|
||||
s += "="
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
b, err = base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func anyToInt(v any, def int) int {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(t))
|
||||
if err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truthy(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user