Add SwiftUI + Network Extension (LibXray/hev), parse Clash Meta YAML from subscription URLs into vless/trojan/hy2 share links, keep the selected server on connect, and ship Navis-3.8.2.13.ipa with versioned changelog. Co-authored-by: Cursor <cursoragent@cursor.com>
582 lines
15 KiB
Go
582 lines
15 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"'<>]+)`)
|
|
)
|
|
|
|
// 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
|
|
}
|