Release 3.10.0+1: sidebar UI (Home / Profiles / Settings / Logs / About), remembered account with 50 GB monthly renew + auto-renew, Windows VPN mode (wintun + tun2socks).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 18:34:25 +03:00
co-authored by Cursor
parent 77bd7da861
commit 01fac376ba
25 changed files with 1803 additions and 602 deletions
+84
View File
@@ -0,0 +1,84 @@
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
}
+55 -1
View File
@@ -189,6 +189,60 @@ func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*
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 {
@@ -198,7 +252,7 @@ func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method,
if err != nil {
return 0, "", err
}
req.Header.Set("User-Agent", "Navis/3.9 (Remnawave provision)")
req.Header.Set("User-Agent", "Navis/3.10 (Remnawave provision)")
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")