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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user