163 lines
3.8 KiB
Go
163 lines
3.8 KiB
Go
package subscription
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"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
|
|
}
|
|
|
|
// 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", "Navis/1.4.0")
|
|
client := &http.Client{Timeout: 45 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
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 ParseBody(string(body))
|
|
}
|
|
|
|
// ParseBody accepts plain text lines or base64 (Clash/V2Ray style subscription).
|
|
func ParseBody(body string) ([]Item, error) {
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
return nil, fmt.Errorf("пустая подписка")
|
|
}
|
|
text := body
|
|
if !looksLikeLinks(body) {
|
|
if decoded, err := decodeMaybeBase64(body); err == nil && looksLikeLinks(decoded) {
|
|
text = decoded
|
|
}
|
|
}
|
|
lines := splitLines(text)
|
|
out := make([]Item, 0, len(lines))
|
|
seen := map[string]int{}
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
// Some panels wrap links in quotes
|
|
line = strings.Trim(line, `"'`)
|
|
proto := config.DetectProtocol(line)
|
|
if proto == "" {
|
|
continue
|
|
}
|
|
normalized, detected, remark, err := linknorm.Normalize(proto, line)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
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 looksLikeLinks(s string) bool {
|
|
lower := strings.ToLower(s)
|
|
return strings.Contains(lower, "hysteria2://") ||
|
|
strings.Contains(lower, "hy2://") ||
|
|
strings.Contains(lower, "naive+") ||
|
|
strings.Contains(lower, "https://") ||
|
|
strings.Contains(lower, "quic://")
|
|
}
|
|
|
|
func decodeMaybeBase64(s string) (string, error) {
|
|
s = strings.Map(func(r rune) rune {
|
|
if unicode.IsSpace(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
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 {
|
|
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
|
|
}
|