|
@@ -0,0 +1,809 @@
|
|
|
|
|
+// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
|
|
|
|
|
+// and subscription bodies (typically base64-encoded newline lists of such links).
|
|
|
|
|
+// The output shape matches the wire format used by the panel's Xray template
|
|
|
|
|
+// outbounds array so that parsed objects can be injected directly.
|
|
|
|
|
+package link
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/base64"
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "net/url"
|
|
|
|
|
+ "regexp"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// Outbound is the minimal shape we emit for each parsed link.
|
|
|
|
|
+// Extra fields (mux, etc.) are carried inside settings/streamSettings.
|
|
|
|
|
+type Outbound map[string]any
|
|
|
|
|
+
|
|
|
|
|
+// ParseResult holds a parsed outbound together with a stable identity string
|
|
|
|
|
+// that can be used to correlate the same logical server across refreshes
|
|
|
|
|
+// (even if the remark changes).
|
|
|
|
|
+type ParseResult struct {
|
|
|
|
|
+ Outbound Outbound
|
|
|
|
|
+ Identity string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
|
|
|
|
|
+// It handles the common case where the body is a base64-encoded blob of
|
|
|
|
|
+// newline-separated links, and also tolerates an already-decoded text body.
|
|
|
|
|
+// It returns the list of successfully parsed outbounds (in order) and their
|
|
|
|
|
+// corresponding identities.
|
|
|
|
|
+func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
|
|
|
|
|
+ text := strings.TrimSpace(string(body))
|
|
|
|
|
+ if text == "" {
|
|
|
|
|
+ return nil, nil, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Try base64 decode first (standard and URL-safe variants).
|
|
|
|
|
+ if decoded, ok := tryBase64(text); ok {
|
|
|
|
|
+ text = strings.TrimSpace(decoded)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lines := splitLines(text)
|
|
|
|
|
+ var outbounds []Outbound
|
|
|
|
|
+ var identities []string
|
|
|
|
|
+
|
|
|
|
|
+ for _, ln := range lines {
|
|
|
|
|
+ ln = strings.TrimSpace(ln)
|
|
|
|
|
+ if ln == "" || strings.HasPrefix(ln, "#") {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ res, err := ParseLink(ln)
|
|
|
|
|
+ if err != nil || res == nil {
|
|
|
|
|
+ // Ignore unparseable lines (comments, unsupported protocols, etc.)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ outbounds = append(outbounds, res.Outbound)
|
|
|
|
|
+ identities = append(identities, res.Identity)
|
|
|
|
|
+ }
|
|
|
|
|
+ return outbounds, identities, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func tryBase64(s string) (string, bool) {
|
|
|
|
|
+ // Remove whitespace that some providers insert.
|
|
|
|
|
+ clean := strings.Map(func(r rune) rune {
|
|
|
|
|
+ if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
|
|
|
|
|
+ return -1
|
|
|
|
|
+ }
|
|
|
|
|
+ return r
|
|
|
|
|
+ }, s)
|
|
|
|
|
+
|
|
|
|
|
+ // Common padding fix
|
|
|
|
|
+ for len(clean)%4 != 0 {
|
|
|
|
|
+ clean += "="
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Standard
|
|
|
|
|
+ if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
|
|
|
|
|
+ return string(b), true
|
|
|
|
|
+ }
|
|
|
|
|
+ // URL-safe (no padding)
|
|
|
|
|
+ if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
|
|
|
|
|
+ return string(b), true
|
|
|
|
|
+ }
|
|
|
|
|
+ // URL-safe with padding
|
|
|
|
|
+ if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
|
|
|
|
|
+ return string(b), true
|
|
|
|
|
+ }
|
|
|
|
|
+ return "", false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func splitLines(s string) []string {
|
|
|
|
|
+ // Accept \n, \r\n, and also some providers use literal \n in the text.
|
|
|
|
|
+ s = strings.ReplaceAll(s, `\n`, "\n")
|
|
|
|
|
+ return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ParseLink parses a single share link and returns the outbound object plus
|
|
|
|
|
+// a stable identity for tag correlation. Supported schemes:
|
|
|
|
|
+// - vmess://
|
|
|
|
|
+// - vless://
|
|
|
|
|
+// - trojan://
|
|
|
|
|
+// - ss:// (modern and legacy)
|
|
|
|
|
+// - hysteria2:// (also hy2://)
|
|
|
|
|
+// - wireguard:// (also wg://)
|
|
|
|
|
+func ParseLink(link string) (*ParseResult, error) {
|
|
|
|
|
+ link = strings.TrimSpace(link)
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case strings.HasPrefix(link, "vmess://"):
|
|
|
|
|
+ return parseVmess(link)
|
|
|
|
|
+ case strings.HasPrefix(link, "vless://"):
|
|
|
|
|
+ return parseVless(link)
|
|
|
|
|
+ case strings.HasPrefix(link, "trojan://"):
|
|
|
|
|
+ return parseTrojan(link)
|
|
|
|
|
+ case strings.HasPrefix(link, "ss://"):
|
|
|
|
|
+ return parseShadowsocks(link)
|
|
|
|
|
+ case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
|
|
|
|
|
+ return parseHysteria2(link)
|
|
|
|
|
+ case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
|
|
|
|
|
+ return parseWireguard(link)
|
|
|
|
|
+ default:
|
|
|
|
|
+ return nil, fmt.Errorf("unsupported link scheme")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- vmess ---
|
|
|
|
|
+
|
|
|
|
|
+func parseVmess(link string) (*ParseResult, error) {
|
|
|
|
|
+ b64 := strings.TrimPrefix(link, "vmess://")
|
|
|
|
|
+ // vmess:// base64(json)
|
|
|
|
|
+ raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ // Some providers use raw URL-safe
|
|
|
|
|
+ raw, err = base64.RawURLEncoding.DecodeString(b64)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("vmess decode: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var j map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(raw, &j); err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("vmess json: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ identity := vmessIdentity(j)
|
|
|
|
|
+
|
|
|
|
|
+ network := getString(j, "net", "tcp")
|
|
|
|
|
+ security := "none"
|
|
|
|
|
+ if tls, _ := j["tls"].(string); tls == "tls" {
|
|
|
|
|
+ security = "tls"
|
|
|
|
|
+ }
|
|
|
|
|
+ stream := buildStream(network, security)
|
|
|
|
|
+
|
|
|
|
|
+ // Map known fields (best effort, matching frontend parser coverage)
|
|
|
|
|
+ switch network {
|
|
|
|
|
+ case "ws":
|
|
|
|
|
+ if host, ok := j["host"].(string); ok {
|
|
|
|
|
+ setWS(stream, host, getString(j, "path", "/"))
|
|
|
|
|
+ }
|
|
|
|
|
+ case "grpc":
|
|
|
|
|
+ svc := getString(j, "path", "")
|
|
|
|
|
+ if auth, ok := j["authority"].(string); ok && auth != "" {
|
|
|
|
|
+ (stream["grpcSettings"].(map[string]any))["authority"] = auth
|
|
|
|
|
+ }
|
|
|
|
|
+ (stream["grpcSettings"].(map[string]any))["serviceName"] = svc
|
|
|
|
|
+ (stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
|
|
|
|
|
+ case "httpupgrade":
|
|
|
|
|
+ setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
|
|
|
|
|
+ case "xhttp":
|
|
|
|
|
+ xh := stream["xhttpSettings"].(map[string]any)
|
|
|
|
|
+ xh["host"] = getString(j, "host", "")
|
|
|
|
|
+ xh["path"] = getString(j, "path", "/")
|
|
|
|
|
+ if m := getString(j, "mode", ""); m != "" {
|
|
|
|
|
+ xh["mode"] = m
|
|
|
|
|
+ }
|
|
|
|
|
+ // xhttp advanced keys are passed through if present in the json
|
|
|
|
|
+ for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
|
|
|
|
|
+ if v, ok := j[k]; ok {
|
|
|
|
|
+ xh[k] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ case "tcp":
|
|
|
|
|
+ if getString(j, "type", "") == "http" {
|
|
|
|
|
+ stream["tcpSettings"] = map[string]any{
|
|
|
|
|
+ "header": map[string]any{
|
|
|
|
|
+ "type": "http",
|
|
|
|
|
+ "request": map[string]any{
|
|
|
|
|
+ "version": "1.1",
|
|
|
|
|
+ "method": "GET",
|
|
|
|
|
+ "path": splitComma(getString(j, "path", "/")),
|
|
|
|
|
+ "headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if security == "tls" {
|
|
|
|
|
+ tls := stream["tlsSettings"].(map[string]any)
|
|
|
|
|
+ tls["serverName"] = getString(j, "sni", "")
|
|
|
|
|
+ tls["fingerprint"] = getString(j, "fp", "")
|
|
|
|
|
+ if alpn := getString(j, "alpn", ""); alpn != "" {
|
|
|
|
|
+ tls["alpn"] = splitComma(alpn)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ port := num(j["port"])
|
|
|
|
|
+ ob := Outbound{
|
|
|
|
|
+ "protocol": "vmess",
|
|
|
|
|
+ "tag": getString(j, "ps", ""),
|
|
|
|
|
+ "settings": map[string]any{
|
|
|
|
|
+ "vnext": []any{
|
|
|
|
|
+ map[string]any{
|
|
|
|
|
+ "address": getString(j, "add", ""),
|
|
|
|
|
+ "port": port,
|
|
|
|
|
+ "users": []any{
|
|
|
|
|
+ map[string]any{
|
|
|
|
|
+ "id": getString(j, "id", ""),
|
|
|
|
|
+ "security": getString(j, "scy", "auto"),
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ "streamSettings": stream,
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ParseResult{Outbound: ob, Identity: identity}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func vmessIdentity(j map[string]any) string {
|
|
|
|
|
+ // Remove ps (remark) for identity
|
|
|
|
|
+ core := map[string]any{}
|
|
|
|
|
+ for k, v := range j {
|
|
|
|
|
+ if k == "ps" {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ core[k] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ b, _ := json.Marshal(core)
|
|
|
|
|
+ return "vmess:" + string(b)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- vless / trojan (URL forms) ---
|
|
|
|
|
+
|
|
|
|
|
+func parseVless(link string) (*ParseResult, error) {
|
|
|
|
|
+ u, err := url.Parse(link)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if u.Scheme != "vless" {
|
|
|
|
|
+ return nil, fmt.Errorf("not vless")
|
|
|
|
|
+ }
|
|
|
|
|
+ id := u.User.Username()
|
|
|
|
|
+ host := u.Hostname()
|
|
|
|
|
+ port := defaultPort(u.Port(), 443)
|
|
|
|
|
+ params := u.Query()
|
|
|
|
|
+ network := params.Get("type")
|
|
|
|
|
+ if network == "" {
|
|
|
|
|
+ network = "tcp"
|
|
|
|
|
+ }
|
|
|
|
|
+ security := params.Get("security")
|
|
|
|
|
+ if security == "" {
|
|
|
|
|
+ security = "none"
|
|
|
|
|
+ }
|
|
|
|
|
+ stream := buildStream(network, security)
|
|
|
|
|
+ applyTransport(stream, params)
|
|
|
|
|
+ applySecurity(stream, params)
|
|
|
|
|
+ applyFinalMask(stream, params)
|
|
|
|
|
+
|
|
|
|
|
+ identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
|
|
|
|
+
|
|
|
|
|
+ ob := Outbound{
|
|
|
|
|
+ "protocol": "vless",
|
|
|
|
|
+ "tag": decodeHash(u.Fragment),
|
|
|
|
|
+ "settings": map[string]any{
|
|
|
|
|
+ "address": host,
|
|
|
|
|
+ "port": port,
|
|
|
|
|
+ "id": id,
|
|
|
|
|
+ "flow": params.Get("flow"),
|
|
|
|
|
+ "encryption": firstNonEmpty(params.Get("encryption"), "none"),
|
|
|
|
|
+ },
|
|
|
|
|
+ "streamSettings": stream,
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ParseResult{Outbound: ob, Identity: identity}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func parseTrojan(link string) (*ParseResult, error) {
|
|
|
|
|
+ u, err := url.Parse(link)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if u.Scheme != "trojan" {
|
|
|
|
|
+ return nil, fmt.Errorf("not trojan")
|
|
|
|
|
+ }
|
|
|
|
|
+ pw := u.User.Username()
|
|
|
|
|
+ host := u.Hostname()
|
|
|
|
|
+ port := defaultPort(u.Port(), 443)
|
|
|
|
|
+ params := u.Query()
|
|
|
|
|
+ network := params.Get("type")
|
|
|
|
|
+ if network == "" {
|
|
|
|
|
+ network = "tcp"
|
|
|
|
|
+ }
|
|
|
|
|
+ security := params.Get("security")
|
|
|
|
|
+ if security == "" {
|
|
|
|
|
+ security = "tls"
|
|
|
|
|
+ }
|
|
|
|
|
+ stream := buildStream(network, security)
|
|
|
|
|
+ applyTransport(stream, params)
|
|
|
|
|
+ applySecurity(stream, params)
|
|
|
|
|
+ applyFinalMask(stream, params)
|
|
|
|
|
+
|
|
|
|
|
+ identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
|
|
|
|
+
|
|
|
|
|
+ ob := Outbound{
|
|
|
|
|
+ "protocol": "trojan",
|
|
|
|
|
+ "tag": decodeHash(u.Fragment),
|
|
|
|
|
+ "settings": map[string]any{
|
|
|
|
|
+ "servers": []any{
|
|
|
|
|
+ map[string]any{"address": host, "port": port, "password": pw},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ "streamSettings": stream,
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ParseResult{Outbound: ob, Identity: identity}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- shadowsocks ---
|
|
|
|
|
+
|
|
|
|
|
+func parseShadowsocks(link string) (*ParseResult, error) {
|
|
|
|
|
+ // Two shapes:
|
|
|
|
|
+ // ss://base64(method:pass)@host:port#remark
|
|
|
|
|
+ // ss://base64(method:pass@host:port)#remark
|
|
|
|
|
+ remark := ""
|
|
|
|
|
+ if i := strings.Index(link, "#"); i >= 0 {
|
|
|
|
|
+ remark, _ = url.QueryUnescape(link[i+1:])
|
|
|
|
|
+ link = link[:i]
|
|
|
|
|
+ }
|
|
|
|
|
+ core := strings.TrimPrefix(link, "ss://")
|
|
|
|
|
+ at := strings.Index(core, "@")
|
|
|
|
|
+ if at >= 0 {
|
|
|
|
|
+ // modern
|
|
|
|
|
+ userB64 := core[:at]
|
|
|
|
|
+ hp := core[at+1:]
|
|
|
|
|
+ userInfo, err := base64DecodeFlexible(userB64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ userInfo = userB64 // not b64, rare
|
|
|
|
|
+ }
|
|
|
|
|
+ colon := strings.LastIndex(hp, ":")
|
|
|
|
|
+ if colon < 0 {
|
|
|
|
|
+ return nil, fmt.Errorf("bad ss host:port")
|
|
|
|
|
+ }
|
|
|
|
|
+ host := hp[:colon]
|
|
|
|
|
+ port, _ := strconv.Atoi(hp[colon+1:])
|
|
|
|
|
+ method, pass := splitMethodPass(userInfo)
|
|
|
|
|
+ identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
|
|
|
|
+ ob := Outbound{
|
|
|
|
|
+ "protocol": "shadowsocks",
|
|
|
|
|
+ "tag": remark,
|
|
|
|
|
+ "settings": map[string]any{
|
|
|
|
|
+ "servers": []any{
|
|
|
|
|
+ map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ParseResult{Outbound: ob, Identity: identity}, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ // legacy: whole thing b64
|
|
|
|
|
+ dec, err := base64DecodeFlexible(core)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ at = strings.Index(dec, "@")
|
|
|
|
|
+ if at < 0 {
|
|
|
|
|
+ return nil, fmt.Errorf("bad legacy ss")
|
|
|
|
|
+ }
|
|
|
|
|
+ userInfo := dec[:at]
|
|
|
|
|
+ hp := dec[at+1:]
|
|
|
|
|
+ colon := strings.LastIndex(hp, ":")
|
|
|
|
|
+ if colon < 0 {
|
|
|
|
|
+ return nil, fmt.Errorf("bad legacy ss hp")
|
|
|
|
|
+ }
|
|
|
|
|
+ host := hp[:colon]
|
|
|
|
|
+ port, _ := strconv.Atoi(hp[colon+1:])
|
|
|
|
|
+ method, pass := splitMethodPass(userInfo)
|
|
|
|
|
+ identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
|
|
|
|
+ ob := Outbound{
|
|
|
|
|
+ "protocol": "shadowsocks",
|
|
|
|
|
+ "tag": remark,
|
|
|
|
|
+ "settings": map[string]any{
|
|
|
|
|
+ "servers": []any{
|
|
|
|
|
+ map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ParseResult{Outbound: ob, Identity: identity}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func splitMethodPass(userInfo string) (string, string) {
|
|
|
|
|
+ colon := strings.Index(userInfo, ":")
|
|
|
|
|
+ if colon < 0 {
|
|
|
|
|
+ return "2022-blake3-aes-128-gcm", userInfo // guess
|
|
|
|
|
+ }
|
|
|
|
|
+ return userInfo[:colon], userInfo[colon+1:]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- hysteria2 ---
|
|
|
|
|
+
|
|
|
|
|
+func parseHysteria2(link string) (*ParseResult, error) {
|
|
|
|
|
+ u, err := url.Parse(link)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
|
|
|
|
|
+ return nil, fmt.Errorf("not hysteria2")
|
|
|
|
|
+ }
|
|
|
|
|
+ auth := u.User.Username()
|
|
|
|
|
+ host := u.Hostname()
|
|
|
|
|
+ port := defaultPort(u.Port(), 443)
|
|
|
|
|
+ params := u.Query()
|
|
|
|
|
+
|
|
|
|
|
+ stream := map[string]any{
|
|
|
|
|
+ "network": "hysteria",
|
|
|
|
|
+ "security": "tls",
|
|
|
|
|
+ "hysteriaSettings": map[string]any{
|
|
|
|
|
+ "version": 2,
|
|
|
|
|
+ "auth": auth,
|
|
|
|
|
+ "udpIdleTimeout": 60,
|
|
|
|
|
+ },
|
|
|
|
|
+ "tlsSettings": map[string]any{
|
|
|
|
|
+ "serverName": params.Get("sni"),
|
|
|
|
|
+ "alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
|
|
|
|
|
+ "fingerprint": params.Get("fp"),
|
|
|
|
|
+ "echConfigList": params.Get("ech"),
|
|
|
|
|
+ "verifyPeerCertByName": "",
|
|
|
|
|
+ "pinnedPeerCertSha256": params.Get("pinSHA256"),
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ applyFinalMask(stream, params)
|
|
|
|
|
+
|
|
|
|
|
+ identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
|
|
|
|
+
|
|
|
|
|
+ ob := Outbound{
|
|
|
|
|
+ "protocol": "hysteria",
|
|
|
|
|
+ "tag": decodeHash(u.Fragment),
|
|
|
|
|
+ "settings": map[string]any{"address": host, "port": port, "version": 2},
|
|
|
|
|
+ "streamSettings": stream,
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ParseResult{Outbound: ob, Identity: identity}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- wireguard ---
|
|
|
|
|
+
|
|
|
|
|
+func parseWireguard(link string) (*ParseResult, error) {
|
|
|
|
|
+ u, err := url.Parse(link)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if u.Scheme != "wireguard" && u.Scheme != "wg" {
|
|
|
|
|
+ return nil, fmt.Errorf("not wireguard")
|
|
|
|
|
+ }
|
|
|
|
|
+ secret, _ := url.QueryUnescape(u.User.Username())
|
|
|
|
|
+ params := u.Query()
|
|
|
|
|
+ host := u.Hostname()
|
|
|
|
|
+ portStr := u.Port()
|
|
|
|
|
+ endpoint := host
|
|
|
|
|
+ if portStr != "" {
|
|
|
|
|
+ endpoint = host + ":" + portStr
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addrRaw := firstParam(params, "address", "ip")
|
|
|
|
|
+ allowedRaw := firstParam(params, "allowedips", "allowed_ips")
|
|
|
|
|
+ addrs := splitComma(addrRaw)
|
|
|
|
|
+ if len(addrs) == 0 {
|
|
|
|
|
+ addrs = []string{"0.0.0.0/0", "::/0"}
|
|
|
|
|
+ }
|
|
|
|
|
+ allowed := splitComma(allowedRaw)
|
|
|
|
|
+ if len(allowed) == 0 {
|
|
|
|
|
+ allowed = []string{"0.0.0.0/0", "::/0"}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ peer := map[string]any{
|
|
|
|
|
+ "publicKey": firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
|
|
|
|
|
+ "endpoint": endpoint,
|
|
|
|
|
+ "allowedIPs": allowed,
|
|
|
|
|
+ }
|
|
|
|
|
+ if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
|
|
|
|
|
+ peer["preSharedKey"] = psk
|
|
|
|
|
+ }
|
|
|
|
|
+ if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
|
|
|
|
|
+ if n, err := strconv.Atoi(ka); err == nil {
|
|
|
|
|
+ peer["keepAlive"] = n
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ settings := map[string]any{
|
|
|
|
|
+ "secretKey": secret,
|
|
|
|
|
+ "address": addrs,
|
|
|
|
|
+ "peers": []any{peer},
|
|
|
|
|
+ }
|
|
|
|
|
+ if mtu := params.Get("mtu"); mtu != "" {
|
|
|
|
|
+ if n, err := strconv.Atoi(mtu); err == nil {
|
|
|
|
|
+ settings["mtu"] = n
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if res := params.Get("reserved"); res != "" {
|
|
|
|
|
+ parts := splitComma(res)
|
|
|
|
|
+ var iv []int
|
|
|
|
|
+ for _, p := range parts {
|
|
|
|
|
+ if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
|
|
|
|
+ iv = append(iv, n)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(iv) > 0 {
|
|
|
|
|
+ settings["reserved"] = iv
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
|
|
|
|
|
+
|
|
|
|
|
+ ob := Outbound{
|
|
|
|
|
+ "protocol": "wireguard",
|
|
|
|
|
+ "tag": decodeHash(u.Fragment),
|
|
|
|
|
+ "settings": settings,
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ParseResult{Outbound: ob, Identity: identity}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- helpers ---
|
|
|
|
|
+
|
|
|
|
|
+func buildStream(network, security string) map[string]any {
|
|
|
|
|
+ stream := map[string]any{"network": network, "security": security}
|
|
|
|
|
+ switch network {
|
|
|
|
|
+ case "tcp":
|
|
|
|
|
+ stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
|
|
|
|
+ case "kcp":
|
|
|
|
|
+ stream["kcpSettings"] = map[string]any{
|
|
|
|
|
+ "mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
|
|
|
|
|
+ "cwndMultiplier": 1, "maxSendingWindow": 2097152,
|
|
|
|
|
+ }
|
|
|
|
|
+ case "ws":
|
|
|
|
|
+ stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
|
|
|
|
|
+ case "grpc":
|
|
|
|
|
+ stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
|
|
|
|
|
+ case "httpupgrade":
|
|
|
|
|
+ stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
|
|
|
|
|
+ case "xhttp":
|
|
|
|
|
+ stream["xhttpSettings"] = map[string]any{
|
|
|
|
|
+ "path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
|
|
|
|
|
+ "xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
|
|
|
|
|
+ }
|
|
|
|
|
+ default:
|
|
|
|
|
+ stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
|
|
|
|
+ }
|
|
|
|
|
+ if security == "tls" {
|
|
|
|
|
+ stream["tlsSettings"] = map[string]any{
|
|
|
|
|
+ "serverName": "", "alpn": []any{}, "fingerprint": "",
|
|
|
|
|
+ "echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if security == "reality" {
|
|
|
|
|
+ stream["realitySettings"] = map[string]any{
|
|
|
|
|
+ "publicKey": "", "fingerprint": "chrome", "serverName": "",
|
|
|
|
|
+ "shortId": "", "spiderX": "", "mldsa65Verify": "",
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return stream
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func setWS(stream map[string]any, host, path string) {
|
|
|
|
|
+ ws := stream["wsSettings"].(map[string]any)
|
|
|
|
|
+ ws["host"] = host
|
|
|
|
|
+ ws["path"] = path
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func setHTTPUpgrade(stream map[string]any, host, path string) {
|
|
|
|
|
+ h := stream["httpupgradeSettings"].(map[string]any)
|
|
|
|
|
+ h["host"] = host
|
|
|
|
|
+ h["path"] = path
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func applyTransport(stream map[string]any, p url.Values) {
|
|
|
|
|
+ net := stream["network"].(string)
|
|
|
|
|
+ host := p.Get("host")
|
|
|
|
|
+ path := firstNonEmpty(p.Get("path"), "/")
|
|
|
|
|
+ switch net {
|
|
|
|
|
+ case "ws":
|
|
|
|
|
+ setWS(stream, host, path)
|
|
|
|
|
+ case "grpc":
|
|
|
|
|
+ gs := stream["grpcSettings"].(map[string]any)
|
|
|
|
|
+ gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
|
|
|
|
|
+ gs["authority"] = p.Get("authority")
|
|
|
|
|
+ gs["multiMode"] = p.Get("mode") == "multi"
|
|
|
|
|
+ case "httpupgrade":
|
|
|
|
|
+ setHTTPUpgrade(stream, host, path)
|
|
|
|
|
+ case "xhttp":
|
|
|
|
|
+ xh := stream["xhttpSettings"].(map[string]any)
|
|
|
|
|
+ xh["host"] = host
|
|
|
|
|
+ xh["path"] = path
|
|
|
|
|
+ if m := p.Get("mode"); m != "" {
|
|
|
|
|
+ xh["mode"] = m
|
|
|
|
|
+ }
|
|
|
|
|
+ // A few advanced xhttp fields that are commonly carried
|
|
|
|
|
+ for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
|
|
|
|
|
+ if v := p.Get(k); v != "" {
|
|
|
|
|
+ xh[k] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ case "tcp":
|
|
|
|
|
+ if p.Get("headerType") == "http" || p.Get("type") == "http" {
|
|
|
|
|
+ stream["tcpSettings"] = map[string]any{
|
|
|
|
|
+ "header": map[string]any{
|
|
|
|
|
+ "type": "http",
|
|
|
|
|
+ "request": map[string]any{
|
|
|
|
|
+ "version": "1.1",
|
|
|
|
|
+ "method": "GET",
|
|
|
|
|
+ "path": splitComma(path),
|
|
|
|
|
+ "headers": map[string]any{"Host": splitComma(host)},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func applySecurity(stream map[string]any, p url.Values) {
|
|
|
|
|
+ sec := stream["security"].(string)
|
|
|
|
|
+ if sec == "tls" {
|
|
|
|
|
+ tls := stream["tlsSettings"].(map[string]any)
|
|
|
|
|
+ tls["serverName"] = p.Get("sni")
|
|
|
|
|
+ tls["fingerprint"] = p.Get("fp")
|
|
|
|
|
+ if alpn := p.Get("alpn"); alpn != "" {
|
|
|
|
|
+ tls["alpn"] = splitComma(alpn)
|
|
|
|
|
+ }
|
|
|
|
|
+ tls["echConfigList"] = p.Get("ech")
|
|
|
|
|
+ tls["pinnedPeerCertSha256"] = p.Get("pcs")
|
|
|
|
|
+ } else if sec == "reality" {
|
|
|
|
|
+ re := stream["realitySettings"].(map[string]any)
|
|
|
|
|
+ re["serverName"] = p.Get("sni")
|
|
|
|
|
+ re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
|
|
|
|
|
+ re["publicKey"] = p.Get("pbk")
|
|
|
|
|
+ re["shortId"] = p.Get("sid")
|
|
|
|
|
+ re["spiderX"] = p.Get("spx")
|
|
|
|
|
+ re["mldsa65Verify"] = p.Get("pqv")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func applyFinalMask(stream map[string]any, p url.Values) {
|
|
|
|
|
+ if fm := p.Get("fm"); fm != "" {
|
|
|
|
|
+ var parsed any
|
|
|
|
|
+ if json.Unmarshal([]byte(fm), &parsed) == nil {
|
|
|
|
|
+ stream["finalmask"] = parsed
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func firstNonEmpty(a, b string) string {
|
|
|
|
|
+ if a != "" {
|
|
|
|
|
+ return a
|
|
|
|
|
+ }
|
|
|
|
|
+ return b
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func firstParam(p url.Values, keys ...string) string {
|
|
|
|
|
+ for _, k := range keys {
|
|
|
|
|
+ if v := p.Get(k); v != "" {
|
|
|
|
|
+ return v
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return ""
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func canonicalQuery(p url.Values) string {
|
|
|
|
|
+ // Sort keys for stable identity
|
|
|
|
|
+ keys := make([]string, 0, len(p))
|
|
|
|
|
+ for k := range p {
|
|
|
|
|
+ keys = append(keys, k)
|
|
|
|
|
+ }
|
|
|
|
|
+ // simple sort
|
|
|
|
|
+ for i := 0; i < len(keys); i++ {
|
|
|
|
|
+ for j := i + 1; j < len(keys); j++ {
|
|
|
|
|
+ if keys[j] < keys[i] {
|
|
|
|
|
+ keys[i], keys[j] = keys[j], keys[i]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ parts := make([]string, 0, len(keys))
|
|
|
|
|
+ for _, k := range keys {
|
|
|
|
|
+ for _, v := range p[k] {
|
|
|
|
|
+ parts = append(parts, k+"="+v)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(parts, "&")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func decodeHash(h string) string {
|
|
|
|
|
+ if h == "" {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ if dec, err := url.QueryUnescape(h); err == nil {
|
|
|
|
|
+ return dec
|
|
|
|
|
+ }
|
|
|
|
|
+ return h
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func defaultPort(p string, def int) int {
|
|
|
|
|
+ if p == "" {
|
|
|
|
|
+ return def
|
|
|
|
|
+ }
|
|
|
|
|
+ n, err := strconv.Atoi(p)
|
|
|
|
|
+ if err != nil || n <= 0 {
|
|
|
|
|
+ return def
|
|
|
|
|
+ }
|
|
|
|
|
+ return n
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func num(v any) int {
|
|
|
|
|
+ switch x := v.(type) {
|
|
|
|
|
+ case float64:
|
|
|
|
|
+ return int(x)
|
|
|
|
|
+ case int:
|
|
|
|
|
+ return x
|
|
|
|
|
+ case int64:
|
|
|
|
|
+ return int(x)
|
|
|
|
|
+ case string:
|
|
|
|
|
+ n, _ := strconv.Atoi(x)
|
|
|
|
|
+ return n
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func getString(m map[string]any, key, def string) string {
|
|
|
|
|
+ if v, ok := m[key]; ok {
|
|
|
|
|
+ if s, ok := v.(string); ok {
|
|
|
|
|
+ return s
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return def
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func splitComma(s string) []string {
|
|
|
|
|
+ if s == "" {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ parts := strings.Split(s, ",")
|
|
|
|
|
+ out := make([]string, 0, len(parts))
|
|
|
|
|
+ for _, p := range parts {
|
|
|
|
|
+ p = strings.TrimSpace(p)
|
|
|
|
|
+ if p != "" {
|
|
|
|
|
+ out = append(out, p)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func splitCommaOrDefault(s string, def []string) []string {
|
|
|
|
|
+ if s == "" {
|
|
|
|
|
+ return def
|
|
|
|
|
+ }
|
|
|
|
|
+ return splitComma(s)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func padBase64(s string) string {
|
|
|
|
|
+ for len(s)%4 != 0 {
|
|
|
|
|
+ s += "="
|
|
|
|
|
+ }
|
|
|
|
|
+ return s
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func base64DecodeFlexible(s string) (string, error) {
|
|
|
|
|
+ s = padBase64(s)
|
|
|
|
|
+ if b, err := base64.StdEncoding.DecodeString(s); err == nil {
|
|
|
|
|
+ return string(b), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
|
|
|
|
|
+ return string(b), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return "", fmt.Errorf("base64 decode failed")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
|
|
|
|
|
+var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
|
+
|
|
|
|
|
+func SlugRemark(remark string) string {
|
|
|
|
|
+ s := strings.ToLower(strings.TrimSpace(remark))
|
|
|
|
|
+ s = slugRe.ReplaceAllString(s, "-")
|
|
|
|
|
+ s = strings.Trim(s, "-")
|
|
|
|
|
+ if s == "" {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ // collapse runs of dashes
|
|
|
|
|
+ for strings.Contains(s, "--") {
|
|
|
|
|
+ s = strings.ReplaceAll(s, "--", "-")
|
|
|
|
|
+ }
|
|
|
|
|
+ return s
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// SuggestTag builds a tag from a prefix and a remark (or index fallback).
|
|
|
|
|
+// It is intended for initial assignment; stability is handled by the service layer.
|
|
|
|
|
+func SuggestTag(prefix, remark string, idx int) string {
|
|
|
|
|
+ base := SlugRemark(remark)
|
|
|
|
|
+ if base == "" {
|
|
|
|
|
+ base = fmt.Sprintf("%d", idx)
|
|
|
|
|
+ }
|
|
|
|
|
+ p := strings.TrimSuffix(prefix, "-")
|
|
|
|
|
+ if p != "" {
|
|
|
|
|
+ return p + "-" + base
|
|
|
|
|
+ }
|
|
|
|
|
+ return base
|
|
|
|
|
+}
|