Release 1.3.0: Hysteria2 BBR/Salamander/masquerade options and subscription URL import.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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.3.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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package subscription
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBodyPlain(t *testing.T) {
|
||||
body := `# comment
|
||||
hysteria2://pass@ex.com:443/?sni=ex.com&obfs=salamander&obfs-password=x#EU
|
||||
naive+https://u:p@n.example.com#naive1
|
||||
`
|
||||
items, err := ParseBody(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d items", len(items))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user