Build cross-platform WireGuard and wstunnel manager with GUI and TUI

This commit is contained in:
Server 2026-11-05 18:44:14 +00:00
commit b7349e3f52
26 changed files with 2253 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/dist/
/cache/
/wswg
*.log
__pycache__/

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
module git.snw.su/snw/wswg
go 1.24

147
gui.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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')

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
package main
import "os"
func terminationSignal() os.Signal { return os.Interrupt }

260
tui.go Normal file
View 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
View 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>