package subscription import ( "context" "encoding/base64" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "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 } // Info is subscription usage metadata from the subscription-userinfo header // (upload/download/total in bytes, expire as unix seconds; 0 = unknown). type Info struct { Upload int64 `json:"upload,omitempty"` Download int64 `json:"download,omitempty"` Total int64 `json:"total,omitempty"` Expire int64 `json:"expire,omitempty"` } // Result is a parsed subscription: imported items plus per-line skip diagnostics. type Result struct { Items []Item Skipped int Warnings []string Info *Info } const maxWarnings = 8 var ( reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`) reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`) // reURILine matches a standalone "scheme://..." line — only such lines are // counted as skipped entries (YAML/HTML noise is ignored silently). reURILine = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://\S`) ) // userAgents to try in order. Remnawave and most panels return raw share links // for a generic UA, but return a Clash YAML config for Clash-like UAs — that // YAML loses vless/trojan entries for us, so try the plain UA first. var userAgents = []string{ "EvilFox/4.0 (+https://evilfox.win)", "ClashMeta/1.18.0", } // Fetch downloads a subscription body and parses proxy share links. // Parsing is tolerant: unsupported or broken entries are skipped (with // warnings), valid ones are imported. func Fetch(ctx context.Context, rawURL string) (*Result, error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return nil, fmt.Errorf("пустой URL подписки") } u, err := url.Parse(rawURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return nil, fmt.Errorf("нужен http(s) URL подписки") } var ( best *Result info *Info lastErr error ) for _, ua := range userAgents { body, header, err := download(ctx, rawURL, ua) if err != nil { lastErr = err continue } if info == nil { info = ParseUserInfoHeader(header.Get("Subscription-Userinfo")) } res, err := ParseBodyDetailed(body) if res != nil && (best == nil || len(res.Items) > 0 || res.Skipped > best.Skipped) { best = res } if err != nil { lastErr = err continue } if res != nil && len(res.Items) > 0 { break } } if best == nil || len(best.Items) == 0 { if lastErr != nil { return nil, lastErr } return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок") } best.Info = info return best, nil } func download(ctx context.Context, rawURL, userAgent string) (string, http.Header, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return "", nil, err } req.Header.Set("User-Agent", userAgent) 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 } return string(body), resp.Header, nil } // ParseUserInfoHeader parses "upload=..; download=..; total=..; expire=". // Returns nil when the header is absent or has no recognized fields. func ParseUserInfoHeader(v string) *Info { v = strings.TrimSpace(v) if v == "" { return nil } info := &Info{} found := false for _, part := range strings.Split(v, ";") { kv := strings.SplitN(part, "=", 2) if len(kv) != 2 { continue } n, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64) if err != nil { continue } switch strings.ToLower(strings.TrimSpace(kv[0])) { case "upload": info.Upload, found = n, true case "download": info.Download, found = n, true case "total": info.Total, found = n, true case "expire": info.Expire, found = n, true } } if !found { return nil } return info } // ParseBody accepts plain text lines, base64, or Clash-like blobs with share links. func ParseBody(body string) ([]Item, error) { res, err := ParseBodyDetailed(body) if err != nil { return nil, err } return res.Items, nil } // ParseBodyDetailed parses a subscription body and reports skipped entries. // The returned Result is non-nil even on error (zero valid entries) so callers // can surface the collected warnings. func ParseBodyDetailed(body string) (*Result, error) { body = strings.TrimSpace(body) if body == "" { return &Result{}, 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 best *Result for _, text := range candidates { res := parseText(text) if len(res.Items) > 0 { return res, nil } if best == nil || res.Skipped > best.Skipped { best = res } } if best == nil { best = &Result{} } if best.Skipped > 0 { return best, fmt.Errorf("нет поддерживаемых ссылок: %d записей пропущено (%s)", best.Skipped, strings.Join(best.Warnings, "; ")) } return best, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)") } func parseText(text string) *Result { res := &Result{} seen := map[string]int{} seenURI := map[string]struct{}{} // countSkips is true only for raw subscription lines; URIs extracted from // YAML/HTML/JSON noise are best-effort and never counted as "skipped". process := func(line string, countSkips bool) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { return } line = strings.Trim(line, `"'`) looksURI := reURILine.MatchString(line) skip := func(reason string) { if !countSkips || !looksURI { return } res.Skipped++ if len(res.Warnings) < maxWarnings { res.Warnings = append(res.Warnings, shortenLink(line)+" — "+reason) } } proto := config.DetectProtocol(line) if proto == "" { skip("неподдерживаемая схема") return } // Bare https:// without credentials (common noise in HTML/YAML, // or a subscription URL pasted among the links) is not a proxy link. if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") { skip("нет логина и пароля в ссылке") return } normalized, detected, remark, err := linknorm.Normalize(proto, line) if err != nil { skip(err.Error()) return } if _, dup := seenURI[normalized]; dup { return } 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 res.Items = append(res.Items, Item{Name: name, Protocol: detected, URI: normalized}) } for _, line := range splitLines(text) { process(line, true) } // Also extract share URIs embedded in YAML/HTML/JSON. for _, m := range reShareLine.FindAllString(text, -1) { process(strings.TrimRight(m, ".,;)]}\"'"), false) } for _, block := range extractClashHY2(text) { process(block, false) } return res } // shortenLink trims a link for warning messages, hiding query/credentials noise. func shortenLink(line string) string { if i := strings.IndexAny(line, "?#"); i >= 0 { line = line[:i] } const max = 48 runes := []rune(line) if len(runes) > max { return string(runes[:max]) + "…" } return line } 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):] at := strings.IndexByte(rest, '@') return at > 0 } } return false } func extractClashHY2(text string) []string { // Very small helper for Clash YAML hysteria2 blocks when share URI is absent. lower := strings.ToLower(text) if !strings.Contains(lower, "type:") || !strings.Contains(lower, "hysteria2") { return nil } var out []string // Split roughly by list items. chunks := splitClashItems(text) for _, chunk := range chunks { cl := strings.ToLower(chunk) if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") { continue } server := yamlField(chunk, "server") port := yamlField(chunk, "port") pass := yamlField(chunk, "password") if pass == "" { pass = yamlField(chunk, "auth") } if server == "" || port == "" || pass == "" { continue } name := yamlField(chunk, "name") sni := yamlField(chunk, "sni") obfs := yamlField(chunk, "obfs") obfsPass := yamlField(chunk, "obfs-password") if obfsPass == "" { obfsPass = yamlField(chunk, "obfs_password") } u := url.URL{ Scheme: "hysteria2", User: url.User(pass), Host: server + ":" + port, } q := url.Values{} if sni != "" { q.Set("sni", sni) } if obfs != "" { q.Set("obfs", obfs) } if obfsPass != "" { q.Set("obfs-password", obfsPass) } u.RawQuery = q.Encode() link := u.String() if name != "" { link += "#" + url.PathEscape(name) } out = append(out, link) } _ = reClashHY2 // kept for possible future tightening return out } // reClashItemStart matches a mapping list item ("- name: X", " - type: y"), // but not nested scalar list entries like "- h3" under alpn. var reClashItemStart = regexp.MustCompile(`^\s*-\s+[^\s:]+\s*:`) // splitClashItems splits a Clash YAML proxies list into per-proxy chunks, // tolerating any list-item indentation ("- name:" at column 0 or nested). func splitClashItems(text string) []string { lines := splitLines(text) var out []string var cur []string flush := func() { if len(cur) > 0 { out = append(out, strings.Join(cur, "\n")) cur = nil } } for _, line := range lines { if reClashItemStart.MatchString(line) { flush() } cur = append(cur, line) } flush() return out } func yamlField(block, key string) string { re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`) m := re.FindStringSubmatch(block) if len(m) < 2 { return "" } return strings.TrimSpace(m[1]) } 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 }