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:
@@ -5,7 +5,11 @@ bin/
|
|||||||
!dist/**/
|
!dist/**/
|
||||||
configs/config.json
|
configs/config.json
|
||||||
configs/remnawave-api.json
|
configs/remnawave-api.json
|
||||||
|
configs/account.json
|
||||||
dist/navis-release/windows/configs/
|
dist/navis-release/windows/configs/
|
||||||
|
# Runtime-downloaded protocol cores + local packaging artifact (not release sources)
|
||||||
|
dist/navis-release/windows/bin/
|
||||||
|
dist/navis-release/navis-release.zip
|
||||||
configs/.navis-webview/
|
configs/.navis-webview/
|
||||||
cmd/vpnapp/resource.syso
|
cmd/vpnapp/resource.syso
|
||||||
*.syso
|
*.syso
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
||||||
versionCode = 3_090_001
|
versionCode = 3_100_001
|
||||||
versionName = "3.9.0+1"
|
versionName = "3.10.0+1"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ type uiState struct {
|
|||||||
Hy2 core.Hy2Options `json:"hy2"`
|
Hy2 core.Hy2Options `json:"hy2"`
|
||||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||||
ProvisionReady bool `json:"provision_ready,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -107,14 +111,15 @@ func main() {
|
|||||||
|
|
||||||
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
|
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
|
||||||
|
|
||||||
|
// Sidebar layout (Hiddify-style) needs a wider window than the old column UI.
|
||||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||||
Debug: false,
|
Debug: false,
|
||||||
AutoFocus: true,
|
AutoFocus: true,
|
||||||
DataPath: dataPath,
|
DataPath: dataPath,
|
||||||
WindowOptions: webview2.WindowOptions{
|
WindowOptions: webview2.WindowOptions{
|
||||||
Title: "Navis 2",
|
Title: "Navis 2",
|
||||||
Width: 500,
|
Width: 920,
|
||||||
Height: 900,
|
Height: 680,
|
||||||
Center: true,
|
Center: true,
|
||||||
IconId: 1,
|
IconId: 1,
|
||||||
},
|
},
|
||||||
@@ -129,7 +134,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
applyWindowIcon(w.Window())
|
applyWindowIcon(w.Window())
|
||||||
w.SetSize(500, 900, webview2.HintNone)
|
w.SetSize(920, 680, webview2.HintNone)
|
||||||
|
|
||||||
mustBind(w, "getState", a.getState)
|
mustBind(w, "getState", a.getState)
|
||||||
mustBind(w, "connect", a.connect)
|
mustBind(w, "connect", a.connect)
|
||||||
@@ -150,8 +155,12 @@ func main() {
|
|||||||
mustBind(w, "saveRemnawave", a.saveRemnawave)
|
mustBind(w, "saveRemnawave", a.saveRemnawave)
|
||||||
mustBind(w, "importRemnawave", a.importRemnawave)
|
mustBind(w, "importRemnawave", a.importRemnawave)
|
||||||
mustBind(w, "provisionUser", a.provisionUser)
|
mustBind(w, "provisionUser", a.provisionUser)
|
||||||
|
mustBind(w, "renewAccount", a.renewAccount)
|
||||||
|
mustBind(w, "setMode", a.setMode)
|
||||||
|
mustBind(w, "getLogs", a.getLogs)
|
||||||
|
|
||||||
go a.autoCheckUpdate()
|
go a.autoCheckUpdate()
|
||||||
|
mgr.StartAutoRenew(6 * time.Hour)
|
||||||
|
|
||||||
w.SetHtml(appui.IndexHTML)
|
w.SetHtml(appui.IndexHTML)
|
||||||
w.Run()
|
w.Run()
|
||||||
@@ -221,6 +230,10 @@ func (a *app) getState() (uiState, error) {
|
|||||||
Hy2: a.mgr.ActiveHy2Options(),
|
Hy2: a.mgr.ActiveHy2Options(),
|
||||||
StorePackaged: update.IsStorePackaged(),
|
StorePackaged: update.IsStorePackaged(),
|
||||||
ProvisionReady: a.mgr.ProvisionConfigured(),
|
ProvisionReady: a.mgr.ProvisionConfigured(),
|
||||||
|
Account: a.mgr.Account(),
|
||||||
|
Mode: a.mgr.Mode(),
|
||||||
|
VPNSupported: a.mgr.VPNModeSupported(),
|
||||||
|
VPNActive: st.VPNActive,
|
||||||
}
|
}
|
||||||
if out.Protocol == "" {
|
if out.Protocol == "" {
|
||||||
if p, err := cfg.ActiveProfile(); err == nil {
|
if p, err := cfg.ActiveProfile(); err == nil {
|
||||||
@@ -348,6 +361,26 @@ func (a *app) provisionUser(username string) (core.ProvisionOutcome, error) {
|
|||||||
return a.mgr.ProvisionAccess(username)
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
type pingBestResult struct {
|
type pingBestResult struct {
|
||||||
Pings []netcheck.Result `json:"pings"`
|
Pings []netcheck.Result `json:"pings"`
|
||||||
BestName string `json:"best_name,omitempty"`
|
BestName string `json:"best_name,omitempty"`
|
||||||
|
|||||||
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.9.0",
|
"version": "3.10.0",
|
||||||
"notes": "3.9.0+3: fix pasting a subscription URL into the server-link field (no more 'proxy URI missing username'), RU country names + city hints for emoji flags, admin provisioning via Remnawave API («Выдать доступ», configs/remnawave-api.json). Windows 3.9.0.3.",
|
"notes": "3.10.0+1: новый интерфейс с боковым меню (Главная / Профили / Настройки / Логи / О программе), аккаунт с продлением 50 ГБ / +30 дней и автопродлением, режим VPN на Windows (TUN, весь трафик, нужны права администратора и wintun.dll). Windows 3.10.0.1.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.9.0",
|
"version": "3.10.0",
|
||||||
"notes": "3.9.0+3: fix pasting a subscription URL into the server-link field (no more 'proxy URI missing username'), RU country names + city hints for emoji flags, admin provisioning via Remnawave API («Выдать доступ», configs/remnawave-api.json). Windows 3.9.0.3.",
|
"notes": "3.10.0+1: новый интерфейс с боковым меню (Главная / Профили / Настройки / Логи / О программе), аккаунт с продлением 50 ГБ / +30 дней и автопродлением, режим VPN на Windows (TUN, весь трафик, нужны права администратора и wintun.dll). Windows 3.10.0.1.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
module vpnclient
|
module vpnclient
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/amnezia-vpn/amneziawg-go v0.2.19
|
github.com/amnezia-vpn/amneziawg-go v0.2.19
|
||||||
github.com/diskfs/go-diskfs v1.9.4
|
github.com/diskfs/go-diskfs v1.9.4
|
||||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
|
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
|
||||||
|
github.com/xjasonlyu/tun2socks/v2 v2.7.0
|
||||||
golang.org/x/image v0.44.0
|
golang.org/x/image v0.44.0
|
||||||
golang.org/x/sys v0.43.0
|
golang.org/x/sys v0.46.0
|
||||||
|
gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -22,9 +24,13 @@ require (
|
|||||||
github.com/pkg/xattr v0.4.12 // indirect
|
github.com/pkg/xattr v0.4.12 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
golang.org/x/crypto v0.42.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/net v0.44.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/time v0.9.0 // indirect
|
go.uber.org/zap v1.28.0 // indirect
|
||||||
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ github.com/amnezia-vpn/amneziawg-go v0.2.19 h1:l3rOmrA4o5z38kpgnA5iSk1yOm7Cv3Aaf
|
|||||||
github.com/amnezia-vpn/amneziawg-go v0.2.19/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A=
|
github.com/amnezia-vpn/amneziawg-go v0.2.19/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A=
|
||||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc=
|
github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc=
|
||||||
github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
|
github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
|
||||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||||
@@ -32,29 +32,43 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/xjasonlyu/tun2socks/v2 v2.7.0 h1:fYEN0Q1sSanuoID8xvUTsMODbKLJJH/J5ywYoMRxIPw=
|
||||||
|
github.com/xjasonlyu/tun2socks/v2 v2.7.0/go.mod h1:XHgJ3wbs63mZxGbJawt6jjNX65aPDl+Qhn2ePPEE4KI=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||||
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
|
||||||
|
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE=
|
gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96 h1:LZXOf4NwvAwUy/eI8P+Y3DUdYLyaCEDNPEjsL2OP2ro=
|
||||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
|
gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY=
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ type UIState struct {
|
|||||||
Hy2 core.Hy2Options `json:"hy2"`
|
Hy2 core.Hy2Options `json:"hy2"`
|
||||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||||
ProvisionReady bool `json:"provision_ready,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 {
|
type PingBestResult struct {
|
||||||
@@ -70,6 +74,8 @@ type PingBestResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
|
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{
|
return &App{
|
||||||
Mgr: mgr,
|
Mgr: mgr,
|
||||||
CfgPath: cfgPath,
|
CfgPath: cfgPath,
|
||||||
@@ -139,6 +145,10 @@ func (a *App) GetState() (UIState, error) {
|
|||||||
Hy2: a.Mgr.ActiveHy2Options(),
|
Hy2: a.Mgr.ActiveHy2Options(),
|
||||||
StorePackaged: update.IsStorePackaged(),
|
StorePackaged: update.IsStorePackaged(),
|
||||||
ProvisionReady: a.Mgr.ProvisionConfigured(),
|
ProvisionReady: a.Mgr.ProvisionConfigured(),
|
||||||
|
Account: a.Mgr.Account(),
|
||||||
|
Mode: a.Mgr.Mode(),
|
||||||
|
VPNSupported: a.Mgr.VPNModeSupported(),
|
||||||
|
VPNActive: st.VPNActive,
|
||||||
}
|
}
|
||||||
if out.Protocol == "" {
|
if out.Protocol == "" {
|
||||||
if p, err := cfg.ActiveProfile(); err == nil {
|
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)
|
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) {
|
func (a *App) PingServers() ([]netcheck.Result, error) {
|
||||||
return a.runPings()
|
return a.runPings()
|
||||||
}
|
}
|
||||||
@@ -519,6 +550,12 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
|||||||
return a.ImportRemnawave(s)
|
return a.ImportRemnawave(s)
|
||||||
case "provisionUser":
|
case "provisionUser":
|
||||||
return a.ProvisionUser(arg(args, 0, ""))
|
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":
|
case "quit":
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|||||||
+783
-404
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,10 @@ type Config struct {
|
|||||||
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
|
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
|
||||||
SystemProxy bool `json:"system_proxy"`
|
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 is where protocol binaries (naive.exe / hysteria.exe) are stored.
|
||||||
BinDir string `json:"bin_dir,omitempty"`
|
BinDir string `json:"bin_dir,omitempty"`
|
||||||
|
|
||||||
@@ -197,6 +201,11 @@ func stripUTF8BOM(data []byte) []byte {
|
|||||||
|
|
||||||
// Validate checks required fields.
|
// Validate checks required fields.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
|
switch c.Mode {
|
||||||
|
case "", "proxy", "vpn":
|
||||||
|
default:
|
||||||
|
c.Mode = "proxy"
|
||||||
|
}
|
||||||
if len(c.Profiles) == 0 {
|
if len(c.Profiles) == 0 {
|
||||||
return fmt.Errorf("config: no profiles defined")
|
return fmt.Errorf("config: no profiles defined")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ type Status struct {
|
|||||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||||
SystemProxy bool `json:"system_proxy"`
|
SystemProxy bool `json:"system_proxy"`
|
||||||
|
VPNActive bool `json:"vpn_active,omitempty"`
|
||||||
Extra map[string]string `json:"extra,omitempty"`
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+229
-4
@@ -19,9 +19,11 @@ import (
|
|||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
"vpnclient/internal/protocols/xray"
|
"vpnclient/internal/protocols/xray"
|
||||||
|
"vpnclient/internal/netcheck"
|
||||||
"vpnclient/internal/remnawave"
|
"vpnclient/internal/remnawave"
|
||||||
"vpnclient/internal/subscription"
|
"vpnclient/internal/subscription"
|
||||||
"vpnclient/internal/sysproxy"
|
"vpnclient/internal/sysproxy"
|
||||||
|
"vpnclient/internal/vpnmode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager orchestrates protocol engines and Windows system proxy.
|
// Manager orchestrates protocol engines and Windows system proxy.
|
||||||
@@ -34,6 +36,13 @@ type Manager struct {
|
|||||||
profile *config.Profile
|
profile *config.Profile
|
||||||
stderr io.Writer
|
stderr io.Writer
|
||||||
binDir string
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
return &Manager{
|
||||||
cfgPath: cfgPath,
|
cfgPath: cfgPath,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
sys: sysproxy.New(),
|
sys: sysproxy.New(),
|
||||||
stderr: stderr,
|
stderr: stderr,
|
||||||
binDir: binDir,
|
binDir: binDir,
|
||||||
|
account: acc,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +93,37 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
|||||||
return err
|
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()
|
httpProxy, ok := engine.LocalHTTPProxy()
|
||||||
if !ok {
|
if !ok {
|
||||||
_ = engine.Stop()
|
_ = 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")
|
fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
|
||||||
} else {
|
} else {
|
||||||
_ = engine.Stop()
|
_ = engine.Stop()
|
||||||
|
if m.vpn != nil {
|
||||||
|
_ = m.vpn.Close()
|
||||||
|
m.vpn = nil
|
||||||
|
}
|
||||||
return fmt.Errorf("enable system proxy: %w", err)
|
return fmt.Errorf("enable system proxy: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,11 +152,18 @@ func (m *Manager) Disconnect() error {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
sys := m.sys
|
sys := m.sys
|
||||||
engine := m.engine
|
engine := m.engine
|
||||||
|
vpn := m.vpn
|
||||||
m.engine = nil
|
m.engine = nil
|
||||||
m.profile = nil
|
m.profile = nil
|
||||||
|
m.vpn = nil
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
var first error
|
var first error
|
||||||
|
if vpn != nil {
|
||||||
|
if err := vpn.Close(); err != nil && first == nil {
|
||||||
|
first = err
|
||||||
|
}
|
||||||
|
}
|
||||||
if sys != nil && sys.Enabled() {
|
if sys != nil && sys.Enabled() {
|
||||||
if err := sys.Disable(); err != nil && first == nil {
|
if err := sys.Disable(); err != nil && first == nil {
|
||||||
first = err
|
first = err
|
||||||
@@ -135,7 +191,7 @@ func (m *Manager) Status() Status {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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() {
|
if m.engine == nil || !m.engine.Running() {
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -220,6 +276,46 @@ func (m *Manager) SetSystemProxy(enabled bool) {
|
|||||||
m.cfg.SystemProxy = enabled
|
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 {
|
func (m *Manager) UpdateProxyURI(proxy string) error {
|
||||||
// A plain http(s) URL without credentials is a subscription URL, not a
|
// A plain http(s) URL without credentials is a subscription URL, not a
|
||||||
// proxy link — run the subscription import instead of failing with
|
// proxy link — run the subscription import instead of failing with
|
||||||
@@ -611,9 +707,34 @@ func (m *Manager) ProvisionConfigured() bool {
|
|||||||
return err == nil && !st.IsDir()
|
return err == nil && !st.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionAccess creates (or finds) a panel user via the Remnawave admin API
|
// Account returns a copy of the remembered panel account (nil when absent).
|
||||||
// and imports its subscription configs into the app.
|
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) {
|
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))
|
cfg, err := remnawave.LoadAdminConfig(remnawave.AdminConfigPath(m.cfgPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ProvisionOutcome{}, err
|
return ProvisionOutcome{}, err
|
||||||
@@ -639,6 +760,18 @@ func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) {
|
|||||||
if subURL == "" {
|
if subURL == "" {
|
||||||
return out, fmt.Errorf("панель не вернула subscription URL для %s", res.Username)
|
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)
|
imp, err := m.ImportSubscription(subURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, fmt.Errorf("доступ выдан (%s), но импорт конфигов не удался: %w", res.Username, err)
|
return out, fmt.Errorf("доступ выдан (%s), но импорт конфигов не удался: %w", res.Username, err)
|
||||||
@@ -648,6 +781,98 @@ func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) {
|
|||||||
return out, nil
|
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 {
|
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+30
-36
@@ -98,45 +98,23 @@ func BestOK(results []Result) (Result, bool) {
|
|||||||
return best, found
|
return best, found
|
||||||
}
|
}
|
||||||
|
|
||||||
// PingProfile pings using protocol hints when available.
|
// HostPort extracts the remote server endpoint from a share/proxy URI using
|
||||||
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
|
// protocol hints. Used for pings and for VPN-mode route exclusions.
|
||||||
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
|
func HostPort(proto config.Protocol, proxyURI string) (string, string, error) {
|
||||||
res := Result{Name: name}
|
|
||||||
if strings.TrimSpace(proxyURI) == "" {
|
|
||||||
res.Error = "нет ссылки сервера"
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
||||||
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
|
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
|
||||||
isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
|
isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
|
||||||
var host, port string
|
switch {
|
||||||
if isAWG {
|
case isAWG:
|
||||||
h, p, err := awg.HostPort(proxyURI)
|
return awg.HostPort(proxyURI)
|
||||||
if err != nil {
|
case isXray:
|
||||||
res.Error = err.Error()
|
return xray.HostPort(proxyURI)
|
||||||
return res
|
case hy2:
|
||||||
|
return hysteria2.HostPort(proxyURI)
|
||||||
}
|
}
|
||||||
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)
|
normalized, _, _, err := linknorm.Normalize(proto, proxyURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Error = err.Error()
|
return "", "", err
|
||||||
return res
|
|
||||||
}
|
}
|
||||||
rest := normalized
|
rest := normalized
|
||||||
if i := strings.Index(rest, "://"); i >= 0 {
|
if i := strings.Index(rest, "://"); i >= 0 {
|
||||||
@@ -148,12 +126,29 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
|
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
|
||||||
rest = rest[:i]
|
rest = rest[:i]
|
||||||
}
|
}
|
||||||
host = rest
|
host, port := rest, "443"
|
||||||
port = "443"
|
|
||||||
if i := strings.LastIndex(rest, ":"); i >= 0 {
|
if i := strings.LastIndex(rest, ":"); i >= 0 {
|
||||||
host = rest[:i]
|
host = rest[:i]
|
||||||
port = rest[i+1:]
|
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 {
|
||||||
|
res := Result{Name: name}
|
||||||
|
if strings.TrimSpace(proxyURI) == "" {
|
||||||
|
res.Error = "нет ссылки сервера"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
||||||
|
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
|
||||||
|
host, port, err := HostPort(proto, proxyURI)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
res.Host = host
|
res.Host = host
|
||||||
@@ -164,7 +159,6 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var err error
|
|
||||||
if hy2 || isAWG {
|
if hy2 || isAWG {
|
||||||
err = probeUDP(ctx, host, port)
|
err = probeUDP(ctx, host, port)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -189,6 +189,60 @@ func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*
|
|||||||
return res, nil
|
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) {
|
func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method, endpoint string, body []byte) (int, string, error) {
|
||||||
var rd io.Reader
|
var rd io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -198,7 +252,7 @@ func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", err
|
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")
|
req.Header.Set("Accept", "application/json")
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import (
|
|||||||
|
|
||||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||||
// Keep major.minor.patch only — no build suffix here.
|
// 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,
|
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
// 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).
|
// 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"
|
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="EvilFox.Navis"
|
Name="EvilFox.Navis"
|
||||||
Publisher="CN=EvilFox"
|
Publisher="CN=EvilFox"
|
||||||
Version="3.9.0.3"
|
Version="3.10.0.1"
|
||||||
ProcessorArchitecture="x64" />
|
ProcessorArchitecture="x64" />
|
||||||
|
|
||||||
<Properties>
|
<Properties>
|
||||||
|
|||||||
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.9.0",
|
"version": "3.10.0",
|
||||||
"notes": "3.9.0+3: fix pasting a subscription URL into the server-link field (no more 'proxy URI missing username'), RU country names + city hints for emoji flags, admin provisioning via Remnawave API («Выдать доступ», configs/remnawave-api.json). Windows 3.9.0.3.",
|
"notes": "3.10.0+1: новый интерфейс с боковым меню (Главная / Профили / Настройки / Логи / О программе), аккаунт с продлением 50 ГБ / +30 дней и автопродлением, режим VPN на Windows (TUN, весь трафик, нужны права администратора и wintun.dll). Windows 3.10.0.1.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Temporary UI preview server for development: serves the embedded UI +
|
||||||
|
// real /api handlers on a fixed local port. Not shipped.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"vpnclient/internal/apphost"
|
||||||
|
"vpnclient/internal/config"
|
||||||
|
"vpnclient/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath, err := config.LocateConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
logBuf := &bytes.Buffer{}
|
||||||
|
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
a := apphost.New(mgr, cfgPath, logBuf)
|
||||||
|
fmt.Println("preview on http://127.0.0.1:8765/")
|
||||||
|
log.Fatal(http.ListenAndServe("127.0.0.1:8765", a.Handler()))
|
||||||
|
}
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 3 },
|
"FileVersion": { "Major": 3, "Minor": 10, "Patch": 0, "Build": 1 },
|
||||||
"ProductVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 3 },
|
"ProductVersion": { "Major": 3, "Minor": 10, "Patch": 0, "Build": 1 },
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
"FileOS": "40004",
|
"FileOS": "40004",
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "3.9.0.3",
|
"FileVersion": "3.10.0.1",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "3.9.0.3",
|
"ProductVersion": "3.10.0.1",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user