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>
355 lines
9.3 KiB
Go
355 lines
9.3 KiB
Go
package subscription
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"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))
|
|
}
|
|
}
|
|
|
|
func TestParseBodyBase64(t *testing.T) {
|
|
plain := "hysteria2://secret@host.example:443/?sni=host.example#HY\nhttps://user:pass@naive.example.com#NV\n"
|
|
body := base64.StdEncoding.EncodeToString([]byte(plain))
|
|
items, err := ParseBody(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Fatalf("got %d items: %+v", len(items), items)
|
|
}
|
|
}
|
|
|
|
// TestParseBodyTolerant: one broken entry must never abort the import —
|
|
// valid links are kept, unsupported/broken lines are skipped with warnings.
|
|
func TestParseBodyTolerant(t *testing.T) {
|
|
body := `vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ
|
|
|
|
hysteria2://secret@de.example.com:443/?sni=de.example.com#DE-HY2
|
|
https://api.example.com/ZKszcVcC3xbWb8qj
|
|
foobar://what@is.this:1
|
|
# comment line
|
|
`
|
|
res, err := ParseBodyDetailed(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Items) != 2 {
|
|
t.Fatalf("imported %d items, want 2: %+v", len(res.Items), res.Items)
|
|
}
|
|
if res.Items[0].Protocol != "vless" || res.Items[1].Protocol != "hysteria2" {
|
|
t.Fatalf("protocols: %s, %s", res.Items[0].Protocol, res.Items[1].Protocol)
|
|
}
|
|
// https:// without userinfo + unknown scheme → skipped; empty/comment lines are not counted.
|
|
if res.Skipped != 2 {
|
|
t.Fatalf("skipped %d, want 2 (%v)", res.Skipped, res.Warnings)
|
|
}
|
|
if len(res.Warnings) != 2 {
|
|
t.Fatalf("warnings: %v", res.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestParseBodyZeroValidListsReasons(t *testing.T) {
|
|
body := "https://api.example.com/sub-token-line\nfoobar://x@y:1\n"
|
|
res, err := ParseBodyDetailed(body)
|
|
if err == nil {
|
|
t.Fatal("want error for zero valid entries")
|
|
}
|
|
if res.Skipped != 2 {
|
|
t.Fatalf("skipped %d, want 2", res.Skipped)
|
|
}
|
|
if !strings.Contains(err.Error(), "2 записей пропущено") {
|
|
t.Fatalf("error should list skip count: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseUserInfoHeader(t *testing.T) {
|
|
info := ParseUserInfoHeader("upload=0; download=5679561725; total=53687091200000; expire=1799366585")
|
|
if info == nil {
|
|
t.Fatal("nil info")
|
|
}
|
|
if info.Download != 5679561725 || info.Total != 53687091200000 || info.Expire != 1799366585 || info.Upload != 0 {
|
|
t.Fatalf("bad parse: %+v", info)
|
|
}
|
|
if ParseUserInfoHeader("") != nil {
|
|
t.Fatal("empty header must give nil")
|
|
}
|
|
if ParseUserInfoHeader("garbage") != nil {
|
|
t.Fatal("garbage header must give nil")
|
|
}
|
|
}
|
|
|
|
// Remnawave returns Clash YAML for Clash-like UAs and raw base64 links otherwise.
|
|
// Fetch must obtain the full server list (plain UA) and the userinfo header.
|
|
func TestFetchPrefersPlainUA(t *testing.T) {
|
|
links := "vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ\n" +
|
|
"trojan://pass@no.example.com:20000?security=tls&type=ws#NO\n" +
|
|
"hysteria2://secret@de.example.com:443/?sni=de.example.com#DE\n" +
|
|
"ss://YWVzLTI1Ni1nY206cGFzcw@ss.example.com:8388#SS\n"
|
|
clashYAML := "proxies:\n - name: only-hy2\n type: hysteria2\n server: de.example.com\n port: 443\n password: secret\n"
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Subscription-Userinfo", "upload=1; download=2; total=100; expire=1799366585")
|
|
if strings.Contains(strings.ToLower(r.Header.Get("User-Agent")), "clash") {
|
|
_, _ = w.Write([]byte(clashYAML))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString([]byte(links))))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res, err := Fetch(context.Background(), srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Items) != 3 {
|
|
t.Fatalf("imported %d, want 3 (vless+trojan+hy2): %+v", len(res.Items), res.Items)
|
|
}
|
|
if res.Skipped != 1 {
|
|
t.Fatalf("skipped %d, want 1 (ss://)", res.Skipped)
|
|
}
|
|
if res.Info == nil || res.Info.Expire != 1799366585 || res.Info.Total != 100 {
|
|
t.Fatalf("info: %+v", res.Info)
|
|
}
|
|
}
|
|
|
|
// Indented Clash proxies with nested lists (alpn) must split into separate blocks.
|
|
func TestParseBodyClashYAMLIndented(t *testing.T) {
|
|
body := `proxies:
|
|
- name: DE HY2
|
|
type: hysteria2
|
|
server: de.example.com
|
|
port: 443
|
|
udp: true
|
|
password: secret1
|
|
sni: de.example.com
|
|
alpn:
|
|
- h3
|
|
- name: NO HY2
|
|
type: hysteria2
|
|
server: no.example.com
|
|
port: 9000
|
|
password: secret2
|
|
alpn:
|
|
- h3
|
|
`
|
|
items, err := ParseBody(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Fatalf("got %d items, want 2: %+v", len(items), items)
|
|
}
|
|
for i, host := range []string{"de.example.com:443", "no.example.com:9000"} {
|
|
if !strings.Contains(items[i].URI, host) {
|
|
t.Fatalf("item %d uri %s missing %s", i, items[i].URI, host)
|
|
}
|
|
}
|
|
for i, pass := range []string{"secret1", "secret2"} {
|
|
if !strings.Contains(items[i].URI, pass) {
|
|
t.Fatalf("item %d uri %s missing password %s", i, items[i].URI, pass)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseBodyClashYAML(t *testing.T) {
|
|
body := `
|
|
proxies:
|
|
- name: "DE HY2"
|
|
type: hysteria2
|
|
server: de.example.com
|
|
port: 443
|
|
password: secret
|
|
sni: de.example.com
|
|
obfs: salamander
|
|
obfs-password: obfspass
|
|
`
|
|
items, err := ParseBody(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("got %d", len(items))
|
|
}
|
|
if items[0].Protocol != "hysteria2" {
|
|
t.Fatalf("proto %s", items[0].Protocol)
|
|
}
|
|
if !strings.Contains(items[0].URI, "de.example.com:443") {
|
|
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)
|
|
}
|
|
}
|