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 }