Release 4.0.1+1: remove account binding / centralized provisioning (no panel credentials in binary; configs via user's own subscription URL); add recommended-services block on About page; Windows 4.0.1.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,84 +0,0 @@
|
||||
package remnawave
|
||||
|
||||
// Local account storage: after the first successful provisioning the client
|
||||
// "remembers" the panel user in configs/account.json (never committed) and
|
||||
// from then on only renews that same user — it never creates a second one.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Account is the locally remembered panel user.
|
||||
type Account struct {
|
||||
Username string `json:"username"`
|
||||
UUID string `json:"uuid"`
|
||||
ShortUUID string `json:"short_uuid,omitempty"`
|
||||
SubscriptionURL string `json:"subscription_url,omitempty"`
|
||||
TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"`
|
||||
ExpireAt int64 `json:"expire_at,omitempty"` // unix seconds; 0 = unknown
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // unix seconds
|
||||
LastRenewedAt int64 `json:"last_renewed_at,omitempty"` // unix seconds; anti-loop guard
|
||||
LastRenewCheckAt int64 `json:"last_renew_check,omitempty"` // unix seconds of last auto check
|
||||
}
|
||||
|
||||
// AccountPath returns configs/account.json next to the main config.json.
|
||||
func AccountPath(cfgPath string) string {
|
||||
return filepath.Join(filepath.Dir(cfgPath), "account.json")
|
||||
}
|
||||
|
||||
// LoadAccount reads the remembered account; returns (nil, nil) when absent.
|
||||
func LoadAccount(path string) (*Account, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var a Account
|
||||
if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &a); err != nil {
|
||||
return nil, fmt.Errorf("account.json: некорректный JSON: %w", err)
|
||||
}
|
||||
if a.Username == "" && a.UUID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// SaveAccount persists the account with restrictive permissions.
|
||||
func SaveAccount(path string, a *Account) error {
|
||||
if a == nil {
|
||||
return fmt.Errorf("пустой аккаунт")
|
||||
}
|
||||
data, err := json.MarshalIndent(a, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if dir := filepath.Dir(path); dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
// NeedsRenewal reports whether the plan should be renewed now:
|
||||
// expiry within 72h / past, or traffic exhausted (usedBytes vs limit).
|
||||
func (a *Account) NeedsRenewal(usedBytes int64, now time.Time) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if a.ExpireAt > 0 && time.Unix(a.ExpireAt, 0).Before(now.Add(72*time.Hour)) {
|
||||
return true
|
||||
}
|
||||
if a.TrafficLimitBytes > 0 && usedBytes >= a.TrafficLimitBytes {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package remnawave
|
||||
|
||||
// Centralized (NordVPN-style) provisioning: the panel address and API token
|
||||
// are baked into the binary at build time, so every user gets configs
|
||||
// automatically and cannot change the panel.
|
||||
//
|
||||
// build.bat copies configs/remnawave-api.json into embeddedcfg/ before
|
||||
// `go build`; the copy is gitignored so credentials never land in the repo.
|
||||
// Without the embedded file the app falls back to the external
|
||||
// configs/remnawave-api.json (developer mode).
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed embeddedcfg
|
||||
var embeddedCfgFS embed.FS
|
||||
|
||||
// EmbeddedAdminConfig returns the admin config baked into the binary,
|
||||
// or nil when this build has no embedded credentials.
|
||||
func EmbeddedAdminConfig() *AdminConfig {
|
||||
data, err := embeddedCfgFS.ReadFile("embeddedcfg/remnawave-api.json")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cfg, err := parseAdminConfig(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GenerateUsername makes a unique panel username for silent auto-provisioning
|
||||
// (users never type anything): "fox_" + 10 hex chars, e.g. fox_3fa9c02b71.
|
||||
func GenerateUsername() string {
|
||||
var b [5]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// Keep within Remnawave's 6–34 char username limit.
|
||||
s := fmt.Sprintf("fox_%x%x", os.Getpid()&0xffff, time.Now().UnixNano()&0xffffffff)
|
||||
if len(s) > 34 {
|
||||
s = s[:34]
|
||||
}
|
||||
return s
|
||||
}
|
||||
return "fox_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"_comment": "build.bat overwrites remnawave-api.json here from configs/ before go build. This placeholder keeps the embed directory non-empty for go build without credentials."
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
package remnawave
|
||||
|
||||
// Provisioning: the app itself can issue access via the Remnawave admin API
|
||||
// (docs: https://docs.rw / TypeScript SDK commands):
|
||||
//
|
||||
// GET {base}/api/users/by-username/{username} (GetUserByUsernameCommand)
|
||||
// POST {base}/api/users (CreateUserCommand)
|
||||
//
|
||||
// Auth: Authorization: Bearer <admin API token> (+ optional Caddy X-Api-Key).
|
||||
// Credentials live in a separate config file configs/remnawave-api.json —
|
||||
// never committed; see configs/remnawave-api.example.json.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProvisionOptions are plan defaults for newly created users.
|
||||
type ProvisionOptions struct {
|
||||
TrafficGB int64 `json:"traffic_gb"` // default 50
|
||||
Days int `json:"days"` // default 30
|
||||
Strategy string `json:"strategy"` // NO_RESET | DAY | WEEK | MONTH (default MONTH)
|
||||
HwidDeviceLimit int `json:"hwid_device_limit"` // 0 = unlimited
|
||||
}
|
||||
|
||||
// AdminConfig is loaded from configs/remnawave-api.json (admin credentials).
|
||||
type AdminConfig struct {
|
||||
PanelURL string `json:"panel_url"`
|
||||
APIToken string `json:"api_token"`
|
||||
CaddyAPIKey string `json:"caddy_api_key,omitempty"`
|
||||
Provision ProvisionOptions `json:"provision"`
|
||||
}
|
||||
|
||||
// AdminConfigPath returns the expected path of remnawave-api.json —
|
||||
// next to the main config.json (configs dir).
|
||||
func AdminConfigPath(cfgPath string) string {
|
||||
return filepath.Join(filepath.Dir(cfgPath), "remnawave-api.json")
|
||||
}
|
||||
|
||||
// ResolveAdminConfig prefers the baked-in (embedded) panel credentials;
|
||||
// falls back to configs/remnawave-api.json for local/developer builds.
|
||||
func ResolveAdminConfig(cfgPath string) (*AdminConfig, error) {
|
||||
if cfg := EmbeddedAdminConfig(); cfg != nil {
|
||||
return cfg, nil
|
||||
}
|
||||
return LoadAdminConfig(AdminConfigPath(cfgPath))
|
||||
}
|
||||
|
||||
// LoadAdminConfig reads and validates the admin API config file.
|
||||
func LoadAdminConfig(path string) (*AdminConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("нет файла %s (скопируйте remnawave-api.example.json и вставьте ключ)", filepath.Base(path))
|
||||
}
|
||||
return parseAdminConfig(data)
|
||||
}
|
||||
|
||||
// parseAdminConfig validates raw remnawave-api.json bytes (file or embedded).
|
||||
func parseAdminConfig(data []byte) (*AdminConfig, error) {
|
||||
var cfg AdminConfig
|
||||
if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("remnawave-api.json: некорректный JSON: %w", err)
|
||||
}
|
||||
cfg.PanelURL = NormalizeBase(cfg.PanelURL)
|
||||
cfg.APIToken = strings.TrimSpace(cfg.APIToken)
|
||||
cfg.CaddyAPIKey = strings.TrimSpace(cfg.CaddyAPIKey)
|
||||
if cfg.PanelURL == "" || strings.Contains(cfg.PanelURL, "panel.example.com") {
|
||||
return nil, fmt.Errorf("remnawave-api.json: укажите panel_url")
|
||||
}
|
||||
if cfg.APIToken == "" || cfg.APIToken == "PASTE_API_TOKEN_HERE" {
|
||||
return nil, fmt.Errorf("remnawave-api.json: укажите api_token (панель → API Tokens)")
|
||||
}
|
||||
// Plan defaults: 50 GB / 30 days / MONTH reset.
|
||||
if cfg.Provision.TrafficGB <= 0 {
|
||||
cfg.Provision.TrafficGB = 50
|
||||
}
|
||||
if cfg.Provision.Days <= 0 {
|
||||
cfg.Provision.Days = 30
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) {
|
||||
case "NO_RESET", "DAY", "WEEK", "MONTH":
|
||||
cfg.Provision.Strategy = strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy))
|
||||
default:
|
||||
cfg.Provision.Strategy = "MONTH"
|
||||
}
|
||||
if cfg.Provision.HwidDeviceLimit < 0 {
|
||||
cfg.Provision.HwidDeviceLimit = 0
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// ProvisionResult describes the created or found panel user.
|
||||
type ProvisionResult struct {
|
||||
Username string `json:"username"`
|
||||
UUID string `json:"uuid"`
|
||||
ShortUUID string `json:"short_uuid"`
|
||||
SubscriptionURL string `json:"subscription_url"`
|
||||
TrafficLimitBytes int64 `json:"traffic_limit_bytes"`
|
||||
ExpireAt int64 `json:"expire_at"` // unix seconds; 0 = unknown
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]{6,34}$`)
|
||||
|
||||
// NormalizeUsername sanitizes free-form input (telegram @handle, email, etc.)
|
||||
// into a Remnawave-compatible username: [a-zA-Z0-9_-], 6..34 chars.
|
||||
func NormalizeUsername(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
s = strings.TrimPrefix(s, "@")
|
||||
if i := strings.IndexByte(s, '@'); i > 0 {
|
||||
s = s[:i] // email → local part
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == ' ', r == '+':
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
s = b.String()
|
||||
for len(s) > 0 && len(s) < 6 {
|
||||
s += "_vpn" // pad short handles ("ivan" → "ivan_vpn")
|
||||
}
|
||||
if len(s) > 34 {
|
||||
s = s[:34]
|
||||
}
|
||||
if !reUsername.MatchString(s) {
|
||||
return "", fmt.Errorf("имя пользователя: 6–34 символа, латиница/цифры/_/- (получилось %q)", s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ProvisionUser finds an existing panel user by username or creates one with
|
||||
// the configured plan (traffic_gb / days / strategy / hwid_device_limit),
|
||||
// then returns its subscription URL for import.
|
||||
func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*ProvisionResult, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("нет конфигурации remnawave-api")
|
||||
}
|
||||
username, err := NormalizeUsername(rawUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// 1) Existing user?
|
||||
getURL := cfg.PanelURL + "/api/users/by-username/" + url.PathEscape(username)
|
||||
status, body, err := adminDo(ctx, client, cfg, http.MethodGet, getURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusOK:
|
||||
res, err := parseProvisionUser(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Username = username
|
||||
return res, nil
|
||||
case status == http.StatusNotFound:
|
||||
// fall through to create
|
||||
default:
|
||||
return nil, adminHTTPError(status, body)
|
||||
}
|
||||
|
||||
// 2) Create with plan defaults.
|
||||
payload := map[string]any{
|
||||
"username": username,
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024,
|
||||
"trafficLimitStrategy": cfg.Provision.Strategy,
|
||||
"expireAt": time.Now().UTC().AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339),
|
||||
}
|
||||
if cfg.Provision.HwidDeviceLimit > 0 {
|
||||
payload["hwidDeviceLimit"] = cfg.Provision.HwidDeviceLimit
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
status, body, err = adminDo(ctx, client, cfg, http.MethodPost, cfg.PanelURL+"/api/users", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusCreated {
|
||||
return nil, adminHTTPError(status, body)
|
||||
}
|
||||
res, err := parseProvisionUser(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Username = username
|
||||
res.Created = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RenewUser extends the SAME panel user: resets used traffic
|
||||
// (POST /api/users/{uuid}/actions/reset-traffic) and pushes the plan forward
|
||||
// via PATCH /api/users — trafficLimitBytes = plan (50 GB default) and
|
||||
// expireAt = max(now, current expiry) + plan days (30 default).
|
||||
// It never creates users.
|
||||
func RenewUser(ctx context.Context, cfg *AdminConfig, userUUID string, currentExpire int64) (*ProvisionResult, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("нет конфигурации remnawave-api")
|
||||
}
|
||||
userUUID = strings.TrimSpace(userUUID)
|
||||
if userUUID == "" {
|
||||
return nil, fmt.Errorf("нет UUID пользователя для продления")
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// 1) Reset used traffic. Some panels return 404 for this action route —
|
||||
// tolerate it, the PATCH below still extends the plan.
|
||||
resetURL := cfg.PanelURL + "/api/users/" + url.PathEscape(userUUID) + "/actions/reset-traffic"
|
||||
status, body, err := adminDo(ctx, client, cfg, http.MethodPost, resetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusNotFound {
|
||||
return nil, fmt.Errorf("сброс трафика: %w", adminHTTPError(status, body))
|
||||
}
|
||||
|
||||
// 2) Extend expiry + (re)set the traffic limit.
|
||||
base := time.Now().UTC()
|
||||
if currentExpire > 0 {
|
||||
if cur := time.Unix(currentExpire, 0).UTC(); cur.After(base) {
|
||||
base = cur
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"uuid": userUUID,
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024,
|
||||
"expireAt": base.AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339),
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
status, body, err = adminDo(ctx, client, cfg, http.MethodPatch, cfg.PanelURL+"/api/users", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusCreated {
|
||||
return nil, fmt.Errorf("продление: %w", adminHTTPError(status, body))
|
||||
}
|
||||
res, err := parseProvisionUser(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method, endpoint string, body []byte) (int, string, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, rd)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "EvilFox/4.0 (Remnawave provision)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIToken)
|
||||
if cfg.CaddyAPIKey != "" {
|
||||
req.Header.Set("X-Api-Key", cfg.CaddyAPIKey)
|
||||
}
|
||||
if u, err := url.Parse(endpoint); err == nil && shouldSendProxyHeaders(u) {
|
||||
req.Header.Set("X-Forwarded-Proto", u.Scheme)
|
||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("Remnawave API недоступен: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return resp.StatusCode, "", err
|
||||
}
|
||||
return resp.StatusCode, string(raw), nil
|
||||
}
|
||||
|
||||
func adminHTTPError(status int, body string) error {
|
||||
msg := strings.TrimSpace(body)
|
||||
if len(msg) > 160 {
|
||||
msg = msg[:160] + "…"
|
||||
}
|
||||
switch status {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("Неверный API ключ (HTTP %d) — проверьте api_token в remnawave-api.json", status)
|
||||
case http.StatusConflict:
|
||||
return fmt.Errorf("Пользователь уже существует, но панель вернула конфликт (HTTP 409): %s", msg)
|
||||
case http.StatusBadRequest:
|
||||
return fmt.Errorf("Панель отклонила запрос (HTTP 400): %s", msg)
|
||||
default:
|
||||
return fmt.Errorf("Remnawave HTTP %d: %s", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func parseProvisionUser(body string) (*ProvisionResult, error) {
|
||||
var payload struct {
|
||||
Response provisionUserDTO `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &payload); err != nil || payload.Response.UUID == "" {
|
||||
// Some deployments return the DTO without the envelope.
|
||||
var direct provisionUserDTO
|
||||
if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil || direct.UUID == "" {
|
||||
return nil, fmt.Errorf("Remnawave: не удалось разобрать ответ панели")
|
||||
}
|
||||
payload.Response = direct
|
||||
}
|
||||
u := payload.Response
|
||||
res := &ProvisionResult{
|
||||
UUID: u.UUID,
|
||||
ShortUUID: u.ShortUUID,
|
||||
SubscriptionURL: strings.TrimSpace(u.SubscriptionURL),
|
||||
TrafficLimitBytes: u.TrafficLimitBytes,
|
||||
}
|
||||
if u.ExpireAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil {
|
||||
res.ExpireAt = t.Unix()
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type provisionUserDTO struct {
|
||||
UUID string `json:"uuid"`
|
||||
ShortUUID string `json:"shortUuid"`
|
||||
SubscriptionURL string `json:"subscriptionUrl"`
|
||||
TrafficLimitBytes int64 `json:"trafficLimitBytes"`
|
||||
ExpireAt string `json:"expireAt"`
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package remnawave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testAdminConfig(baseURL string) *AdminConfig {
|
||||
return &AdminConfig{
|
||||
PanelURL: baseURL,
|
||||
APIToken: "test-token",
|
||||
Provision: ProvisionOptions{
|
||||
TrafficGB: 50,
|
||||
Days: 30,
|
||||
Strategy: "MONTH",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionUserExisting(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/users/by-username/ivan_petrov" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{
|
||||
"uuid": "u-1",
|
||||
"shortUuid": "short1",
|
||||
"subscriptionUrl": "https://sub.example.com/short1",
|
||||
"trafficLimitBytes": int64(50) << 30,
|
||||
"expireAt": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
|
||||
}})
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "ivan_petrov")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Created {
|
||||
t.Fatal("expected existing user, got created")
|
||||
}
|
||||
if res.SubscriptionURL != "https://sub.example.com/short1" {
|
||||
t.Fatalf("subscription url: %q", res.SubscriptionURL)
|
||||
}
|
||||
if res.Username != "ivan_petrov" {
|
||||
t.Fatalf("username: %q", res.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionUserCreate(t *testing.T) {
|
||||
var created map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/users/by-username/"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/users":
|
||||
if err := json.NewDecoder(r.Body).Decode(&created); err != nil {
|
||||
t.Errorf("decode create body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{
|
||||
"uuid": "u-2",
|
||||
"shortUuid": "short2",
|
||||
"subscriptionUrl": "https://sub.example.com/short2",
|
||||
"trafficLimitBytes": created["trafficLimitBytes"],
|
||||
"expireAt": created["expireAt"],
|
||||
}})
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "@new_client")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Created {
|
||||
t.Fatal("expected created user")
|
||||
}
|
||||
if res.Username != "new_client" {
|
||||
t.Fatalf("username: %q", res.Username)
|
||||
}
|
||||
if got := created["trafficLimitBytes"].(float64); int64(got) != 50<<30 {
|
||||
t.Fatalf("trafficLimitBytes = %v, want %d (50 GB)", got, int64(50)<<30)
|
||||
}
|
||||
if created["trafficLimitStrategy"] != "MONTH" {
|
||||
t.Fatalf("strategy = %v", created["trafficLimitStrategy"])
|
||||
}
|
||||
if created["status"] != "ACTIVE" {
|
||||
t.Fatalf("status = %v", created["status"])
|
||||
}
|
||||
expireStr, _ := created["expireAt"].(string)
|
||||
expire, err := time.Parse(time.RFC3339, expireStr)
|
||||
if err != nil {
|
||||
t.Fatalf("expireAt %q: %v", expireStr, err)
|
||||
}
|
||||
days := time.Until(expire).Hours() / 24
|
||||
if days < 29 || days > 31 {
|
||||
t.Fatalf("expireAt %.1f days from now, want ~30", days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionUserBadToken(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "somebody1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Неверный API ключ") {
|
||||
t.Fatalf("error = %v, want «Неверный API ключ»", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUsername(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ivan_petrov", "ivan_petrov", false},
|
||||
{"@tg_handle", "tg_handle", false},
|
||||
{"user@example.com", "user_vpn", false},
|
||||
{"Иван", "", true},
|
||||
{"ab", "ab_vpn", false}, // padded to min length
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := NormalizeUsername(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("%q: expected error, got %q", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("%q: got %q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAdminConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "remnawave-api.json")
|
||||
|
||||
if _, err := LoadAdminConfig(path); err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
|
||||
body := `{"panel_url":"https://panel.test/","api_token":"tok123","provision":{}}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadAdminConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.PanelURL != "https://panel.test" {
|
||||
t.Fatalf("panel url: %q", cfg.PanelURL)
|
||||
}
|
||||
if cfg.Provision.TrafficGB != 50 || cfg.Provision.Days != 30 || cfg.Provision.Strategy != "MONTH" {
|
||||
t.Fatalf("defaults not applied: %+v", cfg.Provision)
|
||||
}
|
||||
|
||||
// Placeholder token must be rejected.
|
||||
body = `{"panel_url":"https://panel.test","api_token":"PASTE_API_TOKEN_HERE"}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadAdminConfig(path); err == nil {
|
||||
t.Fatal("expected error for placeholder token")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user