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 @@
Доступно обновление

- +
@@ -365,8 +373,8 @@
- - + +
@@ -472,7 +480,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": {