Компактный локальный инструмент для управления VPN-туннелем AmneziaWG / WireGuard
на рабочих станциях Ubuntu. Позволяет загрузить пользовательский .conf-файл,
включить/выключить VPN одной кнопкой и следить за статусом подключения.
Инструмент работает локально: нет облака, нет сервера, нет регистрации. Все данные
(включая приватный ключ) остаются на машине, под контролем root.
$ ./vpnctl import WARPv2_54.conf
импортировано: WARPv2_54.conf (interface=warpv2_54, Endpoint=1.2.3.4:51820)
подключение: vpnctl up warpv2_54
$ ./vpnctl up
поднимаю awg0 (сервер 1.2.3.4:51820)...
VPN поднят.
$ ./vpnctl status
интерфейс: awg0 (включен)
внешний IP: 203.0.113.7Те же действия доступны в графическом окне: выбрать .conf, нажать «Загрузить»,
затем «ВКЛЮЧИТЬ VPN» — и следить за статусом в реальном времени.
- Ubuntu (проверялось на Ubuntu с GNOME), Linux x86_64.
- Go 1.26+ для сборки из исходников.
- Утилиты AmneziaWG:
awg,awg-quick(это инструменты AmneziaWG, а не стандартного WireGuard). pkexec(входит вpolicykit-1) — графический запрос пароля там, где в sudoers нет правилаNOPASSWD.- Для графического интерфейса — GTK3 и dev-заголовки при сборке.
sudo apt install build-essential pkg-config libgtk-3-devDev-заголовки нужны только на этапе компиляции. Уже собранный бинарник требует только библиотеку рантайма
libgtk-3.
cd vpnctl
go build -mod=vendor -o vpnctl .Результат — один исполняемый файл vpnctl (~12 МБ).
Сборка использует каталог vendor/ (зависимость gotk3 уже зафиксирована и, при
необходимости, пропатчена — см. раздел «Известные проблемы»).
Собранный пакет (например, dist/vpnctl_0.1.0_linux_amd64.tar.gz):
tar -xzf vpnctl_0.1.0_linux_amd64.tar.gz
sudo install -m 0755 vpnctl /usr/local/bin/vpnctlЛибо собрать и установить из исходников:
cd vpnctl
go install -mod=vendor ../vpnctl trayОткроется окно «AmneziaWG VPN» в тёмной теме:
- Шапка — иконка, название и бейдж с именем текущего интерфейса.
- Карточка статуса — цветной индикатор и слово
ПОДКЛЮЧЕНО/ОТКЛЮЧЕНО, внешний IP и строкасервер <host:port>. - Конфигурация — кнопка выбора
.confи кнопка «Загрузить». - Основная кнопка — «ВКЛЮЧИТЬ VPN» / «ВЫКЛЮЧИТЬ VPN» на всю ширину.
- Строка лога — последнее событие или ошибка.
Порядок работы:
- Нажмите «Выбрать конфиг…» и укажите ваш файл
*.conf. - Нажмите «Загрузить». Откроется графический запрос пароля (
pkexec) — введите пароль. Конфиг будет скопирован в системный каталог AmneziaWG. Если такой конфиг уже установлен, импорт ничего не делает и сообщает об этом. - Нажмите «ВКЛЮЧИТЬ VPN». Произойдёт подъём туннеля; окно дождётся, что интернет через туннель действительно появился.
- Статус и внешний IP обновляются автоматически. Кнопка меняется на «ВЫКЛЮЧИТЬ VPN», когда туннель активен.
Окно запоминает, какой интерфейс был импортирован последним, и дальше кнопка
управляет именно им — жёсткой привязки к awg0 нет.
Имена
tray,uiиguiравнозначны и запускают одно и то же окно.
| Команда | Описание |
|---|---|
./vpnctl status [iface] |
Показать статус туннеля и внешний IP |
./vpnctl up [iface] |
Поднять VPN с проверкой и автооткатом |
./vpnctl down [iface] |
Опустить VPN |
./vpnctl import <файл.conf> |
Импортировать конфиг в систему |
./vpnctl tray (ui/gui — синонимы) |
Открыть графический интерфейс |
iface по умолчанию — awg0.
Примеры:
# импорт конфига с проверкой
./vpnctl import ~/Загрузки/WARPv2_54.conf
# поднять / опустить VPN
./vpnctl up
./vpnctl down
# посмотреть состояние
./vpnctl status- Форма загрузки конфигурации в собственном графическом окне (выбор файла через
системный диалог, фильтр
*.conf). - Импорт конфига в системный каталог AmneziaWG с правильными правами (владелец
root, режим0600) — одним привилегированным вызовом, то есть одним запросом пароля. - Кнопка включения / выключения VPN: подъём с проверкой, что интернет через туннель действительно появился, и автооткатом, если не появился.
- Мониторинг статуса — обновляется автоматически каждые 3 секунды: состояние интерфейса, внешний IP, сервер (Endpoint).
- Тёмная терминальная тема окна, не зависящая от оформления рабочего стола.
- Ярлык на рабочем столе и в меню приложений со своей иконкой.
- CLI с теми же командами для автоматизации и скриптов.
В репозитории ярлыка нет — он ставится в домашний каталог пользователя тремя файлами и не требует прав root.
Иконка лежит в репозитории, в assets/icons/hicolor/:
assets/icons/hicolor/
├── scalable/apps/vpnctl.svg
├── 48x48/apps/vpnctl.png
├── 64x64/apps/vpnctl.png
├── 128x128/apps/vpnctl.png
├── 256x256/apps/vpnctl.png
└── 512x512/apps/vpnctl.png
SVG — исходник, PNG-размеры отрендерены из него: часть шеллов не умеет тянуть SVG напрямую. Чтобы иконка стала видна системе, скопируйте набор в домашний каталог тем:
cp -r assets/icons/hicolor/* ~/.local/share/icons/hicolor/
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolorКаталог намеренно без index.theme — он дополняет системный hicolor; поиск по
имени vpnctl работает и без него (проверено Gtk.IconTheme.lookup_icon). Если
какой-то шелл иконку всё же не видит, скопируйте туда
/usr/share/icons/hicolor/index.theme.
~/.local/share/applications/vpnctl.desktop:
[Desktop Entry]
Version=1.0
Type=Application
Name=AmneziaWG VPN
Comment=Управление туннелем AmneziaWG (awg0)
Exec=/home/robert/go-projects/vpnctl/vpnctl tray
Icon=vpnctl
Terminal=false
Categories=Network;Security;System;
Keywords=vpn;wireguard;amneziawg;awg;vpnctl;
StartupNotify=true
StartupWMClass=vpnctlStartupWMClass=vpnctl должен совпадать с WM_CLASS окна — иначе GNOME не свяжет
окно с иконкой и нарисует дефолтную. Проверить:
xprop -id "$(xdotool search --name 'AmneziaWG VPN' | head -1)" WM_CLASS
# → WM_CLASS(STRING) = "vpnctl", "Vpnctl"cp ~/.local/share/applications/vpnctl.desktop ~/Рабочий\ стол/"AmneziaWG VPN.desktop"
chmod 755 ~/Рабочий\ стол/"AmneziaWG VPN.desktop"
gio set ~/Рабочий\ стол/"AmneziaWG VPN.desktop" metadata::trusted truemetadata::trusted true обязателен: без него GNOME помечает ярлык как «Ненадёжный»
и не запускает его по клику.
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
update-desktop-database ~/.local/share/applicationsdesktop-file-validate ~/.local/share/applications/vpnctl.desktop
gtk-launch vpnctlExec указывает на бинарник в каталоге проекта. Если перенести репозиторий или
установить vpnctl в /usr/local/bin, поправьте путь в обоих .desktop-файлах —
иначе ярлык сломается. Пересборка бинаря на месте ярлык не ломает.
Ярлык vpnctl заявляется только для application/x-desktop и обработчиком каталогов
не является. Но в ~/.config/mimeapps.list может оказаться чужая запись вида:
[Added Associations]
inode/directory=dev.warp.Warp.desktop;org.gnome.Nautilus.desktop;
Терминал Warp принимает только URI и падает на голом пути:
error: invalid value '/home/robert' for '[URLS]...': relative URL without a base
Если папки не открываются — уберите Warp из этой строки, оставив:
inode/directory=org.gnome.Nautilus.desktop;
Проверить, кто обработчик сейчас:
gio mime inode/directory # список кандидатов
xdg-mime query default inode/directoryvpnctl import (и кнопка «Загрузить» в окне) выполняет:
- Проверку файла — читается, не пуст, размер ≤ 1 МБ, содержит секции
[Interface]и[Peer]. - Определение имени интерфейса из имени файла:
WARPv2_54.conf→ интерфейсwarpv2_54. Имя приводится к нижнему регистру и к безопасному формату (только буквы, цифры,_,-; недопустимые символы заменяются на_), иначе берётсяawg0. Разрешённые символы сохраняются как есть — то есть_в имени файла остаётся и в имени интерфейса. - Один привилегированный вызов
sh -cсо скриптом:- если
<каталог>/<имя>.confуже существует — печатаетEXISTSи выходит с кодом 2; mkdir -pцелевого каталога;install -o root -g root -m 600 <источник> .<имя>.tmp— копия с владельцемrootи режимом0600;mv .<имя>.tmp <имя>.conf— атомарная замена;- при любой ошибке —
rm -f .<имя>.tmp.
- если
Всё это одна команда, поэтому один импорт = максимум один запрос пароля, и неудачный импорт не оставляет временных файлов.
Эскалация: сначала пробуется беспарольный sudo -n (если в sudoers есть правило на
нужные команды), и только если он недоступен — pkexec с графическим запросом.
Если конфиг с таким именем уже установлен, conf.Import возвращает ErrExists, и
приложение сообщает: «конфиг уже установлен (…) — повторно загружать не нужно».
Рабочий конфиг не перезаписывается. CLI дополнительно печатает подсказку
подключение: vpnctl up <iface>.
- Если интерфейс уже поднят — сразу возвращает успех, туннель не пересоздаётся.
- Запускает
awg-quick up <iface>. - Ждёт появления интернета через туннель (до 20 секунд). Проверка идёт по очереди по нескольким хостам, и каждый запрос открывает новое соединение.
- Если интернет не появился — автоматически откатывается (
awg-quick down) и возвращает ошибку с указанием сервера и последней ошибки проверки.
Отдельной предварительной проверки сервера по TCP нет: AmneziaWG/WireGuard работают по UDP, поэтому TCP-коннект к Endpoint ничего не говорит о работоспособности туннеля. Единственный честный критерий — «появился ли интернет после подъёма».
Тема окна задаётся целиком в internal/ui/style.go (CSS через CssProvider +
AddProviderForScreen, плюс gtk-application-prefer-dark-theme). Окно не наследует
оформление рабочего стола: фон #14161a, карточка #1b1f26, акцент состояния
зелёный #46d17a / серый #6b7480, основная кнопка синяя #2f6feb, адреса —
моноширинным шрифтом.
CSS-классы вешаются через StyleContext.AddClass (хелпер cls), а не через
Widget.SetName: SetName задаёт CSS-имя (#name) и с селекторами .class
молча не совпадает — виджеты остаются в дефолтной теме без единой ошибки.
Проект построен по принципам KISS, YAGNI, DRY: минимальный набор пакетов, по одной реализации каждого действия, которую используют и CLI, и GUI.
vpnctl/
├── go.mod # модуль, одна внешняя зависимость (gotk3)
├── main.go # точка входа, диспетчер подкоманд CLI
├── internal/
│ ├── priv/ priv.go # ЕДИНСТВЕННОЕ место повышения привилегий
│ ├── conf/ conf.go # валидация и импорт .conf
│ ├── vpn/ vpn.go # up/down/status + внешний IP
│ └── ui/ style.go # тёмная тема (CSS) и хелперы классов
│ ui.go # окно: конструктор и раскладка
│ actions.go # обработчики «Загрузить» и «ВКЛ/ВЫКЛ»
│ render.go # мониторинг и отрисовка статуса
└── vendor/ # зафиксированные зависимости (gotk3)
пользователь → форма/CLI → conf.Validate → conf.Import → priv.RunWG → sh -c
│ install 0600 + mv
└─► /etc/amnezia/amneziawg/*.conf
пользователь → кнопка/CLI → vpn.Up/Down ──► priv.RunWG ──► awg-quick up/down <iface>
мониторинг → vpn.Status.StatusAt → /sys/class/net/<iface> (без прав)
+ внешний IP (HTTPS) (без прав)
→ conf.InstalledEndpoint → awg-quick strip (sudo -n, без окна)
| Решение | Обоснование |
|---|---|
Единая точка повышения привилегий — internal/priv (Run / RunWG / TryWG) |
DRY; RunWG сначала пробует беспарольный sudo -n и только потом pkexec, а TryWG вообще не показывает окно |
Импорт — один sh -c на весь сценарий |
Один запрос пароля на импорт; неудача не оставляет временных файлов |
| Проверка статуса почти без прав | Опрос каждые 3 с не должен требовать пароль |
| Приватный ключ — только в системном конфиге | Не дублируем секреты в хранилище приложения |
Атомарный импорт (install → mv) |
Никогда не оставляем наполовину записанного конфига |
| Отказ импорта при существующем конфиге | Не затираем рабочее подключение |
Up не трогает уже поднятый туннель |
Не «дёргаем» подключение, включённое ранее |
| Проверка связи — новым соединением на каждый хост | Пуловый keep-alive сокет со старого маршрута после подъёма туннеля мёртв и съедает весь таймаут |
refresh() без ввода-вывода, первый опрос — в monitor() |
Иначе главный цикл блокируется до gtk.Main(), и при недоступной сети окно не появляется |
-
internal/priv— единственное место повышения привилегий.Run(name, args...)—pkexec(графический запрос пароля).RunWG(name, args...)— сначала беспарольныйsudo -n, при неудачеpkexec.TryWG(name, args...)— только беспарольныйsudo -n, никогда не показывает окно.
Ошибки оборачиваются в
*Errorс режимом, командой, аргументами и выводом. -
internal/conf—Validate(path)(проверка файла, извлечение имени интерфейса и Endpoint),InterfaceName(path),Import(path)(один привилегированныйsh -c: guard →mkdir -p→install 0600→mv→ очистка; при существующем конфиге возвращаетErrExists),InstalledEndpoint(iface)(Endpoint установленного конфига черезawg-quick strip, без запроса пароля). Путь каталога переопределяется переменнойVPNCTL_CONF_DIR. -
internal/vpn—Status,StatusAt()(состояние интерфейса + внешний IP),RemoteIP()/probe()(внешний IP; список хостов,VPNCTL_IP_URLпервым, каждый запрос — новым соединением),Up()/Down()(подъём с ожиданием интернета и автооткатом). Отдельной функции проверки Endpoint по TCP нет. -
internal/ui— GTK3-окно: тёмная тема (style.go), раскладка (buildContent), форма загрузки (FileChooserButton+ кнопка «Загрузить»),ToggleButton«ВКЛЮЧИТЬ/ВЫКЛЮЧИТЬ VPN», карточка статуса. Мониторинг — тикер каждые 3 с, обновления перекидываются в главный цикл черезglib.IdleAdd.
renderStatus синхронизирует состояние ToggleButton вызовом SetActive, что
генерирует сигнал toggled. Чтобы опрос статуса не включал/выключал VPN сам по
себе, обработчик onToggle пропускает программные изменения через флаг syncing
и пропускает операции, пока активен флаг busy.
Endpoint для интерфейса читается один раз и кэшируется (endpointResolved /
setEndpoint): это привилегированный вызов, а опрос идёт каждые 3 секунды.
- Приватный ключ хранится в файле с правами
0600, владелец —root. Сторонние приложения и обычный пользователь его не прочитают. - В системный каталог пишется только через привилегированный вызов; имя интерфейса
проверяется регулярным выражением (защита от инъекции пути), путь экранируется
(
shquote). - Проверка файла (размер, секции, Endpoint) выполняется до копирования.
- Импорт не перезаписывает существующий конфиг — рабочее подключение защищено от случайного затирания.
- «ВКЛ» не перезапускает уже поднятый туннель — активное подключение не сбрасывается.
- Приложение работает от обычного пользователя и повышает привилегии только в момент
команд
up/down/import.
Единицы логики покрыты table-driven тестами с детектором гонок и рандомизированным фаззингом парсера конфигов:
# юнит-тесты (не требуют прав root, sudo или pkexec)
go test -mod=vendor ./...
# с детектором гонок
go test -mod=vendor -race ./...
# рандомизированные проверки парсера (по 10 секунд на цель)
go test -mod=vendor ./internal/conf/ -run '^$' -fuzz=FuzzInterfaceName -fuzztime=10s
go test -mod=vendor ./internal/conf/ -run '^$' -fuzz=FuzzValidate -fuzztime=10s
# отчёт о покрытии
go test -mod=vendor ./internal/conf/ ./internal/priv/ ./internal/vpn/ -cover
# линтер (v1.64.x; настройка — в .golangci.yml)
golangci-lint run ./...Привилегированные действия (up, down, import) в тестах подменяются фейковым
исполнителем команд (priv.Command), поэтому тесты безопасно гонять без sudo,
pkexec и installed AmneziaWG. GUI (internal/ui) юнит-тестами не покрывается —
для него нужен дисплей.
Ручная проверка (как раньше):
go build -mod=vendor -o vpnctl .
./vpnctl statusДля повторяемых ручных проверок можно переопределить каталог конфигов переменной
VPNCTL_CONF_DIR, а адрес проверки внешнего IP — переменной VPNCTL_IP_URL.
- Трей-иконка в GNOME/Wayland не отображается. По умолчанию GNOME и
Wayland-сессии не показывают legacy-трей (
Gtk.StatusIcon). Используется обычное окно. Для трея нужен отдельный компонент наlibayatana-appindicator(стандарт AppIndicator), который в этой версии не включён (YAGNI). gtk.StatusIcon— deprecated и ломает сборку. Включение трея через-tags gtk_deprecatedвтягивает неиспользуемый C-колбэк (Seat.Grab), который не компилируется на свежих GTK (undefined: callback). Вvendor/этот колбэк закомментирован, поэтому используется только окно.- Snap-окружение может ронять запуск GUI из-за несовпадения версий
libc/libpthread(ошибкаundefined symbol: __libc_pthread_init). Запускайтеvpnctl trayв обычной сессии рабочего стола, а не в snap-sandbox. - Handshake и счётчики трафика в окне не показываются намеренно. Для них нужен
awg show, а он требует root и давал бы запрос пароля на каждом опросе (раз в 3 с). В окне есть состояние интерфейса и внешний IP — их можно читать без прав. - Ярлык и чужие ассоциации каталогов. Если папки на рабочем столе перестали
открываться, проверьте
~/.config/mimeapps.list— см. раздел «Ярлык на рабочем столе», подраздел про[Added Associations].
- Системный трей через
libayatana-appindicator(AppIndicator) для GNOME/Wayland. - Несколько сохранённых конфигов с переключением в окне.
- Автостарт при входе в систему.
Проект открыт для вклада — правила и процесс описаны в
CONTRIBUTING.md: требования, сборка, архитектурные
ограничения (привилегии — только через internal/priv) и стиль коммитов.
История изменений ведётся в CHANGELOG.md.
Автор и основной мейнтейнер — turkprogrammer. Спасибо всем, кто открывает issues и отправляет pull requests.
Распространяется под лицензией MIT. Зависимости — под своими лицензиями (gotk3 — MIT).