Эта статья о том, как я хотел сэкономить несколько секунд при переключении системного прокси в Nekobox, а в итоге уже несколько месяцев пишу мини-программу для управления sing-box.
Началось с того, что для прокси на Windows я стал использовать Nekobox. Про гибкое раздельное туннелирование я еще не знал, и приходилось постоянно включать и выключать системный прокси, чтобы зайти то туда (сайт заблокирован), то сюда (сайт блокирует IP прокси). Много раз в час: клик по значку в трее, режим системного прокси, отключить (а потом обратно). И я подумал, что было бы удобнее просто кликать по значку. Ничего сложного — почему бы не реализовать? Начал я, конечно же, с рисования значка. Решил, что хорошо подойдет портал из «Рика и Морти» как метафора беспрепятственного перемещения между измерениями. Провел целый вечер в Procreate на iPad, замучился, устал и отложил затею на потом.
В следующие полгода прокси намного глубже вошли в мою жизнь: клиент на телефон и телевизор, хитрая маршрутизация, разные программы, сложные конфиги. Я понял, что Nekobox — это просто красивая обертка вокруг sing-box для новичков. В конце концов я пришел к оригинальному sing-box и к тому, чтобы на всех устройствах использовать общую конфигурацию. Проблема только в том, что оригинальный sing-box на Windows — это консольное окошко без управления. Пришло время вернуться к моему зеленому порталу. Программу я решил писать на Delphi (вспомнить былое). Значок в трее, запуск ядра, системный прокси по клику — задача на один вечер. Так появился sing-box-drover. Как всегда, учел я не всё.

Версия 0.1: самое начало
Программа на один вечер, поэтому делаем только самое основное. Используем Mutex, чтобы нельзя было запустить две копии программы. Создаем значок в трее через TTrayIcon. Принцип переключения системного прокси через WinAPI подсматриваем в Nekobox.
Запускаем невидимое ядро sing-box при старте программы через CreateProcess с CREATE_NO_WINDOW (никаких черных окошек). Остается проблема, что если убить нашу программу, то ядро незаметно продолжит работать. Решение подсказала LLM. Создаем JobObject с флагом JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, добавляем процесс sing-box в эту «работу». Как только закрывается последний Handle на этот JobObject (в том числе при аварийном закрытии нашей программы), система принудительно завершает все привязанные процессы.
Еще я добавил возможность переключения селекторов через выпадающее меню в трее. Как их переключать? Sing-box может принимать команды через Clash API (изначально появился в программе Clash, стал своеобразным стандартом, который реализуют многие «прокси-платформы»). По-хорошему, список доступных селекторов тоже нужно было получать через этот же API, но мне показалось более простым решением читать список из конфига самостоятельно, а через API только переключать. На решение повлияло и то, что конфиг я уже читал для определения настроек mixed inbound (какие IP и порт указывать при настройке системного прокси). В итоге нам нужно читать конфиг, находить там настройки Clash API (IP, порт, пароль), читать список селекторов со значениями, рисовать меню, дергать API при нажатиях. С API работаем через THTTPClient в отдельном потоке, чтобы не подвешивать интерфейс (каждый раз запускаем новый поток и за ним не следим; сам умрёт и освободит память). Тонкость: у этого THTTPClient явно отключаем системный прокси (ProxySettings := TProxySettings.Create('http://direct')), иначе запрос к API на localhost пойдет через сам sing-box.

