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:
Navis
2026-08-01 20:31:29 +03:00
co-authored by Cursor
parent 1144fab54d
commit 5f768c7732
26 changed files with 237 additions and 1255 deletions
+1 -234
View File
@@ -15,11 +15,11 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/linknorm"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/netcheck"
"vpnclient/internal/remnawave"
"vpnclient/internal/subscription"
"vpnclient/internal/sysproxy"
@@ -37,12 +37,8 @@ type Manager struct {
stderr io.Writer
binDir string
// account is the locally remembered Remnawave user (configs/account.json).
account *remnawave.Account
// vpn is the active VPN-mode (TUN) session, nil in proxy mode.
vpn *vpnmode.Session
// renewMu serializes manual + automatic renewals.
renewMu sync.Mutex
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -53,18 +49,12 @@ func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager,
if err != nil {
return nil, err
}
acc, err := remnawave.LoadAccount(remnawave.AccountPath(cfgPath))
if err != nil {
fmt.Fprintf(stderr, "account.json: %v\n", err)
acc = nil
}
return &Manager{
cfgPath: cfgPath,
cfg: cfg,
sys: sysproxy.New(),
stderr: stderr,
binDir: binDir,
account: acc,
}, nil
}
@@ -689,229 +679,6 @@ func (m *Manager) applySubscription(ctx context.Context, res *subscription.Resul
return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil
}
// ProvisionOutcome is what the UI shows after issuing access via the panel.
type ProvisionOutcome struct {
Username string `json:"username"`
Created bool `json:"created"`
SubscriptionURL string `json:"subscription_url"`
TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"`
ExpireAt int64 `json:"expire_at,omitempty"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
}
// ProvisionConfigured reports whether the Remnawave admin API is available
// (embedded credentials or configs/remnawave-api.json). Used for renew UI.
func (m *Manager) ProvisionConfigured() bool {
if remnawave.EmbeddedAdminConfig() != nil {
return true
}
st, err := os.Stat(remnawave.AdminConfigPath(m.cfgPath))
return err == nil && !st.IsDir()
}
// Account returns a copy of the remembered panel account (nil when absent).
func (m *Manager) Account() *remnawave.Account {
m.mu.Lock()
defer m.mu.Unlock()
if m.account == nil {
return nil
}
cp := *m.account
return &cp
}
func (m *Manager) saveAccount(a *remnawave.Account) {
m.mu.Lock()
m.account = a
m.mu.Unlock()
if err := remnawave.SaveAccount(remnawave.AccountPath(m.cfgPath), a); err != nil {
fmt.Fprintf(m.stderr, "account.json: сохранение не удалось: %v\n", err)
}
}
// ProvisionAccess creates (or finds) a panel user via the Remnawave admin API,
// remembers it locally and imports its subscription configs into the app.
// Once an account is remembered, creating another one is refused —
// paid subscription flow will replace free provisioning later.
func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) {
if acc := m.Account(); acc != nil {
return ProvisionOutcome{}, fmt.Errorf("аккаунт уже привязан (%s) — новый создавать нельзя, используйте «Продлить»", acc.Username)
}
cfg, err := remnawave.ResolveAdminConfig(m.cfgPath)
if err != nil {
return ProvisionOutcome{}, err
}
return m.provisionWithConfig(cfg, username)
}
// EnsureAutoProvision silently creates a panel user (if none remembered yet)
// and imports its subscription configs. NordVPN-style: panel address / API
// token are embedded; the user never edits them. Safe to call on every start.
func (m *Manager) EnsureAutoProvision() (ProvisionOutcome, error) {
if acc := m.Account(); acc != nil {
// Already provisioned — refresh configs in the background if empty.
if len(m.Profiles()) == 0 && acc.SubscriptionURL != "" {
if imp, err := m.ImportSubscription(acc.SubscriptionURL); err == nil {
return ProvisionOutcome{
Username: acc.Username,
SubscriptionURL: acc.SubscriptionURL,
TrafficLimitBytes: acc.TrafficLimitBytes,
ExpireAt: acc.ExpireAt,
Imported: imp.Imported,
Skipped: imp.Skipped,
}, nil
}
}
return ProvisionOutcome{
Username: acc.Username,
SubscriptionURL: acc.SubscriptionURL,
TrafficLimitBytes: acc.TrafficLimitBytes,
ExpireAt: acc.ExpireAt,
}, nil
}
cfg, err := remnawave.ResolveAdminConfig(m.cfgPath)
if err != nil {
return ProvisionOutcome{}, err
}
return m.provisionWithConfig(cfg, remnawave.GenerateUsername())
}
func (m *Manager) provisionWithConfig(cfg *remnawave.AdminConfig, username string) (ProvisionOutcome, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
res, err := remnawave.ProvisionUser(ctx, cfg, username)
if err != nil {
return ProvisionOutcome{}, err
}
out := ProvisionOutcome{
Username: res.Username,
Created: res.Created,
SubscriptionURL: res.SubscriptionURL,
TrafficLimitBytes: res.TrafficLimitBytes,
ExpireAt: res.ExpireAt,
}
subURL := res.SubscriptionURL
if subURL == "" && res.ShortUUID != "" {
subURL = remnawave.PublicSubURL(cfg.PanelURL, res.ShortUUID)
out.SubscriptionURL = subURL
}
if subURL == "" {
return out, fmt.Errorf("панель не вернула subscription URL для %s", res.Username)
}
// Remember the account — from now on only renewals of this user.
m.saveAccount(&remnawave.Account{
Username: res.Username,
UUID: res.UUID,
ShortUUID: res.ShortUUID,
SubscriptionURL: subURL,
TrafficLimitBytes: res.TrafficLimitBytes,
ExpireAt: res.ExpireAt,
CreatedAt: time.Now().Unix(),
})
imp, err := m.ImportSubscription(subURL)
if err != nil {
return out, fmt.Errorf("доступ выдан (%s), но импорт конфигов не удался: %w", res.Username, err)
}
out.Imported = imp.Imported
out.Skipped = imp.Skipped
return out, nil
}
// RenewAccount extends the remembered user: resets traffic to the plan
// (50 GB default) and pushes expiry +30 days. Never creates users.
func (m *Manager) RenewAccount() (ProvisionOutcome, error) {
m.renewMu.Lock()
defer m.renewMu.Unlock()
acc := m.Account()
if acc == nil {
return ProvisionOutcome{}, fmt.Errorf("нет привязанного аккаунта — сначала выдайте доступ")
}
cfg, err := remnawave.ResolveAdminConfig(m.cfgPath)
if err != nil {
return ProvisionOutcome{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
res, err := remnawave.RenewUser(ctx, cfg, acc.UUID, acc.ExpireAt)
if err != nil {
return ProvisionOutcome{}, err
}
acc.LastRenewedAt = time.Now().Unix()
if res.ExpireAt > 0 {
acc.ExpireAt = res.ExpireAt
}
if res.TrafficLimitBytes > 0 {
acc.TrafficLimitBytes = res.TrafficLimitBytes
}
if res.SubscriptionURL != "" {
acc.SubscriptionURL = res.SubscriptionURL
}
m.saveAccount(acc)
out := ProvisionOutcome{
Username: acc.Username,
SubscriptionURL: acc.SubscriptionURL,
TrafficLimitBytes: acc.TrafficLimitBytes,
ExpireAt: acc.ExpireAt,
}
// Refresh configs + usage info; renewal itself already succeeded.
if acc.SubscriptionURL != "" {
if imp, err := m.ImportSubscription(acc.SubscriptionURL); err == nil {
out.Imported = imp.Imported
out.Skipped = imp.Skipped
} else {
fmt.Fprintf(m.stderr, "renew: обновление подписки после продления: %v\n", err)
}
}
fmt.Fprintf(m.stderr, "renew: аккаунт %s продлён до %s\n", acc.Username,
time.Unix(acc.ExpireAt, 0).Format("2006-01-02"))
return out, nil
}
// StartAutoRenew launches the monthly auto-renew loop: checks on start and
// then every interval (6h default); renews when expiry is near/past or the
// traffic is exhausted. LastRenewedAt guards against renew loops.
func (m *Manager) StartAutoRenew(interval time.Duration) {
if interval <= 0 {
interval = 6 * time.Hour
}
go func() {
time.Sleep(15 * time.Second) // let the app start up first
for {
m.maybeAutoRenew()
time.Sleep(interval)
}
}()
}
func (m *Manager) maybeAutoRenew() {
acc := m.Account()
if acc == nil || !m.ProvisionConfigured() {
return
}
now := time.Now()
// Anti-loop: never auto-renew more often than every 12 hours.
if acc.LastRenewedAt > 0 && now.Sub(time.Unix(acc.LastRenewedAt, 0)) < 12*time.Hour {
return
}
var used int64
if si := m.SubscriptionInfo(); si != nil {
used = si.Upload + si.Download
}
if !acc.NeedsRenewal(used, now) {
return
}
fmt.Fprintf(m.stderr, "auto-renew: аккаунт %s требует продления (истекает/трафик исчерпан) — продлеваю…\n", acc.Username)
if _, err := m.RenewAccount(); err != nil {
fmt.Fprintf(m.stderr, "auto-renew: не удалось: %v\n", err)
}
}
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
if info == nil {
return nil
-15
View File
@@ -4,7 +4,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -88,17 +87,3 @@ func TestSaveProfileKeepsNaiveLink(t *testing.T) {
t.Fatalf("naive profile broken: %+v", p)
}
}
func TestProvisionConfigured(t *testing.T) {
m := newTestManager(t)
if m.ProvisionConfigured() {
t.Fatal("should be false without remnawave-api.json")
}
path := filepath.Join(filepath.Dir(m.ConfigPath()), "remnawave-api.json")
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if !m.ProvisionConfigured() {
t.Fatal("should be true with remnawave-api.json present")
}
}