286 lines
7.3 KiB
Go
286 lines
7.3 KiB
Go
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
|
|
}
|