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:
Navis
2026-08-02 15:40:13 +03:00
co-authored by Cursor
parent e416327fbf
commit 50f7f71393
39 changed files with 3128 additions and 112 deletions
+23 -2
View File
@@ -476,8 +476,14 @@ var allowedLinkHosts = map[string]bool{
"t.me": true,
}
// allowedMailto are support addresses the UI may open via mailto:.
var allowedMailto = map[string]bool{
"animegold3@gmail.com": true,
"admin@evilfox.win": true,
}
// LinkAllowed validates that raw is an http(s) URL pointing at one of the
// allowed hosts; returns the normalized URL.
// allowed hosts, or a mailto: address from the allowlist; returns the normalized URL.
func LinkAllowed(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -487,8 +493,23 @@ func LinkAllowed(raw string) (string, error) {
if err != nil {
return "", fmt.Errorf("некорректная ссылка")
}
if strings.EqualFold(u.Scheme, "mailto") {
addr := strings.TrimSpace(u.Opaque)
if addr == "" {
addr = strings.TrimSpace(u.Path)
}
addr = strings.TrimPrefix(addr, "//")
if i := strings.IndexAny(addr, "?#"); i >= 0 {
addr = addr[:i]
}
addr = strings.ToLower(addr)
if !allowedMailto[addr] {
return "", fmt.Errorf("почта %s не разрешена", addr)
}
return "mailto:" + addr, nil
}
if u.Scheme != "https" && u.Scheme != "http" {
return "", fmt.Errorf("разрешены только http(s) ссылки")
return "", fmt.Errorf("разрешены только http(s) и mailto ссылки")
}
if !allowedLinkHosts[strings.ToLower(u.Hostname())] {
return "", fmt.Errorf("ссылка на %s не разрешена", u.Hostname())
+20 -13
View File
@@ -1379,6 +1379,11 @@
gap: 8px;
}
.key-box h3 { font-family: Outfit, sans-serif; font-size: .95rem; letter-spacing: -.02em; margin: 0; }
.key-box #subUrl {
width: 100%; min-height: 64px; max-height: 160px; resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .8rem; line-height: 1.35;
}
.key-hint { margin: 0; font-size: .76rem; color: var(--muted); line-height: 1.4; }
/* Статус конфигурации: 🟢 добавлена / 🔴 не добавлена (сам URL не показываем). */
.key-status { margin: 0; font-size: .8rem; font-weight: 700; }
@@ -1623,9 +1628,9 @@
<section class="key-box" aria-label="Добавить конфигурацию" data-i18n-aria="add_config_aria">
<h3 data-i18n="add_config_title">🔗 Добавить конфигурацию</h3>
<p class="key-status" id="subStatus">🔴 Конфигурация не добавлена</p>
<input id="subUrl" type="text" placeholder="https://" spellcheck="false" />
<textarea id="subUrl" rows="3" placeholder="https://… или awg:// / naive / .conf" spellcheck="false"></textarea>
<button class="action primary" id="subBtn" type="button" data-i18n="add_config_btn">Добавить конфигурацию</button>
<p class="key-hint" data-i18n="add_config_hint">Вставьте URL-ссылку для подключения</p>
<p class="key-hint" data-i18n="add_config_hint">http(s) URL подписки/файла — или вставьте awg://, AWG .conf, naive, hy2, vless…</p>
<button class="key-link" id="buyKeyLink" type="button" data-i18n="where_url">Где взять URL-ссылку?</button>
</section>
@@ -1910,7 +1915,7 @@
<h4>14. Изменения</h4>
<p>Мы можем обновлять эту политику. Актуальная версия доступна в приложении (Помощь) и на сайте. Продолжение использования после обновления означает ознакомление с новой редакцией.</p>
<h4>15. Контакты</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · поддержка: <code>https://t.me/evilfox100Bot</code></p>
<p>EvilFox · <code>https://evilfox.win/</code><br />Основная почта: <code>animegold3@gmail.com</code> · Резервная: <code>admin@evilfox.win</code> · Telegram: <code>https://t.me/evilfox100Bot</code></p>
</div>
<div class="legal-body" data-lang-block="en" hidden>
<p><strong>Publisher:</strong> EvilFox. <strong>Effective date:</strong> August 2, 2026.</p>
@@ -1949,7 +1954,7 @@
<h4>14. Changes</h4>
<p>We may update this policy. The current version is in the app (Help) and on the website. Continued use after an update means you have had the chance to review the new text.</p>
<h4>15. Contact</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · support: <code>https://t.me/evilfox100Bot</code></p>
<p>EvilFox · <code>https://evilfox.win/</code><br />Primary: <code>animegold3@gmail.com</code> · Backup: <code>admin@evilfox.win</code> · Telegram: <code>https://t.me/evilfox100Bot</code></p>
</div>
</details>
@@ -1976,7 +1981,7 @@
<h4>9. Применимое право</h4>
<p>Эти Условия толкуются по законам страны вашего проживания, если иное не требуется императивными нормами. Для потребителей в ЕС сохраняются обязательные права по праву страны проживания.</p>
<h4>10. Контакты</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · <code>https://t.me/evilfox100Bot</code></p>
<p>EvilFox · <code>https://evilfox.win/</code><br />Основная: <code>animegold3@gmail.com</code> · Резервная: <code>admin@evilfox.win</code> · Telegram: <code>https://t.me/evilfox100Bot</code></p>
</div>
<div class="legal-body" data-lang-block="en" hidden>
<p><strong>Publisher:</strong> EvilFox. <strong>Effective date:</strong> August 2, 2026.</p>
@@ -1999,7 +2004,7 @@
<h4>9. Governing law</h4>
<p>These Terms are interpreted under the laws of your country of residence unless mandatory rules require otherwise. EU consumers keep mandatory rights under the law of their country of residence.</p>
<h4>10. Contact</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · <code>https://t.me/evilfox100Bot</code></p>
<p>EvilFox · <code>https://evilfox.win/</code><br />Primary: <code>animegold3@gmail.com</code> · Backup: <code>admin@evilfox.win</code> · Telegram: <code>https://t.me/evilfox100Bot</code></p>
</div>
</details>
@@ -2142,7 +2147,7 @@
offer_text: "🎁 3 дня бесплатного доступа",
page_profiles: "Мои серверы", add_config_aria: "Добавить конфигурацию",
add_config_title: "🔗 Добавить конфигурацию", add_config_btn: "Добавить конфигурацию",
add_config_hint: "Вставьте URL-ссылку для подключения", where_url: "Где взять URL-ссылку?",
add_config_hint: "http(s) URL подписки/файла — или вставьте awg://, AWG .conf, naive, hy2, vless…", where_url: "Где взять URL-ссылку?",
servers_title: "🌍 Серверы", ping_btn: "Пинг", best_btn: "Выбрать лучший",
best_btn_title: "Выбрать сервер с наименьшим пингом",
account_info: "Информация об аккаунте", expires_label: "Действует до",
@@ -2233,8 +2238,8 @@
meta_opening_site: "Открываю evilfox.win…", meta_opened: "Открыто в браузере",
meta_open_fail: "Не удалось открыть ссылку",
meta_opening_named: "Открываю {name}…",
svc_site: "Сайт", svc_support: "Поддержка: Telegram",
meta_need_url: "Вставьте URL-ссылку для подключения",
svc_site: "Сайт", svc_mail: "Основная почта", svc_mail_backup: "Резервная почта", svc_support: "Поддержка: Telegram",
meta_need_url: "Вставьте URL или конфигурацию (AWG / naive / hy2…)",
meta_adding_config: "Добавление конфигурации…",
meta_picking_best: "подбираю оптимальный сервер…",
import_ok: "Конфигурация добавлена · серверов: {n}",
@@ -2266,7 +2271,7 @@
offer_text: "🎁 3 days free access",
page_profiles: "My servers", add_config_aria: "Add configuration",
add_config_title: "🔗 Add configuration", add_config_btn: "Add configuration",
add_config_hint: "Paste a connection URL", where_url: "Where do I get a URL?",
add_config_hint: "http(s) subscription/file URL — or paste awg://, AWG .conf, naive, hy2, vless…", where_url: "Where do I get a URL?",
servers_title: "🌍 Servers", ping_btn: "Ping", best_btn: "Pick best",
best_btn_title: "Select the server with the lowest ping",
account_info: "Account info", expires_label: "Valid until",
@@ -2357,8 +2362,8 @@
meta_opening_site: "Opening evilfox.win…", meta_opened: "Opened in browser",
meta_open_fail: "Could not open link",
meta_opening_named: "Opening {name}…",
svc_site: "Website", svc_support: "Support: Telegram",
meta_need_url: "Paste a connection URL",
svc_site: "Website", svc_mail: "Primary email", svc_mail_backup: "Backup email", svc_support: "Support: Telegram",
meta_need_url: "Paste a URL or config (AWG / naive / hy2…)",
meta_adding_config: "Adding configuration…",
meta_picking_best: "picking optimal server…",
import_ok: "Configuration added · servers: {n}",
@@ -2777,6 +2782,8 @@
// (internal/apphost/app.go → allowedLinkHosts).
const RECOMMENDED_SERVICES = [
{ icon: "🌐", nameKey: "svc_site", url: "https://evilfox.win/" },
{ icon: "✉️", nameKey: "svc_mail", url: "mailto:animegold3@gmail.com" },
{ icon: "📧", nameKey: "svc_mail_backup", url: "mailto:admin@evilfox.win" },
{ icon: "✈️", nameKey: "svc_support", url: "https://t.me/evilfox100Bot" }
];
const AUTO_BEST_KEY = "navis.autoBest";
@@ -3989,7 +3996,7 @@
} catch (e) { setMeta(String(e), "err"); }
}));
subUrl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
subBtn.click();
}
+21 -4
View File
@@ -632,6 +632,9 @@ type ImportResult struct {
func (m *Manager) ImportSubscription(rawURL string) (ImportResult, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return ImportResult{}, fmt.Errorf("пустой URL или конфигурация")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
@@ -650,11 +653,25 @@ func (m *Manager) ImportSubscription(rawURL string) (ImportResult, error) {
return m.applySubscription(ctx, res, used, &s)
}
res, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return ImportResult{}, err
// Remote subscription / hosted config file (http(s) without user:pass@).
if linknorm.LooksLikeSubscriptionURL(rawURL) {
res, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return ImportResult{}, err
}
return m.applySubscription(ctx, res, rawURL, nil)
}
return m.applySubscription(ctx, res, rawURL, nil)
// Direct paste into «Добавить конфигурацию»: awg://, AWG 2.0 .conf / JSON,
// naive share links, Clash YAML body, multi-line link lists — no HTTP fetch.
res, err := subscription.ParseBodyDetailed(rawURL)
if err != nil || res == nil || len(res.Items) == 0 {
if err != nil {
return ImportResult{}, fmt.Errorf("%w — либо вставьте http(s) URL конфигурации, либо ссылку/тело AWG, naive, hy2, vless…", err)
}
return ImportResult{}, fmt.Errorf("не удалось распознать конфигурацию — нужен http(s) URL либо ссылка/тело (AWG, naive, hy2, vless…)")
}
return m.applySubscription(ctx, res, "", nil)
}
// ImportRemnawave fetches configs using saved / provided panel settings.
+57
View File
@@ -87,3 +87,60 @@ func TestSaveProfileKeepsNaiveLink(t *testing.T) {
t.Fatalf("naive profile broken: %+v", p)
}
}
// «Добавить конфигурацию» must accept pasted AWG 2.0 .conf / naive links,
// not only http(s) subscription URLs (regression: "нужен http(s) URL").
func TestImportSubscriptionDirectAWGAndNaive(t *testing.T) {
m := newTestManager(t)
awgBody := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
Jc = 4
Jmin = 40
Jmax = 70
H1 = 1
H2 = 2
H3 = 3
H4 = 4
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0
`
r, err := m.ImportSubscription(awgBody)
if err != nil {
t.Fatalf("AWG paste: %v", err)
}
if r.Imported < 1 {
t.Fatalf("AWG imported=%d", r.Imported)
}
foundAWG := false
for _, p := range m.Profiles() {
if config.Protocol(p.Protocol) == config.ProtocolAWG && strings.Contains(p.Proxy, "203.0.113.10:51820") {
foundAWG = true
break
}
}
if !foundAWG {
t.Fatal("AWG profile missing")
}
r2, err := m.ImportSubscription("https://user:secret@naive.example.com:443#NV")
if err != nil {
t.Fatalf("naive paste: %v", err)
}
if r2.Imported < 1 {
t.Fatalf("naive imported=%d", r2.Imported)
}
foundNaive := false
for _, p := range m.Profiles() {
if config.Protocol(p.Protocol) == config.ProtocolNaive && strings.Contains(p.Proxy, "naive.example.com") {
foundNaive = true
break
}
}
if !foundNaive {
t.Fatal("naive profile missing")
}
}
+245 -56
View File
@@ -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 {
+159
View File
@@ -3,6 +3,7 @@ package subscription
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -193,3 +194,161 @@ proxies:
t.Fatalf("uri %s", items[0].URI)
}
}
func TestParseBodyAWGConf(t *testing.T) {
body := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
DNS = 1.1.1.1
Jc = 4
Jmin = 40
Jmax = 70
H1 = 1
H2 = 2
H3 = 3
H4 = 4
I1 = <r 128>
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("got %d items: %+v", len(items), items)
}
if items[0].Protocol != "awg" {
t.Fatalf("proto %s", items[0].Protocol)
}
if !strings.Contains(items[0].URI, "Jc = 4") || !strings.Contains(items[0].URI, "203.0.113.10:51820") {
t.Fatalf("uri missing AWG2 fields: %s", items[0].URI)
}
}
func TestParseBodyAmneziaJSON(t *testing.T) {
body := `{
"client_priv_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"server_pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=",
"client_ip": "10.8.0.2/32",
"hostName": "203.0.113.10",
"port": "51820",
"Jc": 4,
"Jmin": 10,
"Jmax": 50,
"H1": "1",
"H2": "2",
"H3": "3",
"H4": "4"
}`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Protocol != "awg" {
t.Fatalf("got %+v", items)
}
if !strings.Contains(items[0].URI, "203.0.113.10:51820") {
t.Fatalf("uri %s", items[0].URI)
}
}
func TestParseBodyClashWireguardAWG20(t *testing.T) {
body := `proxies:
- name: AWG-EU
type: wireguard
server: 203.0.113.20
port: 51820
ip: 10.8.0.2/32
private-key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
public-key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
allowed-ips: ['0.0.0.0/0']
dns: [1.1.1.1]
jc: 4
jmin: 40
jmax: 70
h1: 1
h2: 2
h3: 3
h4: 4
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("got %d: %+v", len(items), items)
}
if items[0].Protocol != "awg" {
t.Fatalf("proto %s", items[0].Protocol)
}
if items[0].Name != "AWG-EU" {
t.Fatalf("name %q", items[0].Name)
}
if !strings.Contains(items[0].URI, "Jc = 4") || !strings.Contains(items[0].URI, "203.0.113.20:51820") {
t.Fatalf("uri %s", items[0].URI)
}
}
func TestParseBodyClashNaive(t *testing.T) {
body := `proxies:
- name: Naive-HTTPS
type: naive
server: naive.example.com
port: 443
username: user
password: secret
- name: Naive-QUIC
type: naive
server: quic.example.com
port: 443
username: u2
password: p2
udp: true
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d: %+v", len(items), items)
}
if items[0].Protocol != "naive" || items[1].Protocol != "naive" {
t.Fatalf("protos %s %s", items[0].Protocol, items[1].Protocol)
}
if !strings.Contains(items[0].URI, "https://") || !strings.Contains(items[0].URI, "user:secret@naive.example.com") {
t.Fatalf("https uri %s", items[0].URI)
}
if !strings.Contains(items[1].URI, "quic://") || !strings.Contains(items[1].URI, "u2:p2@quic.example.com") {
t.Fatalf("quic uri %s", items[1].URI)
}
}
func TestFetchAWGConfOverHTTP(t *testing.T) {
conf := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
Jc = 3
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 198.51.100.7:51820
AllowedIPs = 0.0.0.0/0
`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, conf)
}))
defer srv.Close()
res, err := Fetch(context.Background(), srv.URL+"/awg.conf")
if err != nil {
t.Fatal(err)
}
if len(res.Items) != 1 || res.Items[0].Protocol != "awg" {
t.Fatalf("got %+v", res.Items)
}
}