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
+37
View File
@@ -59,6 +59,10 @@ type UIState struct {
Hy2 core.Hy2Options `json:"hy2"`
StorePackaged bool `json:"store_packaged,omitempty"`
ProvisionReady bool `json:"provision_ready,omitempty"`
Account *remnawave.Account `json:"account,omitempty"`
Mode string `json:"mode"`
VPNSupported bool `json:"vpn_mode_supported"`
VPNActive bool `json:"vpn_active"`
}
type PingBestResult struct {
@@ -70,6 +74,8 @@ type PingBestResult struct {
}
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
// Monthly plan upkeep: check at start and then periodically.
mgr.StartAutoRenew(6 * time.Hour)
return &App{
Mgr: mgr,
CfgPath: cfgPath,
@@ -139,6 +145,10 @@ func (a *App) GetState() (UIState, error) {
Hy2: a.Mgr.ActiveHy2Options(),
StorePackaged: update.IsStorePackaged(),
ProvisionReady: a.Mgr.ProvisionConfigured(),
Account: a.Mgr.Account(),
Mode: a.Mgr.Mode(),
VPNSupported: a.Mgr.VPNModeSupported(),
VPNActive: st.VPNActive,
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
@@ -259,6 +269,27 @@ func (a *App) ProvisionUser(username string) (core.ProvisionOutcome, error) {
return a.Mgr.ProvisionAccess(username)
}
func (a *App) RenewAccount() (core.ProvisionOutcome, error) {
return a.Mgr.RenewAccount()
}
func (a *App) SetMode(mode string) error {
return a.Mgr.SetMode(strings.TrimSpace(mode))
}
// GetLogs returns the tail of the core log buffer for the «Логи» page.
func (a *App) GetLogs() (string, error) {
if a.LogBuf == nil {
return "", nil
}
const maxTail = 64 * 1024
b := a.LogBuf.Bytes()
if len(b) > maxTail {
b = b[len(b)-maxTail:]
}
return string(b), nil
}
func (a *App) PingServers() ([]netcheck.Result, error) {
return a.runPings()
}
@@ -519,6 +550,12 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return a.ImportRemnawave(s)
case "provisionUser":
return a.ProvisionUser(arg(args, 0, ""))
case "renewAccount":
return a.RenewAccount()
case "setMode":
return nil, a.SetMode(arg(args, 0, ""))
case "getLogs":
return a.GetLogs()
case "quit":
go func() {
time.Sleep(200 * time.Millisecond)
+886 -507
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -27,6 +27,10 @@ type Config struct {
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
SystemProxy bool `json:"system_proxy"`
// Mode selects traffic capture: "proxy" (default — local proxy + optional
// system proxy) or "vpn" (Windows: TUN device, ALL traffic incl. apps/games).
Mode string `json:"mode,omitempty"`
// BinDir is where protocol binaries (naive.exe / hysteria.exe) are stored.
BinDir string `json:"bin_dir,omitempty"`
@@ -197,6 +201,11 @@ func stripUTF8BOM(data []byte) []byte {
// Validate checks required fields.
func (c *Config) Validate() error {
switch c.Mode {
case "", "proxy", "vpn":
default:
c.Mode = "proxy"
}
if len(c.Profiles) == 0 {
return fmt.Errorf("config: no profiles defined")
}
+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
+40 -46
View File
@@ -98,6 +98,42 @@ func BestOK(results []Result) (Result, bool) {
return best, found
}
// HostPort extracts the remote server endpoint from a share/proxy URI using
// protocol hints. Used for pings and for VPN-mode route exclusions.
func HostPort(proto config.Protocol, proxyURI string) (string, string, error) {
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
switch {
case isAWG:
return awg.HostPort(proxyURI)
case isXray:
return xray.HostPort(proxyURI)
case hy2:
return hysteria2.HostPort(proxyURI)
}
normalized, _, _, err := linknorm.Normalize(proto, proxyURI)
if err != nil {
return "", "", err
}
rest := normalized
if i := strings.Index(rest, "://"); i >= 0 {
rest = rest[i+3:]
}
if at := strings.LastIndex(rest, "@"); at >= 0 {
rest = rest[at+1:]
}
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
rest = rest[:i]
}
host, port := rest, "443"
if i := strings.LastIndex(rest, ":"); i >= 0 {
host = rest[:i]
port = rest[i+1:]
}
return host, port, nil
}
// PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
@@ -109,51 +145,10 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
var host, port string
if isAWG {
h, p, err := awg.HostPort(proxyURI)
if err != nil {
res.Error = err.Error()
return res
}
host, port = h, p
} else if isXray {
h, p, err := xray.HostPort(proxyURI)
if err != nil {
res.Error = err.Error()
return res
}
host, port = h, p
} else if hy2 {
h, p, err := hysteria2.HostPort(proxyURI)
if err != nil {
res.Error = err.Error()
return res
}
host, port = h, p
} else {
normalized, _, _, err := linknorm.Normalize(proto, proxyURI)
if err != nil {
res.Error = err.Error()
return res
}
rest := normalized
if i := strings.Index(rest, "://"); i >= 0 {
rest = rest[i+3:]
}
if at := strings.LastIndex(rest, "@"); at >= 0 {
rest = rest[at+1:]
}
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
rest = rest[:i]
}
host = rest
port = "443"
if i := strings.LastIndex(rest, ":"); i >= 0 {
host = rest[:i]
port = rest[i+1:]
}
host, port, err := HostPort(proto, proxyURI)
if err != nil {
res.Error = err.Error()
return res
}
res.Host = host
@@ -164,7 +159,6 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
}
start := time.Now()
var err error
if hy2 || isAWG {
err = probeUDP(ctx, host, port)
} else {
+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")
+2 -2
View File
@@ -18,11 +18,11 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "3.9.0"
const CurrentVersion = "3.10.0"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 3
const BuildNumber = 1
// DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
+22
View File
@@ -0,0 +1,22 @@
// Package vpnmode implements "Режим VPN": a wintun TUN device that captures
// ALL system traffic (apps/games included) and feeds it into the local SOCKS
// proxy of the active protocol engine via the embedded tun2socks stack.
//
// Windows-only for now. On other platforms Supported() returns false and the
// UI hides the switch (no fake toggle).
package vpnmode
import "io"
// Config describes one VPN-mode session.
type Config struct {
// SOCKSAddr is the host:port of the running local SOCKS5 proxy.
SOCKSAddr string
// ExcludeHosts are VPN server endpoints (host names or IPs) that must
// bypass the TUN default route, otherwise the tunnel would loop.
ExcludeHosts []string
// DNSServers go through the tunnel (default 1.1.1.1 + 8.8.8.8).
DNSServers []string
// Stderr receives diagnostic messages (may be nil).
Stderr io.Writer
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !windows
package vpnmode
import "fmt"
// Supported: VPN mode ships Windows-first; the UI hides the switch elsewhere.
func Supported() bool { return false }
// IsElevated is meaningless off Windows.
func IsElevated() bool { return false }
// WintunAvailable is Windows-only.
func WintunAvailable() bool { return false }
// Session is a stub off Windows.
type Session struct{}
// Start always fails off Windows.
func Start(cfg Config) (*Session, error) {
return nil, fmt.Errorf("режим VPN пока доступен только на Windows")
}
// Close is a no-op stub.
func (s *Session) Close() error { return nil }
+281
View File
@@ -0,0 +1,281 @@
//go:build windows
package vpnmode
import (
"encoding/binary"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
"gvisor.dev/gvisor/pkg/tcpip/stack"
t2core "github.com/xjasonlyu/tun2socks/v2/core"
"github.com/xjasonlyu/tun2socks/v2/core/device"
t2tun "github.com/xjasonlyu/tun2socks/v2/core/device/tun"
t2log "github.com/xjasonlyu/tun2socks/v2/log"
"github.com/xjasonlyu/tun2socks/v2/proxy/socks5"
"github.com/xjasonlyu/tun2socks/v2/tunnel"
)
const (
tunName = "NavisTun"
tunAddr = "10.255.99.2"
tunMask = "255.255.255.0"
// On-link gateway inside the TUN subnet; wintun swallows everything anyway.
tunGateway = "10.255.99.1"
)
// Supported reports that VPN mode is available on this OS.
func Supported() bool { return true }
// IsElevated reports whether the process runs with administrator rights.
func IsElevated() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// WintunAvailable checks that wintun.dll can be found (exe dir or System32).
func WintunAvailable() bool {
if exe, err := os.Executable(); err == nil {
if st, err := os.Stat(filepath.Join(filepath.Dir(exe), "wintun.dll")); err == nil && !st.IsDir() {
return true
}
}
if sysDir, err := windows.GetSystemDirectory(); err == nil {
if st, err := os.Stat(filepath.Join(sysDir, "wintun.dll")); err == nil && !st.IsDir() {
return true
}
}
return false
}
// Session is a running VPN-mode capture (TUN + tun2socks + routes).
type Session struct {
mu sync.Mutex
dev device.Device
stack *stack.Stack
excluded []exclusionRoute // host routes added via route.exe, removed on Close
stderr io.Writer
closed bool
setupDone bool
}
type exclusionRoute struct {
ip string
gateway string
ifIndex uint32
}
// Start opens the wintun adapter, wires it to the local SOCKS proxy and takes
// over the default route (0.0.0.0/1 + 128.0.0.0/1). Requires admin+wintun.dll.
func Start(cfg Config) (*Session, error) {
if cfg.Stderr == nil {
cfg.Stderr = os.Stderr
}
if strings.TrimSpace(cfg.SOCKSAddr) == "" {
return nil, fmt.Errorf("режим VPN: нет локального SOCKS-прокси у текущего протокола")
}
if !IsElevated() {
return nil, fmt.Errorf("Запустите Navis от имени администратора для режима VPN")
}
if !WintunAvailable() {
return nil, fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с Navis.exe")
}
if len(cfg.DNSServers) == 0 {
cfg.DNSServers = []string{"1.1.1.1", "8.8.8.8"}
}
// Resolve exclusion routes BEFORE the default route changes, otherwise
// DNS + GetBestRoute would already point into the (not yet working) TUN.
var excls []exclusionRoute
for _, h := range cfg.ExcludeHosts {
h = strings.TrimSpace(h)
if h == "" {
continue
}
ips, err := net.LookupIP(h)
if err != nil {
return nil, fmt.Errorf("режим VPN: не удалось определить IP сервера %s: %w", h, err)
}
for _, ip := range ips {
ip4 := ip.To4()
if ip4 == nil || ip4.IsLoopback() || ip4.IsPrivate() {
continue
}
gw, ifIdx, err := bestRoute(ip4)
if err != nil {
fmt.Fprintf(cfg.Stderr, "vpnmode: GetBestRoute %s: %v\n", ip4, err)
continue
}
excls = append(excls, exclusionRoute{ip: ip4.String(), gateway: gw.String(), ifIndex: ifIdx})
}
}
// Keep tun2socks quiet unless something is wrong.
if lvl, err := t2log.ParseLevel("warning"); err == nil {
t2log.SetLogger(t2log.Must(t2log.NewLeveled(lvl)))
}
px, err := socks5.New(cfg.SOCKSAddr, "", "")
if err != nil {
return nil, fmt.Errorf("режим VPN: socks5 %s: %w", cfg.SOCKSAddr, err)
}
tunnel.T().SetProxy(px)
dev, err := t2tun.Open(tunName, 0)
if err != nil {
return nil, fmt.Errorf("режим VPN: не удалось создать TUN-адаптер (wintun): %w", err)
}
st, err := t2core.CreateStack(&t2core.Config{
LinkEndpoint: dev,
TransportHandler: tunnel.T(),
})
if err != nil {
dev.Close()
return nil, fmt.Errorf("режим VPN: сетевой стек: %w", err)
}
s := &Session{dev: dev, stack: st, stderr: cfg.Stderr}
if err := s.setupNetwork(cfg, excls); err != nil {
_ = s.Close()
return nil, err
}
return s, nil
}
func (s *Session) setupNetwork(cfg Config, excls []exclusionRoute) error {
// The adapter needs a moment to register with the IP stack.
var lastErr error
for i := 0; i < 20; i++ {
if _, err := net.InterfaceByName(tunName); err == nil {
lastErr = nil
break
} else {
lastErr = err
}
time.Sleep(150 * time.Millisecond)
}
if lastErr != nil {
return fmt.Errorf("режим VPN: адаптер %s не появился: %w", tunName, lastErr)
}
if out, err := runHidden("netsh", "interface", "ipv4", "set", "address",
"name="+tunName, "source=static", "addr="+tunAddr, "mask="+tunMask); err != nil {
return fmt.Errorf("режим VPN: настройка адреса TUN: %v (%s)", err, out)
}
// DNS goes through the tunnel (queries hit the default route → TUN).
if out, err := runHidden("netsh", "interface", "ipv4", "set", "dnsservers",
"name="+tunName, "source=static", "address="+cfg.DNSServers[0], "register=none", "validate=no"); err != nil {
fmt.Fprintf(s.stderr, "vpnmode: set dns: %v (%s)\n", err, out)
}
for i, d := range cfg.DNSServers[1:] {
if out, err := runHidden("netsh", "interface", "ipv4", "add", "dnsservers",
"name="+tunName, "address="+d, "index="+strconv.Itoa(i+2), "validate=no"); err != nil {
fmt.Fprintf(s.stderr, "vpnmode: add dns: %v (%s)\n", err, out)
}
}
// Exclusions first — the server endpoint must keep using the real uplink.
for _, e := range excls {
if e.gateway == "0.0.0.0" || e.gateway == "" {
continue
}
if out, err := runHidden("route", "add", e.ip, "mask", "255.255.255.255",
e.gateway, "metric", "1", "if", strconv.FormatUint(uint64(e.ifIndex), 10)); err != nil {
return fmt.Errorf("режим VPN: маршрут-исключение для %s: %v (%s)", e.ip, err, out)
}
s.excluded = append(s.excluded, e)
}
// Two half-default routes beat 0.0.0.0/0 without touching the original.
for _, prefix := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
if out, err := runHidden("netsh", "interface", "ipv4", "add", "route",
prefix, tunName, tunGateway, "metric=1", "store=active"); err != nil {
return fmt.Errorf("режим VPN: маршрут %s: %v (%s)", prefix, err, out)
}
}
s.setupDone = true
fmt.Fprintf(s.stderr, "vpnmode: TUN %s активен, весь трафик через %s (исключений: %d)\n",
tunName, cfg.SOCKSAddr, len(s.excluded))
return nil
}
// Close removes exclusion routes and destroys the TUN adapter (its own routes
// disappear together with the adapter).
func (s *Session) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
for _, e := range s.excluded {
if out, err := runHidden("route", "delete", e.ip, "mask", "255.255.255.255", e.gateway); err != nil {
fmt.Fprintf(s.stderr, "vpnmode: route delete %s: %v (%s)\n", e.ip, err, out)
}
}
s.excluded = nil
if s.dev != nil {
s.dev.Close()
s.dev = nil
}
if s.stack != nil {
s.stack.Close()
s.stack = nil
}
return nil
}
func runHidden(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
var (
modiphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
procGetBestRoute = modiphlpapi.NewProc("GetBestRoute")
)
// mibIPForwardRow mirrors MIB_IPFORWARDROW (iphlpapi).
type mibIPForwardRow struct {
Dest uint32
Mask uint32
Policy uint32
NextHop uint32
IfIndex uint32
Type uint32
Proto uint32
Age uint32
NextHopAS uint32
Metric1 uint32
Metric2 uint32
Metric3 uint32
Metric4 uint32
Metric5 uint32
}
// bestRoute asks Windows for the current gateway + interface toward ip
// (IPv4, network byte order DWORDs).
func bestRoute(ip4 net.IP) (net.IP, uint32, error) {
dest := binary.LittleEndian.Uint32(ip4.To4())
var row mibIPForwardRow
r, _, _ := procGetBestRoute.Call(uintptr(dest), 0, uintptr(unsafe.Pointer(&row)))
if r != 0 {
return nil, 0, fmt.Errorf("GetBestRoute: код %d", r)
}
gw := make(net.IP, 4)
binary.LittleEndian.PutUint32(gw, row.NextHop)
return gw, row.IfIndex, nil
}