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