Всё готово. Пошел хвастаться в китайский Telegram-чат по sing-box. Первый же доброволец написал, что у него ничего не работает.
Версия 0.2: JSONC
Первая версия почти ни у кого не работала из-за того, что я читал конфиг sing-box через TJSONObject, который не поддерживает лишние запятые и комментарии. Нужно было реализовать поддержку JSONC. Искал готовые библиотеки, не нашел ничего подходящего (чтобы дешево и сердито). Решил, что проще будет прочитать файл в строку, пробежаться по строке и удалить из нее все комментарии и лишние запятые (но для этого придется еще определять начало и конец литералов, потому что запятые могут быть и внутри JSON-строк), а потом передать нормализованный результат в обычный TJSONObject. Реализация оказалась простой.
Версия 0.3: быстрое переключение селекторов
Изначально для каждого селектора в меню трея было отдельное выпадающее подменю. Мне надоело водить мышкой по подменю для переключения серверов, ждать лишние доли секунды и иногда промазывать. Сделал альтернативный вариант отображения, когда все селекторы выводятся одним плоским списком с разделителями (хороший вариант, когда серверов мало). Показал в том же Telegram-чате. Некоторые пользователи снова посмеялись над моей программой, увидев выпадающее меню на весь экран с бесконечной прокруткой (оказывается, у многих в конфиге сотни серверов). Но всё равно оставил новый режим по умолчанию (позже переделаю).

