365 lines
9.8 KiB
Go
365 lines
9.8 KiB
Go
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
|
|
}
|
|
}
|