Build cross-platform WireGuard and wstunnel manager with GUI and TUI
This commit is contained in:
commit
b7349e3f52
26 changed files with 2253 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/dist/
|
||||||
|
/cache/
|
||||||
|
/wswg
|
||||||
|
*.log
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 snw
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
147
README.md
Normal file
147
README.md
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# wswg
|
||||||
|
|
||||||
|
Простой менеджер WireGuard + wstunnel. Один статический Go-бинарник, встроенный
|
||||||
|
GUI без JS-фреймворков и терминальное меню. GUI открывается в локальном браузере,
|
||||||
|
не требует GTK, Qt, WebView или интернета. Закрытие вкладки не останавливает VPN;
|
||||||
|
для отключения нажмите Disconnect или завершите процесс Ctrl+C.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Распакуйте **весь** ZIP, сохраняя папки `bin/` и `redist/` рядом с программой.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./wswg # DISPLAY/WAYLAND_DISPLAY: GUI, иначе TUI; Windows: GUI
|
||||||
|
./wswg -t # принудительно TUI
|
||||||
|
./wswg -g # принудительно GUI
|
||||||
|
./wswg -c 1 # подключить первый конфиг, обновлять строку статуса
|
||||||
|
./wswg -c 'Home' # подключить по имени
|
||||||
|
./wswg -a # подключить последний успешно использованный конфиг
|
||||||
|
./wswg -a -d # то же в фоне
|
||||||
|
./wswg -c 1 -d # конкретный конфиг в фоне
|
||||||
|
./wswg -a --no-wstunnel # последний конфиг напрямую, не меняя сохранённые настройки
|
||||||
|
./wswg -c Home -d --no-wstunnel # тот же временный override для демона
|
||||||
|
./wswg --data-dir /path # отдельное хранилище
|
||||||
|
```
|
||||||
|
|
||||||
|
Короткие флаги имеют длинные варианты: `--tui`, `--gui`, `--config`, `--auto`,
|
||||||
|
`--daemon`. `-d` без `-a` или `-c` считается ошибкой. `--no-wstunnel` требует `-a` или `-c`, действует на один запуск и не сохраняет
|
||||||
|
изменение в профиль. `-t` и `-g` несовместимы.
|
||||||
|
`-a` и `-c` запускают подключение со строкой статуса вместо меню.
|
||||||
|
|
||||||
|
Нужны root на Linux/Android и Administrator на Windows. Запускайте приложение
|
||||||
|
сразу с этими правами. Хранилище по умолчанию: `os.UserConfigDir()/wswg`, обычно
|
||||||
|
`~/.config/wswg` на Linux, `%AppData%\wswg` на Windows. При запуске через sudo
|
||||||
|
укажите `--data-dir`, чтобы использовать ту же папку. Конфиги содержат приватные
|
||||||
|
ключи и сохраняются с правами 0600, каталог 0700. На Windows доступ регулирует
|
||||||
|
ACL пользовательского профиля, не кладите data-dir в общедоступную папку.
|
||||||
|
|
||||||
|
## Конфиги и интерфейс
|
||||||
|
|
||||||
|
New → вставить WireGuard-конфиг → Save → Connect. Редактирование и удаление
|
||||||
|
подключённого профиля запрещено. WireGuard-конфиги с PreUp, PostUp, PreDown,
|
||||||
|
PostDown и SaveConfig не поддерживаются: приложение не исполняет конфиги как код.
|
||||||
|
Сохранённый оригинал не меняется при подключении. Галочка DNS определяет,
|
||||||
|
попадёт ли DNS в рабочую копию.
|
||||||
|
|
||||||
|
В TUI выберите номер профиля, затем номер операции. WireGuard открывается через
|
||||||
|
`$EDITOR`, иначе `nano`. Допускаются аргументы и путь в кавычках, например
|
||||||
|
`EDITOR='"/path with spaces/editor" --wait'`. После подключения статус обновляется
|
||||||
|
в одной строке. Enter возвращает в меню, соединение остаётся активным.
|
||||||
|
|
||||||
|
Статус: адрес сервера, возраст последнего WireGuard-handshake в секундах,
|
||||||
|
ICMP ping, `Packet loss: N%`. Один ICMP-запрос в секунду с таймаутом 950 мс;
|
||||||
|
процент округляется до ближайшего целого по последним 100 попыткам. До 100
|
||||||
|
попыток используются имеющиеся результаты. Окно сбрасывается при подключении.
|
||||||
|
Если сервер блокирует ICMP, потеря будет 100%, даже если VPN работает.
|
||||||
|
`Connected` означает поднятый интерфейс; успешный handshake показывается отдельно.
|
||||||
|
|
||||||
|
## wstunnel
|
||||||
|
|
||||||
|
Поле IP/host задаёт адрес **wstunnel-сервера**. Пустое поле берёт host из WG
|
||||||
|
Endpoint. Порт по умолчанию 443. HTTPS включает `wss://`, выключенный переключатель
|
||||||
|
использует `ws://`. Как в стандартном wstunnel, допускаются self-signed TLS
|
||||||
|
сертификаты; аутентификацию VPN обеспечивает ключ WireGuard. На сервере должен быть запущен wstunnel, например:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wstunnel server --restrict-to 127.0.0.1:51820 wss://0.0.0.0:443
|
||||||
|
```
|
||||||
|
|
||||||
|
UDP на серверной стороне направляется на `127.0.0.1:<порт исходного WG Endpoint>`.
|
||||||
|
То есть wstunnel и WireGuard должны находиться на одной машине или в одном
|
||||||
|
сетевом namespace. Режим wstunnel поддерживает один Peer на профиль. На клиенте
|
||||||
|
Endpoint временно заменяется на локальный UDP-порт. До подъёма WG добавляется
|
||||||
|
отдельный маршрут до wstunnel, после отключения удаляются только маршруты,
|
||||||
|
созданные приложением. Для доменного имени сохраняются TLS SNI и HTTP Host.
|
||||||
|
При проблемах с MTU попробуйте `MTU = 1280` в секции Interface.
|
||||||
|
|
||||||
|
## Системные требования
|
||||||
|
|
||||||
|
* Linux: ядро с WireGuard, `bash`, `iproute2`, `ping`, `nft` либо `iptables` для
|
||||||
|
полного туннеля. Для включённого DNS нужен `resolvconf`. `wg` и `wg-quick`
|
||||||
|
включены в архив. Пример для Debian: `apt install iproute2 iputils-ping
|
||||||
|
nftables openresolv bash`. Root без сетевых capabilities в контейнере не хватит.
|
||||||
|
* Windows: Administrator. Официальные `wireguard.exe` и `wg.exe` включены в архив;
|
||||||
|
WireGuard устанавливает подписанный драйвер через tunnel service. В `redist/`
|
||||||
|
лежит VC++ Redistributable своей архитектуры. При коде отсутствующей runtime
|
||||||
|
0xC0000135/0xC000007B он устанавливается автоматически и запуск повторяется
|
||||||
|
один раз. Другие ошибки не запускают установку. Сам Go-бинарник не требует VC++.
|
||||||
|
* Termux: root и Android-ядро с WireGuard. `pkg install bash iproute2 nano
|
||||||
|
iputils`. Статические Linux ELF для ARMv7 и ARM64 не требуют Bionic/NDK.
|
||||||
|
Запускайте из Termux через root-окружение с сохранением PATH, например
|
||||||
|
`su -c 'PATH=/data/data/com.termux/files/usr/bin:/system/bin /полный/путь/wswg -t --data-dir /полный/путь/data'`.
|
||||||
|
В Android нет обычного resolvconf: отключите DNS из конфига и настройте DNS
|
||||||
|
средствами системы. Доступность маршрутов и root зависит от прошивки. Для
|
||||||
|
надёжности в Termux используйте числовые IP, Linux resolver не использует Android DNS API.
|
||||||
|
|
||||||
|
Процесс должен работать всё время подключения. Ctrl+C/SIGTERM отключает туннель,
|
||||||
|
останавливает wstunnel и удаляет временный конфиг. SIGKILL/отключение питания не
|
||||||
|
дают выполнить очистку; при необходимости удалите интерфейс командой
|
||||||
|
`bin/wg-quick down <data-dir>/wswg0.conf` или удалите Windows tunnel service.
|
||||||
|
Параллельные подключения не поддерживаются, интерфейс называется `wswg0`.
|
||||||
|
Фоновый процесс пишет в `daemon.log`; его PID выводится при запуске. Для остановки
|
||||||
|
используйте `kill <PID>` на Linux или завершите процесс Windows.
|
||||||
|
|
||||||
|
## Автозапуск
|
||||||
|
|
||||||
|
* Autorun в GUI автоматически подключает последний профиль при следующем
|
||||||
|
обычном запуске GUI. Флаг `-a` действует независимо от этой галочки.
|
||||||
|
* On boot на Linux создаёт `/etc/systemd/system/wswg.service`, включает его
|
||||||
|
через `systemctl enable` и запускает приложение с `-a`. Нужен работающий
|
||||||
|
systemd. Файлы программы и data-dir должны оставаться на постоянном месте.
|
||||||
|
* В Windows On boot создаёт задачу `wswg` в Task Scheduler от SYSTEM с ONSTART.
|
||||||
|
* В TUI Settings содержит только On boot. На системах без systemd настройка
|
||||||
|
возвращает ошибку и не записывает фиктивное состояние.
|
||||||
|
* Сначала подключите профиль вручную, чтобы сохранить последний выбор. On boot
|
||||||
|
вступает в силу при следующей загрузке. Выключение галочки отменяет будущий
|
||||||
|
автозапуск, текущую сессию можно отключить кнопкой Disconnect.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Go 1.24+, Python 3.11+, make, gcc, cross GCC для arm-linux-gnueabi,
|
||||||
|
arm-linux-gnueabihf и aarch64-linux-gnu, 32-битные заголовки gcc, `msiextract`.
|
||||||
|
На Debian: `apt install --no-install-recommends build-essential gcc-arm-linux-gnueabi
|
||||||
|
gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu libc6-dev-i386 lib32gcc-12-dev msitools`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/build.sh
|
||||||
|
# Только приложение, без загрузки и сборки сторонних инструментов:
|
||||||
|
SKIP_BUNDLE=1 ./scripts/build.sh
|
||||||
|
# После осознанного обновления зависимостей, записать новые SHA-256:
|
||||||
|
UPDATE_LOCK=1 ./scripts/build.sh
|
||||||
|
# На сервере с уже настроенным html_plans.t.snw.su:
|
||||||
|
python3 scripts/publish.py 0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`dist/` содержит папки и ZIP для Linux amd64, 386, ARMv6, ARMv7, ARM64;
|
||||||
|
Windows amd64, x86, ARM64; Termux ARMv7, ARM64. ARMv8 здесь ARM64. Windows
|
||||||
|
ARM32 не поддерживается Go. `wswg-all.zip` содержит все платформенные ZIP,
|
||||||
|
`SHA256SUMS` позволяет проверить целостность. Бинарники Go и Linux wg статические,
|
||||||
|
wstunnel берётся из официальных Linux-сборок и проверяется по upstream checksums.
|
||||||
|
Библиотеки системных утилит и браузер принадлежат ОС, они не встраиваются.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -race ./...
|
||||||
|
go vet ./...
|
||||||
|
# Необязательный локальный тест настоящего handshake, нужны root/CAP_NET_ADMIN:
|
||||||
|
sudo python3 scripts/integration.py
|
||||||
|
```
|
||||||
16
THIRD_PARTY.md
Normal file
16
THIRD_PARTY.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Bundled software
|
||||||
|
|
||||||
|
* wstunnel 11.1.0, BSD-3-Clause, https://github.com/erebe/wstunnel
|
||||||
|
* wireguard-tools 1.0.20250521, GPL-2.0, https://git.zx2c4.com/wireguard-tools/
|
||||||
|
* WireGuard for Windows 1.1.1, GPL-2.0, https://git.zx2c4.com/wireguard-windows/
|
||||||
|
* Microsoft Visual C++ Redistributable, Microsoft license, https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist
|
||||||
|
* musl 1.2.3, MIT, https://musl.libc.org/
|
||||||
|
|
||||||
|
Upstream licenses are placed in licenses/ by scripts/bundle.py. Windows packages
|
||||||
|
include wireguard.exe and wg.exe extracted from the official WireGuard MSI.
|
||||||
|
The tunnel service installs the signed WireGuard driver.
|
||||||
|
|
||||||
|
Source for the GPL components is available at the URLs above. The bundling script
|
||||||
|
pins upstream versions, checks downloaded hashes against scripts/dependencies.lock.json,
|
||||||
|
and builds wireguard-tools from its source archive. The wswg application communicates
|
||||||
|
with these programs as separate processes.
|
||||||
28
VALIDATION.md
Normal file
28
VALIDATION.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Проверки 0.1.0
|
||||||
|
|
||||||
|
Проверено на Linux amd64:
|
||||||
|
|
||||||
|
* `go test -race ./...`, `go vet ./...`.
|
||||||
|
* Парсинг WireGuard, подмена Endpoint без изменения оригинала, DNS toggle,
|
||||||
|
отклонение исполняемых hooks, сохранение 0600, выбор по имени/номеру,
|
||||||
|
дубликаты имён, удаление последнего профиля, аргументы EDITOR.
|
||||||
|
* Окно потерь: первые попытки, 100 попыток, вытеснение старых результатов.
|
||||||
|
* GUI в Chromium: New, Save, повторное открытие страницы, узкий viewport;
|
||||||
|
отсутствие JS-ошибок, запрет API без токена и с чужим Origin.
|
||||||
|
* Настоящий WireGuard-интерфейс и локальный peer: handshake, ping через VPN,
|
||||||
|
status с packet loss, отключение по SIGTERM и удаление временного конфига.
|
||||||
|
* Тот же тест через wstunnel с ws и wss.
|
||||||
|
* `-a -d`: родитель подтверждает запуск после поднятия интерфейса; SIGTERM
|
||||||
|
отключает интерфейс и дочерний wstunnel.
|
||||||
|
* `--no-wstunnel` с `-c`, а также с `-a -d`: прямой WG работает при включённой
|
||||||
|
галочке в сохранённом профиле, сохранённая галочка остаётся включённой.
|
||||||
|
* Ошибки `-d` без выбора конфига и `--no-wstunnel` без `-a`/`-c`.
|
||||||
|
|
||||||
|
Для всех Linux/Termux ELF проверены архитектура, отсутствие PT_INTERP и DT_NEEDED.
|
||||||
|
Приложение, wg и wstunnel запускаются с `--version` на amd64/x86 и через QEMU на
|
||||||
|
ARMv6/ARMv7/ARM64. Для Windows проверены PE machine type всех приложенных программ
|
||||||
|
и наличие VC++ Redistributable нужной архитектуры. SHA-256 архивов в SHA256SUMS.
|
||||||
|
|
||||||
|
На настоящих Windows и Android/Termux подключение не проверялось. Не проверялись
|
||||||
|
реальная загрузка через systemd/Task Scheduler и установка Windows VC++ runtime.
|
||||||
|
Эмуляция ARM проверяет запуск ELF, но не права Android, DNS и особенности прошивки.
|
||||||
80
boot.go
Normal file
80
boot.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) setOptions(autorun, onBoot bool) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if onBoot && a.settings.Last == "" {
|
||||||
|
return errors.New("connect a profile once before enabling On boot")
|
||||||
|
}
|
||||||
|
if onBoot != a.settings.OnBoot {
|
||||||
|
if err := a.installBoot(onBoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old := a.settings
|
||||||
|
a.settings.Autorun = autorun
|
||||||
|
a.settings.OnBoot = onBoot
|
||||||
|
if err := saveSettings(a.dir, a.settings); err != nil {
|
||||||
|
if old.OnBoot != onBoot {
|
||||||
|
_ = a.installBoot(old.OnBoot)
|
||||||
|
}
|
||||||
|
a.settings = old
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func systemdQuote(s string) string {
|
||||||
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, `%`, `%%`, `$`, `$$`, "\n", `\n`, "\r", `\r`).Replace(s) + `"`
|
||||||
|
}
|
||||||
|
func (a *App) installBoot(enable bool) error {
|
||||||
|
if !admin() {
|
||||||
|
return errors.New("On boot requires root / Administrator")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exe, err = filepath.EvalSymlinks(exe)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if !enable {
|
||||||
|
return command("schtasks", "/Delete", "/TN", "wswg", "/F")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(exe+a.dir, "\"\r\n") {
|
||||||
|
return errors.New("unsupported path characters")
|
||||||
|
}
|
||||||
|
return command("schtasks", "/Create", "/TN", "wswg", "/SC", "ONSTART", "/RU", "SYSTEM", "/RL", "HIGHEST", "/TR", fmt.Sprintf(`"%s" --data-dir "%s" -a`, exe, a.dir), "/F")
|
||||||
|
}
|
||||||
|
if _, err = os.Stat("/run/systemd/system"); err != nil {
|
||||||
|
return errors.New("On boot is available only on systems running systemd")
|
||||||
|
}
|
||||||
|
path := "/etc/systemd/system/wswg.service"
|
||||||
|
if !enable {
|
||||||
|
if err = command("systemctl", "disable", "wswg.service"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return command("systemctl", "daemon-reload")
|
||||||
|
}
|
||||||
|
unit := fmt.Sprintf("[Unit]\nDescription=WireGuard with optional wstunnel\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=simple\nExecStart=%s --data-dir %s -a\nRestart=on-failure\nRestartSec=5\nUMask=0077\n\n[Install]\nWantedBy=multi-user.target\n", systemdQuote(exe), systemdQuote(a.dir))
|
||||||
|
if err = os.WriteFile(path, []byte(unit), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = command("systemctl", "daemon-reload"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return command("systemctl", "enable", "wswg.service")
|
||||||
|
}
|
||||||
210
config.go
Normal file
210
config.go
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tunnel struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
HTTPS bool `json:"https"`
|
||||||
|
}
|
||||||
|
type Profile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
WireGuard string `json:"wireguard"`
|
||||||
|
DNS bool `json:"dns"`
|
||||||
|
Tunnel Tunnel `json:"tunnel"`
|
||||||
|
}
|
||||||
|
type Settings struct {
|
||||||
|
Profiles []Profile `json:"profiles"`
|
||||||
|
Last string `json:"last"`
|
||||||
|
Autorun bool `json:"autorun"`
|
||||||
|
OnBoot bool `json:"onBoot"`
|
||||||
|
}
|
||||||
|
type WG struct {
|
||||||
|
Endpoint string
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Peers int
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWG(raw string) (WG, error) {
|
||||||
|
var w WG
|
||||||
|
section := ""
|
||||||
|
keys := map[string]bool{}
|
||||||
|
private := false
|
||||||
|
address := false
|
||||||
|
for n, line := range strings.Split(raw, "\n") {
|
||||||
|
line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0])
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "[") {
|
||||||
|
section = strings.ToLower(line)
|
||||||
|
if section == "[peer]" {
|
||||||
|
w.Peers++
|
||||||
|
keys = map[string]bool{}
|
||||||
|
} else if section != "[interface]" {
|
||||||
|
return w, fmt.Errorf("line %d: unknown section", n+1)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kv := strings.SplitN(line, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
return w, fmt.Errorf("line %d: expected key = value", n+1)
|
||||||
|
}
|
||||||
|
key, val := strings.ToLower(strings.TrimSpace(kv[0])), strings.TrimSpace(kv[1])
|
||||||
|
if section == "" {
|
||||||
|
return w, errors.New("missing [Interface]")
|
||||||
|
}
|
||||||
|
// Hooks execute shell code as root. Profiles are data, never scripts.
|
||||||
|
switch key {
|
||||||
|
case "preup", "postup", "predown", "postdown", "saveconfig":
|
||||||
|
return w, fmt.Errorf("%s is not supported: remove executable hooks and SaveConfig", key)
|
||||||
|
}
|
||||||
|
if keys[key] {
|
||||||
|
return w, fmt.Errorf("duplicate %s", key)
|
||||||
|
}
|
||||||
|
keys[key] = true
|
||||||
|
if key == "privatekey" || key == "publickey" || key == "presharedkey" {
|
||||||
|
b, e := base64.StdEncoding.DecodeString(val)
|
||||||
|
if e != nil || len(b) != 32 {
|
||||||
|
return w, fmt.Errorf("invalid %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if section == "[interface]" && key == "privatekey" {
|
||||||
|
private = true
|
||||||
|
}
|
||||||
|
if section == "[interface]" && key == "address" {
|
||||||
|
address = true
|
||||||
|
for _, a := range strings.Split(val, ",") {
|
||||||
|
if _, _, e := net.ParseCIDR(strings.TrimSpace(a)); e != nil {
|
||||||
|
return w, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if section == "[peer]" && key == "endpoint" {
|
||||||
|
h, p, e := net.SplitHostPort(val)
|
||||||
|
if e != nil {
|
||||||
|
return w, fmt.Errorf("Endpoint: %w", e)
|
||||||
|
}
|
||||||
|
port, e := strconv.Atoi(p)
|
||||||
|
if e != nil || port < 1 || port > 65535 || h == "" {
|
||||||
|
return w, errors.New("invalid Endpoint port")
|
||||||
|
}
|
||||||
|
w.Endpoint = val
|
||||||
|
w.Host = h
|
||||||
|
w.Port = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !private || !address || w.Peers == 0 || w.Endpoint == "" {
|
||||||
|
return w, errors.New("config requires PrivateKey, Address, [Peer] and Endpoint")
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
func validateProfile(p Profile) error {
|
||||||
|
if strings.TrimSpace(p.Name) == "" {
|
||||||
|
return errors.New("name is empty")
|
||||||
|
}
|
||||||
|
w, e := parseWG(p.WireGuard)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if p.Tunnel.Enabled {
|
||||||
|
if w.Peers != 1 {
|
||||||
|
return errors.New("wstunnel supports exactly one peer per profile")
|
||||||
|
}
|
||||||
|
if p.Tunnel.Port < 1 || p.Tunnel.Port > 65535 {
|
||||||
|
return errors.New("wstunnel port must be 1..65535")
|
||||||
|
}
|
||||||
|
h := p.Tunnel.Host
|
||||||
|
if h != "" && net.ParseIP(h) == nil {
|
||||||
|
if strings.ContainsAny(h, " /\\:@?#\r\n\t") || strings.HasPrefix(h, "-") {
|
||||||
|
return errors.New("invalid wstunnel host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func renderWG(p Profile, endpoint string) string {
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(p.WireGuard, "\n") {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(strings.SplitN(line, "=", 2)[0]))
|
||||||
|
if key == "dns" && !p.DNS {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key == "endpoint" && endpoint != "" {
|
||||||
|
line = "Endpoint = " + endpoint
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n") + "\n"
|
||||||
|
}
|
||||||
|
func newID() string {
|
||||||
|
var b [8]byte
|
||||||
|
if _, e := rand.Read(b[:]); e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
func loadSettings(dir string) (Settings, error) {
|
||||||
|
s := Settings{Profiles: []Profile{}}
|
||||||
|
b, e := os.ReadFile(filepath.Join(dir, "settings.json"))
|
||||||
|
if os.IsNotExist(e) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
return s, e
|
||||||
|
}
|
||||||
|
e = json.Unmarshal(b, &s)
|
||||||
|
return s, e
|
||||||
|
}
|
||||||
|
func saveSettings(dir string, s Settings) error {
|
||||||
|
b, e := json.MarshalIndent(s, "", " ")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
f, e := os.CreateTemp(dir, ".settings-*")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
tmp := f.Name()
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
if e = f.Chmod(0600); e == nil {
|
||||||
|
_, e = f.Write(b)
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
e = f.Sync()
|
||||||
|
}
|
||||||
|
ce := f.Close()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if ce != nil {
|
||||||
|
return ce
|
||||||
|
}
|
||||||
|
return replaceFile(tmp, filepath.Join(dir, "settings.json"))
|
||||||
|
}
|
||||||
|
func (s Settings) find(v string) (Profile, error) {
|
||||||
|
if n, e := strconv.Atoi(v); e == nil && n > 0 && n <= len(s.Profiles) {
|
||||||
|
return s.Profiles[n-1], nil
|
||||||
|
}
|
||||||
|
for _, p := range s.Profiles {
|
||||||
|
if p.ID == v || p.Name == v {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Profile{}, fmt.Errorf("config %q not found", v)
|
||||||
|
}
|
||||||
119
config_test.go
Normal file
119
config_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const validWG = `[Interface]
|
||||||
|
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
|
Address = 10.8.0.2/32
|
||||||
|
DNS = 1.1.1.1
|
||||||
|
[Peer]
|
||||||
|
PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=
|
||||||
|
AllowedIPs = 0.0.0.0/0
|
||||||
|
Endpoint = [2001:db8::1]:51820
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestConfigAndRewrite(t *testing.T) {
|
||||||
|
w, e := parseWG(validWG)
|
||||||
|
if e != nil || w.Host != "2001:db8::1" || w.Port != 51820 {
|
||||||
|
t.Fatalf("%+v %v", w, e)
|
||||||
|
}
|
||||||
|
p := Profile{Name: "home", WireGuard: validWG, Tunnel: Tunnel{Enabled: true, Port: 443}}
|
||||||
|
if e = validateProfile(p); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
s := renderWG(p, "127.0.0.1:1234")
|
||||||
|
if strings.Contains(s, "DNS =") || !strings.Contains(s, "Endpoint = 127.0.0.1:1234") {
|
||||||
|
t.Fatal(s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.WireGuard, "[2001:db8::1]") {
|
||||||
|
t.Fatal("original modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRejectHooksAndBadConfig(t *testing.T) {
|
||||||
|
for _, s := range []string{"", strings.Replace(validWG, "PrivateKey = ", "PrivateKey = BAD", 1), validWG + "PostUp = rm -rf /\n", strings.Replace(validWG, "51820", "0", 1)} {
|
||||||
|
if _, e := parseWG(s); e == nil {
|
||||||
|
t.Fatalf("accepted %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestSettingsRoundTrip(t *testing.T) {
|
||||||
|
d := t.TempDir()
|
||||||
|
s := Settings{Profiles: []Profile{{ID: "home", Name: "дом", WireGuard: validWG}}, Last: "home"}
|
||||||
|
if e := saveSettings(d, s); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
s, e := loadSettings(d)
|
||||||
|
if e != nil || len(s.Profiles) != 1 || s.Last != "home" {
|
||||||
|
t.Fatalf("%+v %v", s, e)
|
||||||
|
}
|
||||||
|
if _, e = s.find("1"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, e = s.find("дом"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, e = s.find("2"); e == nil {
|
||||||
|
t.Fatal("unknown config accepted")
|
||||||
|
}
|
||||||
|
st, _ := os.Stat(filepath.Join(d, "settings.json"))
|
||||||
|
if st.Mode().Perm() != 0600 {
|
||||||
|
t.Fatal(st.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestLossRollingHundred(t *testing.T) {
|
||||||
|
var w lossWindow
|
||||||
|
if got := w.add(false); got != 100 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
for i := 0; i < 99; i++ {
|
||||||
|
w.add(true)
|
||||||
|
}
|
||||||
|
if got := w.add(true); got != 0 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
w.add(false)
|
||||||
|
}
|
||||||
|
if got := w.add(true); got != 25 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
w.add(true)
|
||||||
|
}
|
||||||
|
if w.lost != 0 || w.count != 100 {
|
||||||
|
t.Fatalf("%+v", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestEditorArguments(t *testing.T) {
|
||||||
|
a, e := editorArgs(`"/some path/editor" --wait`)
|
||||||
|
if e != nil || len(a) != 2 || a[0] != "/some path/editor" {
|
||||||
|
t.Fatalf("%q %v", a, e)
|
||||||
|
}
|
||||||
|
if _, e = editorArgs(`"unfinished`); e == nil {
|
||||||
|
t.Fatal("unclosed quote accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestProfileDuplicateAndDeleteLast(t *testing.T) {
|
||||||
|
d := t.TempDir()
|
||||||
|
a := App{dir: d, settings: Settings{Profiles: []Profile{}}}
|
||||||
|
p := Profile{Name: "home", WireGuard: validWG}
|
||||||
|
if e := a.save(p); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e := a.save(p); e == nil {
|
||||||
|
t.Fatal("duplicate accepted")
|
||||||
|
}
|
||||||
|
id := a.settings.Profiles[0].ID
|
||||||
|
a.settings.Last = id
|
||||||
|
if e := a.remove(id); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if a.settings.Last != "" || len(a.settings.Profiles) != 0 {
|
||||||
|
t.Fatal(a.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
411
engine.go
Normal file
411
engine.go
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Config string `json:"config"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Handshake string `json:"handshake"`
|
||||||
|
Ping string `json:"ping"`
|
||||||
|
PacketLoss int `json:"packetLoss"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Status) Line() string {
|
||||||
|
if !s.Connected {
|
||||||
|
if s.Error != "" {
|
||||||
|
return "Disconnected | " + s.Error
|
||||||
|
}
|
||||||
|
return "Disconnected"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Connected to %s | Last handshake %ss | Ping %sms | Packet loss: %d%%", s.IP, s.Handshake, s.Ping, s.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dir string
|
||||||
|
profile Profile
|
||||||
|
conf string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
childDone chan error
|
||||||
|
routes [][]string
|
||||||
|
state Status
|
||||||
|
stopMonitor chan struct{}
|
||||||
|
loss lossWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
func bin(name string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
for _, p := range []string{filepath.Join(filepath.Dir(exe), "bin", name), filepath.Join(filepath.Dir(exe), name)} {
|
||||||
|
if st, e := os.Stat(p); e == nil && !st.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" && (name == "wireguard.exe" || name == "wg.exe") {
|
||||||
|
p := filepath.Join(os.Getenv("ProgramFiles"), "WireGuard", name)
|
||||||
|
if _, e := os.Stat(p); e == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
func command(name string, args ...string) error {
|
||||||
|
c := exec.Command(bin(name), args...)
|
||||||
|
c.Env = commandEnv()
|
||||||
|
b, e := c.CombinedOutput()
|
||||||
|
if e != nil {
|
||||||
|
return fmt.Errorf("%s: %w: %s", name, e, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func commandEnv() []string {
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
return append(os.Environ(), "NO_COLOR=true", "PATH="+filepath.Join(filepath.Dir(exe), "bin")+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
func (e *Engine) activeID() string {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.state.Connected {
|
||||||
|
return e.profile.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (e *Engine) status() Status { e.mu.Lock(); defer e.mu.Unlock(); return e.state }
|
||||||
|
func (e *Engine) start(p Profile) (err error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.state.Connected {
|
||||||
|
return errors.New("disconnect current profile first")
|
||||||
|
}
|
||||||
|
if err = validateProfile(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !admin() {
|
||||||
|
return errors.New("WireGuard needs root / Administrator; Termux also needs a WireGuard-enabled Android kernel")
|
||||||
|
}
|
||||||
|
probe := exec.Command(bin("wg"), "show", "wswg0")
|
||||||
|
probe.Env = commandEnv()
|
||||||
|
if probe.Run() == nil {
|
||||||
|
return errors.New("interface wswg0 already exists; disconnect it before starting")
|
||||||
|
}
|
||||||
|
e.loss = lossWindow{}
|
||||||
|
e.state = Status{Handshake: "—", Ping: "—"}
|
||||||
|
e.profile = p
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
e.cleanup()
|
||||||
|
e.state = Status{Error: err.Error()}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
w, _ := parseWG(p.WireGuard)
|
||||||
|
endpoint := ""
|
||||||
|
if p.Tunnel.Enabled {
|
||||||
|
host := p.Tunnel.Host
|
||||||
|
if host == "" {
|
||||||
|
host = w.Host
|
||||||
|
}
|
||||||
|
ips, x := net.LookupIP(host)
|
||||||
|
if x != nil || len(ips) == 0 {
|
||||||
|
return fmt.Errorf("resolve wstunnel host %s: %v", host, x)
|
||||||
|
}
|
||||||
|
var ip net.IP
|
||||||
|
for _, v := range ips {
|
||||||
|
if v.To4() != nil {
|
||||||
|
ip = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ip == nil {
|
||||||
|
ip = ips[0]
|
||||||
|
}
|
||||||
|
if err = e.pinRoute(ip.String()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u, x := net.ListenPacket("udp4", "127.0.0.1:0")
|
||||||
|
if x != nil {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
addr := u.LocalAddr().String()
|
||||||
|
u.Close()
|
||||||
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
|
endpoint = addr
|
||||||
|
scheme := "ws"
|
||||||
|
if p.Tunnel.HTTPS {
|
||||||
|
scheme = "wss"
|
||||||
|
}
|
||||||
|
// The remote UDP destination is resolved by the server. localhost matches a WireGuard server on that host.
|
||||||
|
args := []string{"client", "-L", "udp://127.0.0.1:" + port + ":127.0.0.1:" + strconv.Itoa(w.Port) + "?timeout_sec=0"}
|
||||||
|
if net.ParseIP(host) == nil {
|
||||||
|
if p.Tunnel.HTTPS {
|
||||||
|
args = append(args, "--tls-sni-override", host)
|
||||||
|
}
|
||||||
|
args = append(args, "--http-headers", "Host: "+net.JoinHostPort(host, strconv.Itoa(p.Tunnel.Port)))
|
||||||
|
}
|
||||||
|
args = append(args, scheme+"://"+net.JoinHostPort(ip.String(), strconv.Itoa(p.Tunnel.Port)))
|
||||||
|
if err = e.startTunnel(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.conf = filepath.Join(e.dir, "wswg0.conf")
|
||||||
|
if err = os.WriteFile(e.conf, []byte(renderWG(p, endpoint)), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
err = command("wireguard", "/installtunnelservice", e.conf)
|
||||||
|
} else {
|
||||||
|
err = command("wg-quick", "up", e.conf)
|
||||||
|
}
|
||||||
|
if err != nil { // wg-quick rolls back partially configured interfaces itself.
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
_ = command("wireguard", "/uninstalltunnelservice", "wswg0")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.state = Status{Connected: true, Config: p.Name, IP: w.Host, Handshake: "—", Ping: "—"}
|
||||||
|
e.stopMonitor = make(chan struct{})
|
||||||
|
go e.monitor(e.stopMonitor)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (e *Engine) startTunnel(args []string) error {
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
log, x := os.OpenFile(filepath.Join(e.dir, "wstunnel.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if x != nil {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
c := exec.Command(bin("wstunnel"), args...)
|
||||||
|
c.Env = commandEnv()
|
||||||
|
c.Stdout = log
|
||||||
|
c.Stderr = log
|
||||||
|
x = c.Start()
|
||||||
|
if x == nil {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- c.Wait(); log.Close() }()
|
||||||
|
select {
|
||||||
|
case x = <-done:
|
||||||
|
if x == nil {
|
||||||
|
x = errors.New("wstunnel exited")
|
||||||
|
}
|
||||||
|
case <-time.After(1200 * time.Millisecond):
|
||||||
|
e.cmd = c
|
||||||
|
e.childDone = done
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Close()
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" || attempt != 0 || !missingRuntime(x) {
|
||||||
|
return fmt.Errorf("wstunnel failed: %w; see %s", x, filepath.Join(e.dir, "wstunnel.log"))
|
||||||
|
}
|
||||||
|
if x = installRuntime(); x != nil {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("wstunnel failed after redistributable installation")
|
||||||
|
}
|
||||||
|
func missingRuntime(err error) bool {
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(s, "0xc0000135") || strings.Contains(s, "0xc000007b") || strings.Contains(s, "3221225781") || strings.Contains(s, "3221225595")
|
||||||
|
}
|
||||||
|
func installRuntime() error {
|
||||||
|
arch := runtime.GOARCH
|
||||||
|
if arch == "amd64" {
|
||||||
|
arch = "x64"
|
||||||
|
}
|
||||||
|
if arch == "386" {
|
||||||
|
arch = "x86"
|
||||||
|
}
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
p := filepath.Join(filepath.Dir(exe), "redist", "vc_redist."+arch+".exe")
|
||||||
|
if _, e := os.Stat(p); e != nil {
|
||||||
|
return fmt.Errorf("bundled Visual C++ redistributable: %w", e)
|
||||||
|
}
|
||||||
|
e := exec.Command(p, "/install", "/quiet", "/norestart").Run()
|
||||||
|
if x, ok := e.(*exec.ExitError); ok && (x.ExitCode() == 3010 || x.ExitCode() == 1638) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
func (e *Engine) cleanup() {
|
||||||
|
if e.cmd != nil {
|
||||||
|
_ = e.cmd.Process.Kill()
|
||||||
|
<-e.childDone
|
||||||
|
e.cmd = nil
|
||||||
|
e.childDone = nil
|
||||||
|
}
|
||||||
|
for i := len(e.routes) - 1; i >= 0; i-- {
|
||||||
|
r := e.routes[i]
|
||||||
|
_ = command(r[0], r[1:]...)
|
||||||
|
}
|
||||||
|
e.routes = nil
|
||||||
|
if e.conf != "" {
|
||||||
|
os.Remove(e.conf)
|
||||||
|
e.conf = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (e *Engine) stop() {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.stopMonitor != nil {
|
||||||
|
close(e.stopMonitor)
|
||||||
|
e.stopMonitor = nil
|
||||||
|
}
|
||||||
|
if e.state.Connected {
|
||||||
|
var err error
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
err = command("wireguard", "/uninstalltunnelservice", "wswg0")
|
||||||
|
} else {
|
||||||
|
err = command("wg-quick", "down", e.conf)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.state.Error = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.cleanup()
|
||||||
|
e.state = Status{}
|
||||||
|
}
|
||||||
|
func (e *Engine) monitor(stop <-chan struct{}) {
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
e.mu.Lock()
|
||||||
|
if !e.state.Connected {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.childDone != nil {
|
||||||
|
select {
|
||||||
|
case err := <-e.childDone:
|
||||||
|
e.cmd = nil
|
||||||
|
e.childDone = nil
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.stop()
|
||||||
|
e.mu.Lock()
|
||||||
|
e.state.Error = fmt.Sprintf("wstunnel exited: %v; see wstunnel.log", err)
|
||||||
|
e.mu.Unlock()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := exec.Command(bin("wg"), "show", "wswg0", "latest-handshakes")
|
||||||
|
c.Env = commandEnv()
|
||||||
|
out, err := c.Output()
|
||||||
|
if err == nil {
|
||||||
|
latest := int64(0)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) == 2 {
|
||||||
|
v, _ := strconv.ParseInt(f[1], 10, 64)
|
||||||
|
if v > latest {
|
||||||
|
latest = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if latest > 0 {
|
||||||
|
e.state.Handshake = strconv.FormatInt(time.Now().Unix()-latest, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host := e.state.IP
|
||||||
|
e.mu.Unlock()
|
||||||
|
ping, success := pingHost(host)
|
||||||
|
e.mu.Lock()
|
||||||
|
if e.state.Connected && e.stopMonitor == stop {
|
||||||
|
e.state.Ping = ping
|
||||||
|
e.state.PacketLoss = e.loss.add(success)
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func pingHost(host string) (string, bool) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 950*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
args := []string{"-n", "-c", "1", "-W", "1", host}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
args = []string{"-n", "1", "-w", "1000", host}
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, "ping", args...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return "—", false
|
||||||
|
}
|
||||||
|
s := strings.ToLower(string(out))
|
||||||
|
// Windows ping uses the system OEM encoding. TTL remains ASCII even when
|
||||||
|
// the time label and the unit are localized.
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
for i, part := range f {
|
||||||
|
if i > 0 && strings.HasPrefix(part, "ttl=") {
|
||||||
|
value := f[i-1]
|
||||||
|
if j := strings.LastIndexAny(value, "=<"); j >= 0 {
|
||||||
|
value = value[j+1:]
|
||||||
|
k := 0
|
||||||
|
for k < len(value) && ((value[k] >= '0' && value[k] <= '9') || value[k] == '.' || value[k] == ',') {
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
if k > 0 {
|
||||||
|
return value[:k], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"time=", "time<", "время="} {
|
||||||
|
i := strings.Index(s, key)
|
||||||
|
if i >= 0 {
|
||||||
|
tail := s[i+len(key):]
|
||||||
|
end := 0
|
||||||
|
for end < len(tail) && ((tail[end] >= '0' && tail[end] <= '9') || tail[end] == '.' || tail[end] == ',') {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if end > 0 {
|
||||||
|
return tail[:end], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "—", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// lossWindow stores the last 100 ICMP attempts; an unanswered probe is a loss.
|
||||||
|
type lossWindow struct {
|
||||||
|
results [100]bool
|
||||||
|
next, count, lost int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *lossWindow) add(success bool) int {
|
||||||
|
if w.count == len(w.results) {
|
||||||
|
if !w.results[w.next] {
|
||||||
|
w.lost--
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.count++
|
||||||
|
}
|
||||||
|
w.results[w.next] = success
|
||||||
|
if !success {
|
||||||
|
w.lost++
|
||||||
|
}
|
||||||
|
w.next = (w.next + 1) % len(w.results)
|
||||||
|
return (w.lost*100 + w.count/2) / w.count
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module git.snw.su/snw/wswg
|
||||||
|
|
||||||
|
go 1.24
|
||||||
147
gui.go
Normal file
147
gui.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/index.html
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func (a *App) gui() error {
|
||||||
|
ln, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token := newID() + newID()
|
||||||
|
origin := "http://" + ln.Addr().String()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'; connect-src 'self'")
|
||||||
|
if r.Host != ln.Addr().String() {
|
||||||
|
http.Error(w, "invalid host", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
http.Error(w, "method", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := assets.ReadFile("web/index.html")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+token)) != 1 {
|
||||||
|
http.Error(w, "unauthorized", 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if o := r.Header.Get("Origin"); o != "" && o != origin {
|
||||||
|
http.Error(w, "origin", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var result any = map[string]bool{"ok": true}
|
||||||
|
var err error
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/api/state" {
|
||||||
|
a.mu.Lock()
|
||||||
|
s := a.settings
|
||||||
|
a.mu.Unlock()
|
||||||
|
result = struct {
|
||||||
|
Settings Settings `json:"settings"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
}{s, a.engine.status()}
|
||||||
|
} else if r.Method == "POST" {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
decode := func(v any) error {
|
||||||
|
if e := dec.Decode(v); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := dec.Decode(new(any)); e != io.EOF {
|
||||||
|
return errors.New("unexpected trailing JSON")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/save":
|
||||||
|
var p Profile
|
||||||
|
if err = decode(&p); err == nil {
|
||||||
|
err = a.save(p)
|
||||||
|
}
|
||||||
|
case "/api/delete", "/api/connect":
|
||||||
|
var p struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err = decode(&p); err == nil {
|
||||||
|
if r.URL.Path == "/api/delete" {
|
||||||
|
err = a.remove(p.ID)
|
||||||
|
} else {
|
||||||
|
err = a.connect(p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "/api/disconnect":
|
||||||
|
a.engine.stop()
|
||||||
|
if s := a.engine.status(); s.Error != "" {
|
||||||
|
err = errors.New(s.Error)
|
||||||
|
}
|
||||||
|
case "/api/options":
|
||||||
|
var o struct {
|
||||||
|
Autorun bool `json:"autorun"`
|
||||||
|
OnBoot bool `json:"onBoot"`
|
||||||
|
}
|
||||||
|
if err = decode(&o); err == nil {
|
||||||
|
err = a.setOptions(o.Autorun, o.OnBoot)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
result = map[string]string{"error": err.Error()}
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(result)
|
||||||
|
})
|
||||||
|
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 45 * time.Second, IdleTimeout: 60 * time.Second}
|
||||||
|
defer server.Close()
|
||||||
|
url := origin + "/#" + token
|
||||||
|
fmt.Println("GUI:", url, "\nKeep this process running. Ctrl+C stops the VPN.")
|
||||||
|
var c *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||||
|
case "darwin":
|
||||||
|
c = exec.Command("open", url)
|
||||||
|
default:
|
||||||
|
if strings.Contains(runtime.GOOS, "android") {
|
||||||
|
c = exec.Command("termux-open-url", url)
|
||||||
|
} else {
|
||||||
|
c = exec.Command("xdg-open", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = c.Start(); err != nil {
|
||||||
|
fmt.Println("Open the GUI URL in your browser:", err)
|
||||||
|
} else {
|
||||||
|
go c.Wait()
|
||||||
|
}
|
||||||
|
return server.Serve(ln)
|
||||||
|
}
|
||||||
286
main.go
Normal file
286
main.go
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dir string
|
||||||
|
settings Settings
|
||||||
|
engine Engine
|
||||||
|
noTunnel bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if e := run(); e != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "wswg:", e)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func run() error {
|
||||||
|
enableTerminal()
|
||||||
|
var auto, daemon, tui, gui, ver, noTunnel bool
|
||||||
|
var config, dir string
|
||||||
|
flag.BoolVar(&auto, "a", false, "connect last profile")
|
||||||
|
flag.BoolVar(&auto, "auto", false, "connect last profile")
|
||||||
|
flag.BoolVar(&daemon, "d", false, "run in background, requires -a or -c")
|
||||||
|
flag.BoolVar(&daemon, "daemon", false, "run in background")
|
||||||
|
flag.BoolVar(&tui, "t", false, "force terminal UI")
|
||||||
|
flag.BoolVar(&tui, "tui", false, "force terminal UI")
|
||||||
|
flag.BoolVar(&gui, "g", false, "force browser GUI")
|
||||||
|
flag.BoolVar(&gui, "gui", false, "force browser GUI")
|
||||||
|
flag.StringVar(&config, "c", "", "profile number or name")
|
||||||
|
flag.StringVar(&config, "config", "", "profile number or name")
|
||||||
|
flag.StringVar(&dir, "data-dir", "", "settings directory")
|
||||||
|
flag.BoolVar(&ver, "version", false, "show version")
|
||||||
|
flag.BoolVar(&noTunnel, "no-wstunnel", false, "disable wstunnel for this -a / -c connection only")
|
||||||
|
flag.Parse()
|
||||||
|
if ver {
|
||||||
|
fmt.Println("wswg", version, runtime.GOOS, runtime.GOARCH)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if tui && gui {
|
||||||
|
return errors.New("-t and -g cannot be combined")
|
||||||
|
}
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
return errors.New("unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
base, e := os.UserConfigDir()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
dir = filepath.Join(base, "wswg")
|
||||||
|
}
|
||||||
|
var e error
|
||||||
|
dir, e = filepath.Abs(dir)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e = os.MkdirAll(dir, 0700); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e = os.Chmod(dir, 0700); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
s, e := loadSettings(dir)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if noTunnel && !auto && config == "" {
|
||||||
|
return errors.New("--no-wstunnel requires -a or -c")
|
||||||
|
}
|
||||||
|
selected := config
|
||||||
|
if selected == "" && auto {
|
||||||
|
selected = s.Last
|
||||||
|
if selected == "" {
|
||||||
|
return errors.New("no previous profile; choose -c first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if daemon {
|
||||||
|
if selected == "" {
|
||||||
|
return errors.New("-d requires -a or -c")
|
||||||
|
}
|
||||||
|
if _, e = s.find(selected); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
exe, e := os.Executable()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
log, e := os.OpenFile(filepath.Join(dir, "daemon.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer log.Close()
|
||||||
|
args := []string{"--data-dir", dir, "-c", selected}
|
||||||
|
if noTunnel {
|
||||||
|
args = append(args, "--no-wstunnel")
|
||||||
|
}
|
||||||
|
c := exec.Command(exe, args...)
|
||||||
|
ready := filepath.Join(dir, ".ready-"+newID())
|
||||||
|
defer os.Remove(ready)
|
||||||
|
c.Env = append(os.Environ(), "WSWG_DAEMON=1", "WSWG_READY="+ready)
|
||||||
|
c.Stdout = log
|
||||||
|
c.Stderr = log
|
||||||
|
background(c)
|
||||||
|
if e = c.Start(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
wait := make(chan error, 1)
|
||||||
|
go func() { wait <- c.Wait() }()
|
||||||
|
deadline := time.NewTimer(30 * time.Second)
|
||||||
|
defer deadline.Stop()
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-wait:
|
||||||
|
return fmt.Errorf("daemon exited: %v; see %s", err, log.Name())
|
||||||
|
case <-deadline.C:
|
||||||
|
_ = c.Process.Kill()
|
||||||
|
<-wait
|
||||||
|
return fmt.Errorf("daemon startup timed out; see %s", log.Name())
|
||||||
|
case <-ticker.C:
|
||||||
|
if _, err := os.Stat(ready); err == nil {
|
||||||
|
fmt.Println("Daemon connected, PID", c.Process.Pid, "log:", log.Name())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := fnv.New32a()
|
||||||
|
h.Write([]byte(dir))
|
||||||
|
lock, e := net.Listen("tcp4", fmt.Sprintf("127.0.0.1:%d", 30000+h.Sum32()%20000))
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("another wswg instance is running for this data directory, or its lock port is busy")
|
||||||
|
}
|
||||||
|
defer lock.Close()
|
||||||
|
a := &App{dir: dir, settings: s, noTunnel: noTunnel}
|
||||||
|
a.engine.dir = dir
|
||||||
|
defer a.engine.stop()
|
||||||
|
signals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signals, os.Interrupt, terminationSignal())
|
||||||
|
defer signal.Stop(signals)
|
||||||
|
done := make(chan struct{})
|
||||||
|
defer close(done)
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-signals:
|
||||||
|
a.engine.stop()
|
||||||
|
os.Exit(0)
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if selected != "" {
|
||||||
|
p, e := s.find(selected)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e = a.connect(p.ID); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if os.Getenv("WSWG_DAEMON") == "1" {
|
||||||
|
if ready := os.Getenv("WSWG_READY"); ready != "" {
|
||||||
|
if e = os.WriteFile(ready, []byte("ready"), 0600); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
if s := a.engine.status(); !s.Connected {
|
||||||
|
return fmt.Errorf("daemon disconnected: %s", s.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if s := a.engine.status(); !s.Connected {
|
||||||
|
return fmt.Errorf("disconnected: %s", s.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("\r\033[K%s", a.engine.status().Line())
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !tui && !gui {
|
||||||
|
gui = runtime.GOOS == "windows" || os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
|
||||||
|
}
|
||||||
|
if gui {
|
||||||
|
if s.Autorun && s.Last != "" {
|
||||||
|
if e = a.connect(s.Last); e != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.gui()
|
||||||
|
}
|
||||||
|
return a.tui()
|
||||||
|
}
|
||||||
|
func (a *App) connect(id string) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
p, e := a.settings.find(id)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if a.noTunnel {
|
||||||
|
p.Tunnel.Enabled = false
|
||||||
|
}
|
||||||
|
if e = a.engine.start(p); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
old := a.settings.Last
|
||||||
|
a.settings.Last = p.ID
|
||||||
|
if e = saveSettings(a.dir, a.settings); e != nil {
|
||||||
|
a.settings.Last = old
|
||||||
|
a.engine.stop()
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
func (a *App) save(p Profile) error {
|
||||||
|
if e := validateProfile(p); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.engine.activeID() == p.ID && p.ID != "" {
|
||||||
|
return errors.New("disconnect before editing this profile")
|
||||||
|
}
|
||||||
|
for _, x := range a.settings.Profiles {
|
||||||
|
if x.Name == p.Name && x.ID != p.ID {
|
||||||
|
return errors.New("name already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old := append([]Profile(nil), a.settings.Profiles...)
|
||||||
|
found := false
|
||||||
|
for i, x := range a.settings.Profiles {
|
||||||
|
if x.ID == p.ID && p.ID != "" {
|
||||||
|
a.settings.Profiles[i] = p
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
p.ID = newID()
|
||||||
|
a.settings.Profiles = append(a.settings.Profiles, p)
|
||||||
|
}
|
||||||
|
if e := saveSettings(a.dir, a.settings); e != nil {
|
||||||
|
a.settings.Profiles = old
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (a *App) remove(id string) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.engine.activeID() == id {
|
||||||
|
return errors.New("disconnect before deleting")
|
||||||
|
}
|
||||||
|
old := a.settings
|
||||||
|
next := a.settings
|
||||||
|
next.Profiles = []Profile{}
|
||||||
|
for _, p := range old.Profiles {
|
||||||
|
if p.ID != id {
|
||||||
|
next.Profiles = append(next.Profiles, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.Last == id {
|
||||||
|
next.Last = ""
|
||||||
|
}
|
||||||
|
if e := saveSettings(a.dir, next); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
a.settings = next
|
||||||
|
return nil
|
||||||
|
}
|
||||||
15
platform_unix.go
Normal file
15
platform_unix.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func replaceFile(a, b string) error { return os.Rename(a, b) }
|
||||||
|
func background(c *exec.Cmd) { c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} }
|
||||||
|
func admin() bool { return os.Geteuid() == 0 }
|
||||||
|
|
||||||
|
func enableTerminal() {}
|
||||||
40
platform_windows.go
Normal file
40
platform_windows.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
func replaceFile(a, b string) error {
|
||||||
|
x, e := syscall.UTF16PtrFromString(a)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
y, e := syscall.UTF16PtrFromString(b)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
r, _, e := syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW").Call(uintptr(unsafe.Pointer(x)), uintptr(unsafe.Pointer(y)), 3)
|
||||||
|
if r == 0 {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func background(c *exec.Cmd) {
|
||||||
|
c.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x00000008 | 0x00000200}
|
||||||
|
}
|
||||||
|
func admin() bool {
|
||||||
|
r, _, _ := syscall.NewLazyDLL("shell32.dll").NewProc("IsUserAnAdmin").Call()
|
||||||
|
return r != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableTerminal() {
|
||||||
|
k := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
var mode uint32
|
||||||
|
handle := uintptr(syscall.Stdout)
|
||||||
|
r, _, _ := k.NewProc("GetConsoleMode").Call(handle, uintptr(unsafe.Pointer(&mode)))
|
||||||
|
if r != 0 {
|
||||||
|
k.NewProc("SetConsoleMode").Call(handle, uintptr(mode|4))
|
||||||
|
}
|
||||||
|
}
|
||||||
68
route.go
Normal file
68
route.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Engine) pinRoute(ip string) error {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return e.windowsRoute(ip)
|
||||||
|
}
|
||||||
|
family := "-4"
|
||||||
|
bits := "/32"
|
||||||
|
if net.ParseIP(ip).To4() == nil {
|
||||||
|
family = "-6"
|
||||||
|
bits = "/128"
|
||||||
|
}
|
||||||
|
out, err := exec.Command("ip", family, "-j", "route", "get", ip).Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find route: %w", err)
|
||||||
|
}
|
||||||
|
var routes []struct {
|
||||||
|
Dev string `json:"dev"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(out, &routes) != nil || len(routes) == 0 || routes[0].Dev == "" {
|
||||||
|
return errors.New("cannot determine physical route to wstunnel")
|
||||||
|
}
|
||||||
|
r := routes[0]
|
||||||
|
// Never replace an existing host route: cleanup must only remove routes we own.
|
||||||
|
existing, err := exec.Command("ip", family, "route", "show", "exact", ip+bits).Output()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(existing)) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := []string{family, "route", "add", ip + bits}
|
||||||
|
if r.Gateway != "" {
|
||||||
|
args = append(args, "via", r.Gateway)
|
||||||
|
}
|
||||||
|
args = append(args, "dev", r.Dev)
|
||||||
|
if err = command("ip", args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
del := append([]string{"ip", family, "route", "del", ip + bits}, args[4:]...)
|
||||||
|
e.routes = append(e.routes, del)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (e *Engine) windowsRoute(ip string) error {
|
||||||
|
// Numeric IP is resolved locally before reaching PowerShell; it cannot contain script text.
|
||||||
|
script := fmt.Sprintf(`$ErrorActionPreference='Stop'; $ip='%s'; $r=Find-NetRoute -RemoteIPAddress $ip | Where-Object {$_.NextHop -ne $null} | Select-Object -First 1; if (!$r) { throw 'No physical route' }; $prefix=$ip+'/32'; if ($ip.Contains(':')) {$prefix=$ip+'/128'}; $exists=Get-NetRoute -DestinationPrefix $prefix -ErrorAction SilentlyContinue; if (!$exists) { New-NetRoute -DestinationPrefix $prefix -InterfaceIndex $r.InterfaceIndex -NextHop $r.NextHop -PolicyStore ActiveStore | Out-Null; Write-Output ($prefix+'|'+$r.InterfaceIndex+'|'+$r.NextHop) }`, ip)
|
||||||
|
out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("route: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
v := strings.Split(strings.TrimSpace(string(out)), "|")
|
||||||
|
if len(v) == 3 {
|
||||||
|
cleanup := fmt.Sprintf("Remove-NetRoute -DestinationPrefix '%s' -InterfaceIndex %s -NextHop '%s' -Confirm:$false -ErrorAction SilentlyContinue", v[0], v[1], v[2])
|
||||||
|
e.routes = append(e.routes, []string{"powershell", "-NoProfile", "-NonInteractive", "-Command", cleanup})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
22
scripts/archive.py
Normal file
22
scripts/archive.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Preserve Unix executable permissions in each ZIP and in the aggregate."""
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZipFile, ZIP_DEFLATED
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
out = root / 'dist'
|
||||||
|
archives=[]
|
||||||
|
for folder in sorted(out.glob('wswg-*')):
|
||||||
|
if not folder.is_dir(): continue
|
||||||
|
target = out / (folder.name + '.zip')
|
||||||
|
with ZipFile(target, 'w', ZIP_DEFLATED, compresslevel=6) as z:
|
||||||
|
for p in sorted(folder.rglob('*')):
|
||||||
|
if p.is_file(): z.write(p, p.relative_to(out))
|
||||||
|
archives.append(target)
|
||||||
|
all_zip = out / 'wswg-all.zip'
|
||||||
|
with ZipFile(all_zip, 'w', ZIP_DEFLATED, compresslevel=1) as z:
|
||||||
|
for p in archives: z.write(p, p.name)
|
||||||
|
with (out/'SHA256SUMS').open('w') as f:
|
||||||
|
for p in archives+[all_zip]:
|
||||||
|
f.write(hashlib.file_digest(p.open('rb'),'sha256').hexdigest()+' '+p.name+'\n')
|
||||||
|
print(f'{len(archives)} platform archives + {all_zip}')
|
||||||
23
scripts/build.sh
Executable file
23
scripts/build.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build every supported platform and an aggregate ZIP. Run from any directory.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export PATH="/usr/local/go/bin:$PATH"
|
||||||
|
command -v go >/dev/null
|
||||||
|
version=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}
|
||||||
|
mkdir -p dist
|
||||||
|
platforms=(linux-amd64 linux-386 linux-arm linux-armv7 linux-arm64 windows-amd64 windows-386 windows-arm64 termux-armv7 termux-arm64)
|
||||||
|
for target in "${platforms[@]}"; do
|
||||||
|
os=${target%%-*}; arch=${target#*-}; goos=$os; goarch=$arch; arm=7
|
||||||
|
[[ $os != termux ]] || goos=linux
|
||||||
|
case $arch in arm) arm=6;; armv7) goarch=arm;; esac
|
||||||
|
dest="dist/wswg-$target"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
ext=; [[ $os != windows ]] || ext=.exe
|
||||||
|
echo "Building $target"
|
||||||
|
CGO_ENABLED=0 GOOS=$goos GOARCH=$goarch GOARM=$arm go build -trimpath -buildvcs=false -ldflags="-s -w -X main.version=$version" -o "$dest/wswg$ext" .
|
||||||
|
cp README.md LICENSE THIRD_PARTY.md "$dest/"
|
||||||
|
done
|
||||||
|
if [[ ${SKIP_BUNDLE:-0} != 1 ]]; then python3 scripts/bundle.py; fi
|
||||||
|
if [[ ${SKIP_BUNDLE:-0} != 1 ]]; then python3 scripts/check-builds.py; fi
|
||||||
|
python3 scripts/archive.py
|
||||||
143
scripts/bundle.py
Normal file
143
scripts/bundle.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch pinned companions, verify hashes and build static Linux wg tools."""
|
||||||
|
import concurrent.futures
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parent.parent
|
||||||
|
CACHE=ROOT/'cache'; CACHE.mkdir(exist_ok=True)
|
||||||
|
(CACHE/'go.mod').write_text('module wswg-build-cache\n\ngo 1.24\n')
|
||||||
|
LOCK=ROOT/'scripts/dependencies.lock.json'
|
||||||
|
known=json.loads(LOCK.read_text()) if LOCK.exists() else {}
|
||||||
|
update=os.environ.get('UPDATE_LOCK')=='1'
|
||||||
|
WST='11.1.0'; WG='1.0.20250521'; WIN='1.1.1'; MUSL='1.2.3'
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
entry=known.get(url)
|
||||||
|
download_url=entry.get('url',url) if isinstance(entry,dict) else url
|
||||||
|
path=CACHE/url.rsplit('/',1)[-1]
|
||||||
|
if not path.exists():
|
||||||
|
print('Download',path.name,flush=True)
|
||||||
|
tmp=path.with_suffix(path.suffix+'.part')
|
||||||
|
with urllib.request.urlopen(download_url,timeout=90) as r,tmp.open('wb') as f: shutil.copyfileobj(r,f)
|
||||||
|
tmp.replace(path)
|
||||||
|
digest=hashlib.file_digest(path.open('rb'),'sha256').hexdigest()
|
||||||
|
if url not in known:
|
||||||
|
if not update: raise RuntimeError(f'Unpinned dependency: {url}. Use UPDATE_LOCK=1 only when reviewing a dependency update.')
|
||||||
|
known[url]=digest
|
||||||
|
if (known[url].get('sha256') if isinstance(known[url],dict) else known[url])!=digest: raise RuntimeError(f'Checksum mismatch: {path}')
|
||||||
|
return path
|
||||||
|
|
||||||
|
def extract(archive,dest):
|
||||||
|
dest.mkdir(parents=True,exist_ok=True)
|
||||||
|
with tarfile.open(archive) as t:
|
||||||
|
root=dest.resolve()
|
||||||
|
for m in t.getmembers():
|
||||||
|
target=(root/m.name).resolve()
|
||||||
|
if not target.is_relative_to(root) or not (m.isfile() or m.isdir() or m.issym() or m.islnk()):
|
||||||
|
raise RuntimeError(f'Unsafe archive member: {m.name}')
|
||||||
|
if m.issym() or m.islnk():
|
||||||
|
link=((target.parent if m.issym() else root)/m.linkname).resolve()
|
||||||
|
if not link.is_relative_to(root): raise RuntimeError(f'Unsafe archive link: {m.name}')
|
||||||
|
t.extractall(dest)
|
||||||
|
|
||||||
|
def run(args,**kw):
|
||||||
|
logfile=CACHE/'build.log'
|
||||||
|
with logfile.open('a') as log:
|
||||||
|
r=subprocess.run(args,stdout=log,stderr=subprocess.STDOUT,**kw)
|
||||||
|
if r.returncode: raise RuntimeError(f'{args} failed. See {logfile}')
|
||||||
|
|
||||||
|
def check_static(path):
|
||||||
|
for args,marker in [(['readelf','-l',str(path)],'INTERP'),(['readelf','-d',str(path)],'(NEEDED)')]:
|
||||||
|
if marker in subprocess.check_output(args,text=True): raise RuntimeError(f'Dynamic dependency in {path}')
|
||||||
|
|
||||||
|
def copy(src,dst):
|
||||||
|
dst.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(src,dst)
|
||||||
|
|
||||||
|
def static_wg(arch,archive,musl):
|
||||||
|
result=CACHE/f'wg-static-{arch}'
|
||||||
|
if result.exists(): return result
|
||||||
|
compilers={'amd64':('gcc','x86_64',[]),'386':('gcc','i386',['-m32']), 'arm':('arm-linux-gnueabi-gcc','arm',['-march=armv6','-mfloat-abi=soft']), 'armv7':('arm-linux-gnueabihf-gcc','arm',['-march=armv7-a','-mfpu=vfpv3-d16','-mfloat-abi=hard']), 'arm64':('aarch64-linux-gnu-gcc','aarch64',[])}
|
||||||
|
cc,host,flags=compilers[arch];work=CACHE/f'compile-{arch}';extract(musl,work)
|
||||||
|
source=work/f'musl-{MUSL}';prefix=work/'musl-install'
|
||||||
|
prefixcc=cc.removesuffix('gcc')
|
||||||
|
env=dict(os.environ,CC=cc,REALGCC=cc,AR=prefixcc+'ar',RANLIB=prefixcc+'ranlib',CFLAGS='-Os '+' '.join(flags))
|
||||||
|
if flags: env['CC']=cc+' '+' '.join(flags)
|
||||||
|
run(['./configure',f'--prefix={prefix}',f'--target={host}','--disable-shared'],cwd=source,env=env)
|
||||||
|
run(['make','-j4'],cwd=source,env=env);run(['make','install'],cwd=source,env=env)
|
||||||
|
extract(archive,work);wg=work/f'wireguard-tools-{WG}'/'src'
|
||||||
|
# Add only kernel UAPI headers, never the host glibc headers.
|
||||||
|
headers=work/'kernel-headers';headers.mkdir(exist_ok=True)
|
||||||
|
kernelbase=Path('/usr/include') if cc=='gcc' else Path('/usr')/cc.removesuffix('-gcc')/'include'
|
||||||
|
for name in ['linux','asm-generic']:
|
||||||
|
shutil.copytree(kernelbase/name,headers/name,dirs_exist_ok=True)
|
||||||
|
asm=Path('/usr/include/x86_64-linux-gnu/asm') if cc=='gcc' else kernelbase/'asm'
|
||||||
|
shutil.copytree(asm,headers/'asm',dirs_exist_ok=True)
|
||||||
|
(headers/'linux/wireguard.h').unlink(missing_ok=True)
|
||||||
|
run(['make','-j4',f'CPPFLAGS=-I{headers}', f'CC={prefix}/bin/musl-gcc {" ".join(flags)}','LDFLAGS=-static'+(' -Wl,-m,elf_i386' if arch=='386' else ''),'wg'],cwd=wg,env=env)
|
||||||
|
copy(wg/'wg',result);result.chmod(0o755)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def main():
|
||||||
|
wg=fetch(f'https://git.zx2c4.com/wireguard-tools/snapshot/wireguard-tools-{WG}.tar.xz')
|
||||||
|
musl=fetch(f'https://deb.debian.org/debian/pool/main/m/musl/musl_{MUSL}.orig.tar.gz')
|
||||||
|
extract(wg,CACHE/'wg-source');wgsrc=CACHE/'wg-source'/f'wireguard-tools-{WG}'
|
||||||
|
base=f'https://github.com/erebe/wstunnel/releases/download/v{WST}/'
|
||||||
|
sums=fetch(base+'checksums.txt');expected={}
|
||||||
|
for line in sums.read_text().splitlines():
|
||||||
|
parts=line.split()
|
||||||
|
if len(parts)==2: expected[parts[1].lstrip('*')]=parts[0]
|
||||||
|
targets=sorted(p for p in (ROOT/'dist').glob('wswg-*') if p.is_dir())
|
||||||
|
urls=[]
|
||||||
|
for folder in targets:
|
||||||
|
platform,arch=folder.name.removeprefix('wswg-').split('-',1)
|
||||||
|
osname='linux' if platform=='termux' else platform
|
||||||
|
wa='armv6' if arch=='arm' else arch
|
||||||
|
urls.append(base+f'wstunnel_{WST}_{osname}_{wa}.tar.gz')
|
||||||
|
if platform=='windows':
|
||||||
|
winarch={'386':'x86','amd64':'amd64','arm64':'arm64'}[arch]
|
||||||
|
urls.extend([f'https://download.wireguard.com/windows-client/wireguard-{winarch}-{WIN}.msi',f'https://aka.ms/vs/17/release/vc_redist.{"x64" if arch=="amd64" else winarch}.exe'])
|
||||||
|
# Independent downloads only; all archive extraction and builds stay sequential.
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
files=dict(zip(sorted(set(urls)),pool.map(fetch,sorted(set(urls)))))
|
||||||
|
for folder in targets:
|
||||||
|
platform,arch=folder.name.removeprefix('wswg-').split('-',1);osname='linux' if platform=='termux' else platform;wa='armv6' if arch=='arm' else arch
|
||||||
|
archive=files[base+f'wstunnel_{WST}_{osname}_{wa}.tar.gz']
|
||||||
|
actual=hashlib.file_digest(archive.open('rb'),'sha256').hexdigest()
|
||||||
|
if expected.get(archive.name)!=actual: raise RuntimeError(f'Upstream wstunnel checksum mismatch: {archive.name}')
|
||||||
|
unpack=CACHE/(archive.name+'.unpacked');extract(archive,unpack)
|
||||||
|
name='wstunnel.exe' if platform=='windows' else 'wstunnel'
|
||||||
|
binary=next(unpack.rglob(name));copy(binary,folder/'bin'/name);(folder/'bin'/name).chmod(0o755)
|
||||||
|
licenses=folder/'licenses';licenses.mkdir(exist_ok=True)
|
||||||
|
copy(wgsrc/'COPYING',licenses/'wireguard-tools.txt')
|
||||||
|
wlicense=fetch(f'https://raw.githubusercontent.com/erebe/wstunnel/v{WST}/LICENSE')
|
||||||
|
copy(wlicense,licenses/'wstunnel.txt')
|
||||||
|
if platform=='windows':
|
||||||
|
winarch={'386':'x86','amd64':'amd64','arm64':'arm64'}[arch]
|
||||||
|
msi=files[f'https://download.wireguard.com/windows-client/wireguard-{winarch}-{WIN}.msi']
|
||||||
|
unpack=CACHE/(msi.name+'.unpacked');unpack.mkdir(exist_ok=True)
|
||||||
|
run(['msiextract','-C',str(unpack),str(msi)])
|
||||||
|
for name in ['wireguard.exe','wg.exe']: copy(next(unpack.rglob(name)),folder/'bin'/name)
|
||||||
|
redarch='x64' if arch=='amd64' else winarch
|
||||||
|
redist=files[f'https://aka.ms/vs/17/release/vc_redist.{redarch}.exe'];copy(redist,folder/'redist'/redist.name)
|
||||||
|
wlicense=fetch('https://raw.githubusercontent.com/WireGuard/wireguard-windows/v1.1.1/COPYING');copy(wlicense,licenses/'wireguard-windows.txt')
|
||||||
|
else:
|
||||||
|
check_static(binary)
|
||||||
|
static=static_wg(arch,wg,musl);check_static(static)
|
||||||
|
copy(static,folder/'bin'/'wg')
|
||||||
|
quick=wgsrc/'src/wg-quick/linux.bash';copy(quick,folder/'bin'/'wg-quick');(folder/'bin'/'wg-quick').chmod(0o755)
|
||||||
|
if platform=='termux':
|
||||||
|
# A static Linux ELF runs under Android's kernel. No Bionic shared libraries.
|
||||||
|
# Use Termux's bash: Android does not have /bin/bash.
|
||||||
|
q=folder/'bin'/'wg-quick';q.write_text(q.read_text().replace('#!/bin/bash','#!/data/data/com.termux/files/usr/bin/bash',1))
|
||||||
|
copy(CACHE/f'compile-{arch}'/f'musl-{MUSL}'/'COPYRIGHT',licenses/'musl.txt')
|
||||||
|
if update: LOCK.write_text(json.dumps(known,indent=2,sort_keys=True)+'\n')
|
||||||
|
print('Companions bundled.',flush=True)
|
||||||
|
|
||||||
|
if __name__=='__main__': main()
|
||||||
30
scripts/check-builds.py
Normal file
30
scripts/check-builds.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check architecture and absence of shared-library imports in Linux payloads."""
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
root=Path(__file__).resolve().parent.parent
|
||||||
|
for folder in sorted((root/'dist').glob('wswg-*')):
|
||||||
|
if not folder.is_dir(): continue
|
||||||
|
platform,arch=folder.name.removeprefix('wswg-').split('-',1)
|
||||||
|
expected={'amd64':'Advanced Micro Devices X86-64','386':'Intel 80386','arm':'ARM','armv7':'ARM','arm64':'AArch64'}[arch]
|
||||||
|
if platform=='windows':
|
||||||
|
for p in [folder/'wswg.exe',folder/'bin/wg.exe',folder/'bin/wireguard.exe',folder/'bin/wstunnel.exe']:
|
||||||
|
b=p.read_bytes();offset=struct.unpack_from('<I',b,0x3c)[0]
|
||||||
|
machine=struct.unpack_from('<H',b,offset+4)[0]
|
||||||
|
if b[:2]!=b'MZ' or b[offset:offset+4]!=b'PE\0\0' or machine!={'amd64':0x8664,'386':0x14c,'arm64':0xaa64}[arch]:raise RuntimeError(f'Wrong Windows architecture: {p}')
|
||||||
|
assert list((folder/'redist').glob('*.exe')),folder
|
||||||
|
print(folder.name+': PE executables + redist OK');continue
|
||||||
|
runner={'amd64':[],'386':[],'arm':['qemu-arm'],'armv7':['qemu-arm'],'arm64':['qemu-aarch64']}[arch]
|
||||||
|
for name in ['wswg','bin/wg','bin/wstunnel']:
|
||||||
|
p=folder/name
|
||||||
|
hdr=subprocess.check_output(['readelf','-h',str(p)],text=True)
|
||||||
|
if expected not in hdr:raise RuntimeError(f'Wrong architecture: {p}')
|
||||||
|
for args,marker in [(['readelf','-l',str(p)],'INTERP'),(['readelf','-d',str(p)],'(NEEDED)')]:
|
||||||
|
if marker in subprocess.check_output(args,text=True):raise RuntimeError(f'Shared library dependency: {p}')
|
||||||
|
if not runner or shutil.which(runner[0]):
|
||||||
|
proc=subprocess.run(runner+[str(p),'--version'],capture_output=True,text=True,timeout=30,env=dict(os.environ,NO_COLOR='true'))
|
||||||
|
if proc.returncode!=0:raise RuntimeError(f'Cannot run {p}: {proc.stderr}')
|
||||||
|
print(folder.name+': ELF architecture, static linkage, available runtime smoke tests OK')
|
||||||
30
scripts/dependencies.lock.json
Normal file
30
scripts/dependencies.lock.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"https://aka.ms/vs/17/release/vc_redist.arm64.exe": {
|
||||||
|
"sha256": "5139e1440c3a20b92153a4db561c069a0175aaf76c276c3e5b6f56099edcf4b0",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/d7450eb5-03e1-436d-9e7e-deb5fe4759b3/5139E1440C3A20B92153A4DB561C069A0175AAF76C276C3E5B6F56099EDCF4B0/VC_redist.arm64.exe"
|
||||||
|
},
|
||||||
|
"https://aka.ms/vs/17/release/vc_redist.x64.exe": {
|
||||||
|
"sha256": "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/990c720a-46ce-4dc7-87b9-c2eb615d34b9/CC0FF0EB1DC3F5188AE6300FAEF32BF5BEEBA4BDD6E8E445A9184072096B713B/VC_redist.x64.exe"
|
||||||
|
},
|
||||||
|
"https://aka.ms/vs/17/release/vc_redist.x86.exe": {
|
||||||
|
"sha256": "0c09f2611660441084ce0df425c51c11e147e6447963c3690f97e0b25c55ed64",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/990c720a-46ce-4dc7-87b9-c2eb615d34b9/0C09F2611660441084CE0DF425C51C11E147E6447963C3690F97E0B25C55ED64/VC_redist.x86.exe"
|
||||||
|
},
|
||||||
|
"https://deb.debian.org/debian/pool/main/m/musl/musl_1.2.3.orig.tar.gz": "7d5b0b6062521e4627e099e4c9dc8248d32a30285e959b7eecaa780cf8cfd4a4",
|
||||||
|
"https://download.wireguard.com/windows-client/wireguard-amd64-1.1.1.msi": "7bfed60ad61b785c914b38b61555a975488e1d3ec472dbfb2fcdf498fca75242",
|
||||||
|
"https://download.wireguard.com/windows-client/wireguard-arm64-1.1.1.msi": "8336335c738d4aff040fa9d11b752da65a80b6f41d3262d6ba5e82f97ebd1bd4",
|
||||||
|
"https://download.wireguard.com/windows-client/wireguard-x86-1.1.1.msi": "e3ec714fbfce3ba3a416671252e16cd71517a837a7ebd18b8c24b29a441e8724",
|
||||||
|
"https://git.zx2c4.com/wireguard-tools/snapshot/wireguard-tools-1.0.20250521.tar.xz": "61f520e7c1664ae9301fa36a2b8e90cf4680887a71f456c290d5d8b879f1e2e6",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/checksums.txt": "f27fb7feaa415db8ebd5215c3e8a4b9eea6fadb2f60f4062d12bb70e3a53d10b",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_linux_386.tar.gz": "dfdb0a0183d37405ed83768eb5c53215c39e92502d459e0e425c75a2e2ce2b9d",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_linux_amd64.tar.gz": "512edf4e7bb64fc133522220123514868fae75b5bae4124b53e396787276aa23",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_linux_arm64.tar.gz": "148895f957a68be39e2b65d28c5f2065b9c58c4a0cfd48c560b2b3267f398464",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_linux_armv6.tar.gz": "da42f0dea5a43353a81a4077c4b8753e6217eb3dd1daea30c8f2c34b864a5931",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_linux_armv7.tar.gz": "0a37948d6b68885acb5b2f8d275a6e256b65770fc39f9fe67882284eeafd40e3",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_windows_386.tar.gz": "9bad9e6339c5b332cc1c0ab3d91359b8b544914fe2cf96cc0cdd32dd134c85ad",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_windows_amd64.tar.gz": "b271d076fbba92fe3d8c9b7612bf17f4ac905d68614a6c40debd73107883d75e",
|
||||||
|
"https://github.com/erebe/wstunnel/releases/download/v11.1.0/wstunnel_11.1.0_windows_arm64.tar.gz": "041b36f0e59863c84e43c9e9409dd7ad166936ce4d538f89dbb5fdff4c618c6f",
|
||||||
|
"https://raw.githubusercontent.com/WireGuard/wireguard-windows/v1.1.1/COPYING": "fb2a821211c8d0022e3e630a126783b23b58c6dc21c1855154fc77c9c6452166",
|
||||||
|
"https://raw.githubusercontent.com/erebe/wstunnel/v11.1.0/LICENSE": "fbb93c6ebd04d1413b36fff9e3226028aeb0624c15e409e48f4b7e19573686c2"
|
||||||
|
}
|
||||||
76
scripts/integration.py
Normal file
76
scripts/integration.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Root-only, opt-in local WireGuard handshake test. Never touches default routes."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parent.parent
|
||||||
|
APP=Path(os.environ.get('WSWG_BINARY',ROOT/'dist/wswg-linux-amd64/wswg'))
|
||||||
|
WST=ROOT/'dist/wswg-linux-amd64/bin/wstunnel'
|
||||||
|
IFACE='wswgtest'
|
||||||
|
|
||||||
|
def run(*args,**kw): return subprocess.check_output(args,**kw).decode().strip()
|
||||||
|
def main():
|
||||||
|
if os.geteuid()!=0: raise SystemExit('Needs root and CAP_NET_ADMIN')
|
||||||
|
if subprocess.run(['ip','link','show',IFACE],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:
|
||||||
|
raise SystemExit('Test interface already exists; refusing to change it')
|
||||||
|
if subprocess.run(['ip','link','show','wswg0'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:
|
||||||
|
raise SystemExit('wswg0 already exists; disconnect before testing')
|
||||||
|
app=None;server=None
|
||||||
|
with tempfile.TemporaryDirectory(prefix='wswg-integration-') as temp:
|
||||||
|
temp=Path(temp);keyfile=temp/'server.key';serverkey=run('wg','genkey');clientkey=run('wg','genkey')
|
||||||
|
keyfile.write_text(serverkey);keyfile.chmod(0o600)
|
||||||
|
serverpub=run('wg','pubkey',input=(serverkey+'\n').encode());clientpub=run('wg','pubkey',input=(clientkey+'\n').encode())
|
||||||
|
try:
|
||||||
|
subprocess.run(['ip','link','add',IFACE,'type','wireguard'],check=True)
|
||||||
|
subprocess.run(['ip','addr','add','10.253.253.1/32','dev',IFACE],check=True)
|
||||||
|
subprocess.run(['wg','set',IFACE,'private-key',str(keyfile),'listen-port','0','peer',clientpub,'allowed-ips','10.253.253.2/32'],check=True)
|
||||||
|
subprocess.run(['ip','link','set',IFACE,'up'],check=True)
|
||||||
|
subprocess.run(['ip','route','add','10.253.253.2/32','dev',IFACE],check=True)
|
||||||
|
port=run('wg','show',IFACE,'listen-port')
|
||||||
|
for mode in ['direct','ws','wss','override']:
|
||||||
|
tunneled=mode in ['ws','wss']
|
||||||
|
data=temp/mode;data.mkdir()
|
||||||
|
config=f'[Interface]\nPrivateKey = {clientkey}\nAddress = 10.253.253.2/32\n[Peer]\nPublicKey = {serverpub}\nAllowedIPs = 10.253.253.1/32\nEndpoint = 127.0.0.1:{port}\nPersistentKeepalive = 1\n'
|
||||||
|
if tunneled:
|
||||||
|
server=subprocess.Popen([str(WST),'server','--restrict-to','127.0.0.1:'+port,mode+'://127.0.0.1:18764'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=dict(os.environ,NO_COLOR="true"));time.sleep(.5)
|
||||||
|
settings={'profiles':[{'id':'local','name':'Local test','wireguard':config,'dns':False,'tunnel':{'enabled':mode!='direct','host':'127.0.0.1','port':18764,'https':mode=='wss'}}],'last':'local'}
|
||||||
|
(data/'settings.json').write_text(json.dumps(settings));(data/'settings.json').chmod(0o600)
|
||||||
|
with (temp/'app.log').open('w') as log:
|
||||||
|
app=subprocess.Popen([str(APP),'--data-dir',str(data),'-c','1']+(['--no-wstunnel'] if mode=='override' else []),stdout=log,stderr=log)
|
||||||
|
for _ in range(40):
|
||||||
|
if app.poll() is not None: raise RuntimeError((temp/'app.log').read_text()+((data/'wstunnel.log').read_text() if (data/'wstunnel.log').exists() else ''))
|
||||||
|
hs=subprocess.run(['wg','show','wswg0','latest-handshakes'],capture_output=True,text=True)
|
||||||
|
if hs.returncode==0 and hs.stdout.strip() and int(hs.stdout.split()[-1])>0: break
|
||||||
|
time.sleep(.25)
|
||||||
|
else: raise RuntimeError('No handshake: '+(temp/'app.log').read_text())
|
||||||
|
subprocess.run(['ping','-c','2','-W','1','10.253.253.1'],check=True,stdout=subprocess.DEVNULL)
|
||||||
|
time.sleep(2)
|
||||||
|
app.send_signal(signal.SIGTERM);app.wait(timeout=15);app=None
|
||||||
|
if subprocess.run(['ip','link','show','wswg0'],capture_output=True).returncode==0: raise RuntimeError('Interface leaked')
|
||||||
|
if (data/'wswg0.conf').exists(): raise RuntimeError('Private runtime config leaked')
|
||||||
|
text=(temp/'app.log').read_text()
|
||||||
|
if 'Packet loss: 0%' not in text: raise RuntimeError('Loss status missing: '+text)
|
||||||
|
print(mode+': handshake, VPN ping, packet-loss status, SIGTERM cleanup OK')
|
||||||
|
if mode in ['direct','override']:
|
||||||
|
import re
|
||||||
|
daemon=subprocess.run([str(APP),'--data-dir',str(data),'-a','-d']+(['--no-wstunnel'] if mode=='override' else []),capture_output=True,text=True,timeout=35,check=True)
|
||||||
|
pid=int(re.search(r'PID (\d+)',daemon.stdout)[1]);os.kill(pid,signal.SIGTERM)
|
||||||
|
for _ in range(40):
|
||||||
|
if subprocess.run(['ip','link','show','wswg0'],capture_output=True).returncode!=0: break
|
||||||
|
time.sleep(.1)
|
||||||
|
else: raise RuntimeError('Daemon did not clean up its interface')
|
||||||
|
print(mode+' daemon: -a -d startup acknowledgment and SIGTERM cleanup OK')
|
||||||
|
if mode=='override' and not json.loads((data/'settings.json').read_text())['profiles'][0]['tunnel']['enabled']: raise RuntimeError('Override changed saved settings')
|
||||||
|
if server: server.terminate();server.wait(timeout=10);server=None
|
||||||
|
finally:
|
||||||
|
if app and app.poll() is None: app.terminate();app.wait(timeout=15)
|
||||||
|
if server and server.poll() is None: server.terminate();server.wait(timeout=10)
|
||||||
|
subprocess.run(['ip','link','del',IFACE],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
if __name__=='__main__': main()
|
||||||
31
scripts/publish.py
Normal file
31
scripts/publish.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish built artifacts through the existing html_plans Caddy site."""
|
||||||
|
from pathlib import Path
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
version=sys.argv[1] if len(sys.argv)>1 else '0.1.0'
|
||||||
|
if not version.replace('.','').replace('-','').isalnum():raise SystemExit('Invalid version')
|
||||||
|
root=Path(__file__).resolve().parent.parent
|
||||||
|
source=root/'dist';dest=Path('/opt/public-downloads/wswg')/version
|
||||||
|
if not (source/'wswg-all.zip').exists():raise SystemExit('Run scripts/build.sh first')
|
||||||
|
rows=[]
|
||||||
|
for archive in sorted(source.glob('wswg-*.zip')):
|
||||||
|
if archive.name=='wswg-all.zip':continue
|
||||||
|
folder=archive.stem
|
||||||
|
exe='wswg.exe' if 'windows' in folder else 'wswg'
|
||||||
|
rows.append(f'<tr><td>{html.escape(folder.removeprefix("wswg-"))}</td><td><a href="{archive.name}">ZIP</a></td><td>{archive.stat().st_size/1024/1024:.1f} MB</td><td><a href="{folder}/{exe}">wswg</a></td></tr>')
|
||||||
|
page='''<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>wswg builds</title><style>body{font:15px system-ui;max-width:850px;padding:24px;margin:auto;color:#222}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:9px;border-bottom:1px solid #ccc}a{color:#125ba4}code{background:#eee;padding:2px 4px}</style>'''
|
||||||
|
page+=f'<h1>wswg {html.escape(version)}</h1><p>WireGuard + wstunnel. Простой GUI в локальном браузере и TUI.</p><p><a href="wswg-all.zip">Все платформы одним ZIP</a> · <a href="SHA256SUMS">SHA256SUMS</a> · <a href="README.md">Инструкция</a></p><table><tr><th>Платформа</th><th>Скачать</th><th>Размер</th><th>Бинарник отдельно</th></tr>'+''.join(rows)+'</table><p>Для обычной установки скачайте ZIP и распакуйте целиком. Папки bin и redist должны остаться рядом с программой.</p><p><code>./wswg -t</code> · <code>./wswg -a</code> · <code>./wswg -c 1 -d --no-wstunnel</code></p><p>Linux и Termux требуют root и ядро с WireGuard. Windows требует Administrator. Windows ARM32 не поддерживается. Системные требования и настройка DNS описаны в инструкции.</p><p><a href="https://git.snw.su/snw/wswg">Исходники</a> · <a href="VALIDATION.md">Проверки сборки</a></p></html>'
|
||||||
|
(source/'index.html').write_text(page)
|
||||||
|
shutil.copy2(root/'README.md',source/'README.md')
|
||||||
|
shutil.copy2(root/'VALIDATION.md',source/'VALIDATION.md')
|
||||||
|
shutil.copytree(source,dest,dirs_exist_ok=True)
|
||||||
|
shared=Path('/opt/html_plans');shared.mkdir(exist_ok=True)
|
||||||
|
link=shared/'wswg-builds'
|
||||||
|
if link.exists() and not link.is_symlink():raise SystemExit(f'Refusing to replace {link}: it is not a symlink')
|
||||||
|
temp=shared/'.wswg-builds-new'
|
||||||
|
if temp.is_symlink():temp.unlink()
|
||||||
|
temp.symlink_to(dest,target_is_directory=True);os.replace(temp,link)
|
||||||
|
print('http://html_plans.t.snw.su/wswg-builds/')
|
||||||
10
signal_unix.go
Normal file
10
signal_unix.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func terminationSignal() os.Signal { return syscall.SIGTERM }
|
||||||
5
signal_windows.go
Normal file
5
signal_windows.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func terminationSignal() os.Signal { return os.Interrupt }
|
||||||
260
tui.go
Normal file
260
tui.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type terminal struct{ r *bufio.Reader }
|
||||||
|
|
||||||
|
func (t terminal) ask(label, current string) (string, error) {
|
||||||
|
fmt.Printf("%s [%s]: ", label, current)
|
||||||
|
s, e := t.r.ReadString('\n')
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
func (a *App) tui() error {
|
||||||
|
t := terminal{bufio.NewReader(os.Stdin)}
|
||||||
|
for {
|
||||||
|
a.mu.Lock()
|
||||||
|
s := a.settings
|
||||||
|
a.mu.Unlock()
|
||||||
|
fmt.Println("\nwswg |", a.engine.status().Line())
|
||||||
|
for i, p := range s.Profiles {
|
||||||
|
mark := ""
|
||||||
|
if p.ID == a.engine.activeID() {
|
||||||
|
mark = " [connected]"
|
||||||
|
}
|
||||||
|
fmt.Printf("%d. %s%s\n", i+1, p.Name, mark)
|
||||||
|
}
|
||||||
|
fmt.Println("n. New config\ns. Settings\nx. Disconnect\nq. Quit")
|
||||||
|
v, err := t.ask(">", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case "q":
|
||||||
|
return nil
|
||||||
|
case "x":
|
||||||
|
a.engine.stop()
|
||||||
|
case "s":
|
||||||
|
fmt.Printf("On boot: %t\n", s.OnBoot)
|
||||||
|
v, err = t.ask("Toggle on boot? y/n", "n")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if v == "y" {
|
||||||
|
if err = a.setOptions(s.Autorun, !s.OnBoot); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "n":
|
||||||
|
name, err := t.ask("Name", "New config")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p := Profile{Name: name, DNS: true, Tunnel: Tunnel{Port: 443, HTTPS: true}}
|
||||||
|
if err = editConfig(a.dir, &p); err == nil {
|
||||||
|
err = a.save(p)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
p, err := s.find(v)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = a.profileMenu(t, p); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (a *App) profileMenu(t terminal, p Profile) error {
|
||||||
|
for {
|
||||||
|
fmt.Printf("\n%s\n1. WireGuard (DNS: %t)\n2. wstunnel (enabled: %t, host: %s, port: %d, HTTPS: %t)\n3. Use DNS from WireGuard config: %t\n4. Rename\n5. Connect\n6. Disconnect\n7. Delete\n0. Back\n", p.Name, p.DNS, p.Tunnel.Enabled, p.Tunnel.Host, p.Tunnel.Port, p.Tunnel.HTTPS, p.DNS)
|
||||||
|
v, err := t.ask(">", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next := p
|
||||||
|
switch v {
|
||||||
|
case "0":
|
||||||
|
return nil
|
||||||
|
case "1":
|
||||||
|
err = editConfig(a.dir, &next)
|
||||||
|
case "2":
|
||||||
|
err = tunnelMenu(t, &next)
|
||||||
|
case "3":
|
||||||
|
next.DNS = !next.DNS
|
||||||
|
case "4":
|
||||||
|
next.Name, err = t.ask("Name", p.Name)
|
||||||
|
case "5":
|
||||||
|
err = a.connect(p.ID)
|
||||||
|
if err == nil {
|
||||||
|
return a.watch(t)
|
||||||
|
}
|
||||||
|
case "6":
|
||||||
|
a.engine.stop()
|
||||||
|
continue
|
||||||
|
case "7":
|
||||||
|
v, err = t.ask("Delete this profile? y/n", "n")
|
||||||
|
if err == nil && v == "y" {
|
||||||
|
return a.remove(p.ID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == nil && v != "5" {
|
||||||
|
err = a.save(next)
|
||||||
|
if err == nil {
|
||||||
|
p = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func tunnelMenu(t terminal, p *Profile) error {
|
||||||
|
for {
|
||||||
|
fmt.Printf("\n1. Enabled: %t\n2. IP/host: %s (empty = WireGuard endpoint)\n3. Port: %d\n4. HTTPS: %t\n0. Back\n", p.Tunnel.Enabled, p.Tunnel.Host, p.Tunnel.Port, p.Tunnel.HTTPS)
|
||||||
|
v, e := t.ask(">", "")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case "0":
|
||||||
|
return nil
|
||||||
|
case "1":
|
||||||
|
p.Tunnel.Enabled = !p.Tunnel.Enabled
|
||||||
|
case "2":
|
||||||
|
h, e := t.ask("Host, '-' to reset", p.Tunnel.Host)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if h == "-" {
|
||||||
|
h = ""
|
||||||
|
}
|
||||||
|
p.Tunnel.Host = h
|
||||||
|
case "3":
|
||||||
|
v, e = t.ask("Port", strconv.Itoa(p.Tunnel.Port))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
n, e := strconv.Atoi(v)
|
||||||
|
if e != nil || n < 1 || n > 65535 {
|
||||||
|
fmt.Println("Port must be 1..65535")
|
||||||
|
} else {
|
||||||
|
p.Tunnel.Port = n
|
||||||
|
}
|
||||||
|
case "4":
|
||||||
|
p.Tunnel.HTTPS = !p.Tunnel.HTTPS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func editConfig(dir string, p *Profile) error {
|
||||||
|
f, err := os.CreateTemp(dir, "edit-*.conf")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := f.Name()
|
||||||
|
defer os.Remove(path)
|
||||||
|
if err = f.Chmod(0600); err == nil {
|
||||||
|
_, err = f.WriteString(p.WireGuard)
|
||||||
|
}
|
||||||
|
ce := f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ce != nil {
|
||||||
|
return ce
|
||||||
|
}
|
||||||
|
editor := os.Getenv("EDITOR")
|
||||||
|
if editor == "" {
|
||||||
|
editor = "nano"
|
||||||
|
}
|
||||||
|
args, err := editorArgs(editor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c := exec.Command(args[0], append(args[1:], filepath.Clean(path))...)
|
||||||
|
c.Stdin = os.Stdin
|
||||||
|
c.Stdout = os.Stdout
|
||||||
|
c.Stderr = os.Stderr
|
||||||
|
if err = c.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.WireGuard = string(b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse quoted executable paths and arguments without executing a shell.
|
||||||
|
func editorArgs(s string) ([]string, error) {
|
||||||
|
var args []string
|
||||||
|
var word strings.Builder
|
||||||
|
var quote rune
|
||||||
|
for _, r := range s {
|
||||||
|
if quote != 0 {
|
||||||
|
if r == quote {
|
||||||
|
quote = 0
|
||||||
|
} else {
|
||||||
|
word.WriteRune(r)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '\'' || r == '"' {
|
||||||
|
quote = r
|
||||||
|
} else if r == ' ' || r == '\t' {
|
||||||
|
if word.Len() > 0 {
|
||||||
|
args = append(args, word.String())
|
||||||
|
word.Reset()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
word.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quote != 0 {
|
||||||
|
return nil, fmt.Errorf("unclosed quote in EDITOR")
|
||||||
|
}
|
||||||
|
if word.Len() > 0 {
|
||||||
|
args = append(args, word.String())
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty EDITOR")
|
||||||
|
}
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
func (a *App) watch(t terminal) error {
|
||||||
|
fmt.Println("Connected. Enter returns to menu, Ctrl+C quits.")
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { defer close(done); _, _ = t.r.ReadString('\n') }()
|
||||||
|
for {
|
||||||
|
fmt.Printf("\r\033[K%s", a.engine.status().Line())
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
web/index.html
Normal file
26
web/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>wswg</title>
|
||||||
|
<style>
|
||||||
|
:root{font:14px system-ui,sans-serif;color-scheme:light dark;background:#eee;color:#222}*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button{padding:5px 12px;cursor:pointer}input[type=text],input[type=number],textarea{padding:6px;border:1px solid #999;background:#fff;color:#222;border-radius:2px}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid #1769c2;outline-offset:1px}main{display:grid;grid-template-columns:210px 1fr;height:calc(100dvh - 45px);min-height:420px;max-width:1200px;margin:auto}aside{padding:14px 10px;border-right:1px solid #aaa;display:flex;flex-direction:column;gap:12px}h1,h2{font-size:14px;font-weight:600;margin:0}#profiles{flex:1;overflow:auto;display:flex;flex-direction:column;gap:3px;align-items:stretch}.profile{text-align:left;border:1px solid transparent;background:transparent;padding:8px;overflow-wrap:anywhere}.profile[aria-pressed=true]{background:#cfe2fa;border-color:#7ba8da}article{padding:18px;overflow:auto}.row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}#name{font-size:16px;overflow-wrap:anywhere}textarea{display:block;width:100%;height:240px;resize:vertical;font:13px ui-monospace,monospace;margin:10px 0}textarea[readonly]{background:#f7f7f7}fieldset{padding:12px;border:1px solid #aaa;margin:16px 0}fieldset .row:last-child{margin-bottom:0}fieldset label{min-width:85px}#host{width:min(300px,100%)}#port{width:85px}footer{min-height:45px;display:flex;justify-content:space-between;align-items:center;gap:10px;border-top:1px solid #aaa;padding:7px 12px;flex-wrap:wrap;font-size:12px}footer label{white-space:nowrap}#error{color:#a00012;white-space:pre-wrap;margin-top:12px}#empty{color:#666}button:disabled{cursor:default;opacity:.55}[hidden]{display:none!important}@media(prefers-color-scheme:dark){:root{background:#202020;color:#eee}input[type=text],input[type=number],textarea{background:#292929;color:#eee}textarea[readonly]{background:#252525}.profile[aria-pressed=true]{background:#254568}#empty{color:#bbb}#error{color:#ff9b9b}}@media(max-width:560px){main{grid-template-columns:130px 1fr}aside{padding:10px 5px}article{padding:12px}button{padding:6px 8px}textarea{height:220px}footer{font-size:11px}}
|
||||||
|
</style>
|
||||||
|
<main><aside><h1>Configs</h1><nav id="profiles" aria-label="Конфигурации"></nav><button id="new">New</button></aside><article><p id="empty">Выберите конфигурацию или нажмите New.</p><div id="editor" hidden><div class="row"><strong id="name"></strong><button id="rename">Rename</button><button id="delete">Delete</button></div><div class="row"><h2>WireGuard</h2><button id="edit">Edit</button></div><textarea id="wg" aria-label="WireGuard config" spellcheck="false" readonly></textarea><label><input id="dns" type="checkbox"> Use DNS from config</label><fieldset><legend><label><input id="enabled" type="checkbox"> Wstunnel</label></legend><div class="row"><label for="host">IP / host</label><input id="host" type="text" placeholder="From WireGuard"></div><div class="row"><label for="port">Port</label><input id="port" type="number" min="1" max="65535" value="443"></div><label><input id="https" type="checkbox" checked> Use HTTPS</label></fieldset><div class="row"><button id="save">Save</button><button id="connect">Connect</button><button id="disconnect">Disconnect</button><span id="saved" role="status"></span></div></div><div id="error" role="alert"></div></article></main><footer><span id="status" role="status">Disconnected</span><span><label><input id="autorun" type="checkbox"> Autorun</label> | <label><input id="boot" type="checkbox"> On boot</label></span></footer>
|
||||||
|
<script>
|
||||||
|
'use strict';const $=id=>document.getElementById(id),token=location.hash.slice(1)||sessionStorage.getItem('wswg-token')||'';sessionStorage.setItem('wswg-token',token);history.replaceState(null,'',location.pathname);let state,selected='',draft=null,editing=false,busy=false,dirty=false;
|
||||||
|
async function api(path,data){const r=await fetch('/api/'+path,{method:data===undefined?'GET':'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},body:data===undefined?undefined:JSON.stringify(data)});const j=await r.json();if(!r.ok)throw Error(j.error||r.statusText);return j}
|
||||||
|
function fail(e){$('error').textContent=e.message||String(e)}
|
||||||
|
async function action(fn){if(busy)return;busy=true;$('error').textContent='';try{await fn()}catch(e){fail(e)}finally{busy=false}}
|
||||||
|
function select(p){if(dirty&&!confirm('Discard unsaved changes?'))return;draft=structuredClone(p);selected=p.id;dirty=false;editing=!p.id;draw()}
|
||||||
|
function draw(){const p=draft;$('editor').hidden=!p;$('empty').hidden=!!p;list();if(!p)return;$('name').textContent=p.name;$('wg').value=p.wireguard;$('wg').readOnly=!editing;$('edit').textContent=editing?'Read only':'Edit';$('dns').checked=p.dns;$('enabled').checked=p.tunnel.enabled;$('host').value=p.tunnel.host;$('port').value=p.tunnel.port;$('https').checked=p.tunnel.https;$('delete').disabled=!p.id;$('connect').disabled=!p.id||dirty;$('saved').textContent=dirty?'Unsaved':'';locks()}
|
||||||
|
function locks(){const active=state&&state.status.connected&&selected&&state.settings.profiles.find(p=>p.id===selected)?.name===state.status.config;for(const id of ['wg','edit','dns','enabled','host','port','https','save','delete','rename'])$(id).disabled=!!active;$('disconnect').disabled=!state?.status.connected;if(active)$('connect').disabled=true}
|
||||||
|
function list(){if(!state)return;$('profiles').replaceChildren();for(const p of state.settings.profiles){const b=document.createElement('button');b.className='profile';b.textContent=p.name+(state.status.connected&&state.status.config===p.name?' [✓]':'');b.setAttribute('aria-pressed',p.id===selected);b.onclick=()=>select(p);$('profiles').append(b)}}
|
||||||
|
function read(){if(!draft)return;draft.wireguard=$('wg').value;draft.dns=$('dns').checked;draft.tunnel={enabled:$('enabled').checked,host:$('host').value.trim(),port:Number($('port').value),https:$('https').checked}}
|
||||||
|
async function refresh(){state=await api('state');const s=state.status;$('status').textContent=s.connected?`Connected to ${s.ip} | Last handshake ${s.handshake}s | Ping ${s.ping}ms | Packet loss: ${s.packetLoss}%`:'Disconnected'+(s.error?' | '+s.error:'');$('autorun').checked=state.settings.autorun;$('boot').checked=state.settings.onBoot;list();locks()}
|
||||||
|
for(const id of ['wg','dns','enabled','host','port','https'])$(id).addEventListener('input',()=>{dirty=true;read();$('saved').textContent='Unsaved';$('connect').disabled=true});
|
||||||
|
$('new').onclick=()=>select({id:'',name:'New config',wireguard:'[Interface]\nPrivateKey = \nAddress = \n\n[Peer]\nPublicKey = \nAllowedIPs = 0.0.0.0/0\nEndpoint = \nPersistentKeepalive = 25\n',dns:true,tunnel:{enabled:false,host:'',port:443,https:true}});
|
||||||
|
$('rename').onclick=()=>{const n=prompt('Config name',draft.name);if(n?.trim()){draft.name=n.trim();dirty=true;read();draw()}};
|
||||||
|
$('edit').onclick=()=>{editing=!editing;$('wg').readOnly=!editing;$('edit').textContent=editing?'Read only':'Edit';if(editing)$('wg').focus()};
|
||||||
|
$('save').onclick=()=>action(async()=>{read();await api('save',draft);const name=draft.name;await refresh();draft=structuredClone(state.settings.profiles.find(p=>p.name===name));selected=draft.id;dirty=false;editing=false;draw();$('saved').textContent='Saved'});
|
||||||
|
$('delete').onclick=()=>action(async()=>{if(!confirm('Delete '+draft.name+'?'))return;await api('delete',{id:selected});draft=null;selected='';dirty=false;await refresh();draw()});
|
||||||
|
$('connect').onclick=()=>action(async()=>{await api('connect',{id:selected});await refresh()});$('disconnect').onclick=()=>action(async()=>{await api('disconnect',{});await refresh()});
|
||||||
|
for(const id of ['autorun','boot'])$(id).onchange=()=>action(async()=>{try{await api('options',{autorun:$('autorun').checked,onBoot:$('boot').checked})}finally{await refresh()}});
|
||||||
|
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue=''}});(async()=>{try{await refresh();if(state.settings.profiles.length)select(state.settings.profiles.find(p=>p.id===state.settings.last)||state.settings.profiles[0])}catch(e){fail(e)}setInterval(()=>{if(!busy)refresh().catch(fail)},1000)})();
|
||||||
|
</script></html>
|
||||||
Loading…
Add table
Reference in a new issue