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 {
+122
View File
@@ -0,0 +1,122 @@
package remnawave
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// UserInfo is subscription usage metadata from the Remnawave admin API
// (GET /api/users/by-short-uuid/{short} + GET /api/hwid/devices/{uuid}).
type UserInfo struct {
UsedTraffic int64 // bytes
TrafficLimit int64 // bytes; 0 = unlimited
ExpireAt int64 // unix seconds; 0 = unknown
DeviceLimit int // 0 = no limit / unknown
Devices int
DevicesKnown bool
}
// FetchUserInfo loads traffic/expiry and HWID device info via the admin API.
// Requires BaseURL, ShortUUID and Token; degrade gracefully — the caller
// should treat any error as "info unavailable".
func FetchUserInfo(ctx context.Context, s Settings) (*UserInfo, error) {
s.BaseURL = NormalizeBase(s.BaseURL)
s.ShortUUID = strings.TrimSpace(s.ShortUUID)
s.Token = strings.TrimSpace(s.Token)
if s.BaseURL == "" || s.ShortUUID == "" {
return nil, fmt.Errorf("нет URL панели / short UUID")
}
if s.Token == "" {
return nil, fmt.Errorf("нет API токена Remnawave")
}
client := &http.Client{Timeout: 20 * time.Second}
endpoint := s.BaseURL + "/api/users/by-short-uuid/" + url.PathEscape(s.ShortUUID)
body, _, err := doGET(ctx, client, s, endpoint, true)
if err != nil {
return nil, err
}
var payload struct {
Response userDTO `json:"response"`
}
if err := json.Unmarshal([]byte(body), &payload); err != nil {
// Some deployments return the DTO without the {"response": ...} envelope.
var direct userDTO
if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil {
return nil, fmt.Errorf("Remnawave user info: %w", err)
}
payload.Response = direct
}
u := payload.Response
if u.UUID == "" && u.UsedTrafficBytes == 0 && u.ExpireAt == "" {
return nil, fmt.Errorf("Remnawave: пустой ответ user info")
}
info := &UserInfo{
UsedTraffic: u.UsedTrafficBytes,
TrafficLimit: u.TrafficLimitBytes,
DeviceLimit: u.HwidDeviceLimit,
}
if u.ExpireAt != "" {
if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil {
info.ExpireAt = t.Unix()
}
}
if u.UUID != "" {
if total, ok := fetchDeviceCount(ctx, client, s, u.UUID); ok {
info.Devices = total
info.DevicesKnown = true
}
}
return info, nil
}
type userDTO struct {
UUID string `json:"uuid"`
UsedTrafficBytes int64 `json:"usedTrafficBytes"`
TrafficLimitBytes int64 `json:"trafficLimitBytes"`
ExpireAt string `json:"expireAt"`
HwidDeviceLimit int `json:"hwidDeviceLimit"`
}
// fetchDeviceCount returns the number of HWID devices attached to a user.
// Handles both response shapes: {"response":{"total":N,"devices":[...]}} and
// the legacy {"response":[...]}.
func fetchDeviceCount(ctx context.Context, client *http.Client, s Settings, userUUID string) (int, bool) {
endpoint := s.BaseURL + "/api/hwid/devices/" + url.PathEscape(userUUID)
body, _, err := doGET(ctx, client, s, endpoint, true)
if err != nil {
return 0, false
}
var wrapped struct {
Response json.RawMessage `json:"response"`
}
raw := json.RawMessage(body)
if err := json.Unmarshal([]byte(body), &wrapped); err == nil && len(wrapped.Response) > 0 {
raw = wrapped.Response
}
var obj struct {
Total *int `json:"total"`
Devices []json.RawMessage `json:"devices"`
}
if err := json.Unmarshal(raw, &obj); err == nil {
if obj.Total != nil {
return *obj.Total, true
}
if obj.Devices != nil {
return len(obj.Devices), true
}
}
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
return len(arr), true
}
return 0, false
}