Версия 0.4: TUN, управление процессом по-взрослому
Я все-таки решил добавить режим TUN, но пришлось многое переделать. Сам sing-box поддерживает TUN из коробки, достаточно добавить inbound tun в конфиг. Проблема в том, что TUN будет подниматься сразу при запуске, а еще для этого требуются права администратора. Прав нет — ничего не запускается. А еще мне нужен переключатель для пользователя. Я решил сделать так: пользователь сам добавляет inbound tun в конфиг, а программа при необходимости оттуда его удаляет и потом возвращает на место. При запуске ядра мы подсовываем ему нужную версию конфига, используя отдельные файлы для разных случаев.
К счастью, sing-box умеет читать конфиг не из файла, а из stdin (стандартный поток ввода). Мы сначала читаем конфиг, ищем в нем настройки TUN, вырезаем их, рисуем переключатель TUN, запускаем ядро с обрезанным конфигом. При нажатии кнопки TUN мы убиваем старое ядро и запускаем его заново — с правами администратора и расширенным конфигом.
Это всё потребовало более грамотного управления ядром (в том числе правильного завершения, а не просто убийства, чтобы ядро могло почистить за собой временные сетевые интерфейсы). Основная проблема была в том, что ядро может плавно завершить работу только при получении сигнала Ctrl+C, а этот сигнал невозможно отправить, если мы запускаем ядро без консоли (иначе будет черное окошко). Но есть вариант запустить с консолью и при этом с SW_HIDE, а потом перед отправкой сигнала подключиться к консоли через AttachConsole (GenerateConsoleCtrlEvent работает только в пределах своей консоли):
function TCoreSupervisor.SendCtrlCToConsole(processId: DWORD): boolean; begin FreeConsole; if not AttachConsole(processId) then exit(false); try SetConsoleCtrlHandler(nil, true); try result := GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0); sleep(10); finally SetConsoleCtrlHandler(nil, false); end; finally FreeConsole; end; end;
GenerateConsoleCtrlEvent рассылает Ctrl+C всем процессам, прикреплённым к консоли — после AttachConsole мы в их числе. Поэтому SetConsoleCtrlHandler(nil, true) на время отправки заставляет наш процесс игнорировать сигнал.
С этой задачей помогли LLM, но и они тупили, путались, вводили в заблуждение (иногда писали, что без мигания консоли сделать невозможно).
Реализация оказалась замороченной: много пограничных случаев, легко выстрелить себе в ногу. CoreSupervisor работает в отдельном потоке, получает задачи через TThreadedQueue, сообщает в основной поток о результатах через TThread.Queue, мониторит состояние ядра (не упало ли). Всё это происходит в одном потоке, поэтому проверка состояния не должна тормозить обработку команд.
Добавил проверку наличия прав администратора, перезапуск с повышением прав. А еще, так как мы теперь правим конфиг на лету, добавил автоматическую генерацию секции Clash API с рандомным ключом, если эта секция у пользователя отсутствует.
Доделал, отправил в знакомый вам чат — у многих ничего не работает (программа зависает, невозможно закрыть).
Версия 0.5: правильно пишем в stdin
Ошибку удалось найти быстро. Оказалось, что дело в размере конфига. Я запускал ядро замороженным (с CREATE_SUSPENDED), писал конфиг в stdin, делал ResumeThread. Если буфера для записи не хватало, то всё зависало, потому что ядро не читало поток из-за заморозки. Ядро запускается замороженным, чтобы успеть добавить процесс в JobObject до возможного порождения им дочерних процессов (чтобы они тоже попали в JobObject и закрывались вместе со всеми). Стал писать в stdin после ResumeThread, и у всех всё заработало.
Версия 0.6: ждем готовности API перед сбросом селекторов
При запуске ядра программа сбрасывает селекторы через API в значения по умолчанию. Проблема в том, что API может подниматься долго (особенно при автозапуске вместе с Windows). Мы отправляем запросы, они не проходят, селекторы отображаются неверно. Пришлось переделать работу с API. Раньше мы запускали новые потоки и не следили за результатом. Теперь работа с API переехала в CoreSupervisor, управляется командами, результат контролируется. При запуске ядра мы ждем готовности API (периодически делаем запрос /version) и только потом сбрасываем селекторы в начальное состояние.
Версия 0.7: автоматическое обновление конфигурации с сервера
Я раздал программу членам семьи. Трудность в том, что при перенастройке прокси-сервера нужно каждому передать новый конфиг и помочь его обновить. Нужно сделать автоматическое обновление с сервера. Где хранить ссылку, где хранить время обновления? В конфиге sing-box подходящих полей нет, а любые неизвестные поля приводят к падению. Их можно вырезать перед передачей конфига ядру, но добавление новых полей идет вразрез с первоначальной идеей: ядро и конфиг должны быть оригинальные. Можно хранить новую информацию в отдельном файле, но у этого решения тоже есть минусы. Решил не изобретать велосипед и реализовать поддержку файлов BPF.
BPF — это недокументированный локальный формат конфигов мобильных клиентов sing-box. По сути, это архив с JSON-конфигом и дополнительными полями. Формат бинарный, вручную в текстовом редакторе не отредактировать. Содержимое:
1 байт — тип сообщения
1 байт — версия
gzip-данные с именем профиля, типом конфига, JSON-конфигом
Для удаленных конфигов: URL, флаг автоматического обновления, интервал обновления, дата последнего обновления
Программа загружает новую версию по URL через минуту после запуска, а потом с указанным интервалом. У этого THTTPClient, наоборот, системный прокси не отключаем: если URL заблокирован, запрос пройдёт через sing-box. К сожалению, конфиг только обновляется автоматически, но не применяется. Для применения новой версии всё еще требуется перезагрузка программы.
Версия 0.8: автозапуск через планировщик
С самого начала в программе не было встроенной возможности автозапуска вместе с Windows — пора это исправить. Решил делать через планировщик Windows, чтобы программа запускалась как можно быстрее. Через простой автозапуск приходится иногда ждать по несколько минут (а доступ в интернет нужен как можно быстрее). Плюс через планировщик можно сразу запускать с правами администратора, чтобы включение TUN было в один клик без дополнительного запроса прав.
Работа с планировщиком идет через OleObject, с реализацией помогли LLM. Сначала создал задание вручную через «Планировщик задач», экспортировал в XML, а потом на его основе формировал задание программно. Задача в планировщике запускается с повышенными правами, и для её создания нужны такие же — поэтому при изменении автозапуска появляется запрос прав.
При тестировании изредка всплывала проблема, что значок программы не появляется (всё работает, а значка нет). Думал, что баг в самом TTrayIcon. Найти настоящую причину снова помогли LLM.
Процесс стартовал раньше, чем Explorer создавал окно Shell_TrayWnd. Первый Shell_NotifyIcon(NIM_ADD, ...) уходил в никуда и тихо возвращал ошибку (VCL результат не проверяет). Стандартный механизм восстановления в Windows для таких случаев — broadcast-сообщение TaskbarCreated. Explorer рассылает его окнам, когда панель задач создана. VCL внутри TTrayIcon слушает это сообщение и при его получении повторно вызывает Shell_NotifyIcon(NIM_ADD, ...). То есть в норме после прихода TaskbarCreated значок появился бы сам. Но не появлялся — мешала встроенная защита Windows: по умолчанию она блокирует приватные оконные сообщения, идущие от процесса с обычными правами пользователя в процесс, запущенный от администратора. Explorer работает с правами пользователя, наша программа — с правами администратора, поэтому broadcast от Explorer до нас не доходил, а значит, и до VCL внутри нашего процесса.
Лечится в конструкторе наследника TTrayIcon (handle внутреннего окна доступен через protected-свойство Data.Wnd):
constructor TElevatedTrayIcon.Create(AOwner: TComponent); var msgId: UINT; begin inherited Create(AOwner); msgId := RegisterWindowMessage('TaskbarCreated'); ChangeWindowMessageFilterEx(Data.Wnd, msgId, MSGFLT_ALLOW, nil); end;
После этого блокировка для конкретного сообщения снята, broadcast приходит штатно, и VCL автоматически делает NIM_ADD.
Версия 0.9: полировка
Пришло время исправить старые проблемы и причесать функциональность.
Сделал опциональное сохранение состояния селекторов при перезапуске (раньше всегда устанавливались значения по умолчанию). Можно вернуть старое поведение через конфиг программы.
Многие жаловались на очень длинное выпадающее меню из-за большого количества серверов (и переставали пользоваться программой, хотя всегда была возможность сменить режим отображения). Добавил автоматический выбор режима отображения селекторов. Flat (прежний режим по умолчанию) — плоское меню (меньше движений, когда серверов мало, но невозможно пользоваться, если их много). Nested — вложенные меню (хорошо, когда серверов много). Теперь Auto — режим по умолчанию: стиль зависит от количества пунктов.
Переключение селекторов стало работать понятнее и стабильнее. Выбранный пункт визуально замораживается до получения ответа от API. Появляется меньше ошибок при переключении, так как теперь задержка в несколько секунд при закрытии старых соединений не приводит к появлению оповещения.
В случае проблемы при запуске ядра (или при падении в процессе работы) в оповещение вставляется текст ошибки, полученный из вывода ядра. Ранее было только универсальное сообщение.

