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:
+229
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user