Release 1.8.0: auto-update with restart and AmneziaWG 2.0 support.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Navis
|
||||
|
||||
Клиент [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 при старте;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+8
-16
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"version": "1.7.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+8
-16
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 @@
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<strong id="updateTitle">Доступно обновление</strong>
|
||||
<p id="updateNotes"></p>
|
||||
<button class="action primary" id="updateBtn" type="button">Обновить сейчас</button>
|
||||
<button class="action primary" id="updateBtn" type="button">Обновить и перезапустить</button>
|
||||
</div>
|
||||
|
||||
<div class="top">
|
||||
@@ -365,8 +373,8 @@
|
||||
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="proxy">Ссылка сервера</label>
|
||||
<input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" />
|
||||
<label class="field" for="proxy">Ссылка / конфиг сервера</label>
|
||||
<textarea id="proxy" rows="4" placeholder="naive+https://… · hysteria2://… · или весь .conf AmneziaWG 2.0 ([Interface]/Jc/I1…)" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="subUrl">URL подписки</label>
|
||||
@@ -472,7 +480,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="newProxy">Ссылка</label>
|
||||
<input id="newProxy" type="text" placeholder="hysteria2://pass@host:443/ или naive+https://…" />
|
||||
<input id="newProxy" type="text" placeholder="hysteria2://… / naive+https://… / awg://… или вставьте .conf" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -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"); }
|
||||
}));
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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://"):
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
package awg
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Conf is a parsed AmneziaWG / WireGuard client configuration (AWG 2.0 fields included).
|
||||
type Conf struct {
|
||||
PrivateKey string
|
||||
Address []string
|
||||
DNS []string
|
||||
MTU int
|
||||
Jc int
|
||||
Jmin int
|
||||
Jmax int
|
||||
S1, S2, S3, S4 int
|
||||
H1, H2, H3, H4 string
|
||||
I1, I2, I3, I4, I5 string
|
||||
PublicKey string
|
||||
PresharedKey string
|
||||
Endpoint string
|
||||
AllowedIPs []string
|
||||
Keepalive int
|
||||
Name string
|
||||
}
|
||||
|
||||
// Detect reports whether raw looks like an AWG/WireGuard config or share link.
|
||||
func Detect(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "awg://") || strings.HasPrefix(lower, "amneziawg://") ||
|
||||
strings.HasPrefix(lower, "wireguard://") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "[interface]") && strings.Contains(lower, "privatekey") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse accepts a WireGuard/AWG .conf body or awg:// URI and returns Conf.
|
||||
func Parse(raw string) (Conf, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Conf{}, fmt.Errorf("пустой AWG конфиг")
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
|
||||
strings.HasPrefix(lower, "wireguard://"):
|
||||
return parseURI(raw)
|
||||
default:
|
||||
return parseINI(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func parseINI(raw string) (Conf, error) {
|
||||
var c Conf
|
||||
section := ""
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
|
||||
continue
|
||||
}
|
||||
key, val, ok := splitKV(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k := strings.ToLower(key)
|
||||
switch section {
|
||||
case "interface":
|
||||
switch k {
|
||||
case "privatekey":
|
||||
c.PrivateKey = val
|
||||
case "address":
|
||||
c.Address = appendCSV(c.Address, val)
|
||||
case "dns":
|
||||
c.DNS = appendCSV(c.DNS, val)
|
||||
case "mtu":
|
||||
c.MTU, _ = strconv.Atoi(val)
|
||||
case "jc":
|
||||
c.Jc, _ = strconv.Atoi(val)
|
||||
case "jmin":
|
||||
c.Jmin, _ = strconv.Atoi(val)
|
||||
case "jmax":
|
||||
c.Jmax, _ = strconv.Atoi(val)
|
||||
case "s1":
|
||||
c.S1, _ = strconv.Atoi(val)
|
||||
case "s2":
|
||||
c.S2, _ = strconv.Atoi(val)
|
||||
case "s3":
|
||||
c.S3, _ = strconv.Atoi(val)
|
||||
case "s4":
|
||||
c.S4, _ = strconv.Atoi(val)
|
||||
case "h1":
|
||||
c.H1 = val
|
||||
case "h2":
|
||||
c.H2 = val
|
||||
case "h3":
|
||||
c.H3 = val
|
||||
case "h4":
|
||||
c.H4 = val
|
||||
case "i1":
|
||||
c.I1 = val
|
||||
case "i2":
|
||||
c.I2 = val
|
||||
case "i3":
|
||||
c.I3 = val
|
||||
case "i4":
|
||||
c.I4 = val
|
||||
case "i5":
|
||||
c.I5 = val
|
||||
}
|
||||
case "peer":
|
||||
switch k {
|
||||
case "publickey":
|
||||
c.PublicKey = val
|
||||
case "presharedkey":
|
||||
c.PresharedKey = val
|
||||
case "endpoint":
|
||||
c.Endpoint = val
|
||||
case "allowedips":
|
||||
c.AllowedIPs = appendCSV(c.AllowedIPs, val)
|
||||
case "persistentkeepalive":
|
||||
c.Keepalive, _ = strconv.Atoi(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Conf{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func parseURI(raw string) (Conf, error) {
|
||||
// awg://host:port/?public_key=...&private_key=...&address=10.8.0.2/32&jc=4&...
|
||||
u := raw
|
||||
schemeIdx := strings.Index(u, "://")
|
||||
if schemeIdx < 0 {
|
||||
return Conf{}, fmt.Errorf("bad awg uri")
|
||||
}
|
||||
rest := u[schemeIdx+3:]
|
||||
hostPort := rest
|
||||
query := ""
|
||||
if i := strings.Index(rest, "?"); i >= 0 {
|
||||
hostPort = rest[:i]
|
||||
query = rest[i+1:]
|
||||
}
|
||||
hostPort = strings.TrimSuffix(hostPort, "/")
|
||||
c := Conf{Endpoint: hostPort}
|
||||
for _, part := range strings.Split(query, "&") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
v = strings.TrimSpace(v)
|
||||
v, _ = urlDecode(v)
|
||||
switch k {
|
||||
case "private_key", "privatekey":
|
||||
c.PrivateKey = v
|
||||
case "public_key", "publickey":
|
||||
c.PublicKey = v
|
||||
case "preshared_key", "presharedkey", "psk":
|
||||
c.PresharedKey = v
|
||||
case "address", "addr":
|
||||
c.Address = appendCSV(c.Address, v)
|
||||
case "dns":
|
||||
c.DNS = appendCSV(c.DNS, v)
|
||||
case "allowed_ips", "allowedips":
|
||||
c.AllowedIPs = appendCSV(c.AllowedIPs, v)
|
||||
case "mtu":
|
||||
c.MTU, _ = strconv.Atoi(v)
|
||||
case "keepalive", "persistentkeepalive":
|
||||
c.Keepalive, _ = strconv.Atoi(v)
|
||||
case "jc":
|
||||
c.Jc, _ = strconv.Atoi(v)
|
||||
case "jmin":
|
||||
c.Jmin, _ = strconv.Atoi(v)
|
||||
case "jmax":
|
||||
c.Jmax, _ = strconv.Atoi(v)
|
||||
case "s1":
|
||||
c.S1, _ = strconv.Atoi(v)
|
||||
case "s2":
|
||||
c.S2, _ = strconv.Atoi(v)
|
||||
case "s3":
|
||||
c.S3, _ = strconv.Atoi(v)
|
||||
case "s4":
|
||||
c.S4, _ = strconv.Atoi(v)
|
||||
case "h1":
|
||||
c.H1 = v
|
||||
case "h2":
|
||||
c.H2 = v
|
||||
case "h3":
|
||||
c.H3 = v
|
||||
case "h4":
|
||||
c.H4 = v
|
||||
case "i1":
|
||||
c.I1 = v
|
||||
case "i2":
|
||||
c.I2 = v
|
||||
case "i3":
|
||||
c.I3 = v
|
||||
case "i4":
|
||||
c.I4 = v
|
||||
case "i5":
|
||||
c.I5 = v
|
||||
case "name", "remark":
|
||||
c.Name = v
|
||||
case "endpoint":
|
||||
c.Endpoint = v
|
||||
}
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Conf{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Conf) validate() error {
|
||||
if c.PrivateKey == "" {
|
||||
return fmt.Errorf("awg: нет PrivateKey")
|
||||
}
|
||||
if c.PublicKey == "" {
|
||||
return fmt.Errorf("awg: нет PublicKey")
|
||||
}
|
||||
if c.Endpoint == "" {
|
||||
return fmt.Errorf("awg: нет Endpoint")
|
||||
}
|
||||
if len(c.Address) == 0 {
|
||||
return fmt.Errorf("awg: нет Address")
|
||||
}
|
||||
if len(c.AllowedIPs) == 0 {
|
||||
c.AllowedIPs = []string{"0.0.0.0/0", "::/0"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HostPort returns UDP endpoint host and port for ping.
|
||||
func HostPort(raw string) (host, port string, err error) {
|
||||
c, err := Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
h, p, err := net.SplitHostPort(c.Endpoint)
|
||||
if err != nil {
|
||||
// bare host without port
|
||||
return c.Endpoint, "51820", nil
|
||||
}
|
||||
return h, p, nil
|
||||
}
|
||||
|
||||
// ToIPC builds the amneziawg-go UAPI configuration string.
|
||||
func (c Conf) ToIPC() (string, error) {
|
||||
priv, err := decodeKey(c.PrivateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("private key: %w", err)
|
||||
}
|
||||
pub, err := decodeKey(c.PublicKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("public key: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("private_key=")
|
||||
b.WriteString(hex.EncodeToString(priv))
|
||||
writeInt(&b, "jc", c.Jc)
|
||||
writeInt(&b, "jmin", c.Jmin)
|
||||
writeInt(&b, "jmax", c.Jmax)
|
||||
writeInt(&b, "s1", c.S1)
|
||||
writeInt(&b, "s2", c.S2)
|
||||
writeInt(&b, "s3", c.S3)
|
||||
writeInt(&b, "s4", c.S4)
|
||||
writeStr(&b, "h1", c.H1)
|
||||
writeStr(&b, "h2", c.H2)
|
||||
writeStr(&b, "h3", c.H3)
|
||||
writeStr(&b, "h4", c.H4)
|
||||
writeStr(&b, "i1", c.I1)
|
||||
writeStr(&b, "i2", c.I2)
|
||||
writeStr(&b, "i3", c.I3)
|
||||
writeStr(&b, "i4", c.I4)
|
||||
writeStr(&b, "i5", c.I5)
|
||||
|
||||
b.WriteString("\npublic_key=")
|
||||
b.WriteString(hex.EncodeToString(pub))
|
||||
b.WriteString("\nendpoint=")
|
||||
b.WriteString(c.Endpoint)
|
||||
for _, ip := range c.AllowedIPs {
|
||||
b.WriteString("\nallowed_ip=")
|
||||
b.WriteString(strings.TrimSpace(ip))
|
||||
}
|
||||
if c.PresharedKey != "" {
|
||||
psk, err := decodeKey(c.PresharedKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preshared key: %w", err)
|
||||
}
|
||||
b.WriteString("\npreshared_key=")
|
||||
b.WriteString(hex.EncodeToString(psk))
|
||||
}
|
||||
if c.Keepalive > 0 {
|
||||
writeInt(&b, "persistent_keepalive_interval", c.Keepalive)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// LocalAddrs parses Interface Address values.
|
||||
func (c Conf) LocalAddrs() ([]netip.Addr, error) {
|
||||
var out []netip.Addr
|
||||
for _, a := range c.Address {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if p, err := netip.ParsePrefix(a); err == nil {
|
||||
out = append(out, p.Addr())
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("address %q: %w", a, err)
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no local addresses")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DNSAddrs parses DNS servers (defaults to 1.1.1.1).
|
||||
func (c Conf) DNSAddrs() []netip.Addr {
|
||||
var out []netip.Addr
|
||||
for _, d := range c.DNS {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(d); err == nil {
|
||||
out = append(out, addr)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, netip.MustParseAddr("1.1.1.1"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c Conf) EffectiveMTU() int {
|
||||
if c.MTU > 0 {
|
||||
return c.MTU
|
||||
}
|
||||
return 1420
|
||||
}
|
||||
|
||||
func decodeKey(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected 32-byte base64/hex key")
|
||||
}
|
||||
|
||||
func writeInt(b *strings.Builder, key string, v int) {
|
||||
if v == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(strconv.Itoa(v))
|
||||
}
|
||||
|
||||
func writeStr(b *strings.Builder, key, v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(v)
|
||||
}
|
||||
|
||||
func splitKV(line string) (key, val string, ok bool) {
|
||||
i := strings.IndexAny(line, "=:")
|
||||
if i < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true
|
||||
}
|
||||
|
||||
func appendCSV(dst []string, raw string) []string {
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
dst = append(dst, p)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func urlDecode(s string) (string, error) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); {
|
||||
switch s[i] {
|
||||
case '+':
|
||||
b.WriteByte(' ')
|
||||
i++
|
||||
case '%':
|
||||
if i+2 >= len(s) {
|
||||
return s, nil
|
||||
}
|
||||
var v byte
|
||||
for _, c := range []byte{s[i+1], s[i+2]} {
|
||||
v <<= 4
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
v |= c - '0'
|
||||
case c >= 'a' && c <= 'f':
|
||||
v |= c - 'a' + 10
|
||||
case c >= 'A' && c <= 'F':
|
||||
v |= c - 'A' + 10
|
||||
default:
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
b.WriteByte(v)
|
||||
i += 3
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// NormalizeShareLink stores a canonical conf body (or passes through awg URI).
|
||||
func NormalizeShareLink(raw string) (normalized string, remark string, err error) {
|
||||
c, err := Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if c.Name != "" {
|
||||
remark = c.Name
|
||||
} else if h, _, e := net.SplitHostPort(c.Endpoint); e == nil {
|
||||
remark = h
|
||||
} else {
|
||||
remark = c.Endpoint
|
||||
}
|
||||
// Prefer keeping original INI when pasted; re-serialize only for URI imports.
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
if strings.HasPrefix(lower, "awg://") || strings.HasPrefix(lower, "amneziawg://") ||
|
||||
strings.HasPrefix(lower, "wireguard://") {
|
||||
return serializeINI(c), remark, nil
|
||||
}
|
||||
return strings.TrimSpace(raw), remark, nil
|
||||
}
|
||||
|
||||
func serializeINI(c Conf) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[Interface]\n")
|
||||
b.WriteString("PrivateKey = " + c.PrivateKey + "\n")
|
||||
if len(c.Address) > 0 {
|
||||
b.WriteString("Address = " + strings.Join(c.Address, ", ") + "\n")
|
||||
}
|
||||
if len(c.DNS) > 0 {
|
||||
b.WriteString("DNS = " + strings.Join(c.DNS, ", ") + "\n")
|
||||
}
|
||||
if c.MTU > 0 {
|
||||
b.WriteString("MTU = " + strconv.Itoa(c.MTU) + "\n")
|
||||
}
|
||||
if c.Jc != 0 {
|
||||
b.WriteString("Jc = " + strconv.Itoa(c.Jc) + "\n")
|
||||
}
|
||||
if c.Jmin != 0 {
|
||||
b.WriteString("Jmin = " + strconv.Itoa(c.Jmin) + "\n")
|
||||
}
|
||||
if c.Jmax != 0 {
|
||||
b.WriteString("Jmax = " + strconv.Itoa(c.Jmax) + "\n")
|
||||
}
|
||||
if c.S1 != 0 {
|
||||
b.WriteString("S1 = " + strconv.Itoa(c.S1) + "\n")
|
||||
}
|
||||
if c.S2 != 0 {
|
||||
b.WriteString("S2 = " + strconv.Itoa(c.S2) + "\n")
|
||||
}
|
||||
if c.S3 != 0 {
|
||||
b.WriteString("S3 = " + strconv.Itoa(c.S3) + "\n")
|
||||
}
|
||||
if c.S4 != 0 {
|
||||
b.WriteString("S4 = " + strconv.Itoa(c.S4) + "\n")
|
||||
}
|
||||
if c.H1 != "" {
|
||||
b.WriteString("H1 = " + c.H1 + "\n")
|
||||
}
|
||||
if c.H2 != "" {
|
||||
b.WriteString("H2 = " + c.H2 + "\n")
|
||||
}
|
||||
if c.H3 != "" {
|
||||
b.WriteString("H3 = " + c.H3 + "\n")
|
||||
}
|
||||
if c.H4 != "" {
|
||||
b.WriteString("H4 = " + c.H4 + "\n")
|
||||
}
|
||||
if c.I1 != "" {
|
||||
b.WriteString("I1 = " + c.I1 + "\n")
|
||||
}
|
||||
if c.I2 != "" {
|
||||
b.WriteString("I2 = " + c.I2 + "\n")
|
||||
}
|
||||
if c.I3 != "" {
|
||||
b.WriteString("I3 = " + c.I3 + "\n")
|
||||
}
|
||||
if c.I4 != "" {
|
||||
b.WriteString("I4 = " + c.I4 + "\n")
|
||||
}
|
||||
if c.I5 != "" {
|
||||
b.WriteString("I5 = " + c.I5 + "\n")
|
||||
}
|
||||
b.WriteString("\n[Peer]\n")
|
||||
b.WriteString("PublicKey = " + c.PublicKey + "\n")
|
||||
if c.PresharedKey != "" {
|
||||
b.WriteString("PresharedKey = " + c.PresharedKey + "\n")
|
||||
}
|
||||
b.WriteString("Endpoint = " + c.Endpoint + "\n")
|
||||
if len(c.AllowedIPs) > 0 {
|
||||
b.WriteString("AllowedIPs = " + strings.Join(c.AllowedIPs, ", ") + "\n")
|
||||
}
|
||||
if c.Keepalive > 0 {
|
||||
b.WriteString("PersistentKeepalive = " + strconv.Itoa(c.Keepalive) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package awg
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseINI_AWG20(t *testing.T) {
|
||||
raw := `[Interface]
|
||||
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
Address = 10.8.0.2/32
|
||||
DNS = 1.1.1.1
|
||||
Jc = 4
|
||||
Jmin = 40
|
||||
Jmax = 70
|
||||
H1 = 1
|
||||
H2 = 2
|
||||
H3 = 3
|
||||
H4 = 4
|
||||
I1 = <r 128>
|
||||
|
||||
[Peer]
|
||||
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
|
||||
Endpoint = 203.0.113.10:51820
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
PersistentKeepalive = 25
|
||||
`
|
||||
c, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Jc != 4 || c.I1 != "<r 128>" || c.Endpoint != "203.0.113.10:51820" {
|
||||
t.Fatalf("unexpected conf: %+v", c)
|
||||
}
|
||||
if !c.IsAWG20() {
|
||||
t.Fatal("expected AWG2 markers")
|
||||
}
|
||||
ipc, err := c.ToIPC()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !containsAll(ipc, "jc=4", "i1=<r 128>", "endpoint=203.0.113.10:51820") {
|
||||
t.Fatalf("bad ipc: %s", ipc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
if !Detect("[Interface]\nPrivateKey = x") {
|
||||
t.Fatal("expected detect")
|
||||
}
|
||||
if !Detect("awg://1.2.3.4:51820/?private_key=a&public_key=b&address=10.0.0.2/32") {
|
||||
t.Fatal("expected awg uri detect")
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, parts ...string) bool {
|
||||
for _, p := range parts {
|
||||
if !stringsContains(s, p) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringsContains(s, sub string) bool {
|
||||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package awg
|
||||
|
||||
import "strings"
|
||||
|
||||
// Detect is also used by linknorm / netcheck.
|
||||
func LooksLike(raw string) bool { return Detect(raw) }
|
||||
|
||||
// IsAWG20 returns true when obfuscation params typical of AWG 1.5/2.0 are present.
|
||||
func (c Conf) IsAWG20() bool {
|
||||
if c.I1 != "" || c.I2 != "" || c.I3 != "" || c.I4 != "" || c.I5 != "" {
|
||||
return true
|
||||
}
|
||||
if c.Jc != 0 || c.Jmin != 0 || c.Jmax != 0 {
|
||||
return true
|
||||
}
|
||||
if c.S1 != 0 || c.S2 != 0 || c.S3 != 0 || c.S4 != 0 {
|
||||
return true
|
||||
}
|
||||
if c.H1 != "" || c.H2 != "" || c.H3 != "" || c.H4 != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StripBOM removes a UTF-8 BOM if present.
|
||||
func StripBOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\ufeff")
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package awg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/amnezia-vpn/amneziawg-go/conn"
|
||||
"github.com/amnezia-vpn/amneziawg-go/device"
|
||||
"github.com/amnezia-vpn/amneziawg-go/tun/netstack"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// Engine runs AmneziaWG 2.0 in userspace (gvisor netstack) and exposes local SOCKS/HTTP.
|
||||
type Engine struct {
|
||||
mu sync.Mutex
|
||||
dev *device.Device
|
||||
tnet *netstack.Net
|
||||
proxies *proxyServer
|
||||
profile config.Profile
|
||||
stderr io.Writer
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(stderr io.Writer) *Engine {
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
return &Engine{stderr: stderr}
|
||||
}
|
||||
|
||||
func (e *Engine) Protocol() config.Protocol { return config.ProtocolAWG }
|
||||
|
||||
func (e *Engine) Start(ctx context.Context, profile config.Profile, _ string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.running {
|
||||
return fmt.Errorf("awg: already running")
|
||||
}
|
||||
|
||||
cfg, err := Parse(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ipc, err := cfg.ToIPC()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addrs, err := cfg.LocalAddrs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tunDev, tnet, err := netstack.CreateNetTUN(addrs, cfg.DNSAddrs(), cfg.EffectiveMTU())
|
||||
if err != nil {
|
||||
return fmt.Errorf("awg netstack: %w", err)
|
||||
}
|
||||
logger := &device.Logger{
|
||||
Verbosef: func(format string, args ...any) {},
|
||||
Errorf: func(format string, args ...any) {
|
||||
fmt.Fprintf(e.stderr, "awg: "+format+"\n", args...)
|
||||
},
|
||||
}
|
||||
dev := device.NewDevice(tunDev, conn.NewDefaultBind(), logger)
|
||||
if err := dev.IpcSet(ipc); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("awg configure: %w", err)
|
||||
}
|
||||
if err := dev.Up(); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("awg up: %w", err)
|
||||
}
|
||||
|
||||
socksHP, _ := profile.SOCKSListenHostPort()
|
||||
httpHP, _ := profile.HTTPListenHostPort()
|
||||
if socksHP == "" {
|
||||
socksHP = "127.0.0.1:1080"
|
||||
}
|
||||
if httpHP == "" {
|
||||
httpHP = "127.0.0.1:1081"
|
||||
}
|
||||
|
||||
proxies, err := startProxies(socksHP, httpHP, tnet.DialContext)
|
||||
if err != nil {
|
||||
dev.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
e.dev = dev
|
||||
e.tnet = tnet
|
||||
e.proxies = proxies
|
||||
e.profile = profile
|
||||
e.running = true
|
||||
|
||||
// Brief handshake grace; don't fail hard if peer is slow.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = e.stopLocked()
|
||||
return ctx.Err()
|
||||
case <-time.After(400 * time.Millisecond):
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) Stop() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.stopLocked()
|
||||
}
|
||||
|
||||
func (e *Engine) stopLocked() error {
|
||||
if !e.running {
|
||||
return nil
|
||||
}
|
||||
if e.proxies != nil {
|
||||
_ = e.proxies.Close()
|
||||
e.proxies = nil
|
||||
}
|
||||
if e.dev != nil {
|
||||
e.dev.Close()
|
||||
e.dev = nil
|
||||
}
|
||||
e.tnet = nil
|
||||
e.running = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) Running() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.running
|
||||
}
|
||||
|
||||
func (e *Engine) LocalHTTPProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
if e.proxies != nil && e.proxies.httpAddr != "" {
|
||||
return e.proxies.httpAddr, true
|
||||
}
|
||||
return e.profile.HTTPListenHostPort()
|
||||
}
|
||||
|
||||
func (e *Engine) LocalSOCKSProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
if e.proxies != nil && e.proxies.socksAddr != "" {
|
||||
return e.proxies.socksAddr, true
|
||||
}
|
||||
return e.profile.SOCKSListenHostPort()
|
||||
}
|
||||
|
||||
// EnsureBinary is a no-op: AWG is embedded via amneziawg-go.
|
||||
func EnsureBinary(_ string) (string, error) {
|
||||
return "embedded-amneziawg-go", nil
|
||||
}
|
||||
|
||||
// ResolveBinary reports the embedded core.
|
||||
func ResolveBinary(_ string) (string, error) {
|
||||
return "embedded-amneziawg-go", nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package awg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DialFunc dials TCP through the AWG userspace stack.
|
||||
type DialFunc func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
|
||||
type proxyServer struct {
|
||||
mu sync.Mutex
|
||||
socksLn net.Listener
|
||||
httpLn net.Listener
|
||||
dial DialFunc
|
||||
wg sync.WaitGroup
|
||||
closed bool
|
||||
socksAddr string
|
||||
httpAddr string
|
||||
}
|
||||
|
||||
func startProxies(socksHostPort, httpHostPort string, dial DialFunc) (*proxyServer, error) {
|
||||
s := &proxyServer{dial: dial}
|
||||
if socksHostPort != "" {
|
||||
ln, err := net.Listen("tcp", socksHostPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("awg socks listen %s: %w", socksHostPort, err)
|
||||
}
|
||||
s.socksLn = ln
|
||||
s.socksAddr = ln.Addr().String()
|
||||
s.wg.Add(1)
|
||||
go s.serveSOCKS()
|
||||
}
|
||||
if httpHostPort != "" {
|
||||
ln, err := net.Listen("tcp", httpHostPort)
|
||||
if err != nil {
|
||||
_ = s.Close()
|
||||
return nil, fmt.Errorf("awg http listen %s: %w", httpHostPort, err)
|
||||
}
|
||||
s.httpLn = ln
|
||||
s.httpAddr = ln.Addr().String()
|
||||
s.wg.Add(1)
|
||||
go s.serveHTTP()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *proxyServer) Close() error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
if s.socksLn != nil {
|
||||
_ = s.socksLn.Close()
|
||||
}
|
||||
if s.httpLn != nil {
|
||||
_ = s.httpLn.Close()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *proxyServer) serveSOCKS() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
c, err := s.socksLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func(conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
_ = handleSOCKS(conn, s.dial)
|
||||
}(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *proxyServer) serveHTTP() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
c, err := s.httpLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func(conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
_ = handleHTTPProxy(conn, s.dial)
|
||||
}(c)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSOCKS(client net.Conn, dial DialFunc) error {
|
||||
defer client.Close()
|
||||
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
buf := make([]byte, 258)
|
||||
if _, err := io.ReadFull(client, buf[:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
if buf[0] != 0x05 {
|
||||
return fmt.Errorf("socks: not v5")
|
||||
}
|
||||
nmethods := int(buf[1])
|
||||
if _, err := io.ReadFull(client, buf[:nmethods]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Write([]byte{0x05, 0x00}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.ReadFull(client, buf[:4]); err != nil {
|
||||
return err
|
||||
}
|
||||
if buf[0] != 0x05 || buf[1] != 0x01 {
|
||||
_, _ = client.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return fmt.Errorf("socks: only CONNECT supported")
|
||||
}
|
||||
var host string
|
||||
switch buf[3] {
|
||||
case 0x01: // IPv4
|
||||
if _, err := io.ReadFull(client, buf[:4]); err != nil {
|
||||
return err
|
||||
}
|
||||
host = net.IP(buf[:4]).String()
|
||||
case 0x03: // domain
|
||||
if _, err := io.ReadFull(client, buf[:1]); err != nil {
|
||||
return err
|
||||
}
|
||||
l := int(buf[0])
|
||||
if _, err := io.ReadFull(client, buf[:l]); err != nil {
|
||||
return err
|
||||
}
|
||||
host = string(buf[:l])
|
||||
case 0x04: // IPv6
|
||||
if _, err := io.ReadFull(client, buf[:16]); err != nil {
|
||||
return err
|
||||
}
|
||||
host = net.IP(buf[:16]).String()
|
||||
default:
|
||||
_, _ = client.Write([]byte{0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return fmt.Errorf("socks: bad atyp")
|
||||
}
|
||||
if _, err := io.ReadFull(client, buf[:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
port := binary.BigEndian.Uint16(buf[:2])
|
||||
_ = client.SetDeadline(time.Time{})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
remote, err := dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
if err != nil {
|
||||
_, _ = client.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return err
|
||||
}
|
||||
defer remote.Close()
|
||||
if _, err := client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
return relay(client, remote)
|
||||
}
|
||||
|
||||
func handleHTTPProxy(client net.Conn, dial DialFunc) error {
|
||||
defer client.Close()
|
||||
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
br := bufio.NewReader(client)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = client.SetDeadline(time.Time{})
|
||||
|
||||
if strings.EqualFold(req.Method, http.MethodConnect) {
|
||||
host := req.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += ":443"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
remote, err := dial(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
|
||||
return err
|
||||
}
|
||||
defer remote.Close()
|
||||
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return relay(client, remote)
|
||||
}
|
||||
|
||||
// Absolute-form HTTP proxy request.
|
||||
u := req.URL
|
||||
host := u.Host
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
if host == "" {
|
||||
_, _ = client.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
|
||||
return fmt.Errorf("http proxy: no host")
|
||||
}
|
||||
if !strings.Contains(host, ":") {
|
||||
host += ":80"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
remote, err := dial(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
|
||||
return err
|
||||
}
|
||||
defer remote.Close()
|
||||
req.RequestURI = ""
|
||||
if err := req.Write(remote); err != nil {
|
||||
return err
|
||||
}
|
||||
return relay(client, remote)
|
||||
}
|
||||
|
||||
func relay(a, b net.Conn) error {
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := io.Copy(a, b)
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(b, a)
|
||||
errc <- err
|
||||
}()
|
||||
err := <-errc
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
<-errc
|
||||
return err
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type Item struct {
|
||||
}
|
||||
|
||||
var (
|
||||
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"']+)`)
|
||||
)
|
||||
|
||||
|
||||
@@ -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 "<exe>.new".
|
||||
func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, exePath, tmpPath string, err error) {
|
||||
st, err := Check(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
if !st.Available {
|
||||
if st.Error != "" {
|
||||
return "", "", "", "", "", fmt.Errorf("%s", st.Error)
|
||||
}
|
||||
return "", "", "", "", "", fmt.Errorf("обновление не найдено")
|
||||
}
|
||||
if !allowedDownloadURL(st.URL) {
|
||||
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
exe, err = filepath.Abs(exe)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
tmp := exe + ".new"
|
||||
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
wantSHA := st.SHA256
|
||||
if wantSHA == "" {
|
||||
if man, err := fetchManifest(ctx, manifestURL); err == nil {
|
||||
if _, sha2, ok := man.AssetFor(PlatformKey()); ok {
|
||||
wantSHA = sha2
|
||||
}
|
||||
}
|
||||
}
|
||||
if wantSHA != "" {
|
||||
sum, err := fileSHA256(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
if !strings.EqualFold(sum, wantSHA) {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
}
|
||||
return st.Latest, st.URL, wantSHA, exe, tmp, nil
|
||||
}
|
||||
|
||||
@@ -8,88 +8,44 @@ import (
|
||||
"os"
|
||||
"os/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, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -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 <pid> <targetExe>
|
||||
// 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()
|
||||
}
|
||||
|
||||
+8
-16
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user