При обновлении BPF-конфига в заголовок User-Agent HTTP-запроса добавляется версия ядра, чтобы скрипт на сервере мог генерировать конфиг с учетом версии.
Результат
Получилась легкая и удобная программа, которая запускает sing-box в фоне и позволяет управлять им через значок в трее.
Значок отображает статус системного прокси и TUN
Клик по значку переключает системный прокси
Быстрое включение и выключение TUN
Контекстное меню для переключения селекторов
Поддержка профилей с автоматическим обновлением с сервера
Ранний автозапуск через планировщик при загрузке системы
Базовая функциональность готова. Тратить еще больше времени на доработки не хотелось бы, хотя уверен, что руки будут чесаться. Очевидные недостатки:
Нет переключения профилей
Нет списка соединений с возможностью их завершения
Нет окна с логом
Как пользоваться
Скачайте sing-box-drover со страницы последнего релиза.
Скачайте sing-box для Windows с официальной страницы релизов (архив с
windows-amd64в названии).Распакуйте оба архива в одну папку (рядом должны лежать
sing-box-drover.exeиsing-box.exe).Положите туда же свой sing-box-конфиг и пропишите путь к нему в
sb-config-fileini-файла утилиты.Запустите
sing-box-drover.exe— в трее появится значок.
Подробности в README репозитория.
P. S.
Еще меня иногда просят убрать уродскую зеленую цветную капусту и нарисовать нормальный значок. Читать такое обидно, потому что началось всё именно со значка, да и мне самому он нравится. В общем, цветная капуста остаётся.
Репозиторий sing-box-drover: https://github.com/hdrover/sing-box-drover
