diff --git a/README.md b/README.md
index 17d0a7c..c561826 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 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-бинарники с сетью и сменой системного прокси.
+В 1.8.0:
+- автопроверка обновлений при старте и установка с автоперезапуском;
+- AmneziaWG 2.0: вставьте полный `.conf` (Jc/Jmin/Jmax, S1–S4, H1–H4, I1–I5) или `awg://…` — встроенный userspace-туннель + локальный SOCKS/HTTP.
+
В 1.7.1 дополнительно:
- обновление только скачивается в `Downloads` (без самозамены exe);
- нет автопроверки обновлений и тихой загрузки cores при старте;
diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go
index 866d94a..ec7b8bb 100644
--- a/cmd/vpnapp/main_windows.go
+++ b/cmd/vpnapp/main_windows.go
@@ -21,6 +21,7 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/netcheck"
+ "vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/update"
@@ -57,6 +58,10 @@ type uiState struct {
}
func main() {
+ if update.MaybeFinishUpdate(os.Args[1:]) {
+ return
+ }
+
log.SetFlags(log.LstdFlags | log.Lshortfile)
cfgPath, err := config.LocateConfig()
@@ -134,6 +139,8 @@ func main() {
mustBind(w, "saveHy2", a.saveHy2)
mustBind(w, "importSubscription", a.importSubscription)
+ go a.autoCheckUpdate()
+
w.SetHtml(appui.IndexHTML)
w.Run()
}
@@ -164,6 +171,10 @@ func (a *app) getState() (uiState, error) {
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
+ case config.ProtocolAWG:
+ if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
+ corePath, coreReady = path, true
+ }
default:
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
@@ -326,7 +337,30 @@ func (a *app) checkUpdate() (update.Status, error) {
func (a *app) applyUpdate() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
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 {
diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe
index 47cea83..d105aee 100644
Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ
diff --git a/dist/navis-release/darwin-amd64/Navis b/dist/navis-release/darwin-amd64/Navis
index 4850a36..0e451e9 100644
Binary files a/dist/navis-release/darwin-amd64/Navis and b/dist/navis-release/darwin-amd64/Navis differ
diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis
index af9f8a8..cb272fc 100644
Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ
diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json
index 8419cc1..583e942 100644
--- a/dist/navis-release/update.json
+++ b/dist/navis-release/update.json
@@ -1,38 +1,30 @@
{
- "version": "1.7.1",
- "notes": "AV: обновление только в Downloads без самозамены; без netsh и автопроверки при старте",
+ "version": "1.8.0",
+ "notes": "Автообновление с перезапуском + AmneziaWG 2.0 (вставка .conf / awg://)",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
+ "sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
+ "sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "3cb42ab226b0263c8b6566888d80c6e7a6982d8cda992d112989c7f5c8a99bab",
+ "sha256": "e7df0d9d7dd6d9ad53768ffb250bedd8c1dcca7ba085847737dd378bf2aadbd6",
"os": "darwin",
- "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"
+ "arch": "arm64"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
- "sha256": "7bffda217e178224f3a68fed4d5fff4e37b4705b7c8291b84f1d58151ef43b9d",
+ "sha256": "21583e6085e7d4d1b5800c21f5de356dc0f1c68c9658e58c0bd6557a390325cf",
"os": "darwin",
- "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"
+ "arch": "amd64"
}
}
}
diff --git a/dist/update.json b/dist/update.json
index 8419cc1..583e942 100644
--- a/dist/update.json
+++ b/dist/update.json
@@ -1,38 +1,30 @@
{
- "version": "1.7.1",
- "notes": "AV: обновление только в Downloads без самозамены; без netsh и автопроверки при старте",
+ "version": "1.8.0",
+ "notes": "Автообновление с перезапуском + AmneziaWG 2.0 (вставка .conf / awg://)",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
+ "sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
+ "sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "3cb42ab226b0263c8b6566888d80c6e7a6982d8cda992d112989c7f5c8a99bab",
+ "sha256": "e7df0d9d7dd6d9ad53768ffb250bedd8c1dcca7ba085847737dd378bf2aadbd6",
"os": "darwin",
- "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"
+ "arch": "arm64"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
- "sha256": "7bffda217e178224f3a68fed4d5fff4e37b4705b7c8291b84f1d58151ef43b9d",
+ "sha256": "21583e6085e7d4d1b5800c21f5de356dc0f1c68c9658e58c0bd6557a390325cf",
"os": "darwin",
- "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"
+ "arch": "amd64"
}
}
}
diff --git a/go.mod b/go.mod
index d491eda..5c0121b 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module vpnclient
go 1.25.0
require (
+ github.com/amnezia-vpn/amneziawg-go v0.2.19
github.com/diskfs/go-diskfs v1.9.4
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
golang.org/x/image v0.44.0
@@ -13,6 +14,7 @@ require (
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/djherbis/times v1.6.0 // 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/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/klauspost/compress v1.18.5 // indirect
@@ -20,4 +22,9 @@ require (
github.com/pkg/xattr v0.4.12 // indirect
github.com/sirupsen/logrus v1.9.4 // 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
)
diff --git a/go.sum b/go.sum
index 38c814d..64e466e 100644
--- a/go.sum
+++ b/go.sum
@@ -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/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
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/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/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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
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/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-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-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
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/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=
diff --git a/internal/appui/index.html b/internal/appui/index.html
index c951604..ba5c92b 100644
--- a/internal/appui/index.html
+++ b/internal/appui/index.html
@@ -90,14 +90,22 @@
display: grid; grid-template-columns: 1fr auto auto; gap: 8px;
margin-bottom: 12px;
}
- select, input[type="text"] {
+ select, input[type="text"], textarea {
width: 100%; border: 1px solid var(--line);
background: rgba(255,255,255,.92);
border-radius: 14px; padding: 12px 14px;
font: inherit; color: var(--ink); outline: none;
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);
box-shadow: 0 0 0 4px rgba(12,122,85,.12);
}
@@ -331,7 +339,7 @@
@@ -841,9 +849,9 @@
updateBtn.addEventListener("click", () => withBusy(async () => {
try {
- setMeta("Скачивание обновления…");
+ setMeta("Скачивание и установка обновления…");
const msg = await applyUpdate();
- setMeta(String(msg || "Готово"), "ok");
+ setMeta(String(msg || "Перезапуск…"), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
diff --git a/internal/config/config.go b/internal/config/config.go
index 5071dd0..67b2efc 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -13,6 +13,7 @@ type Protocol string
const (
ProtocolNaive Protocol = "naive"
ProtocolHysteria2 Protocol = "hysteria2"
+ ProtocolAWG Protocol = "awg"
)
// 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)
}
switch p.Protocol {
- case ProtocolNaive, ProtocolHysteria2:
- // Proxy may be empty until the user pastes a share link in the UI.
+ case ProtocolNaive, ProtocolHysteria2, ProtocolAWG:
+ // Proxy may be empty until the user pastes a share link / conf in the UI.
case "":
c.Profiles[i].Protocol = ProtocolNaive
default:
diff --git a/internal/config/detect.go b/internal/config/detect.go
index 780a19d..acaf276 100644
--- a/internal/config/detect.go
+++ b/internal/config/detect.go
@@ -2,12 +2,17 @@ package config
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 {
lower := strings.ToLower(strings.TrimSpace(raw))
switch {
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
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://"),
strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "quic://"),
strings.HasPrefix(lower, "http://"):
diff --git a/internal/config/profiles.go b/internal/config/profiles.go
index fd7ae74..bcf4180 100644
--- a/internal/config/profiles.go
+++ b/internal/config/profiles.go
@@ -34,6 +34,21 @@ func proxyHost(proxy string) string {
if proxy == "" {
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 {
proxy = proxy[i+3:]
}
diff --git a/internal/core/manager.go b/internal/core/manager.go
index 2223604..8fcd688 100644
--- a/internal/core/manager.go
+++ b/internal/core/manager.go
@@ -15,6 +15,7 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/linknorm"
+ "vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/subscription"
@@ -411,7 +412,7 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
return 0, err
}
if len(items) == 0 {
- return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
+ return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg)")
}
m.mu.Lock()
@@ -463,6 +464,8 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
return naive.New(m.stderr), nil
case config.ProtocolHysteria2:
return hysteria2.New(m.stderr), nil
+ case config.ProtocolAWG:
+ return awg.New(m.stderr), nil
default:
return nil, fmt.Errorf("unsupported protocol %q", p)
}
@@ -478,12 +481,14 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
switch proto {
case config.ProtocolHysteria2:
return hysteria2.EnsureBinary(m.binDir)
+ case config.ProtocolAWG:
+ return awg.EnsureBinary(m.binDir)
default:
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) {
out := map[string]string{}
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)
}
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
}
diff --git a/internal/linknorm/normalize.go b/internal/linknorm/normalize.go
index 555420e..9ccb9df 100644
--- a/internal/linknorm/normalize.go
+++ b/internal/linknorm/normalize.go
@@ -5,6 +5,7 @@ import (
"strings"
"vpnclient/internal/config"
+ "vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
)
@@ -22,10 +23,16 @@ func Normalize(proto config.Protocol, raw string) (normalized string, detected c
case config.ProtocolHysteria2:
u, rem, err := hysteria2.NormalizeShareLink(raw)
return u, config.ProtocolHysteria2, rem, err
+ case config.ProtocolAWG:
+ u, rem, err := awg.NormalizeShareLink(raw)
+ return u, config.ProtocolAWG, rem, err
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)
if err != nil {
- // try hy2 if naive failed and looks like hy
if hysteria2.Detect(raw) {
u2, rem2, err2 := hysteria2.NormalizeShareLink(raw)
return u2, config.ProtocolHysteria2, rem2, err2
diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go
index 685c663..04c0011 100644
--- a/internal/netcheck/ping.go
+++ b/internal/netcheck/ping.go
@@ -9,6 +9,7 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/linknorm"
+ "vpnclient/internal/protocols/awg"
"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)
+ isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
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)
if err != nil {
res.Error = err.Error()
@@ -78,13 +87,13 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
start := time.Now()
var err error
- if hy2 {
+ if hy2 || isAWG {
err = probeUDP(ctx, host, port)
} else {
err = probeTCP(ctx, host, port)
}
if err != nil {
- res.Error = friendlyDialError(err, hy2)
+ res.Error = friendlyDialError(err, hy2 || isAWG)
return res
}
res.Ms = time.Since(start).Milliseconds()
diff --git a/internal/protocols/awg/conf.go b/internal/protocols/awg/conf.go
new file mode 100644
index 0000000..cadd08c
--- /dev/null
+++ b/internal/protocols/awg/conf.go
@@ -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()
+}
diff --git a/internal/protocols/awg/conf_test.go b/internal/protocols/awg/conf_test.go
new file mode 100644
index 0000000..22cd8d8
--- /dev/null
+++ b/internal/protocols/awg/conf_test.go
@@ -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 =
+
+[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 != "" || 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=", "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
+}
diff --git a/internal/protocols/awg/detect.go b/internal/protocols/awg/detect.go
new file mode 100644
index 0000000..c95bf71
--- /dev/null
+++ b/internal/protocols/awg/detect.go
@@ -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")
+}
diff --git a/internal/protocols/awg/engine.go b/internal/protocols/awg/engine.go
new file mode 100644
index 0000000..afb00dd
--- /dev/null
+++ b/internal/protocols/awg/engine.go
@@ -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
+}
diff --git a/internal/protocols/awg/proxy.go b/internal/protocols/awg/proxy.go
new file mode 100644
index 0000000..8c2afc8
--- /dev/null
+++ b/internal/protocols/awg/proxy.go
@@ -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
+}
diff --git a/internal/subscription/fetch.go b/internal/subscription/fetch.go
index e3c0ef0..284e9be 100644
--- a/internal/subscription/fetch.go
+++ b/internal/subscription/fetch.go
@@ -26,7 +26,7 @@ type Item struct {
}
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"']+)`)
)
diff --git a/internal/update/update.go b/internal/update/update.go
index 123a311..0c60853 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"os"
+ "path/filepath"
"runtime"
"strconv"
"strings"
@@ -16,7 +17,7 @@ import (
)
// 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).
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
}
+
+// prepareDownload fetches the update next to the running binary as ".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
+}
diff --git a/internal/update/update_darwin.go b/internal/update/update_darwin.go
index 0c39ec2..cd92ee9 100644
--- a/internal/update/update_darwin.go
+++ b/internal/update/update_darwin.go
@@ -8,88 +8,44 @@ import (
"os"
"os/exec"
"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) {
- st, err := Check(ctx, manifestURL)
+ latest, _, _, exe, tmp, err := prepareDownload(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")
- }
+ _ = os.Chmod(tmp, 0o755)
+ dir := filepath.Dir(exe)
+ scriptPath := filepath.Join(dir, "navis-update.sh")
+ pid := os.Getpid()
- home, err := os.UserHomeDir()
- if err != nil {
+ script := "#!/bin/bash\n" +
+ "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
}
- dir := filepath.Join(home, "Downloads")
- _ = os.MkdirAll(dir, 0o755)
- dest := filepath.Join(dir, fmt.Sprintf("Navis-%s-darwin-%s", sanitizeDarwinVer(st.Latest), runtime.GOARCH))
- tmp := dest + ".part"
- _ = os.Remove(tmp)
- _ = os.Remove(dest)
-
- if err := downloadFile(ctx, st.URL, tmp); err != nil {
- return "", err
+ cmd := exec.Command("/bin/bash", scriptPath)
+ cmd.Dir = dir
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("start updater: %w", err)
}
- if st.SHA256 != "" {
- 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
+ return latest, nil
}
-func sanitizeDarwinVer(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 {
- 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
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
diff --git a/internal/update/update_windows.go b/internal/update/update_windows.go
index 873457d..4879bd1 100644
--- a/internal/update/update_windows.go
+++ b/internal/update/update_windows.go
@@ -5,113 +5,116 @@ package update
import (
"context"
"fmt"
+ "io"
"os"
"os/exec"
"path/filepath"
+ "strconv"
"syscall"
+ "time"
+
+ "golang.org/x/sys/windows"
)
-// Apply downloads the new build into the user Downloads folder.
-// It does NOT replace the running exe or spawn a self-updater (AV false-positive pattern).
+const finishUpdateFlag = "--navis-finish-update"
+
+// 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) {
- st, err := Check(ctx, manifestURL)
+ latest, _, _, exe, tmp, err := prepareDownload(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")
- }
-
- 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 {
+ pending := filepath.Join(filepath.Dir(exe), "Navis-pending.exe")
+ _ = os.Remove(pending)
+ if err := os.Rename(tmp, pending); err != nil {
+ if err2 := copyFile(tmp, pending); err2 != nil {
_ = 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)
- return "", err
}
+ _ = os.Chmod(pending, 0o755)
- _ = revealInExplorer(dest)
- return fmt.Sprintf("Скачано v%s:\n%s\nЗакройте Navis и запустите новый файл.", st.Latest, dest), nil
+ cmd := exec.Command(pending, finishUpdateFlag, strconv.Itoa(os.Getpid()), exe)
+ 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) {
- if u := os.Getenv("USERPROFILE"); u != "" {
- return filepath.Join(u, "Downloads"), nil
+// MaybeFinishUpdate handles: Navis-pending.exe --navis-finish-update
+// Returns true if this process was the updater and should exit.
+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 {
- 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 {
- path, err := filepath.Abs(path)
+func waitPIDExit(pid uint32, timeout time.Duration) {
+ 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 {
return err
}
- cmd := exec.Command("explorer.exe", "/select,", path)
- const createNoWindow = 0x08000000
- cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
- return cmd.Start()
-}
-
-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)
- }
+ defer in.Close()
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
+ if err != nil {
+ return err
}
- if len(out) == 0 {
- return "update"
+ defer out.Close()
+ if _, err := io.Copy(out, in); err != nil {
+ return err
}
- return string(out)
-}
-
-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
+ return out.Close()
}
diff --git a/server/update.json b/server/update.json
index 8419cc1..583e942 100644
--- a/server/update.json
+++ b/server/update.json
@@ -1,38 +1,30 @@
{
- "version": "1.7.1",
- "notes": "AV: обновление только в Downloads без самозамены; без netsh и автопроверки при старте",
+ "version": "1.8.0",
+ "notes": "Автообновление с перезапуском + AmneziaWG 2.0 (вставка .conf / awg://)",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
+ "sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
- "sha256": "e08425b2eddd17a71a6e083990313811df9a1f3a55586c407798bfeeb82fd68b",
+ "sha256": "cc656a2c9c026c1c0f068eb7b05785965d7b7bc9fc42b5b13f0e6e7ada3dde97",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "3cb42ab226b0263c8b6566888d80c6e7a6982d8cda992d112989c7f5c8a99bab",
+ "sha256": "e7df0d9d7dd6d9ad53768ffb250bedd8c1dcca7ba085847737dd378bf2aadbd6",
"os": "darwin",
- "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"
+ "arch": "arm64"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
- "sha256": "7bffda217e178224f3a68fed4d5fff4e37b4705b7c8291b84f1d58151ef43b9d",
+ "sha256": "21583e6085e7d4d1b5800c21f5de356dc0f1c68c9658e58c0bd6557a390325cf",
"os": "darwin",
- "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"
+ "arch": "amd64"
}
}
}
diff --git a/versioninfo.json b/versioninfo.json
index 31d92e9..44d9888 100644
--- a/versioninfo.json
+++ b/versioninfo.json
@@ -1,7 +1,7 @@
{
"FixedFileInfo": {
- "FileVersion": { "Major": 1, "Minor": 7, "Patch": 1, "Build": 0 },
- "ProductVersion": { "Major": 1, "Minor": 7, "Patch": 1, "Build": 0 },
+ "FileVersion": { "Major": 1, "Minor": 8, "Patch": 0, "Build": 0 },
+ "ProductVersion": { "Major": 1, "Minor": 8, "Patch": 0, "Build": 0 },
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "40004",
@@ -10,13 +10,13 @@
},
"StringFileInfo": {
"CompanyName": "EvilFox",
- "FileDescription": "Navis VPN client (NaiveProxy / Hysteria 2)",
- "FileVersion": "1.7.1.0",
+ "FileDescription": "Navis VPN client (NaiveProxy / Hysteria 2 / AmneziaWG 2.0)",
+ "FileVersion": "1.8.0.0",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
- "ProductVersion": "1.7.1.0",
+ "ProductVersion": "1.8.0.0",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {