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:
Navis
2026-07-29 06:45:57 +03:00
co-authored by Cursor
parent cd0b3f4707
commit 672979be4c
27 changed files with 1396 additions and 228 deletions
+5 -1
View File
@@ -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, S1S4, H1H4, I1I5) или `awg://…` — встроенный userspace-туннель + локальный SOCKS/HTTP.
В 1.7.1 дополнительно: В 1.7.1 дополнительно:
- обновление только скачивается в `Downloads` (без самозамены exe); - обновление только скачивается в `Downloads` (без самозамены exe);
- нет автопроверки обновлений и тихой загрузки cores при старте; - нет автопроверки обновлений и тихой загрузки cores при старте;
+35 -1
View File
@@ -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 {
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -16
View File
@@ -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"
} }
} }
} }
+8 -16
View File
@@ -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"
} }
} }
} }
+7
View File
@@ -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
) )
+16
View File
@@ -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=
+16 -8
View File
@@ -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"); }
})); }));
+3 -2
View File
@@ -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:
+6 -1
View File
@@ -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://"):
+15
View File
@@ -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:]
} }
+12 -2
View File
@@ -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
} }
+8 -1
View File
@@ -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
+12 -3
View File
@@ -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()
+551
View File
@@ -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()
}
+73
View File
@@ -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
}
+28
View File
@@ -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")
}
+170
View File
@@ -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
}
+245
View File
@@ -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
}
+1 -1
View File
@@ -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"']+)`)
) )
+51 -1
View File
@@ -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
}
+28 -72
View File
@@ -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
} }
+85 -82
View File
@@ -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
View File
@@ -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
View File
@@ -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": {