Участие в разработке
Прочитайте это один раз перед первым патчем. Здесь коротко и только то, что касается именно этого репозитория.
С чем вы имеете дело
Заголовок раздела «С чем вы имеете дело»TailSocks — это Android-клиент Tailscale, который запускает настоящий
tailscaled в userspace, поэтому приложение работает вообще без разрешения
Android на VPN. Интерфейс написан на Kotlin/Compose, мост между приложением и
демоном — модуль Gomobile (appctr/), а сам демон — это upstream Tailscale плюс
небольшой набор локальных патчей. Режимы TUN и Root надстроены поверх этого
ядра, а не заменяют его.
Главное правило, из которого следует почти вся структура кода: Go-демон — это
отдельный процесс, а не библиотека внутри приложения. libtailscale.so —
position-independent executable (PIE), который приложение запускает через
fork/exec из jniLibs; собранный с апстримовским тегом ts_include_cli, он
же работает как CLI, когда его вызывают под именем tailscale. Дальше приложение общается с
работающим процессом по unix-сокету files/tailscaled.sock через LocalAPI v0.
Управление демоном никогда не оборачивает CLI-бинарник, и Kotlin никогда не
линкует демон в себя. Если хочется позвать Go-функцию напрямую из Kotlin —
почти всегда правильный ответ это вызов LocalAPI или новый метод моста
appctr, а не новая связь по коду.
Два этапа, строго в этом порядке. Gradle только упаковывает готовые Go- и C-артефакты, он их не собирает.
git clone --recurse-submodules https://github.com/bropines/tailsocks.gitcd tailsocks
# Этап 1 — ядро на Go и JNI-мост (нужен ANDROID_NDK_HOME)cd appctr && bash build.sh && cd ..
# Этап 2 — APK./gradlew app:assembleDebugappctr/build.sh скачивает версию Tailscale, зафиксированную в
appctr/TAILSCALE_VERSION (сейчас v1.104.1), накладывает все патчи из
appctr/patches/, кросс-компилирует PIE-бинарник под arm64-v8a,
armeabi-v7a, x86 и x86_64 в app/src/main/jniLibs/ и собирает
appctr/tmp/appctr.aar.
Для release-сборки нужен настоящий keystore и все четыре переменные; начиная с 4.0.0 сборка отказывается подписывать debug-ключом (release-APK, подписанный одноразовым ключом, потом невозможно обновить нормально подписанным — Android не принимает смену сертификата, и выход только один: удалить приложение вместе со всем его состоянием):
KEYSTORE_FILE="$PWD/tailsocks.jks" KEYSTORE_PASSWORD=... \KEY_ALIAS=... KEY_PASSWORD=... ./gradlew app:assembleReleaseABI-сплиты включены, поэтому на выходе универсальный APK плюс по одному на
каждую ABI в app/build/outputs/apk/<debug|release>/. Весь конвейер описан на странице Сборка.
Не коммитьте сгенерированное и приватное: бинарники .so, appctr/tmp/,
appctr/tailscale_src/, appctr/orig/ и keystore — всё это в .gitignore и
должно там остаться. Английские и русские строки интерфейса в этом репозитории
ведутся руками: добавляйте строку в app/src/main/res/values/strings.xml (или в
собственный strings_<область>.xml экрана) и её перевод в values-ru/ одним
коммитом; непереведённый русский интерфейс здесь считается дефектом, а не
отложенной работой. Все остальные языки приходят из Hosted Weblate
пулл-реквестами — см. Перевод.
Изменения демона — это патчи, а не правки
Заголовок раздела «Изменения демона — это патчи, а не правки»appctr/tailscale_src/ — сгенерированный каталог, он в .gitignore: это
чистый upstream Tailscale с наложенными по алфавиту appctr/patches/*.patch
(01-enable-socks-android … 23-android-loopback-guard, сейчас двадцать три штуки). В
appctr/orig/ лежит чистая копия, относительно которой считается diff. Источник
истины — файлы патчей; дерево исходников это черновик.
Рабочий цикл:
- Сначала прочитайте upstream-файл. Стройте решение вокруг того, как это уже
сделано в Tailscale, — чистая копия лежит рядом, в
appctr/orig/. - Правьте в
appctr/tailscale_src/, собирайте, итерируйте. - Пересоберите набор патчей:
./appctr/patches/recreate_patches.sh. Скрипт удаляетpatches/*.patchи заново снимает difftailscale_srcпротивorig— по файлам, а для taildrop/taildrive/net — по каталогам. - Проверьте с нуля:
cd appctr && bash build.sh --clean. Без--cleanbuild.shвидит существующийtailscale_src/и вообще пропускает наложение патчей, так что сломанный патч обнаружится только у того, кто соберёт следующим.
Два требования к патчу:
- Он должен накладываться на чистые исходники без fuzz.
build.shвызываетpatch -p1 --batch --forward -F0, поэтому смена версии, сдвинувшая контекст, роняет сборку, а не вставляет ханк не туда. - Он не должен быть пустым.
build.shпрерывается на патче нулевого размера, потому что GNUpatchпринимает такой файл и выходит с кодом 0 — потерянное исправление уехало бы молча.
Если вы правите файл, который recreate_patches.sh ещё не диффит, добавьте для
него секцию в том же коммите. Иначе ваша работа исчезнет при первой же чистой
сборке.
Смена версии Tailscale — это правка appctr/TAILSCALE_VERSION; build.sh сам
заметит её и принудительно перекачает и перепатчит исходники.
Три режима туннеля
Заголовок раздела «Три режима туннеля»Настройки → Режим туннеля, выбирается ровно один:
| Режим | Как трафик попадает внутрь | Что нужно |
|---|---|---|
| Proxy (по умолчанию) | Приложения сами настраиваются на локальный SOCKS5/HTTP-прокси и локальный DNS. Ничего не перехватывается. | Ничего — ни разрешения VPN, ни root |
| VPN (TUN) | Устройство Android VpnService, два движка. Нативный (по умолчанию): мост перезапускает tailscaled с --tun=android-vpn на копии дескриптора, и демон сам читает и пишет его. Для совместимости: hev-socks5-tunnel (app/src/main/jni/) читает устройство и заворачивает трафик в тот же локальный SOCKS5. |
Системное разрешение на VPN |
| Root | Демон запускается через su на настоящем интерфейсе ядра tailscale0, с policy routing в таблице 53 и цепочками TAILSOCKS_MARK / TAILSOCKS_DNS. Слот Android VPN остаётся свободным. |
Magisk / KernelSU / APatch |
Конкретно:
- Proxy обязан продолжать работать без разрешения на VPN и без root. Ради этого
приложение и существует — не делайте так, чтобы общий код зависел от
VpnServiceили отsu. - Маршрутизация Root Mode живёт в
core/RootUtils.kt, и почти вся она — это policy routing черезip rule/ip route, а не iptables: правила тайлнета в таблицу53с priority 100, catch-all в таблицу демона52с priority 200, исключения приложенийuidrange … gotoс priority 190 иthrow-маршруты для локальной сети и чужого CGNAT. На iptables остаются только маркировка и DNAT, и только внутриTAILSOCKS_MARK/TAILSOCKS_DNS: никогда не пишите голый--set-mark, вOUTPUT— только защищённые переходы в эти цепочки, вFORWARD— только пара ACCEPT дляtailscale0. Не возвращайте цепочку bypass на mangle-марках: 4.0 удалила старуюTAILSOCKS_BYPASS, потому что метка, поставленная в mangleOUTPUT, приходит уже после того, как выбраны маршрут и адрес источника. Правила разделены на уровни: T1 (доступность тайлнета) уживается с чем угодно, а T2 (перехват маршрута по умолчанию) и T3 (DNS всего устройства) забирают устройство целиком и потому имеют ровно одного владельца. Прежде чем трогать T2 или T3, прочитайте в «Root-режиме изнутри» разделы «Маршрутизация вокруг tailscale0» и «Рядом с другим VPN-клиентом»: описанная там логика сосуществования — это то, что не даёт увести приложения у другого VPN-клиента. - У TUN два движка, выбор — в Настройки → TUN (
tun_engineзаписывается, только когда пользователь выбрал движок сам). По умолчанию нативный:core/TunVpnService.ktподнимает устройство с тем, что поставил бы пустой роутер демона, — адресами узла, диапазонами тайлнета, принятыми subnet-маршрутами (core/TunRoutes.kt), маршрутом по умолчанию за exit node, — а Android фиксирует всё это приestablish(), поэтому любое изменение пересобирает устройство и перезапускает демона.hev-socks5-tunnelостаётся движком для совместимости; оба должны работать. Подмена дескриптора,CallbackRouterиSCM_RIGHTSизdocs/NATIVE_TUN_PLAN.md(единственный документ проекта без русского перевода) оставлены там для истории и не нужны: смена движка перезапускает демона, так задумано.
Напишите в pull request, в каких режимах вы проверяли. «Только proxy» — нормальный ответ; молча не проверить — нет.
Коммиты и changelog
Заголовок раздела «Коммиты и changelog»Заголовки — в стиле conventional commits, на английском, как в git log:
feat(root): health-gate the DNS redirect, guard foreign CGNAT, leave netd's table spacefix(ui): stop the DNS test button from covering its own titleperf(root): react to another tunnel at once instead of on the next tickdocs: hand off what is left — Root Mode device checks and the CLI binary- Префикс:
feat,fix,docs,refactor,perf,style,chore. - Скоуп в скобках, по области —
(root),(ui),(core),(dns),(tun),(build),(appctr),(security),(patches). Опускайте его, только если изменение действительно общерепозиторное (docs:). - Заголовок говорит, каким стало поведение, а не какой файл подвинули. После двоеточия со строчной буквы, без точки в конце; длина до ~90 символов — читаемая фраза лучше обрубленной метки.
- Тело коммита — про «почему» и про то, что ломается без этого. Для мелочей не нужно, для всего неочевидного обязательно. Код, комментарии, логи и сообщения коммитов пишутся по-английски независимо от языка обсуждения.
Всё, что заметит пользователь, попадает в CHANGELOG.md тем же коммитом,
который меняет поведение — не отдельным коммитом позже и не при выпуске
релиза. Запись идёт под самый верхний заголовок ## [X.Y.Z] - Unreleased, в
подходящую группу Added / Changed / Fixed / Security. Заголовок, у
которого уже стоит дата, править нельзя: выпущенные разделы — это история.
Changelog пишется для пользователей и для того, кто выпускает релиз, и пишется
коротко: одна строка на изменение — что изменилось, не длиннее примерно двадцати
слов, без имён классов, путей к файлам, констант, id ресурсов и полей API.
Почему и как — в теле сообщения коммита: кому нужно, прочитает там. CI копирует
секцию тега в заметки к релизу на GitHub как есть. Образец — секция [4.1.1].
Версия записана в одном файле. version.properties хранит VERSION_NAME
и VERSION_CODE (мажор×1000000 + минор×10000 + патч×100); его читают и Gradle,
и F-Droid. Релизный коммит поднимает обе строки, переименовывает заголовок
Unreleased в ## [X.Y.Z] - YYYY-MM-DD и получает тег vX.Y.Z — тег, который
расходится с файлом, CI не пропустит.
Это переименование обязательно: CI собирает описание GitHub-релиза, отыскивая в
CHANGELOG.md заголовок ## [<тег без v>], и тег без такого заголовка даёт
релиз с пустым описанием. Теги ставит мейнтейнер — присылайте pull request в
main и тег не трогайте.
Тестирование
Заголовок раздела «Тестирование»Что можно проверить без устройства:
cd appctr && go test ./...— собственные тесты Go-моста (сейчасextraargs_test.go).gofmtпоappctr/— CI падает на неотформатированном Go.appctr/build.shсам делаетgofmt -w *.go, аgit config core.hooksPath .githooksвключает pre-commit-хук, который делает то же самое перед коммитом../gradlew app:assembleDebug— компиляция, и это, честно говоря, почти всё, что в Kotlin-части проверяется автоматически.- Если вы добавили
external fun, соберите release и дайте отработатьverifyReleaseNativeMethods: задача падает, когда R8 вырезал нативный метод, — иначе это превращается в падениеSystem.loadLibraryуже во время работы. Для новых нативных методов нужен обычный-keepдляnative <methods>вapp/proguard-rules.pro.
Что реально требует устройства:
- Всё, что связано с JNI и нативным кодом: TUN-движок и ByeDPI проверяются только в рантайме, а поведение R8 в debug и release различается.
- Жизненный цикл foreground-сервиса, плитка Quick Settings, виджеты, SAF.
- Весь Root Mode. Policy routing, цепочки iptables и логику сосуществования
с другим VPN-клиентом негде проверить, кроме рутованного телефона; работа над
сосуществованием в 4.0.0 проверялась на рутованном Redmi (APatch), в том числе
через настоящую перезагрузку. Инструменты для этого: Настройки →
Диагностика и разработчику → «Проверить маршрутизацию», вкладка ROOT на
экране логов и
tools/root-debug.sh, запущенный на устройстве подsu, — он печатает полный снимок состояния.
Где спрашивать
Заголовок раздела «Где спрашивать»- Архитектура — как связаны демон, мост и приложение и зачем нужен каждый обходной путь.
- Руководство по Root — полный справочник по Root Mode: три уровня правил, сосуществование с другими VPN-клиентами, загрузочный скрипт, диагностика.
agents.md— полный инженерный регламент, кратким пересказом которого является эта страница.- Сборка — подробности сборочного конвейера.
Если ответа там нет — открывайте issue и укажите режим, версию Android и есть ли на устройстве root.