Release 1.8.0: auto-update with restart and AmneziaWG 2.0 support.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Navis
|
# Navis
|
||||||
|
|
||||||
Клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) + [Hysteria 2](https://github.com/apernet/hysteria) с профилями, пингом, URL-подпиской и автообновлением.
|
Клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) + [Hysteria 2](https://github.com/apernet/hysteria) + [AmneziaWG 2.0](https://docs.amnezia.org/documentation/amnezia-wg/) с профилями, пингом, URL-подпиской и автообновлением.
|
||||||
|
|
||||||
## Платформы
|
## Платформы
|
||||||
|
|
||||||
@@ -210,6 +210,10 @@ https://evilfox.win/
|
|||||||
|
|
||||||
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
||||||
|
|
||||||
|
В 1.8.0:
|
||||||
|
- автопроверка обновлений при старте и установка с автоперезапуском;
|
||||||
|
- AmneziaWG 2.0: вставьте полный `.conf` (Jc/Jmin/Jmax, S1–S4, H1–H4, I1–I5) или `awg://…` — встроенный userspace-туннель + локальный SOCKS/HTTP.
|
||||||
|
|
||||||
В 1.7.1 дополнительно:
|
В 1.7.1 дополнительно:
|
||||||
- обновление только скачивается в `Downloads` (без самозамены exe);
|
- обновление только скачивается в `Downloads` (без самозамены exe);
|
||||||
- нет автопроверки обновлений и тихой загрузки cores при старте;
|
- нет автопроверки обновлений и тихой загрузки cores при старте;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
"vpnclient/internal/netcheck"
|
"vpnclient/internal/netcheck"
|
||||||
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
"vpnclient/internal/update"
|
"vpnclient/internal/update"
|
||||||
@@ -57,6 +58,10 @@ type uiState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if update.MaybeFinishUpdate(os.Args[1:]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
|
||||||
cfgPath, err := config.LocateConfig()
|
cfgPath, err := config.LocateConfig()
|
||||||
@@ -134,6 +139,8 @@ func main() {
|
|||||||
mustBind(w, "saveHy2", a.saveHy2)
|
mustBind(w, "saveHy2", a.saveHy2)
|
||||||
mustBind(w, "importSubscription", a.importSubscription)
|
mustBind(w, "importSubscription", a.importSubscription)
|
||||||
|
|
||||||
|
go a.autoCheckUpdate()
|
||||||
|
|
||||||
w.SetHtml(appui.IndexHTML)
|
w.SetHtml(appui.IndexHTML)
|
||||||
w.Run()
|
w.Run()
|
||||||
}
|
}
|
||||||
@@ -164,6 +171,10 @@ func (a *app) getState() (uiState, error) {
|
|||||||
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
|
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||||
corePath, coreReady = path, true
|
corePath, coreReady = path, true
|
||||||
}
|
}
|
||||||
|
case config.ProtocolAWG:
|
||||||
|
if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||||
|
corePath, coreReady = path, true
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||||
corePath, coreReady = path, true
|
corePath, coreReady = path, true
|
||||||
@@ -326,7 +337,30 @@ func (a *app) checkUpdate() (update.Status, error) {
|
|||||||
func (a *app) applyUpdate() (string, error) {
|
func (a *app) applyUpdate() (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return update.Apply(ctx, update.DefaultManifestURL)
|
latest, err := update.Apply(ctx, update.DefaultManifestURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = a.mgr.Disconnect()
|
||||||
|
go func() {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
if a.webview != nil {
|
||||||
|
a.webview.Terminate()
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) autoCheckUpdate() {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
st, err := a.checkUpdate()
|
||||||
|
if err != nil || !st.Available {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Auto-apply + relaunch when a newer build is published.
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
_, _ = a.applyUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func openURL(raw string) error {
|
func openURL(raw string) error {
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+8
-16
@@ -1,38 +1,30 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.1",
|
"version": "1.8.0",
|
||||||
"notes": "AV: обновление только в Downloads без самозамены; без netsh и автопроверки при старте",
|
"notes": "Автообновление с перезапуском + AmneziaWG 2.0 (вставка .conf / awg://)",
|
||||||
"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/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
|
"sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
|
"sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "3cb42ab226b0263c8b6566888d80c6e7a6982d8cda992d112989c7f5c8a99bab",
|
"sha256": "e7df0d9d7dd6d9ad53768ffb250bedd8c1dcca7ba085847737dd378bf2aadbd6",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64"
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
|
||||||
"dmg_sha256": "65b8fe8c0d294ee923d59125f3e39c37f7c8a201aa6fd4661d080da59928c2e4",
|
|
||||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
|
|
||||||
"zip_sha256": "6685b1f8be9f8aa87613f35b8f8a86a00a6fb667c2f7bdc7116e6908c0cb1513"
|
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
"sha256": "7bffda217e178224f3a68fed4d5fff4e37b4705b7c8291b84f1d58151ef43b9d",
|
"sha256": "21583e6085e7d4d1b5800c21f5de356dc0f1c68c9658e58c0bd6557a390325cf",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64",
|
"arch": "amd64"
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
|
||||||
"dmg_sha256": "959526963936ad22d3c3607fe18295018362ec0d5fedcdf7561edf5d71c3b827",
|
|
||||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
|
||||||
"zip_sha256": "079c2af3fe5de6b9e73bad15a15bddfc93970d27be68b6620b5be3729163fc15"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+8
-16
@@ -1,38 +1,30 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.1",
|
"version": "1.8.0",
|
||||||
"notes": "AV: обновление только в Downloads без самозамены; без netsh и автопроверки при старте",
|
"notes": "Автообновление с перезапуском + AmneziaWG 2.0 (вставка .conf / awg://)",
|
||||||
"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/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
|
"sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
|
"sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "3cb42ab226b0263c8b6566888d80c6e7a6982d8cda992d112989c7f5c8a99bab",
|
"sha256": "e7df0d9d7dd6d9ad53768ffb250bedd8c1dcca7ba085847737dd378bf2aadbd6",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64"
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
|
||||||
"dmg_sha256": "65b8fe8c0d294ee923d59125f3e39c37f7c8a201aa6fd4661d080da59928c2e4",
|
|
||||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
|
|
||||||
"zip_sha256": "6685b1f8be9f8aa87613f35b8f8a86a00a6fb667c2f7bdc7116e6908c0cb1513"
|
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
"sha256": "7bffda217e178224f3a68fed4d5fff4e37b4705b7c8291b84f1d58151ef43b9d",
|
"sha256": "21583e6085e7d4d1b5800c21f5de356dc0f1c68c9658e58c0bd6557a390325cf",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64",
|
"arch": "amd64"
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
|
||||||
"dmg_sha256": "959526963936ad22d3c3607fe18295018362ec0d5fedcdf7561edf5d71c3b827",
|
|
||||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
|
||||||
"zip_sha256": "079c2af3fe5de6b9e73bad15a15bddfc93970d27be68b6620b5be3729163fc15"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module vpnclient
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
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
|
||||||
golang.org/x/image v0.44.0
|
golang.org/x/image v0.44.0
|
||||||
@@ -13,6 +14,7 @@ require (
|
|||||||
github.com/anchore/go-lzo v0.1.0 // indirect
|
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||||
github.com/djherbis/times v1.6.0 // indirect
|
github.com/djherbis/times v1.6.0 // indirect
|
||||||
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
|
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
|
||||||
|
github.com/google/btree v1.1.3 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
@@ -20,4 +22,9 @@ 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
|
||||||
|
golang.org/x/net v0.44.0 // indirect
|
||||||
|
golang.org/x/time v0.9.0 // indirect
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
|
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/amnezia-vpn/amneziawg-go v0.2.19 h1:l3rOmrA4o5z38kpgnA5iSk1yOm7Cv3AafIi4vxpSEV0=
|
||||||
|
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -10,6 +12,8 @@ github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxN
|
|||||||
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -32,13 +36,25 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
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=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
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.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
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=
|
||||||
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-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
|
||||||
|
|||||||
@@ -90,14 +90,22 @@
|
|||||||
display: grid; grid-template-columns: 1fr auto auto; gap: 8px;
|
display: grid; grid-template-columns: 1fr auto auto; gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
select, input[type="text"] {
|
select, input[type="text"], textarea {
|
||||||
width: 100%; border: 1px solid var(--line);
|
width: 100%; border: 1px solid var(--line);
|
||||||
background: rgba(255,255,255,.92);
|
background: rgba(255,255,255,.92);
|
||||||
border-radius: 14px; padding: 12px 14px;
|
border-radius: 14px; padding: 12px 14px;
|
||||||
font: inherit; color: var(--ink); outline: none;
|
font: inherit; color: var(--ink); outline: none;
|
||||||
transition: border-color .15s, box-shadow .15s;
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
select:focus, input[type="text"]:focus {
|
textarea {
|
||||||
|
min-height: 88px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
select:focus, input[type="text"]:focus, textarea:focus {
|
||||||
border-color: rgba(12,122,85,.55);
|
border-color: rgba(12,122,85,.55);
|
||||||
box-shadow: 0 0 0 4px rgba(12,122,85,.12);
|
box-shadow: 0 0 0 4px rgba(12,122,85,.12);
|
||||||
}
|
}
|
||||||
@@ -331,7 +339,7 @@
|
|||||||
<div class="update-banner" id="updateBanner">
|
<div class="update-banner" id="updateBanner">
|
||||||
<strong id="updateTitle">Доступно обновление</strong>
|
<strong id="updateTitle">Доступно обновление</strong>
|
||||||
<p id="updateNotes"></p>
|
<p id="updateNotes"></p>
|
||||||
<button class="action primary" id="updateBtn" type="button">Обновить сейчас</button>
|
<button class="action primary" id="updateBtn" type="button">Обновить и перезапустить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="top">
|
<div class="top">
|
||||||
@@ -365,8 +373,8 @@
|
|||||||
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
|
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="field" for="proxy">Ссылка сервера</label>
|
<label class="field" for="proxy">Ссылка / конфиг сервера</label>
|
||||||
<input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" />
|
<textarea id="proxy" rows="4" placeholder="naive+https://… · hysteria2://… · или весь .conf AmneziaWG 2.0 ([Interface]/Jc/I1…)" spellcheck="false"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="field" for="subUrl">URL подписки</label>
|
<label class="field" for="subUrl">URL подписки</label>
|
||||||
@@ -472,7 +480,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="field" for="newProxy">Ссылка</label>
|
<label class="field" for="newProxy">Ссылка</label>
|
||||||
<input id="newProxy" type="text" placeholder="hysteria2://pass@host:443/ или naive+https://…" />
|
<input id="newProxy" type="text" placeholder="hysteria2://… / naive+https://… / awg://… или вставьте .conf" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -841,9 +849,9 @@
|
|||||||
|
|
||||||
updateBtn.addEventListener("click", () => withBusy(async () => {
|
updateBtn.addEventListener("click", () => withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
setMeta("Скачивание обновления…");
|
setMeta("Скачивание и установка обновления…");
|
||||||
const msg = await applyUpdate();
|
const msg = await applyUpdate();
|
||||||
setMeta(String(msg || "Готово"), "ok");
|
setMeta(String(msg || "Перезапуск…"), "ok");
|
||||||
} catch (e) { setMeta(String(e), "err"); }
|
} catch (e) { setMeta(String(e), "err"); }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Protocol string
|
|||||||
const (
|
const (
|
||||||
ProtocolNaive Protocol = "naive"
|
ProtocolNaive Protocol = "naive"
|
||||||
ProtocolHysteria2 Protocol = "hysteria2"
|
ProtocolHysteria2 Protocol = "hysteria2"
|
||||||
|
ProtocolAWG Protocol = "awg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the top-level client configuration.
|
// Config is the top-level client configuration.
|
||||||
@@ -181,8 +182,8 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("config: profile[%d] missing name", i)
|
return fmt.Errorf("config: profile[%d] missing name", i)
|
||||||
}
|
}
|
||||||
switch p.Protocol {
|
switch p.Protocol {
|
||||||
case ProtocolNaive, ProtocolHysteria2:
|
case ProtocolNaive, ProtocolHysteria2, ProtocolAWG:
|
||||||
// Proxy may be empty until the user pastes a share link in the UI.
|
// Proxy may be empty until the user pastes a share link / conf in the UI.
|
||||||
case "":
|
case "":
|
||||||
c.Profiles[i].Protocol = ProtocolNaive
|
c.Profiles[i].Protocol = ProtocolNaive
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ package config
|
|||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// DetectProtocol infers protocol from a share/proxy URI.
|
// DetectProtocol infers protocol from a share/proxy URI or config body.
|
||||||
func DetectProtocol(raw string) Protocol {
|
func DetectProtocol(raw string) Protocol {
|
||||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
|
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
|
||||||
return ProtocolHysteria2
|
return ProtocolHysteria2
|
||||||
|
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
|
||||||
|
strings.HasPrefix(lower, "wireguard://"):
|
||||||
|
return ProtocolAWG
|
||||||
|
case strings.Contains(lower, "[interface]") && strings.Contains(lower, "privatekey"):
|
||||||
|
return ProtocolAWG
|
||||||
case strings.HasPrefix(lower, "naive+"), strings.HasPrefix(lower, "naive://"),
|
case strings.HasPrefix(lower, "naive+"), strings.HasPrefix(lower, "naive://"),
|
||||||
strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "quic://"),
|
strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "quic://"),
|
||||||
strings.HasPrefix(lower, "http://"):
|
strings.HasPrefix(lower, "http://"):
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ func proxyHost(proxy string) string {
|
|||||||
if proxy == "" {
|
if proxy == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
lower := strings.ToLower(proxy)
|
||||||
|
if strings.Contains(lower, "[interface]") {
|
||||||
|
for _, line := range strings.Split(proxy, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if len(line) >= 9 && strings.EqualFold(line[:8], "endpoint") {
|
||||||
|
_, val, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
_, val, ok = strings.Cut(line, ":")
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if i := strings.Index(proxy, "://"); i >= 0 {
|
if i := strings.Index(proxy, "://"); i >= 0 {
|
||||||
proxy = proxy[i+3:]
|
proxy = proxy[i+3:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/linknorm"
|
"vpnclient/internal/linknorm"
|
||||||
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
"vpnclient/internal/subscription"
|
"vpnclient/internal/subscription"
|
||||||
@@ -411,7 +412,7 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg)")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -463,6 +464,8 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
|
|||||||
return naive.New(m.stderr), nil
|
return naive.New(m.stderr), nil
|
||||||
case config.ProtocolHysteria2:
|
case config.ProtocolHysteria2:
|
||||||
return hysteria2.New(m.stderr), nil
|
return hysteria2.New(m.stderr), nil
|
||||||
|
case config.ProtocolAWG:
|
||||||
|
return awg.New(m.stderr), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported protocol %q", p)
|
return nil, fmt.Errorf("unsupported protocol %q", p)
|
||||||
}
|
}
|
||||||
@@ -478,12 +481,14 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
|
|||||||
switch proto {
|
switch proto {
|
||||||
case config.ProtocolHysteria2:
|
case config.ProtocolHysteria2:
|
||||||
return hysteria2.EnsureBinary(m.binDir)
|
return hysteria2.EnsureBinary(m.binDir)
|
||||||
|
case config.ProtocolAWG:
|
||||||
|
return awg.EnsureBinary(m.binDir)
|
||||||
default:
|
default:
|
||||||
return naive.EnsureBinary(m.binDir)
|
return naive.EnsureBinary(m.binDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureAllCores installs naive + hysteria2 cores.
|
// EnsureAllCores installs naive + hysteria2 cores (AWG is embedded).
|
||||||
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
p1, err := naive.EnsureBinary(m.binDir)
|
p1, err := naive.EnsureBinary(m.binDir)
|
||||||
@@ -496,5 +501,10 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
|||||||
return out, fmt.Errorf("hysteria2: %w", err)
|
return out, fmt.Errorf("hysteria2: %w", err)
|
||||||
}
|
}
|
||||||
out["hysteria2"] = p2
|
out["hysteria2"] = p2
|
||||||
|
p3, err := awg.EnsureBinary(m.binDir)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("awg: %w", err)
|
||||||
|
}
|
||||||
|
out["awg"] = p3
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
)
|
)
|
||||||
@@ -22,10 +23,16 @@ func Normalize(proto config.Protocol, raw string) (normalized string, detected c
|
|||||||
case config.ProtocolHysteria2:
|
case config.ProtocolHysteria2:
|
||||||
u, rem, err := hysteria2.NormalizeShareLink(raw)
|
u, rem, err := hysteria2.NormalizeShareLink(raw)
|
||||||
return u, config.ProtocolHysteria2, rem, err
|
return u, config.ProtocolHysteria2, rem, err
|
||||||
|
case config.ProtocolAWG:
|
||||||
|
u, rem, err := awg.NormalizeShareLink(raw)
|
||||||
|
return u, config.ProtocolAWG, rem, err
|
||||||
case config.ProtocolNaive, "":
|
case config.ProtocolNaive, "":
|
||||||
|
if awg.Detect(raw) {
|
||||||
|
u, rem, err := awg.NormalizeShareLink(raw)
|
||||||
|
return u, config.ProtocolAWG, rem, err
|
||||||
|
}
|
||||||
u, rem, err := naive.ParseShareLink(raw)
|
u, rem, err := naive.ParseShareLink(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// try hy2 if naive failed and looks like hy
|
|
||||||
if hysteria2.Detect(raw) {
|
if hysteria2.Detect(raw) {
|
||||||
u2, rem2, err2 := hysteria2.NormalizeShareLink(raw)
|
u2, rem2, err2 := hysteria2.NormalizeShareLink(raw)
|
||||||
return u2, config.ProtocolHysteria2, rem2, err2
|
return u2, config.ProtocolHysteria2, rem2, err2
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/linknorm"
|
"vpnclient/internal/linknorm"
|
||||||
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,8 +38,16 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
}
|
}
|
||||||
|
|
||||||
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
||||||
|
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
|
||||||
var host, port string
|
var host, port string
|
||||||
if hy2 {
|
if isAWG {
|
||||||
|
h, p, err := awg.HostPort(proxyURI)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
host, port = h, p
|
||||||
|
} else if hy2 {
|
||||||
h, p, err := hysteria2.HostPort(proxyURI)
|
h, p, err := hysteria2.HostPort(proxyURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Error = err.Error()
|
res.Error = err.Error()
|
||||||
@@ -78,13 +87,13 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var err error
|
var err error
|
||||||
if hy2 {
|
if hy2 || isAWG {
|
||||||
err = probeUDP(ctx, host, port)
|
err = probeUDP(ctx, host, port)
|
||||||
} else {
|
} else {
|
||||||
err = probeTCP(ctx, host, port)
|
err = probeTCP(ctx, host, port)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Error = friendlyDialError(err, hy2)
|
res.Error = friendlyDialError(err, hy2 || isAWG)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
res.Ms = time.Since(start).Milliseconds()
|
res.Ms = time.Since(start).Milliseconds()
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
package awg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conf is a parsed AmneziaWG / WireGuard client configuration (AWG 2.0 fields included).
|
||||||
|
type Conf struct {
|
||||||
|
PrivateKey string
|
||||||
|
Address []string
|
||||||
|
DNS []string
|
||||||
|
MTU int
|
||||||
|
Jc int
|
||||||
|
Jmin int
|
||||||
|
Jmax int
|
||||||
|
S1, S2, S3, S4 int
|
||||||
|
H1, H2, H3, H4 string
|
||||||
|
I1, I2, I3, I4, I5 string
|
||||||
|
PublicKey string
|
||||||
|
PresharedKey string
|
||||||
|
Endpoint string
|
||||||
|
AllowedIPs []string
|
||||||
|
Keepalive int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect reports whether raw looks like an AWG/WireGuard config or share link.
|
||||||
|
func Detect(raw string) bool {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
if strings.HasPrefix(lower, "awg://") || strings.HasPrefix(lower, "amneziawg://") ||
|
||||||
|
strings.HasPrefix(lower, "wireguard://") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "[interface]") && strings.Contains(lower, "privatekey") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse accepts a WireGuard/AWG .conf body or awg:// URI and returns Conf.
|
||||||
|
func Parse(raw string) (Conf, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return Conf{}, fmt.Errorf("пустой AWG конфиг")
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
|
||||||
|
strings.HasPrefix(lower, "wireguard://"):
|
||||||
|
return parseURI(raw)
|
||||||
|
default:
|
||||||
|
return parseINI(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseINI(raw string) (Conf, error) {
|
||||||
|
var c Conf
|
||||||
|
section := ""
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||||
|
section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(line)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := strings.ToLower(key)
|
||||||
|
switch section {
|
||||||
|
case "interface":
|
||||||
|
switch k {
|
||||||
|
case "privatekey":
|
||||||
|
c.PrivateKey = val
|
||||||
|
case "address":
|
||||||
|
c.Address = appendCSV(c.Address, val)
|
||||||
|
case "dns":
|
||||||
|
c.DNS = appendCSV(c.DNS, val)
|
||||||
|
case "mtu":
|
||||||
|
c.MTU, _ = strconv.Atoi(val)
|
||||||
|
case "jc":
|
||||||
|
c.Jc, _ = strconv.Atoi(val)
|
||||||
|
case "jmin":
|
||||||
|
c.Jmin, _ = strconv.Atoi(val)
|
||||||
|
case "jmax":
|
||||||
|
c.Jmax, _ = strconv.Atoi(val)
|
||||||
|
case "s1":
|
||||||
|
c.S1, _ = strconv.Atoi(val)
|
||||||
|
case "s2":
|
||||||
|
c.S2, _ = strconv.Atoi(val)
|
||||||
|
case "s3":
|
||||||
|
c.S3, _ = strconv.Atoi(val)
|
||||||
|
case "s4":
|
||||||
|
c.S4, _ = strconv.Atoi(val)
|
||||||
|
case "h1":
|
||||||
|
c.H1 = val
|
||||||
|
case "h2":
|
||||||
|
c.H2 = val
|
||||||
|
case "h3":
|
||||||
|
c.H3 = val
|
||||||
|
case "h4":
|
||||||
|
c.H4 = val
|
||||||
|
case "i1":
|
||||||
|
c.I1 = val
|
||||||
|
case "i2":
|
||||||
|
c.I2 = val
|
||||||
|
case "i3":
|
||||||
|
c.I3 = val
|
||||||
|
case "i4":
|
||||||
|
c.I4 = val
|
||||||
|
case "i5":
|
||||||
|
c.I5 = val
|
||||||
|
}
|
||||||
|
case "peer":
|
||||||
|
switch k {
|
||||||
|
case "publickey":
|
||||||
|
c.PublicKey = val
|
||||||
|
case "presharedkey":
|
||||||
|
c.PresharedKey = val
|
||||||
|
case "endpoint":
|
||||||
|
c.Endpoint = val
|
||||||
|
case "allowedips":
|
||||||
|
c.AllowedIPs = appendCSV(c.AllowedIPs, val)
|
||||||
|
case "persistentkeepalive":
|
||||||
|
c.Keepalive, _ = strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
return Conf{}, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseURI(raw string) (Conf, error) {
|
||||||
|
// awg://host:port/?public_key=...&private_key=...&address=10.8.0.2/32&jc=4&...
|
||||||
|
u := raw
|
||||||
|
schemeIdx := strings.Index(u, "://")
|
||||||
|
if schemeIdx < 0 {
|
||||||
|
return Conf{}, fmt.Errorf("bad awg uri")
|
||||||
|
}
|
||||||
|
rest := u[schemeIdx+3:]
|
||||||
|
hostPort := rest
|
||||||
|
query := ""
|
||||||
|
if i := strings.Index(rest, "?"); i >= 0 {
|
||||||
|
hostPort = rest[:i]
|
||||||
|
query = rest[i+1:]
|
||||||
|
}
|
||||||
|
hostPort = strings.TrimSuffix(hostPort, "/")
|
||||||
|
c := Conf{Endpoint: hostPort}
|
||||||
|
for _, part := range strings.Split(query, "&") {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(part, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k = strings.ToLower(strings.TrimSpace(k))
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
v, _ = urlDecode(v)
|
||||||
|
switch k {
|
||||||
|
case "private_key", "privatekey":
|
||||||
|
c.PrivateKey = v
|
||||||
|
case "public_key", "publickey":
|
||||||
|
c.PublicKey = v
|
||||||
|
case "preshared_key", "presharedkey", "psk":
|
||||||
|
c.PresharedKey = v
|
||||||
|
case "address", "addr":
|
||||||
|
c.Address = appendCSV(c.Address, v)
|
||||||
|
case "dns":
|
||||||
|
c.DNS = appendCSV(c.DNS, v)
|
||||||
|
case "allowed_ips", "allowedips":
|
||||||
|
c.AllowedIPs = appendCSV(c.AllowedIPs, v)
|
||||||
|
case "mtu":
|
||||||
|
c.MTU, _ = strconv.Atoi(v)
|
||||||
|
case "keepalive", "persistentkeepalive":
|
||||||
|
c.Keepalive, _ = strconv.Atoi(v)
|
||||||
|
case "jc":
|
||||||
|
c.Jc, _ = strconv.Atoi(v)
|
||||||
|
case "jmin":
|
||||||
|
c.Jmin, _ = strconv.Atoi(v)
|
||||||
|
case "jmax":
|
||||||
|
c.Jmax, _ = strconv.Atoi(v)
|
||||||
|
case "s1":
|
||||||
|
c.S1, _ = strconv.Atoi(v)
|
||||||
|
case "s2":
|
||||||
|
c.S2, _ = strconv.Atoi(v)
|
||||||
|
case "s3":
|
||||||
|
c.S3, _ = strconv.Atoi(v)
|
||||||
|
case "s4":
|
||||||
|
c.S4, _ = strconv.Atoi(v)
|
||||||
|
case "h1":
|
||||||
|
c.H1 = v
|
||||||
|
case "h2":
|
||||||
|
c.H2 = v
|
||||||
|
case "h3":
|
||||||
|
c.H3 = v
|
||||||
|
case "h4":
|
||||||
|
c.H4 = v
|
||||||
|
case "i1":
|
||||||
|
c.I1 = v
|
||||||
|
case "i2":
|
||||||
|
c.I2 = v
|
||||||
|
case "i3":
|
||||||
|
c.I3 = v
|
||||||
|
case "i4":
|
||||||
|
c.I4 = v
|
||||||
|
case "i5":
|
||||||
|
c.I5 = v
|
||||||
|
case "name", "remark":
|
||||||
|
c.Name = v
|
||||||
|
case "endpoint":
|
||||||
|
c.Endpoint = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
return Conf{}, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conf) validate() error {
|
||||||
|
if c.PrivateKey == "" {
|
||||||
|
return fmt.Errorf("awg: нет PrivateKey")
|
||||||
|
}
|
||||||
|
if c.PublicKey == "" {
|
||||||
|
return fmt.Errorf("awg: нет PublicKey")
|
||||||
|
}
|
||||||
|
if c.Endpoint == "" {
|
||||||
|
return fmt.Errorf("awg: нет Endpoint")
|
||||||
|
}
|
||||||
|
if len(c.Address) == 0 {
|
||||||
|
return fmt.Errorf("awg: нет Address")
|
||||||
|
}
|
||||||
|
if len(c.AllowedIPs) == 0 {
|
||||||
|
c.AllowedIPs = []string{"0.0.0.0/0", "::/0"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostPort returns UDP endpoint host and port for ping.
|
||||||
|
func HostPort(raw string) (host, port string, err error) {
|
||||||
|
c, err := Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
h, p, err := net.SplitHostPort(c.Endpoint)
|
||||||
|
if err != nil {
|
||||||
|
// bare host without port
|
||||||
|
return c.Endpoint, "51820", nil
|
||||||
|
}
|
||||||
|
return h, p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToIPC builds the amneziawg-go UAPI configuration string.
|
||||||
|
func (c Conf) ToIPC() (string, error) {
|
||||||
|
priv, err := decodeKey(c.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("private key: %w", err)
|
||||||
|
}
|
||||||
|
pub, err := decodeKey(c.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("public key: %w", err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("private_key=")
|
||||||
|
b.WriteString(hex.EncodeToString(priv))
|
||||||
|
writeInt(&b, "jc", c.Jc)
|
||||||
|
writeInt(&b, "jmin", c.Jmin)
|
||||||
|
writeInt(&b, "jmax", c.Jmax)
|
||||||
|
writeInt(&b, "s1", c.S1)
|
||||||
|
writeInt(&b, "s2", c.S2)
|
||||||
|
writeInt(&b, "s3", c.S3)
|
||||||
|
writeInt(&b, "s4", c.S4)
|
||||||
|
writeStr(&b, "h1", c.H1)
|
||||||
|
writeStr(&b, "h2", c.H2)
|
||||||
|
writeStr(&b, "h3", c.H3)
|
||||||
|
writeStr(&b, "h4", c.H4)
|
||||||
|
writeStr(&b, "i1", c.I1)
|
||||||
|
writeStr(&b, "i2", c.I2)
|
||||||
|
writeStr(&b, "i3", c.I3)
|
||||||
|
writeStr(&b, "i4", c.I4)
|
||||||
|
writeStr(&b, "i5", c.I5)
|
||||||
|
|
||||||
|
b.WriteString("\npublic_key=")
|
||||||
|
b.WriteString(hex.EncodeToString(pub))
|
||||||
|
b.WriteString("\nendpoint=")
|
||||||
|
b.WriteString(c.Endpoint)
|
||||||
|
for _, ip := range c.AllowedIPs {
|
||||||
|
b.WriteString("\nallowed_ip=")
|
||||||
|
b.WriteString(strings.TrimSpace(ip))
|
||||||
|
}
|
||||||
|
if c.PresharedKey != "" {
|
||||||
|
psk, err := decodeKey(c.PresharedKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("preshared key: %w", err)
|
||||||
|
}
|
||||||
|
b.WriteString("\npreshared_key=")
|
||||||
|
b.WriteString(hex.EncodeToString(psk))
|
||||||
|
}
|
||||||
|
if c.Keepalive > 0 {
|
||||||
|
writeInt(&b, "persistent_keepalive_interval", c.Keepalive)
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalAddrs parses Interface Address values.
|
||||||
|
func (c Conf) LocalAddrs() ([]netip.Addr, error) {
|
||||||
|
var out []netip.Addr
|
||||||
|
for _, a := range c.Address {
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if a == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p, err := netip.ParsePrefix(a); err == nil {
|
||||||
|
out = append(out, p.Addr())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("address %q: %w", a, err)
|
||||||
|
}
|
||||||
|
out = append(out, addr)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no local addresses")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSAddrs parses DNS servers (defaults to 1.1.1.1).
|
||||||
|
func (c Conf) DNSAddrs() []netip.Addr {
|
||||||
|
var out []netip.Addr
|
||||||
|
for _, d := range c.DNS {
|
||||||
|
d = strings.TrimSpace(d)
|
||||||
|
if d == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(d); err == nil {
|
||||||
|
out = append(out, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, netip.MustParseAddr("1.1.1.1"))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Conf) EffectiveMTU() int {
|
||||||
|
if c.MTU > 0 {
|
||||||
|
return c.MTU
|
||||||
|
}
|
||||||
|
return 1420
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKey(s string) ([]byte, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("expected 32-byte base64/hex key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInt(b *strings.Builder, key string, v int) {
|
||||||
|
if v == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.WriteString(key)
|
||||||
|
b.WriteByte('=')
|
||||||
|
b.WriteString(strconv.Itoa(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStr(b *strings.Builder, key, v string) {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.WriteString(key)
|
||||||
|
b.WriteByte('=')
|
||||||
|
b.WriteString(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKV(line string) (key, val string, ok bool) {
|
||||||
|
i := strings.IndexAny(line, "=:")
|
||||||
|
if i < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCSV(dst []string, raw string) []string {
|
||||||
|
for _, p := range strings.Split(raw, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
dst = append(dst, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlDecode(s string) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
switch s[i] {
|
||||||
|
case '+':
|
||||||
|
b.WriteByte(' ')
|
||||||
|
i++
|
||||||
|
case '%':
|
||||||
|
if i+2 >= len(s) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
var v byte
|
||||||
|
for _, c := range []byte{s[i+1], s[i+2]} {
|
||||||
|
v <<= 4
|
||||||
|
switch {
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
v |= c - '0'
|
||||||
|
case c >= 'a' && c <= 'f':
|
||||||
|
v |= c - 'a' + 10
|
||||||
|
case c >= 'A' && c <= 'F':
|
||||||
|
v |= c - 'A' + 10
|
||||||
|
default:
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte(v)
|
||||||
|
i += 3
|
||||||
|
default:
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeShareLink stores a canonical conf body (or passes through awg URI).
|
||||||
|
func NormalizeShareLink(raw string) (normalized string, remark string, err error) {
|
||||||
|
c, err := Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if c.Name != "" {
|
||||||
|
remark = c.Name
|
||||||
|
} else if h, _, e := net.SplitHostPort(c.Endpoint); e == nil {
|
||||||
|
remark = h
|
||||||
|
} else {
|
||||||
|
remark = c.Endpoint
|
||||||
|
}
|
||||||
|
// Prefer keeping original INI when pasted; re-serialize only for URI imports.
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if strings.HasPrefix(lower, "awg://") || strings.HasPrefix(lower, "amneziawg://") ||
|
||||||
|
strings.HasPrefix(lower, "wireguard://") {
|
||||||
|
return serializeINI(c), remark, nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw), remark, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serializeINI(c Conf) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("[Interface]\n")
|
||||||
|
b.WriteString("PrivateKey = " + c.PrivateKey + "\n")
|
||||||
|
if len(c.Address) > 0 {
|
||||||
|
b.WriteString("Address = " + strings.Join(c.Address, ", ") + "\n")
|
||||||
|
}
|
||||||
|
if len(c.DNS) > 0 {
|
||||||
|
b.WriteString("DNS = " + strings.Join(c.DNS, ", ") + "\n")
|
||||||
|
}
|
||||||
|
if c.MTU > 0 {
|
||||||
|
b.WriteString("MTU = " + strconv.Itoa(c.MTU) + "\n")
|
||||||
|
}
|
||||||
|
if c.Jc != 0 {
|
||||||
|
b.WriteString("Jc = " + strconv.Itoa(c.Jc) + "\n")
|
||||||
|
}
|
||||||
|
if c.Jmin != 0 {
|
||||||
|
b.WriteString("Jmin = " + strconv.Itoa(c.Jmin) + "\n")
|
||||||
|
}
|
||||||
|
if c.Jmax != 0 {
|
||||||
|
b.WriteString("Jmax = " + strconv.Itoa(c.Jmax) + "\n")
|
||||||
|
}
|
||||||
|
if c.S1 != 0 {
|
||||||
|
b.WriteString("S1 = " + strconv.Itoa(c.S1) + "\n")
|
||||||
|
}
|
||||||
|
if c.S2 != 0 {
|
||||||
|
b.WriteString("S2 = " + strconv.Itoa(c.S2) + "\n")
|
||||||
|
}
|
||||||
|
if c.S3 != 0 {
|
||||||
|
b.WriteString("S3 = " + strconv.Itoa(c.S3) + "\n")
|
||||||
|
}
|
||||||
|
if c.S4 != 0 {
|
||||||
|
b.WriteString("S4 = " + strconv.Itoa(c.S4) + "\n")
|
||||||
|
}
|
||||||
|
if c.H1 != "" {
|
||||||
|
b.WriteString("H1 = " + c.H1 + "\n")
|
||||||
|
}
|
||||||
|
if c.H2 != "" {
|
||||||
|
b.WriteString("H2 = " + c.H2 + "\n")
|
||||||
|
}
|
||||||
|
if c.H3 != "" {
|
||||||
|
b.WriteString("H3 = " + c.H3 + "\n")
|
||||||
|
}
|
||||||
|
if c.H4 != "" {
|
||||||
|
b.WriteString("H4 = " + c.H4 + "\n")
|
||||||
|
}
|
||||||
|
if c.I1 != "" {
|
||||||
|
b.WriteString("I1 = " + c.I1 + "\n")
|
||||||
|
}
|
||||||
|
if c.I2 != "" {
|
||||||
|
b.WriteString("I2 = " + c.I2 + "\n")
|
||||||
|
}
|
||||||
|
if c.I3 != "" {
|
||||||
|
b.WriteString("I3 = " + c.I3 + "\n")
|
||||||
|
}
|
||||||
|
if c.I4 != "" {
|
||||||
|
b.WriteString("I4 = " + c.I4 + "\n")
|
||||||
|
}
|
||||||
|
if c.I5 != "" {
|
||||||
|
b.WriteString("I5 = " + c.I5 + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n[Peer]\n")
|
||||||
|
b.WriteString("PublicKey = " + c.PublicKey + "\n")
|
||||||
|
if c.PresharedKey != "" {
|
||||||
|
b.WriteString("PresharedKey = " + c.PresharedKey + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("Endpoint = " + c.Endpoint + "\n")
|
||||||
|
if len(c.AllowedIPs) > 0 {
|
||||||
|
b.WriteString("AllowedIPs = " + strings.Join(c.AllowedIPs, ", ") + "\n")
|
||||||
|
}
|
||||||
|
if c.Keepalive > 0 {
|
||||||
|
b.WriteString("PersistentKeepalive = " + strconv.Itoa(c.Keepalive) + "\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package awg
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseINI_AWG20(t *testing.T) {
|
||||||
|
raw := `[Interface]
|
||||||
|
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
|
Address = 10.8.0.2/32
|
||||||
|
DNS = 1.1.1.1
|
||||||
|
Jc = 4
|
||||||
|
Jmin = 40
|
||||||
|
Jmax = 70
|
||||||
|
H1 = 1
|
||||||
|
H2 = 2
|
||||||
|
H3 = 3
|
||||||
|
H4 = 4
|
||||||
|
I1 = <r 128>
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
|
||||||
|
Endpoint = 203.0.113.10:51820
|
||||||
|
AllowedIPs = 0.0.0.0/0
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
`
|
||||||
|
c, err := Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.Jc != 4 || c.I1 != "<r 128>" || c.Endpoint != "203.0.113.10:51820" {
|
||||||
|
t.Fatalf("unexpected conf: %+v", c)
|
||||||
|
}
|
||||||
|
if !c.IsAWG20() {
|
||||||
|
t.Fatal("expected AWG2 markers")
|
||||||
|
}
|
||||||
|
ipc, err := c.ToIPC()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !containsAll(ipc, "jc=4", "i1=<r 128>", "endpoint=203.0.113.10:51820") {
|
||||||
|
t.Fatalf("bad ipc: %s", ipc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect(t *testing.T) {
|
||||||
|
if !Detect("[Interface]\nPrivateKey = x") {
|
||||||
|
t.Fatal("expected detect")
|
||||||
|
}
|
||||||
|
if !Detect("awg://1.2.3.4:51820/?private_key=a&public_key=b&address=10.0.0.2/32") {
|
||||||
|
t.Fatal("expected awg uri detect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(s string, parts ...string) bool {
|
||||||
|
for _, p := range parts {
|
||||||
|
if !stringsContains(s, p) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsContains(s, sub string) bool {
|
||||||
|
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package awg
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Detect is also used by linknorm / netcheck.
|
||||||
|
func LooksLike(raw string) bool { return Detect(raw) }
|
||||||
|
|
||||||
|
// IsAWG20 returns true when obfuscation params typical of AWG 1.5/2.0 are present.
|
||||||
|
func (c Conf) IsAWG20() bool {
|
||||||
|
if c.I1 != "" || c.I2 != "" || c.I3 != "" || c.I4 != "" || c.I5 != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c.Jc != 0 || c.Jmin != 0 || c.Jmax != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c.S1 != 0 || c.S2 != 0 || c.S3 != 0 || c.S4 != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c.H1 != "" || c.H2 != "" || c.H3 != "" || c.H4 != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripBOM removes a UTF-8 BOM if present.
|
||||||
|
func StripBOM(s string) string {
|
||||||
|
return strings.TrimPrefix(s, "\ufeff")
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package awg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/amnezia-vpn/amneziawg-go/conn"
|
||||||
|
"github.com/amnezia-vpn/amneziawg-go/device"
|
||||||
|
"github.com/amnezia-vpn/amneziawg-go/tun/netstack"
|
||||||
|
|
||||||
|
"vpnclient/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine runs AmneziaWG 2.0 in userspace (gvisor netstack) and exposes local SOCKS/HTTP.
|
||||||
|
type Engine struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dev *device.Device
|
||||||
|
tnet *netstack.Net
|
||||||
|
proxies *proxyServer
|
||||||
|
profile config.Profile
|
||||||
|
stderr io.Writer
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(stderr io.Writer) *Engine {
|
||||||
|
if stderr == nil {
|
||||||
|
stderr = os.Stderr
|
||||||
|
}
|
||||||
|
return &Engine{stderr: stderr}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Protocol() config.Protocol { return config.ProtocolAWG }
|
||||||
|
|
||||||
|
func (e *Engine) Start(ctx context.Context, profile config.Profile, _ string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.running {
|
||||||
|
return fmt.Errorf("awg: already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Parse(profile.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ipc, err := cfg.ToIPC()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addrs, err := cfg.LocalAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tunDev, tnet, err := netstack.CreateNetTUN(addrs, cfg.DNSAddrs(), cfg.EffectiveMTU())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("awg netstack: %w", err)
|
||||||
|
}
|
||||||
|
logger := &device.Logger{
|
||||||
|
Verbosef: func(format string, args ...any) {},
|
||||||
|
Errorf: func(format string, args ...any) {
|
||||||
|
fmt.Fprintf(e.stderr, "awg: "+format+"\n", args...)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dev := device.NewDevice(tunDev, conn.NewDefaultBind(), logger)
|
||||||
|
if err := dev.IpcSet(ipc); err != nil {
|
||||||
|
dev.Close()
|
||||||
|
return fmt.Errorf("awg configure: %w", err)
|
||||||
|
}
|
||||||
|
if err := dev.Up(); err != nil {
|
||||||
|
dev.Close()
|
||||||
|
return fmt.Errorf("awg up: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
socksHP, _ := profile.SOCKSListenHostPort()
|
||||||
|
httpHP, _ := profile.HTTPListenHostPort()
|
||||||
|
if socksHP == "" {
|
||||||
|
socksHP = "127.0.0.1:1080"
|
||||||
|
}
|
||||||
|
if httpHP == "" {
|
||||||
|
httpHP = "127.0.0.1:1081"
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies, err := startProxies(socksHP, httpHP, tnet.DialContext)
|
||||||
|
if err != nil {
|
||||||
|
dev.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dev = dev
|
||||||
|
e.tnet = tnet
|
||||||
|
e.proxies = proxies
|
||||||
|
e.profile = profile
|
||||||
|
e.running = true
|
||||||
|
|
||||||
|
// Brief handshake grace; don't fail hard if peer is slow.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = e.stopLocked()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(400 * time.Millisecond):
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Stop() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.stopLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) stopLocked() error {
|
||||||
|
if !e.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e.proxies != nil {
|
||||||
|
_ = e.proxies.Close()
|
||||||
|
e.proxies = nil
|
||||||
|
}
|
||||||
|
if e.dev != nil {
|
||||||
|
e.dev.Close()
|
||||||
|
e.dev = nil
|
||||||
|
}
|
||||||
|
e.tnet = nil
|
||||||
|
e.running = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Running() bool {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.running
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) LocalHTTPProxy() (string, bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if !e.running {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if e.proxies != nil && e.proxies.httpAddr != "" {
|
||||||
|
return e.proxies.httpAddr, true
|
||||||
|
}
|
||||||
|
return e.profile.HTTPListenHostPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) LocalSOCKSProxy() (string, bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if !e.running {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if e.proxies != nil && e.proxies.socksAddr != "" {
|
||||||
|
return e.proxies.socksAddr, true
|
||||||
|
}
|
||||||
|
return e.profile.SOCKSListenHostPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBinary is a no-op: AWG is embedded via amneziawg-go.
|
||||||
|
func EnsureBinary(_ string) (string, error) {
|
||||||
|
return "embedded-amneziawg-go", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBinary reports the embedded core.
|
||||||
|
func ResolveBinary(_ string) (string, error) {
|
||||||
|
return "embedded-amneziawg-go", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package awg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DialFunc dials TCP through the AWG userspace stack.
|
||||||
|
type DialFunc func(ctx context.Context, network, address string) (net.Conn, error)
|
||||||
|
|
||||||
|
type proxyServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
socksLn net.Listener
|
||||||
|
httpLn net.Listener
|
||||||
|
dial DialFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
closed bool
|
||||||
|
socksAddr string
|
||||||
|
httpAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProxies(socksHostPort, httpHostPort string, dial DialFunc) (*proxyServer, error) {
|
||||||
|
s := &proxyServer{dial: dial}
|
||||||
|
if socksHostPort != "" {
|
||||||
|
ln, err := net.Listen("tcp", socksHostPort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("awg socks listen %s: %w", socksHostPort, err)
|
||||||
|
}
|
||||||
|
s.socksLn = ln
|
||||||
|
s.socksAddr = ln.Addr().String()
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.serveSOCKS()
|
||||||
|
}
|
||||||
|
if httpHostPort != "" {
|
||||||
|
ln, err := net.Listen("tcp", httpHostPort)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.Close()
|
||||||
|
return nil, fmt.Errorf("awg http listen %s: %w", httpHostPort, err)
|
||||||
|
}
|
||||||
|
s.httpLn = ln
|
||||||
|
s.httpAddr = ln.Addr().String()
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.serveHTTP()
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *proxyServer) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
if s.socksLn != nil {
|
||||||
|
_ = s.socksLn.Close()
|
||||||
|
}
|
||||||
|
if s.httpLn != nil {
|
||||||
|
_ = s.httpLn.Close()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *proxyServer) serveSOCKS() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
for {
|
||||||
|
c, err := s.socksLn.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func(conn net.Conn) {
|
||||||
|
defer s.wg.Done()
|
||||||
|
_ = handleSOCKS(conn, s.dial)
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *proxyServer) serveHTTP() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
for {
|
||||||
|
c, err := s.httpLn.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func(conn net.Conn) {
|
||||||
|
defer s.wg.Done()
|
||||||
|
_ = handleHTTPProxy(conn, s.dial)
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSOCKS(client net.Conn, dial DialFunc) error {
|
||||||
|
defer client.Close()
|
||||||
|
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
buf := make([]byte, 258)
|
||||||
|
if _, err := io.ReadFull(client, buf[:2]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if buf[0] != 0x05 {
|
||||||
|
return fmt.Errorf("socks: not v5")
|
||||||
|
}
|
||||||
|
nmethods := int(buf[1])
|
||||||
|
if _, err := io.ReadFull(client, buf[:nmethods]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := client.Write([]byte{0x05, 0x00}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(client, buf[:4]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if buf[0] != 0x05 || buf[1] != 0x01 {
|
||||||
|
_, _ = client.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
|
return fmt.Errorf("socks: only CONNECT supported")
|
||||||
|
}
|
||||||
|
var host string
|
||||||
|
switch buf[3] {
|
||||||
|
case 0x01: // IPv4
|
||||||
|
if _, err := io.ReadFull(client, buf[:4]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host = net.IP(buf[:4]).String()
|
||||||
|
case 0x03: // domain
|
||||||
|
if _, err := io.ReadFull(client, buf[:1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
l := int(buf[0])
|
||||||
|
if _, err := io.ReadFull(client, buf[:l]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host = string(buf[:l])
|
||||||
|
case 0x04: // IPv6
|
||||||
|
if _, err := io.ReadFull(client, buf[:16]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host = net.IP(buf[:16]).String()
|
||||||
|
default:
|
||||||
|
_, _ = client.Write([]byte{0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
|
return fmt.Errorf("socks: bad atyp")
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(client, buf[:2]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
port := binary.BigEndian.Uint16(buf[:2])
|
||||||
|
_ = client.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
remote, err := dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||||
|
if err != nil {
|
||||||
|
_, _ = client.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer remote.Close()
|
||||||
|
if _, err := client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return relay(client, remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHTTPProxy(client net.Conn, dial DialFunc) error {
|
||||||
|
defer client.Close()
|
||||||
|
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
br := bufio.NewReader(client)
|
||||||
|
req, err := http.ReadRequest(br)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = client.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
if strings.EqualFold(req.Method, http.MethodConnect) {
|
||||||
|
host := req.Host
|
||||||
|
if !strings.Contains(host, ":") {
|
||||||
|
host += ":443"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
remote, err := dial(ctx, "tcp", host)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer remote.Close()
|
||||||
|
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return relay(client, remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute-form HTTP proxy request.
|
||||||
|
u := req.URL
|
||||||
|
host := u.Host
|
||||||
|
if host == "" {
|
||||||
|
host = req.Host
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
_, _ = client.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
|
||||||
|
return fmt.Errorf("http proxy: no host")
|
||||||
|
}
|
||||||
|
if !strings.Contains(host, ":") {
|
||||||
|
host += ":80"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
remote, err := dial(ctx, "tcp", host)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer remote.Close()
|
||||||
|
req.RequestURI = ""
|
||||||
|
if err := req.Write(remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return relay(client, remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func relay(a, b net.Conn) error {
|
||||||
|
errc := make(chan error, 2)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(a, b)
|
||||||
|
errc <- err
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(b, a)
|
||||||
|
errc <- err
|
||||||
|
}()
|
||||||
|
err := <-errc
|
||||||
|
_ = a.Close()
|
||||||
|
_ = b.Close()
|
||||||
|
<-errc
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ type Item struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
|
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
|
||||||
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
|
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CurrentVersion is the shipped client version.
|
// CurrentVersion is the shipped client version.
|
||||||
const CurrentVersion = "1.7.1"
|
const CurrentVersion = "1.8.0"
|
||||||
|
|
||||||
// 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"
|
||||||
@@ -225,3 +226,52 @@ func fileSHA256(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil)), nil
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepareDownload fetches the update next to the running binary as "<exe>.new".
|
||||||
|
func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, exePath, tmpPath string, err error) {
|
||||||
|
st, err := Check(ctx, manifestURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", "", err
|
||||||
|
}
|
||||||
|
if !st.Available {
|
||||||
|
if st.Error != "" {
|
||||||
|
return "", "", "", "", "", fmt.Errorf("%s", st.Error)
|
||||||
|
}
|
||||||
|
return "", "", "", "", "", fmt.Errorf("обновление не найдено")
|
||||||
|
}
|
||||||
|
if !allowedDownloadURL(st.URL) {
|
||||||
|
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", "", err
|
||||||
|
}
|
||||||
|
exe, err = filepath.Abs(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", "", err
|
||||||
|
}
|
||||||
|
tmp := exe + ".new"
|
||||||
|
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
||||||
|
return "", "", "", "", "", err
|
||||||
|
}
|
||||||
|
wantSHA := st.SHA256
|
||||||
|
if wantSHA == "" {
|
||||||
|
if man, err := fetchManifest(ctx, manifestURL); err == nil {
|
||||||
|
if _, sha2, ok := man.AssetFor(PlatformKey()); ok {
|
||||||
|
wantSHA = sha2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wantSHA != "" {
|
||||||
|
sum, err := fileSHA256(tmp)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", "", "", "", "", err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(sum, wantSHA) {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return st.Latest, st.URL, wantSHA, exe, tmp, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,88 +8,44 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Apply downloads the new binary into ~/Downloads (no silent self-replace).
|
// Apply downloads the new binary and schedules replacement + relaunch after exit.
|
||||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||||
st, err := Check(ctx, manifestURL)
|
latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if !st.Available {
|
_ = os.Chmod(tmp, 0o755)
|
||||||
if st.Error != "" {
|
dir := filepath.Dir(exe)
|
||||||
return "", fmt.Errorf("%s", st.Error)
|
scriptPath := filepath.Join(dir, "navis-update.sh")
|
||||||
}
|
pid := os.Getpid()
|
||||||
return "", fmt.Errorf("обновление не найдено")
|
|
||||||
}
|
|
||||||
if !allowedDownloadURL(st.URL) {
|
|
||||||
return "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
|
||||||
}
|
|
||||||
|
|
||||||
home, err := os.UserHomeDir()
|
script := "#!/bin/bash\n" +
|
||||||
if err != nil {
|
"set -e\n" +
|
||||||
|
"EXE=" + shellQuote(exe) + "\n" +
|
||||||
|
"NEW=" + shellQuote(tmp) + "\n" +
|
||||||
|
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||||
|
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
||||||
|
"sleep 0.5\n" +
|
||||||
|
"mv -f \"$NEW\" \"$EXE\"\n" +
|
||||||
|
"chmod +x \"$EXE\"\n" +
|
||||||
|
"rm -f " + shellQuote(scriptPath) + "\n" +
|
||||||
|
"exec \"$EXE\"\n"
|
||||||
|
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dir := filepath.Join(home, "Downloads")
|
cmd := exec.Command("/bin/bash", scriptPath)
|
||||||
_ = os.MkdirAll(dir, 0o755)
|
cmd.Dir = dir
|
||||||
dest := filepath.Join(dir, fmt.Sprintf("Navis-%s-darwin-%s", sanitizeDarwinVer(st.Latest), runtime.GOARCH))
|
if err := cmd.Start(); err != nil {
|
||||||
tmp := dest + ".part"
|
return "", fmt.Errorf("start updater: %w", err)
|
||||||
_ = os.Remove(tmp)
|
|
||||||
_ = os.Remove(dest)
|
|
||||||
|
|
||||||
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
if st.SHA256 != "" {
|
return latest, nil
|
||||||
sum, err := fileSHA256(tmp)
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if sum != "" && !eqFold(sum, st.SHA256) {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return "", fmt.Errorf("checksum mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, dest); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = os.Chmod(dest, 0o755)
|
|
||||||
_ = exec.Command("open", "-R", dest).Start()
|
|
||||||
return fmt.Sprintf("Скачано v%s:\n%s\nЗакройте Navis и запустите новый файл.", st.Latest, dest), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeDarwinVer(v string) string {
|
func shellQuote(s string) string {
|
||||||
out := make([]rune, 0, len(v))
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
for _, r := range v {
|
|
||||||
switch {
|
|
||||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '.', r == '-':
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return "update"
|
|
||||||
}
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func eqFold(a, b string) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := 0; i < len(a); i++ {
|
|
||||||
ca, cb := a[i], b[i]
|
|
||||||
if ca >= 'A' && ca <= 'F' {
|
|
||||||
ca += 'a' - 'A'
|
|
||||||
}
|
|
||||||
if cb >= 'A' && cb <= 'F' {
|
|
||||||
cb += 'a' - 'A'
|
|
||||||
}
|
|
||||||
if ca != cb {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,113 +5,116 @@ package update
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Apply downloads the new build into the user Downloads folder.
|
const finishUpdateFlag = "--navis-finish-update"
|
||||||
// It does NOT replace the running exe or spawn a self-updater (AV false-positive pattern).
|
|
||||||
|
// Apply downloads the new exe and starts it to replace the running binary after exit.
|
||||||
|
// Avoids writing .bat self-replacer scripts (common AV false-positive pattern).
|
||||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||||
st, err := Check(ctx, manifestURL)
|
latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if !st.Available {
|
pending := filepath.Join(filepath.Dir(exe), "Navis-pending.exe")
|
||||||
if st.Error != "" {
|
_ = os.Remove(pending)
|
||||||
return "", fmt.Errorf("%s", st.Error)
|
if err := os.Rename(tmp, pending); err != nil {
|
||||||
}
|
if err2 := copyFile(tmp, pending); err2 != nil {
|
||||||
return "", fmt.Errorf("обновление не найдено")
|
|
||||||
}
|
|
||||||
if !allowedDownloadURL(st.URL) {
|
|
||||||
return "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir, err := downloadsDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = os.MkdirAll(dir, 0o755)
|
|
||||||
dest := filepath.Join(dir, fmt.Sprintf("Navis-%s-windows-amd64.exe", sanitizeFileVer(st.Latest)))
|
|
||||||
tmp := dest + ".part"
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
_ = os.Remove(dest)
|
|
||||||
|
|
||||||
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if st.SHA256 != "" {
|
|
||||||
sum, err := fileSHA256(tmp)
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
return "", err
|
return "", err2
|
||||||
}
|
}
|
||||||
if !equalFoldHex(sum, st.SHA256) {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return "", fmt.Errorf("checksum mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, dest); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
_ = os.Chmod(pending, 0o755)
|
||||||
|
|
||||||
_ = revealInExplorer(dest)
|
cmd := exec.Command(pending, finishUpdateFlag, strconv.Itoa(os.Getpid()), exe)
|
||||||
return fmt.Sprintf("Скачано v%s:\n%s\nЗакройте Navis и запустите новый файл.", st.Latest, dest), nil
|
cmd.Dir = filepath.Dir(exe)
|
||||||
|
const createNoWindow = 0x08000000
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return "", fmt.Errorf("start updater: %w", err)
|
||||||
|
}
|
||||||
|
return latest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadsDir() (string, error) {
|
// MaybeFinishUpdate handles: Navis-pending.exe --navis-finish-update <pid> <targetExe>
|
||||||
if u := os.Getenv("USERPROFILE"); u != "" {
|
// Returns true if this process was the updater and should exit.
|
||||||
return filepath.Join(u, "Downloads"), nil
|
func MaybeFinishUpdate(args []string) bool {
|
||||||
|
if len(args) < 3 || args[0] != finishUpdateFlag {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
pid, err := strconv.Atoi(args[1])
|
||||||
|
if err != nil || pid <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
target := args[2]
|
||||||
|
self, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return true
|
||||||
}
|
}
|
||||||
return filepath.Join(home, "Downloads"), nil
|
self, _ = filepath.Abs(self)
|
||||||
|
target, _ = filepath.Abs(target)
|
||||||
|
|
||||||
|
waitPIDExit(uint32(pid), 90*time.Second)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
backup := target + ".bak"
|
||||||
|
_ = os.Remove(backup)
|
||||||
|
if err := os.Rename(target, backup); err != nil {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
_ = os.Rename(target, backup)
|
||||||
|
}
|
||||||
|
if err := copyFile(self, target); err != nil {
|
||||||
|
_ = os.Rename(backup, target)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = os.Chmod(target, 0o755)
|
||||||
|
_ = os.Remove(backup)
|
||||||
|
_ = os.Remove(self)
|
||||||
|
|
||||||
|
cmd := exec.Command(target)
|
||||||
|
cmd.Dir = filepath.Dir(target)
|
||||||
|
_ = cmd.Start()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func revealInExplorer(path string) error {
|
func waitPIDExit(pid uint32, timeout time.Duration) {
|
||||||
path, err := filepath.Abs(path)
|
const synchronize = 0x00100000
|
||||||
|
h, err := windows.OpenProcess(synchronize, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
ms := uint32(timeout / time.Millisecond)
|
||||||
|
if ms == 0 {
|
||||||
|
ms = 1000
|
||||||
|
}
|
||||||
|
_, _ = windows.WaitForSingleObject(h, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd := exec.Command("explorer.exe", "/select,", path)
|
defer in.Close()
|
||||||
const createNoWindow = 0x08000000
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
|
if err != nil {
|
||||||
return cmd.Start()
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeFileVer(v string) string {
|
|
||||||
out := make([]rune, 0, len(v))
|
|
||||||
for _, r := range v {
|
|
||||||
switch {
|
|
||||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '.', r == '-':
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
defer out.Close()
|
||||||
return "update"
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return string(out)
|
return out.Close()
|
||||||
}
|
|
||||||
|
|
||||||
func equalFoldHex(a, b string) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := 0; i < len(a); i++ {
|
|
||||||
ca, cb := a[i], b[i]
|
|
||||||
if ca >= 'A' && ca <= 'F' {
|
|
||||||
ca += 'a' - 'A'
|
|
||||||
}
|
|
||||||
if cb >= 'A' && cb <= 'F' {
|
|
||||||
cb += 'a' - 'A'
|
|
||||||
}
|
|
||||||
if ca != cb {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-16
@@ -1,38 +1,30 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.1",
|
"version": "1.8.0",
|
||||||
"notes": "AV: обновление только в Downloads без самозамены; без netsh и автопроверки при старте",
|
"notes": "Автообновление с перезапуском + AmneziaWG 2.0 (вставка .conf / awg://)",
|
||||||
"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/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
|
"sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
|
"sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "3cb42ab226b0263c8b6566888d80c6e7a6982d8cda992d112989c7f5c8a99bab",
|
"sha256": "e7df0d9d7dd6d9ad53768ffb250bedd8c1dcca7ba085847737dd378bf2aadbd6",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64"
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
|
||||||
"dmg_sha256": "65b8fe8c0d294ee923d59125f3e39c37f7c8a201aa6fd4661d080da59928c2e4",
|
|
||||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
|
|
||||||
"zip_sha256": "6685b1f8be9f8aa87613f35b8f8a86a00a6fb667c2f7bdc7116e6908c0cb1513"
|
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
"sha256": "7bffda217e178224f3a68fed4d5fff4e37b4705b7c8291b84f1d58151ef43b9d",
|
"sha256": "21583e6085e7d4d1b5800c21f5de356dc0f1c68c9658e58c0bd6557a390325cf",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64",
|
"arch": "amd64"
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
|
||||||
"dmg_sha256": "959526963936ad22d3c3607fe18295018362ec0d5fedcdf7561edf5d71c3b827",
|
|
||||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
|
||||||
"zip_sha256": "079c2af3fe5de6b9e73bad15a15bddfc93970d27be68b6620b5be3729163fc15"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": { "Major": 1, "Minor": 7, "Patch": 1, "Build": 0 },
|
"FileVersion": { "Major": 1, "Minor": 8, "Patch": 0, "Build": 0 },
|
||||||
"ProductVersion": { "Major": 1, "Minor": 7, "Patch": 1, "Build": 0 },
|
"ProductVersion": { "Major": 1, "Minor": 8, "Patch": 0, "Build": 0 },
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
"FileOS": "40004",
|
"FileOS": "40004",
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
},
|
},
|
||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis VPN client (NaiveProxy / Hysteria 2)",
|
"FileDescription": "Navis VPN client (NaiveProxy / Hysteria 2 / AmneziaWG 2.0)",
|
||||||
"FileVersion": "1.7.1.0",
|
"FileVersion": "1.8.0.0",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "1.7.1.0",
|
"ProductVersion": "1.8.0.0",
|
||||||
"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