Release 3.9.0+2: tolerant subscription import, full Remnawave server list, subscription info card (expiry / traffic / devices).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 17:01:37 +03:00
co-authored by Cursor
parent 015ded9bd5
commit e34312ef9c
18 changed files with 818 additions and 174 deletions
+31 -27
View File
@@ -87,7 +87,7 @@ func ParseInput(raw string) (base, shortUUID string, ok bool) {
// Fetch downloads configs via Remnawave public sub URL and, if a token is set,
// authenticated subscription/user endpoints. Parsed items reuse subscription.ParseBody.
func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error) {
func Fetch(ctx context.Context, s Settings) (*subscription.Result, string, error) {
s.BaseURL = NormalizeBase(s.BaseURL)
s.ShortUUID = strings.TrimSpace(s.ShortUUID)
s.Token = strings.TrimSpace(s.Token)
@@ -105,19 +105,19 @@ func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error)
var lastErr error
// 1) Public subscription (usual end-user path — no token).
if items, err := fetchAndParse(ctx, client, s, pub, false); err == nil && len(items) > 0 {
return items, pub, nil
if res, err := fetchAndParse(ctx, client, s, pub, false); err == nil && res != nil && len(res.Items) > 0 {
return res, pub, nil
} else if err != nil {
lastErr = err
}
// 2) With API token: resolve subscriptionUrl / raw payload.
if s.Token != "" {
if items, used, err := fetchWithToken(ctx, client, s); err == nil && len(items) > 0 {
if res, used, err := fetchWithToken(ctx, client, s); err == nil && res != nil && len(res.Items) > 0 {
if used == "" {
used = pub
}
return items, used, nil
return res, used, nil
} else if err != nil {
lastErr = err
}
@@ -129,7 +129,7 @@ func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error)
return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа подписки)")
}
func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]subscription.Item, string, error) {
func fetchWithToken(ctx context.Context, client *http.Client, s Settings) (*subscription.Result, string, error) {
base := s.BaseURL
short := s.ShortUUID
@@ -142,18 +142,18 @@ func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]sub
}
var lastErr error
for _, endpoint := range candidates {
body, err := doGET(ctx, client, s, endpoint, true)
body, _, err := doGET(ctx, client, s, endpoint, true)
if err != nil {
lastErr = err
continue
}
if items, err := parseRemnawaveBody(body); err == nil && len(items) > 0 {
return items, endpoint, nil
if res, err := parseRemnawaveBody(body); err == nil && res != nil && len(res.Items) > 0 {
return res, endpoint, nil
}
if subURL := extractSubscriptionURL(body); subURL != "" {
items, err := subscription.Fetch(ctx, subURL)
if err == nil && len(items) > 0 {
return items, subURL, nil
res, err := subscription.Fetch(ctx, subURL)
if err == nil && res != nil && len(res.Items) > 0 {
return res, subURL, nil
}
if err != nil {
lastErr = err
@@ -166,18 +166,22 @@ func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]sub
return nil, "", fmt.Errorf("Remnawave API: не удалось получить конфиги")
}
func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) ([]subscription.Item, error) {
body, err := doGET(ctx, client, s, endpoint, auth)
func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (*subscription.Result, error) {
body, header, err := doGET(ctx, client, s, endpoint, auth)
if err != nil {
return nil, err
}
return parseRemnawaveBody(body)
res, err := parseRemnawaveBody(body)
if res != nil && res.Info == nil {
res.Info = subscription.ParseUserInfoHeader(header.Get("Subscription-Userinfo"))
}
return res, err
}
func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, error) {
func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
return "", nil, err
}
req.Header.Set("User-Agent", "Navis/3.9 (Remnawave)")
req.Header.Set("Accept", "*/*")
@@ -196,21 +200,21 @@ func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("Remnawave запрос: %w", err)
return "", nil, fmt.Errorf("Remnawave запрос: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return "", err
return "", nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := strings.TrimSpace(string(raw))
if len(msg) > 180 {
msg = msg[:180] + "…"
}
return "", fmt.Errorf("Remnawave HTTP %d: %s", resp.StatusCode, msg)
return "", nil, fmt.Errorf("Remnawave HTTP %d: %s", resp.StatusCode, msg)
}
return string(raw), nil
return string(raw), resp.Header, nil
}
func shouldSendProxyHeaders(u *url.URL) bool {
@@ -228,24 +232,24 @@ func shouldSendProxyHeaders(u *url.URL) bool {
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
}
func parseRemnawaveBody(body string) ([]subscription.Item, error) {
func parseRemnawaveBody(body string) (*subscription.Result, error) {
body = strings.TrimSpace(body)
if body == "" {
return nil, fmt.Errorf("пустой ответ Remnawave")
}
// Try JSON envelope / raw DTO first, then classic subscription parse.
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
if items, err := subscription.ParseBody(flattenJSONLinks(body)); err == nil && len(items) > 0 {
return items, nil
if res, err := subscription.ParseBodyDetailed(flattenJSONLinks(body)); err == nil && len(res.Items) > 0 {
return res, nil
}
// Also try parsing the whole JSON as if links were string values only.
if flat := extractLinkStrings(body); flat != "" {
if items, err := subscription.ParseBody(flat); err == nil && len(items) > 0 {
return items, nil
if res, err := subscription.ParseBodyDetailed(flat); err == nil && len(res.Items) > 0 {
return res, nil
}
}
}
return subscription.ParseBody(body)
return subscription.ParseBodyDetailed(body)
}
func flattenJSONLinks(body string) string {