package subscription import ( "context" "encoding/base64" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" "unicode" "vpnclient/internal/config" "vpnclient/internal/linknorm" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" ) // Item is one imported share link. type Item struct { Name string Protocol config.Protocol URI string } var ( reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`) ) // Fetch downloads a subscription body and parses proxy share links. func Fetch(ctx context.Context, rawURL string) ([]Item, error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return nil, fmt.Errorf("пустой URL подписки") } u, err := url.Parse(rawURL) if err != nil || u.Scheme != "https" { return nil, fmt.Errorf("нужен https URL подписки") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "ClashMeta/1.18.0") req.Header.Set("Accept", "*/*") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("не удалось скачать подписку: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return nil, err } items, err := ParseBody(string(body)) if err != nil { return nil, err } return items, nil } // ParseBody accepts plain text lines, base64, or Clash-like blobs with share links. func ParseBody(body string) ([]Item, error) { body = strings.TrimSpace(body) if body == "" { return nil, fmt.Errorf("пустая подписка") } candidates := []string{body} if decoded, err := decodeMaybeBase64(body); err == nil && decoded != "" && decoded != body { candidates = append(candidates, decoded) // some panels double-encode if decoded2, err2 := decodeMaybeBase64(decoded); err2 == nil && decoded2 != "" && decoded2 != decoded { candidates = append(candidates, decoded2) } } var lastErr error for _, text := range candidates { items, err := parseText(text) if err == nil && len(items) > 0 { return items, nil } if err != nil { lastErr = err } } if lastErr != nil { return nil, lastErr } return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок") } func parseText(text string) ([]Item, error) { lines := splitLines(text) // Also extract share URIs embedded in YAML/HTML/JSON. extracted := reShareLine.FindAllString(text, -1) for _, m := range extracted { lines = append(lines, strings.TrimRight(m, ".,;)]}\"'")) } for _, block := range extractClashProxies(text) { lines = append(lines, block) } out := make([]Item, 0, len(lines)) seen := map[string]int{} seenURI := map[string]struct{}{} for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } line = strings.Trim(line, `"'`) proto := config.DetectProtocol(line) if proto == "" { continue } // Skip bare https:// without credentials (common noise in HTML/YAML). // Auth must be in the URI authority (before '/'), not path like Qure@master. if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") { continue } normalized, detected, remark, err := linknorm.Normalize(proto, line) if err != nil { continue } if _, dup := seenURI[normalized]; dup { continue } seenURI[normalized] = struct{}{} name := remark if name == "" { name = defaultName(detected, normalized) } base := name for n := 2; ; n++ { if _, ok := seen[name]; !ok { break } name = fmt.Sprintf("%s-%d", base, n) } seen[name] = 1 out = append(out, Item{Name: name, Protocol: detected, URI: normalized}) } if len(out) == 0 { return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок") } return out, nil } func naiveLinkHasAuth(line string) bool { lower := strings.ToLower(strings.TrimSpace(line)) for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} { if strings.HasPrefix(lower, p) { rest := line[len(p):] authEnd := strings.IndexByte(rest, '/') authority := rest if authEnd >= 0 { authority = rest[:authEnd] } at := strings.IndexByte(authority, '@') return at > 0 } } return false } var reClashProxyName = regexp.MustCompile(`(?m)^[ \t]*-\s+name\s*:\s*`) // extractClashProxies converts Clash Meta YAML proxy blocks into share URIs // (vless / trojan / hysteria2) when the subscription has no share-link lines. func extractClashProxies(text string) []string { lower := strings.ToLower(text) if !strings.Contains(lower, "type:") { return nil } if !strings.Contains(lower, "vless") && !strings.Contains(lower, "hysteria2") && !strings.Contains(lower, "trojan") { return nil } scope := text if sec := yamlTopSection(text, "proxies"); sec != "" { scope = sec } var out []string for _, chunk := range splitClashProxyBlocks(scope) { if link := clashBlockToShareURI(chunk); link != "" { out = append(out, link) } } return out } func splitClashProxyBlocks(text string) []string { idxs := reClashProxyName.FindAllStringIndex(text, -1) if len(idxs) == 0 { // Legacy fallback used by older Clash dumps. parts := strings.Split(text, "\n- ") out := make([]string, 0, len(parts)) for i, p := range parts { if i == 0 { continue } out = append(out, "name: "+p) } return out } out := make([]string, 0, len(idxs)) for i, idx := range idxs { start := idx[0] end := len(text) if i+1 < len(idxs) { end = idxs[i+1][0] } out = append(out, text[start:end]) } return out } func clashBlockToShareURI(block string) string { switch strings.ToLower(yamlField(block, "type")) { case "hysteria2", "hy2": return clashHY2URI(block) case "vless": return clashVLESSURI(block) case "trojan": return clashTrojanURI(block) default: return "" } } func clashHY2URI(block string) string { server := yamlField(block, "server") port := yamlField(block, "port") pass := yamlField(block, "password") if pass == "" { pass = yamlField(block, "auth") } if server == "" || port == "" || pass == "" { return "" } u := url.URL{Scheme: "hysteria2", User: url.User(pass), Host: server + ":" + port} q := url.Values{} if sni := yamlField(block, "sni"); sni != "" { q.Set("sni", sni) } if obfs := yamlField(block, "obfs"); obfs != "" { q.Set("obfs", obfs) } obfsPass := yamlField(block, "obfs-password") if obfsPass == "" { obfsPass = yamlField(block, "obfs_password") } if obfsPass != "" { q.Set("obfs-password", obfsPass) } u.RawQuery = q.Encode() return withClashName(u.String(), yamlField(block, "name")) } func clashVLESSURI(block string) string { server := yamlField(block, "server") port := yamlField(block, "port") uuid := yamlField(block, "uuid") if server == "" || port == "" || uuid == "" { return "" } network := strings.ToLower(yamlField(block, "network")) if network == "" { network = "tcp" } if network == "websocket" { network = "ws" } reality := yamlSection(block, "reality-opts") hasReality := reality != "" || strings.Contains(strings.ToLower(block), "reality-opts") tlsFlag := strings.ToLower(yamlField(block, "tls")) security := "none" switch { case hasReality: security = "reality" case tlsFlag == "true" || tlsFlag == "1": security = "tls" case tlsFlag == "false" || tlsFlag == "0": security = "none" default: if s := strings.ToLower(yamlField(block, "security")); s != "" { security = s } } q := url.Values{} q.Set("type", network) q.Set("security", security) if sni := firstNonEmpty(yamlField(block, "servername"), yamlField(block, "sni")); sni != "" { q.Set("sni", sni) } if fp := yamlField(block, "client-fingerprint"); fp != "" { q.Set("fp", fp) } if flow := yamlField(block, "flow"); flow != "" { q.Set("flow", flow) } if alpn := yamlStringOrList(block, "alpn"); alpn != "" { q.Set("alpn", alpn) } if pe := yamlField(block, "packet-encoding"); pe != "" { q.Set("packetEncoding", pe) } switch network { case "ws": ws := yamlSection(block, "ws-opts") src := ws if src == "" { src = block } if path := yamlField(src, "path"); path != "" { q.Set("path", path) } if host := clashWSHost(ws); host != "" { q.Set("host", host) } case "grpc", "gun": grpc := yamlSection(block, "grpc-opts") if sn := firstNonEmpty(yamlField(grpc, "grpc-service-name"), yamlField(block, "servername")); sn != "" { q.Set("serviceName", sn) } case "xhttp", "splithttp": xh := yamlSection(block, "xhttp-opts") src := xh if src == "" { src = block } if path := yamlField(src, "path"); path != "" { q.Set("path", path) } if host := yamlField(xh, "host"); host != "" { q.Set("host", host) } if mode := yamlField(xh, "mode"); mode != "" { q.Set("mode", mode) } } if security == "reality" { if pbk := firstNonEmpty(yamlField(reality, "public-key"), yamlField(block, "public-key")); pbk != "" { q.Set("pbk", pbk) } sid := firstNonEmpty(yamlField(reality, "short-id"), yamlField(block, "short-id")) q.Set("sid", sid) if spx := yamlField(reality, "spider-x"); spx != "" { q.Set("spx", spx) } } if skip := strings.ToLower(yamlField(block, "skip-cert-verify")); skip == "true" || skip == "1" { q.Set("allowInsecure", "1") } u := url.URL{Scheme: "vless", User: url.User(uuid), Host: server + ":" + port, RawQuery: q.Encode()} return withClashName(u.String(), yamlField(block, "name")) } func clashTrojanURI(block string) string { server := yamlField(block, "server") port := yamlField(block, "port") pass := yamlField(block, "password") if server == "" || port == "" || pass == "" { return "" } network := strings.ToLower(yamlField(block, "network")) if network == "" { network = "tcp" } if network == "websocket" { network = "ws" } security := "tls" if tlsFlag := strings.ToLower(yamlField(block, "tls")); tlsFlag == "false" || tlsFlag == "0" { security = "none" } q := url.Values{} q.Set("type", network) q.Set("security", security) if sni := firstNonEmpty(yamlField(block, "sni"), yamlField(block, "servername")); sni != "" { q.Set("sni", sni) } if fp := yamlField(block, "client-fingerprint"); fp != "" { q.Set("fp", fp) } if alpn := yamlStringOrList(block, "alpn"); alpn != "" { q.Set("alpn", alpn) } if network == "ws" { ws := yamlSection(block, "ws-opts") src := ws if src == "" { src = block } if path := yamlField(src, "path"); path != "" { q.Set("path", path) } if host := clashWSHost(ws); host != "" { q.Set("host", host) } } if skip := strings.ToLower(yamlField(block, "skip-cert-verify")); skip == "true" || skip == "1" { q.Set("allowInsecure", "1") } u := url.URL{Scheme: "trojan", User: url.User(pass), Host: server + ":" + port, RawQuery: q.Encode()} return withClashName(u.String(), yamlField(block, "name")) } func clashWSHost(wsOpts string) string { headers := yamlSection(wsOpts, "headers") return firstNonEmpty(yamlField(headers, "Host"), yamlField(headers, "host"), yamlField(wsOpts, "host")) } func withClashName(link, name string) string { if name == "" { return link } return link + "#" + url.PathEscape(name) } func yamlTopSection(text, key string) string { re := regexp.MustCompile(`(?im)^` + regexp.QuoteMeta(key) + `\s*:\s*(?:#.*)?$`) loc := re.FindStringIndex(text) if loc == nil { return "" } rest := text[loc[1]:] end := regexp.MustCompile(`(?m)^[A-Za-z0-9_-]+\s*:`).FindStringIndex(rest) if end == nil { return rest } return rest[:end[0]] } func yamlSection(block, key string) string { lines := strings.Split(block, "\n") keyLower := strings.ToLower(key) + ":" capturing := false parentIndent := 0 var out []string for _, line := range lines { indent := 0 for _, r := range line { if r == ' ' || r == '\t' { indent++ } else { break } } trimmed := strings.TrimSpace(line) if !capturing { if strings.HasPrefix(strings.ToLower(trimmed), keyLower) { capturing = true parentIndent = indent } continue } if trimmed != "" && indent <= parentIndent { break } out = append(out, line) } return strings.Join(out, "\n") } func yamlField(block, key string) string { // Allow list marker: " - name: …" // Use [ \t]* after ':' so we do NOT consume newlines (or else "alpn:\n - h2" becomes "- h2"). re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `:[ \t]*["']?([^"'#\n\r]+)["']?`) m := re.FindStringSubmatch(block) if len(m) < 2 { return "" } return strings.TrimSpace(m[1]) } func yamlStringOrList(block, key string) string { single := strings.TrimSpace(yamlField(block, key)) // Scalar must not look like a YAML list item pulled from the next line. if single != "" && single != "|" && single != ">" && !strings.HasPrefix(single, "-") { if strings.HasPrefix(single, "[") { inner := strings.Trim(single, "[]") parts := strings.Split(inner, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.Trim(strings.TrimSpace(p), `"'`) if p != "" { out = append(out, p) } } return strings.Join(out, ",") } return single } section := yamlSection(block, key) var items []string for _, line := range strings.Split(section, "\n") { t := strings.TrimSpace(line) if !strings.HasPrefix(t, "-") { continue } v := strings.TrimSpace(strings.TrimPrefix(t, "-")) v = strings.Trim(v, `"'`) if i := strings.IndexByte(v, '#'); i >= 0 { v = strings.TrimSpace(v[:i]) } if v != "" { items = append(items, v) } } return strings.Join(items, ",") } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } func decodeMaybeBase64(s string) (string, error) { s = strings.Map(func(r rune) rune { if unicode.IsSpace(r) { return -1 } return r }, s) if s == "" { return "", fmt.Errorf("empty") } raw, err := base64.StdEncoding.DecodeString(s) if err != nil { raw, err = base64.RawStdEncoding.DecodeString(s) } if err != nil { raw, err = base64.URLEncoding.DecodeString(s) } if err != nil { raw, err = base64.RawURLEncoding.DecodeString(s) } if err != nil { return "", err } return string(raw), nil } func splitLines(s string) []string { s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") return strings.Split(s, "\n") } func defaultName(proto config.Protocol, uri string) string { host := "" switch proto { case config.ProtocolHysteria2: if h, _, err := hysteria2.HostPort(uri); err == nil { host = h } default: if u, _, err := naive.ParseShareLink(uri); err == nil { if parsed, err := url.Parse(u); err == nil { host = parsed.Hostname() } } } if host == "" { host = string(proto) } return string(proto) + "-" + host }