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"'<>]+)`) reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\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 != "http" && u.Scheme != "https") { return nil, fmt.Errorf("нужен http(s) 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("в подписке нет поддерживаемых ссылок (naive / hysteria2)") } 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 extractClashHY2(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). 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("в подписке нет поддерживаемых ссылок (naive / hysteria2)") } 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):] 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 := strings.Split(text, "\n- ") 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 } func yamlField(block, key string) string { re := regexp.MustCompile(`(?im)^\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 }