Add site contacts/FAQ and accept AWG 2.0 / naive pastes in Add config.
ImportSubscription no longer requires only http(s): paste awg://, AWG .conf/JSON, or naive links; subscription parser also reads hosted AWG and Clash wireguard/naive. Site lists support emails and FAQ; Store listing texts/tools updated. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+245
-56
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/linknorm"
|
||||
"vpnclient/internal/protocols/awg"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
)
|
||||
@@ -46,7 +48,7 @@ type Result struct {
|
||||
const maxWarnings = 8
|
||||
|
||||
var (
|
||||
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
|
||||
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|vpn|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"']+)`)
|
||||
// reURILine matches a standalone "scheme://..." line — only such lines are
|
||||
// counted as skipped entries (YAML/HTML noise is ignored silently).
|
||||
@@ -222,35 +224,38 @@ func parseText(text string) *Result {
|
||||
|
||||
// countSkips is true only for raw subscription lines; URIs extracted from
|
||||
// YAML/HTML/JSON noise are best-effort and never counted as "skipped".
|
||||
process := func(line string, countSkips bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
process := func(raw string, countSkips bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "#") {
|
||||
return
|
||||
}
|
||||
line = strings.Trim(line, `"'`)
|
||||
looksURI := reURILine.MatchString(line)
|
||||
// Only strip surrounding quotes on single-line share links.
|
||||
if !strings.Contains(raw, "\n") {
|
||||
raw = strings.Trim(raw, `"'`)
|
||||
}
|
||||
looksURI := reURILine.MatchString(raw)
|
||||
skip := func(reason string) {
|
||||
if !countSkips || !looksURI {
|
||||
return
|
||||
}
|
||||
res.Skipped++
|
||||
if len(res.Warnings) < maxWarnings {
|
||||
res.Warnings = append(res.Warnings, shortenLink(line)+" — "+reason)
|
||||
res.Warnings = append(res.Warnings, shortenLink(raw)+" — "+reason)
|
||||
}
|
||||
}
|
||||
|
||||
proto := config.DetectProtocol(line)
|
||||
proto := config.DetectProtocol(raw)
|
||||
if proto == "" {
|
||||
skip("неподдерживаемая схема")
|
||||
return
|
||||
}
|
||||
// Bare https:// without credentials (common noise in HTML/YAML,
|
||||
// or a subscription URL pasted among the links) is not a proxy link.
|
||||
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
|
||||
if proto == config.ProtocolNaive && !naiveLinkHasAuth(raw) && !strings.HasPrefix(strings.ToLower(raw), "naive+") {
|
||||
skip("нет логина и пароля в ссылке")
|
||||
return
|
||||
}
|
||||
normalized, detected, remark, err := linknorm.Normalize(proto, line)
|
||||
normalized, detected, remark, err := linknorm.Normalize(proto, raw)
|
||||
if err != nil {
|
||||
skip(err.Error())
|
||||
return
|
||||
@@ -263,6 +268,12 @@ func parseText(text string) *Result {
|
||||
if name == "" {
|
||||
name = defaultName(detected, normalized)
|
||||
}
|
||||
// Clash wireguard INI may carry "# name: …" — prefer that display name.
|
||||
if detected == config.ProtocolAWG {
|
||||
if n := awgCommentName(raw); n != "" {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
base := name
|
||||
for n := 2; ; n++ {
|
||||
if _, ok := seen[name]; !ok {
|
||||
@@ -274,6 +285,15 @@ func parseText(text string) *Result {
|
||||
res.Items = append(res.Items, Item{Name: name, Protocol: detected, URI: normalized})
|
||||
}
|
||||
|
||||
// Whole-body AWG/WireGuard .conf, Amnezia JSON, vpn:// — hosted as a single
|
||||
// HTTP(S) config file (line-oriented parsing cannot reconstruct these).
|
||||
if awg.Detect(text) {
|
||||
process(text, false)
|
||||
if len(res.Items) > 0 && !looksLikeShareList(text) {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
for _, line := range splitLines(text) {
|
||||
process(line, true)
|
||||
}
|
||||
@@ -281,12 +301,39 @@ func parseText(text string) *Result {
|
||||
for _, m := range reShareLine.FindAllString(text, -1) {
|
||||
process(strings.TrimRight(m, ".,;)]}\"'"), false)
|
||||
}
|
||||
for _, block := range extractClashHY2(text) {
|
||||
for _, block := range extractClashProxies(text) {
|
||||
process(block, false)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// looksLikeShareList reports bodies that also contain line-oriented share links
|
||||
// (mixed subscription + AWG is rare, but do not early-return on those).
|
||||
func looksLikeShareList(text string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
for _, p := range []string{"vless://", "vmess://", "trojan://", "hysteria2://", "hy2://", "naive+", "ss://"} {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func awgCommentName(raw string) string {
|
||||
for _, line := range splitLines(raw) {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(line, "#"))
|
||||
lower := strings.ToLower(rest)
|
||||
if strings.HasPrefix(lower, "name:") {
|
||||
return strings.TrimSpace(rest[len("name:"):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shortenLink trims a link for warning messages, hiding query/credentials noise.
|
||||
func shortenLink(line string) string {
|
||||
if i := strings.IndexAny(line, "?#"); i >= 0 {
|
||||
@@ -312,62 +359,193 @@ func naiveLinkHasAuth(line string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func extractClashHY2(text string) []string {
|
||||
// Very small helper for Clash YAML hysteria2 blocks when share URI is absent.
|
||||
// extractClashProxies builds share links from Clash YAML proxy blocks when the
|
||||
// panel does not embed native share URIs (hysteria2, wireguard/AWG, naive).
|
||||
func extractClashProxies(text string) []string {
|
||||
lower := strings.ToLower(text)
|
||||
if !strings.Contains(lower, "type:") || !strings.Contains(lower, "hysteria2") {
|
||||
if !strings.Contains(lower, "type:") {
|
||||
return nil
|
||||
}
|
||||
need := strings.Contains(lower, "hysteria2") ||
|
||||
strings.Contains(lower, "wireguard") ||
|
||||
strings.Contains(lower, "type: naive") ||
|
||||
strings.Contains(lower, "type:naive")
|
||||
if !need {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
// Split roughly by list items.
|
||||
chunks := splitClashItems(text)
|
||||
for _, chunk := range chunks {
|
||||
for _, chunk := range splitClashItems(text) {
|
||||
cl := strings.ToLower(chunk)
|
||||
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
|
||||
continue
|
||||
typ := strings.ToLower(yamlField(chunk, "type"))
|
||||
switch {
|
||||
case typ == "hysteria2" || (typ == "" && strings.Contains(cl, "hysteria2")):
|
||||
if link := clashHY2Link(chunk); link != "" {
|
||||
out = append(out, link)
|
||||
}
|
||||
case typ == "wireguard":
|
||||
if link := clashWireguardLink(chunk); link != "" {
|
||||
out = append(out, link)
|
||||
}
|
||||
case typ == "naive":
|
||||
if link := clashNaiveLink(chunk); link != "" {
|
||||
out = append(out, link)
|
||||
}
|
||||
}
|
||||
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 clashHY2Link(chunk string) string {
|
||||
server := yamlField(chunk, "server")
|
||||
port := yamlField(chunk, "port")
|
||||
pass := yamlField(chunk, "password")
|
||||
if pass == "" {
|
||||
pass = yamlField(chunk, "auth")
|
||||
}
|
||||
if server == "" || port == "" || pass == "" {
|
||||
return ""
|
||||
}
|
||||
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)
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
func clashWireguardLink(chunk string) string {
|
||||
priv := firstYAML(chunk, "private-key", "private_key")
|
||||
pub := firstYAML(chunk, "public-key", "public_key")
|
||||
server := yamlField(chunk, "server")
|
||||
port := yamlField(chunk, "port")
|
||||
ip := firstYAML(chunk, "ip", "address")
|
||||
if priv == "" || pub == "" || server == "" || port == "" {
|
||||
return ""
|
||||
}
|
||||
name := yamlField(chunk, "name")
|
||||
psk := firstYAML(chunk, "pre-shared-key", "preshared-key", "presharedkey")
|
||||
allowed := stripYAMLList(firstYAML(chunk, "allowed-ips", "allowed_ips"))
|
||||
if allowed == "" {
|
||||
allowed = "0.0.0.0/0"
|
||||
}
|
||||
dns := stripYAMLList(yamlField(chunk, "dns"))
|
||||
mtu := yamlField(chunk, "mtu")
|
||||
keepalive := firstYAML(chunk, "persistent-keepalive", "keepalive")
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("[Interface]\n")
|
||||
b.WriteString("PrivateKey = " + priv + "\n")
|
||||
if ip != "" {
|
||||
b.WriteString("Address = " + ip + "\n")
|
||||
}
|
||||
if dns != "" {
|
||||
b.WriteString("DNS = " + dns + "\n")
|
||||
}
|
||||
if mtu != "" {
|
||||
b.WriteString("MTU = " + mtu + "\n")
|
||||
}
|
||||
// AmneziaWG / AWG 2.0 obfuscation (Clash Meta / Amnezia exports).
|
||||
for _, k := range []string{"jc", "jmin", "jmax", "s1", "s2", "s3", "s4", "h1", "h2", "h3", "h4", "i1", "i2", "i3", "i4", "i5"} {
|
||||
v := firstYAML(chunk, k, strings.ToUpper(k), strings.ToUpper(k[:1])+k[1:])
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
canon := strings.ToUpper(k[:1]) + k[1:]
|
||||
b.WriteString(canon + " = " + v + "\n")
|
||||
}
|
||||
b.WriteString("\n[Peer]\n")
|
||||
b.WriteString("PublicKey = " + pub + "\n")
|
||||
if psk != "" {
|
||||
b.WriteString("PresharedKey = " + psk + "\n")
|
||||
}
|
||||
b.WriteString("Endpoint = " + server + ":" + port + "\n")
|
||||
b.WriteString("AllowedIPs = " + allowed + "\n")
|
||||
if keepalive != "" {
|
||||
b.WriteString("PersistentKeepalive = " + keepalive + "\n")
|
||||
}
|
||||
if name != "" {
|
||||
b.WriteString("# name: " + name + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func clashNaiveLink(chunk string) string {
|
||||
server := yamlField(chunk, "server")
|
||||
port := yamlField(chunk, "port")
|
||||
user := firstYAML(chunk, "username", "user")
|
||||
pass := yamlField(chunk, "password")
|
||||
if server == "" || user == "" || pass == "" {
|
||||
return ""
|
||||
}
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
name := yamlField(chunk, "name")
|
||||
udp := strings.ToLower(yamlField(chunk, "udp"))
|
||||
scheme := "https"
|
||||
if udp == "true" || udp == "yes" || strings.EqualFold(yamlField(chunk, "protocol"), "quic") {
|
||||
scheme = "quic"
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: scheme,
|
||||
User: url.UserPassword(user, pass),
|
||||
Host: server + ":" + port,
|
||||
}
|
||||
link := u.String()
|
||||
if name != "" {
|
||||
link += "#" + url.PathEscape(name)
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
func firstYAML(block string, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := yamlField(block, k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripYAMLList(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "[")
|
||||
v = strings.TrimSuffix(v, "]")
|
||||
parts := strings.Split(v, ",")
|
||||
var clean []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.Trim(p, `"'`)
|
||||
if p != "" {
|
||||
clean = append(clean, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(clean, ", ")
|
||||
}
|
||||
|
||||
// reClashItemStart matches a mapping list item ("- name: X", " - type: y"),
|
||||
// but not nested scalar list entries like "- h3" under alpn.
|
||||
var reClashItemStart = regexp.MustCompile(`^\s*-\s+[^\s:]+\s*:`)
|
||||
@@ -442,6 +620,17 @@ func defaultName(proto config.Protocol, uri string) string {
|
||||
if h, _, err := hysteria2.HostPort(uri); err == nil {
|
||||
host = h
|
||||
}
|
||||
case config.ProtocolAWG:
|
||||
if c, err := awg.Parse(uri); err == nil {
|
||||
if c.Name != "" {
|
||||
return c.Name
|
||||
}
|
||||
if h, _, e := net.SplitHostPort(c.Endpoint); e == nil {
|
||||
host = h
|
||||
} else {
|
||||
host = c.Endpoint
|
||||
}
|
||||
}
|
||||
default:
|
||||
if u, _, err := naive.ParseShareLink(uri); err == nil {
|
||||
if parsed, err := url.Parse(u); err == nil {
|
||||
|
||||
Reference in New Issue
Block a user