commit b7349e3f52c5eea47168de2281cccd7561136d1f Author: Server <10.9.8.204@example.com> Date: Thu Nov 5 18:44:14 2026 +0000 Build cross-platform WireGuard and wstunnel manager with GUI and TUI diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b55d60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/dist/ +/cache/ +/wswg +*.log + +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ef80f4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d19c1b --- /dev/null +++ b/README.md @@ -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 /wswg0.conf` или удалите Windows tunnel service. +Параллельные подключения не поддерживаются, интерфейс называется `wswg0`. +Фоновый процесс пишет в `daemon.log`; его PID выводится при запуске. Для остановки +используйте `kill ` на 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 +``` diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md new file mode 100644 index 0000000..941cac4 --- /dev/null +++ b/THIRD_PARTY.md @@ -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. diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..48816b4 --- /dev/null +++ b/VALIDATION.md @@ -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 и особенности прошивки. diff --git a/boot.go b/boot.go new file mode 100644 index 0000000..2aa27bb --- /dev/null +++ b/boot.go @@ -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") +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..3c27423 --- /dev/null +++ b/config.go @@ -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) +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..01ac5fd --- /dev/null +++ b/config_test.go @@ -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) + } +} diff --git a/engine.go b/engine.go new file mode 100644 index 0000000..b10a106 --- /dev/null +++ b/engine.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..af6fcd5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.snw.su/snw/wswg + +go 1.24 diff --git a/gui.go b/gui.go new file mode 100644 index 0000000..b1e001e --- /dev/null +++ b/gui.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f6bb6c5 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/platform_unix.go b/platform_unix.go new file mode 100644 index 0000000..9aaacb8 --- /dev/null +++ b/platform_unix.go @@ -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() {} diff --git a/platform_windows.go b/platform_windows.go new file mode 100644 index 0000000..37d5b71 --- /dev/null +++ b/platform_windows.go @@ -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)) + } +} diff --git a/route.go b/route.go new file mode 100644 index 0000000..96b2198 --- /dev/null +++ b/route.go @@ -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 +} diff --git a/scripts/archive.py b/scripts/archive.py new file mode 100644 index 0000000..80624d1 --- /dev/null +++ b/scripts/archive.py @@ -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}') diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..c071df9 --- /dev/null +++ b/scripts/build.sh @@ -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 diff --git a/scripts/bundle.py b/scripts/bundle.py new file mode 100644 index 0000000..9a4896e --- /dev/null +++ b/scripts/bundle.py @@ -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() diff --git a/scripts/check-builds.py b/scripts/check-builds.py new file mode 100644 index 0000000..802e577 --- /dev/null +++ b/scripts/check-builds.py @@ -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('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() diff --git a/scripts/publish.py b/scripts/publish.py new file mode 100644 index 0000000..06afc45 --- /dev/null +++ b/scripts/publish.py @@ -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'{html.escape(folder.removeprefix("wswg-"))}ZIP{archive.stat().st_size/1024/1024:.1f} MBwswg') +page='''wswg builds''' +page+=f'

wswg {html.escape(version)}

WireGuard + wstunnel. Простой GUI в локальном браузере и TUI.

Все платформы одним ZIP · SHA256SUMS · Инструкция

'+''.join(rows)+'
ПлатформаСкачатьРазмерБинарник отдельно

Для обычной установки скачайте ZIP и распакуйте целиком. Папки bin и redist должны остаться рядом с программой.

./wswg -t · ./wswg -a · ./wswg -c 1 -d --no-wstunnel

Linux и Termux требуют root и ядро с WireGuard. Windows требует Administrator. Windows ARM32 не поддерживается. Системные требования и настройка DNS описаны в инструкции.

Исходники · Проверки сборки

' +(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/') diff --git a/signal_unix.go b/signal_unix.go new file mode 100644 index 0000000..b871ad6 --- /dev/null +++ b/signal_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package main + +import ( + "os" + "syscall" +) + +func terminationSignal() os.Signal { return syscall.SIGTERM } diff --git a/signal_windows.go b/signal_windows.go new file mode 100644 index 0000000..a891a02 --- /dev/null +++ b/signal_windows.go @@ -0,0 +1,5 @@ +package main + +import "os" + +func terminationSignal() os.Signal { return os.Interrupt } diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..e8ca93c --- /dev/null +++ b/tui.go @@ -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): + } + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d31e125 --- /dev/null +++ b/web/index.html @@ -0,0 +1,26 @@ +wswg + +

Выберите конфигурацию или нажмите New.

Disconnected |
+