Перейти к содержимому

Участие в разработке

Прочитайте это один раз перед первым патчем. Здесь коротко и только то, что касается именно этого репозитория.

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.git
cd tailsocks
# Этап 1 — ядро на Go и JNI-мост (нужен ANDROID_NDK_HOME)
cd appctr && bash build.sh && cd ..
# Этап 2 — APK
./gradlew app:assembleDebug

appctr/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:assembleRelease

ABI-сплиты включены, поэтому на выходе универсальный 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. Источник истины — файлы патчей; дерево исходников это черновик.

Рабочий цикл:

  1. Сначала прочитайте upstream-файл. Стройте решение вокруг того, как это уже сделано в Tailscale, — чистая копия лежит рядом, в appctr/orig/.
  2. Правьте в appctr/tailscale_src/, собирайте, итерируйте.
  3. Пересоберите набор патчей: ./appctr/patches/recreate_patches.sh. Скрипт удаляет patches/*.patch и заново снимает diff tailscale_src против orig — по файлам, а для taildrop/taildrive/net — по каталогам.
  4. Проверьте с нуля: cd appctr && bash build.sh --clean. Без --clean build.sh видит существующий tailscale_src/ и вообще пропускает наложение патчей, так что сломанный патч обнаружится только у того, кто соберёт следующим.

Два требования к патчу:

  • Он должен накладываться на чистые исходники без fuzz. build.sh вызывает patch -p1 --batch --forward -F0, поэтому смена версии, сдвинувшая контекст, роняет сборку, а не вставляет ханк не туда.
  • Он не должен быть пустым. build.sh прерывается на патче нулевого размера, потому что GNU patch принимает такой файл и выходит с кодом 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, потому что метка, поставленная в mangle OUTPUT, приходит уже после того, как выбраны маршрут и адрес источника. Правила разделены на уровни: 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» — нормальный ответ; молча не проверить — нет.

Заголовки — в стиле conventional commits, на английском, как в git log:

feat(root): health-gate the DNS redirect, guard foreign CGNAT, leave netd's table space
fix(ui): stop the DNS test button from covering its own title
perf(root): react to another tunnel at once instead of on the next tick
docs: 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.