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
+1
View File
@@ -24,5 +24,6 @@ type Status struct {
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
VPNActive bool `json:"vpn_active,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
+229 -4
View File
@@ -19,9 +19,11 @@ import (
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/netcheck"
"vpnclient/internal/remnawave"
"vpnclient/internal/subscription"
"vpnclient/internal/sysproxy"
"vpnclient/internal/vpnmode"
)
// Manager orchestrates protocol engines and Windows system proxy.
@@ -34,6 +36,13 @@ type Manager struct {
profile *config.Profile
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) {
@@ -44,12 +53,18 @@ 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
}
@@ -78,7 +93,37 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
return err
}
if m.cfg.SystemProxy {
vpnActive := false
if m.cfg.Mode == "vpn" {
if !vpnmode.Supported() {
_ = engine.Stop()
return fmt.Errorf("режим VPN пока доступен только на Windows — переключитесь на режим прокси")
}
socksAddr, ok := engine.LocalSOCKSProxy()
if !ok {
_ = engine.Stop()
return fmt.Errorf("режим VPN: у протокола нет локального SOCKS-прокси")
}
host, _, err := netcheck.HostPort(profile.Protocol, profile.Proxy)
if err != nil || strings.TrimSpace(host) == "" {
_ = engine.Stop()
return fmt.Errorf("режим VPN: не удалось определить адрес сервера для исключения из туннеля")
}
sess, err := vpnmode.Start(vpnmode.Config{
SOCKSAddr: socksAddr,
ExcludeHosts: []string{host},
Stderr: m.stderr,
})
if err != nil {
_ = engine.Stop()
return err
}
m.vpn = sess
vpnActive = true
}
// System proxy is redundant while the TUN captures everything.
if m.cfg.SystemProxy && !vpnActive {
httpProxy, ok := engine.LocalHTTPProxy()
if !ok {
_ = engine.Stop()
@@ -89,6 +134,10 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
} else {
_ = engine.Stop()
if m.vpn != nil {
_ = m.vpn.Close()
m.vpn = nil
}
return fmt.Errorf("enable system proxy: %w", err)
}
}
@@ -103,11 +152,18 @@ func (m *Manager) Disconnect() error {
m.mu.Lock()
sys := m.sys
engine := m.engine
vpn := m.vpn
m.engine = nil
m.profile = nil
m.vpn = nil
m.mu.Unlock()
var first error
if vpn != nil {
if err := vpn.Close(); err != nil && first == nil {
first = err
}
}
if sys != nil && sys.Enabled() {
if err := sys.Disable(); err != nil && first == nil {
first = err
@@ -135,7 +191,7 @@ func (m *Manager) Status() Status {
m.mu.Lock()
defer m.mu.Unlock()
st := Status{SystemProxy: m.sys.Enabled()}
st := Status{SystemProxy: m.sys.Enabled(), VPNActive: m.vpn != nil}
if m.engine == nil || !m.engine.Running() {
return st
}
@@ -220,6 +276,46 @@ func (m *Manager) SetSystemProxy(enabled bool) {
m.cfg.SystemProxy = enabled
}
// Mode returns the persisted traffic mode: "proxy" (default) or "vpn".
func (m *Manager) Mode() string {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg.Mode == "vpn" {
return "vpn"
}
return "proxy"
}
// SetMode persists the traffic mode; takes effect on next connect.
func (m *Manager) SetMode(mode string) error {
switch mode {
case "proxy", "vpn":
default:
return fmt.Errorf("неизвестный режим %q", mode)
}
if mode == "vpn" {
if !vpnmode.Supported() {
return fmt.Errorf("режим VPN пока доступен только на Windows")
}
if !vpnmode.IsElevated() {
return fmt.Errorf("Запустите Navis от имени администратора для режима VPN")
}
if !vpnmode.WintunAvailable() {
return fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с Navis.exe")
}
}
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("сначала отключитесь, затем меняйте режим")
}
m.cfg.Mode = mode
return config.Save(m.cfgPath, *m.cfg)
}
// VPNModeSupported reports whether the VPN (TUN) switch should be shown.
func (m *Manager) VPNModeSupported() bool { return vpnmode.Supported() }
func (m *Manager) UpdateProxyURI(proxy string) error {
// A plain http(s) URL without credentials is a subscription URL, not a
// proxy link — run the subscription import instead of failing with
@@ -611,9 +707,34 @@ func (m *Manager) ProvisionConfigured() bool {
return err == nil && !st.IsDir()
}
// ProvisionAccess creates (or finds) a panel user via the Remnawave admin API
// and imports its subscription configs into the app.
// 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.LoadAdminConfig(remnawave.AdminConfigPath(m.cfgPath))
if err != nil {
return ProvisionOutcome{}, err
@@ -639,6 +760,18 @@ func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) {
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)
@@ -648,6 +781,98 @@ func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) {
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.LoadAdminConfig(remnawave.AdminConfigPath(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