Files

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
}
}