Реализация простого HTTP CONNECT прокси-сервера на Go, квест с маркировкой сетевых пакетов и запуск программы в Android.
Интро
После долгих лет работы разработчиком софта я хочу быть... всё тем же разработчиком. Мне это так же интересно, как и четверть века назад, когда я только начинал программировать. Даже больше - за это время количество того, чего я не знаю, многократно увеличилось, и всё стало ещё интереснее. Это та стихия, в которой я чувствую себя как рыба в воде.
Практически каждый проект с моим участием связан с какой-то историей, настолько необычной и запоминающейся, что каждый раз я ощущаю себя как в путешествии в фильме Трасса 60, с остановками, успехами и фейлами, не всегда с ожидаемым результатом, но..
"Какая разница, что в коробке. Истории, которыми она обросла, теперь намного важнее."
Большинство проектов ушло в корзину, от них остался только опыт. Некоторыми я пользуюсь время от времени. Но результатом моего недавнего творения я пользуюсь каждый день.
Идея
В какой-то момент я понял, что мне не хватает контроля над трафиком, проходящим через мой телефон. Всякое бывает. Интернет в роуминге стоит денег. Интернет-пакеты с туристических симок заканчиваются довольно быстро. Что происходит внутри китайских смартфонов - это вообще шапито. Я не потребляю столько интернет-трафика, сколько фактически тратится.
При этом на телефоне всё лишнее убрано через adb. Все обновления в ручном режиме. Выбранный в браузере DNS-сервер отсекает часть трекеров и рекламных площадок. Блокировщик рекламы режет её по мере возможности.
Но современный софт и сайты способны жрать всё больше и больше. Да ещё и сотовым операторам иногда взбредает в голову ввести плату за расшаривание интернета.
Поэтому я захотел сделать вот такую штуку:

Прокси на телефоне, через которую я смогу раздавать интернет без включения мобильной точки доступа. В которой можно фильтровать и анализировать потребление трафика.
Но не взять что-то готовое, а начать новую увлекательную историю. Сделать приложение на Golang. Без Android Studio, Kotlin и Gradle. По возможности без фреймворков или сторонних библиотек.
Спойлер
Забегая вперёд, скажу, что у меня всё получилось. Приложение работает почти на всех моих устройствах (кроме зубной щётки и термометра). Проксирует, фильтрует, управляется локально и удалённо.
Например, вот так выглядела исходная статистика проксирования при заходе в браузере на главную страницу mail.ru:
Скриншот

С 43 доменов тянулось около 7 мегабайт всякого мусора.
И вот такой она стала после фильтрации трафика:
Скриншот

Около одного мегабайта с 6 доменов. Экономия трафика в 7 раз.
HTTP прокси на Go
Самый распространённый тип прокси (и доступный для настройки в Android) - обычный HTTP-прокси, поддерживающий HTTP метод CONNECT и создающий туннель между клиентом и целевым сервером. Сделать CONNECT-прокси сервер в Go довольно просто. В стандартной библиотеке Go нет явной реализации этого типа прокси, но зато она встречается в юнит-тестах в исходниках net-пакетов Go, например в коде "go/src/net/http/transport_test.go"
Суть CONNECT-прокси приятна в своей простоте. Клиент делает HTTP CONNECT запрос к прокси с указанием сервера, с которым нужно установить соединение. Например:
CONNECT google.com:443 HTTP/1.1
Прокси в свою очередь подключается к указанному серверу и затем занимается перекачиванием данных от клиента к серверу и наоборот. Всё остальное, что происходит в получившемся туннеле (шифрование, сжатие данных) - дело клиента и целевого сервера.
Если всё сильно упростить, то код прокси-сервера может выглядеть так:
package main import ( "crypto/tls" "fmt" "io" "log" "net" "net/http" ) const proxyListenAddress = "0.0.0.0:3128" func main() { proxyServer := http.Server{ Addr: proxyListenAddress, Handler: http.HandlerFunc(connectHandler), TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, } log.Println("Started proxy at:", proxyServer.Addr) if err := proxyServer.ListenAndServe(); err != nil { log.Println("Server failed:", err) } } func connectHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodConnect { log.Println("Method not allowed:", r.Method) w.WriteHeader(http.StatusMethodNotAllowed) return } log.Println("Hijacking connection:", r.RemoteAddr, "->", r.URL.Host) clientConn, _, err := w.(http.Hijacker).Hijack() if err != nil { log.Println("Hijack error:", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } defer clientConn.Close() log.Println("Connecting to:", r.URL.Host) targetConn, err := net.Dial("tcp", r.URL.Host) if err != nil { log.Println("Connect error:", err) writeRawResponse(clientConn, http.StatusServiceUnavailable, r) return } defer targetConn.Close() writeRawResponse(clientConn, http.StatusOK, r) log.Println("Transferring:", r.RemoteAddr, "->", r.URL.Host) go func() { io.Copy(targetConn, clientConn) targetConn.Close() }() io.Copy(clientConn, targetConn) log.Println("Done:", r.RemoteAddr, "->", r.URL.Host) } func writeRawResponse(conn net.Conn, statusCode int, r *http.Request) { if _, err := fmt.Fprintf(conn, "HTTP/%d.%d %03d %s\r\n\r\n", r.ProtoMajor, r.ProtoMinor, statusCode, http.StatusText(statusCode)); err != nil { log.Println("Writing response failed:", err) } }
Собираю:
go build -o bin/simple-proxy
Запускаю на ноутбуке с линуксом, проверяю курлом и браузером:
curl --proxytunnel -v --proxy http://127.0.0.1:3128 https://google.com chrome --proxy-server=http://127.0.0.1:3128
Ура, отлично работает! Затем кросс-компиляция, запуск в винде, затем на ARM-одноплатнике - тоже всё ок.
Что же андроид? Собираю для андроида:
GOOS=android GOARCH=arm64 go build -o bin/simple-proxy
Включаю на телефоне одновременно Wi-Fi и передачу мобильных данных (как на диаграмме в начале текста). Копирую и запускаю исполняемый файл через adb:
adb push bin/simple-proxy /data/local/tmp adb shell "cd /data/local/tmp && chmod u+x simple-proxy && ./simple-proxy"
Запускается, но как прокси это, увы, не работает:
Started proxy at: 0.0.0.0:3128 Hijacking connection: 127.0.0.1:49467 -> google.com:443 Connecting to: google.com:443 Connect error: dial tcp: lookup google.com on [::1]:53: read udp [::1]:54776->[::1]:53: read: connection refused
При запуске на андроиде в termux результат такой же.
Go resolver
Ошибка из предыдущего лога говорит о том, что, приложению не удаётся достучаться до локального DNS и получить ip-адрес для хоста "google.com".
Специфика работы DNS-ресолвера в Go поверхностно описана в официальной документации Name Resolution, детали же можно посмотреть в исходниках Go (в "go/src/net").
Итак, в винде это сработало, потому что приложение под капотом вызывает WinAPI функции типа GetAddrInfoW. В линкусе оно либо использует DNS сервер и настройки, указанные в /etc/resolv.conf; либо обращается к локальному DNS сервису типа systemd-resolved, который знает, как и куда сходить для ресолва адресов.
В андроиде же всё неоднозначно, согласно AOSPдокументации DNS Resolver
Ок, кастомизировать поведение ресолвера в Go не очень сложно. Пусть пока он напрямую ходит в DNS. К тому же теперь появляется возможность выбора DNS-сервера (например, Adguard DNS, который отрежет часть трекеров и рекламных площадок). Да и в принципе, в своём ресолвере можно прикрутить нормальные протоколы типа DoH/DoT (или кто знает, что ещё понадобится в этом мире завтра).
В предыдущий код добавляю простейшие диалер с ресолвером:
const dnsAddress = "8.8.8.8:53" //Google DNS var dialer = createDialer() func createDialer() *net.Dialer { resolverDialer := &net.Dialer{} resolverDial := func(ctx context.Context, network, addr string) (net.Conn, error) { log.Println("Resolver dial:", network, addr) return resolverDialer.DialContext(ctx, network, dnsAddress) } resolver := &net.Resolver{PreferGo: true, Dial: resolverDial} return &net.Dialer{Resolver: resolver, Timeout: 60 * time.Second} }
И использую его для создания соединения:
targetConn, err := dialer.Dial("tcp", r.URL.Host)
Не самая идеальная реализация, но для проверки подойдёт. Собираю для андроида, запускаю на телефоне:
GOOS=android GOARCH=arm64 go build -o bin/simple-proxy-resolve adb push bin/simple-proxy-resolve /data/local/tmp adb shell "cd /data/local/tmp && chmod u+x simple-proxy-resolve && ./simple-proxy-resolve"
Проверяю, и ошибка меняется на:
2024/02/10 15:48:13 Connect error: dial tcp: lookup google.com on [::1]:53: read udp 192.168.1.61:52554->8.8.8.8:53: i/o timeout
Приложение теперь не может достучаться до указанного DNS. Оно пытается лезть в интернет через вайфай (который не подключён к интернету) и игнорирует наличие мобильной сети.
Действительно, стоит отключить вайфай на телефоне, и всё работает:
Transferring: 127.0.0.1:49682 -> google.com:443 Done: 127.0.0.1:49682 -> google.com:443
В андроиде при включённом вайфае весь трафик пытается идти только через вайфай. Известная проблема, не имеющая единого решения на всём многообразии версий Android и производителей смартфонов. Часто доходит до смешного - человек подключается с телефона к стиральной машинке, и на это время теряет доступ в интернет.
Маркировка сетевых пакетов в Android
По сути, задача сводится к классической: есть несколько сетевых интерфейсов и часть трафика надо гнать через определённый интерфейс. В разных системах это делается по-разному. Часто решается настройкой маршрутизации. Но трогать маршрутизацию на нерутованном андроиде та ещё задача.
Быстрый поиск решения в интернете приводит к подобному Java-коду:
import android.app.Activity; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkRequest; import android.os.Bundle; import android.util.Log; import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); connectivityManager.registerNetworkCallback( new NetworkRequest.Builder().addTransportType(TRANSPORT_CELLULAR).build(), new ConnectivityManager.NetworkCallback() { @Override public void onAvailable(Network network) { connectivityManager.bindProcessToNetwork(network); Log.i("GoLog", "Cellular network: " + network.toString()); } } ); } }
То-есть у обычного APK приложения на Java есть возможность привязаться к определённой сети. Но, если что-то может сделать Java-приложение, то наверняка это же может сделать и нативное приложение.
Исходники андроида открыты. Пройдя по такой цепочке в исходном коде из репозиториев https://android.googlesource.com/platform/packages/modules/Connectivity и https://android.googlesource.com/platform/system/netd:
packages/modules/Connectivity/framework/src/android/net/ - ConnectivityManager.java:bindProcessToNetwork - ConnectivityManager.java:setProcessDefaultNetwork - NetworkUtils.java:bindProcessToNetwork - NetworkUtils.java:bindProcessToNetworkHandle packages/modules/Connectivity/framework/jni/ - android_net_NetworkUtils.cpp:android_net_utils_bindProcessToNetworkHandle frameworks/base/native/android/ - net.c:android_setprocnetwork system/netd/client/ - NetdClient.cpp:setNetworkForProcess - NetdClient.cpp:setNetworkForTarget - NetdClient.cpp:setNetworkForSocket - FwmarkClient.cpp:send system/netd/server/ - FwmarkServer.cpp:processClient
можно прийти к строке кода:
setsockopt(*socketFd, SOL_SOCKET, SO_MARK, &fwmark.intValue, sizeof(fwmark.intValue))
где fwmark.intValue - это 32 бита, в которых содержится информация для маршрутизации передаваемых через этот сокет пакетов в указанную сеть. Вот так в C-коде разложены эти биты:
union Fwmark { uint32_t intValue; struct { uint16_t netId : 16; uint8_t explicitlySelected : 1; uint8_t protectedFromVpn : 1; uint8_t permission : 2; uint8_t uidBillingDone : 1; uint8_t reserved : 8; uint8_t vendor : 2; uint8_t ingress_cpu_wakeup : 1; }; constexpr Fwmark() : intValue(0) {} };
В "system/netd/server/FwmarkServer.cpp" можно посмотреть как правильно заполнить этот юнион.
Но вот этот netId (число, начинающееся с 100 и инкрементирующееся при каждом подключении к сети) узнать в не-Java приложении может оказаться довольно нетривиальной задачей. Ок, на самом деле на этом этапе можно собрать APK приложение из тех 10 строк Java-кода из начала главы, чтобы распечатать айдишник сети в лог.
Без особой надежды, но первое, что я решил попробовать, узнав netId - это вызвать в Go-коде:
syscall.SetsockoptInt(socketFd, SOL_SOCKET, SO_MARK, fwmarkIntValue)
на что получил от андроида ожидаемое "operation not permitted". Приложение в системных вызовах довольно ограничено.
Но исходники "system/netd" дают ответ, как это сделать из непривилегированного кода.
Сборка Go+C
Итак, одно из решений предыдущей проблемы - привязать те Go-сокеты, которые должны общаться с внешним миром, к интерфейсу мобильной передачи данных, используя "netd/client" как прослойку.
Код в "system/netd/client/NetdClient.cpp" предоставляет доступные функции для привязки сокета к сети:
extern "C" int setNetworkForSocket(unsigned netId, int socketFd) { if (socketFd < 0) { return -EBADF; } FwmarkCommand command = {FwmarkCommand::SELECT_NETWORK, netId, 0}; return FwmarkClient().send(&command, socketFd); }
То-есть, если вызвать "NetdClient:setNetworkForSocket" после создания сокета в Go, но до момента отправки данных - в этом случае передаваемые в сокет данные уйдут к указанную сеть.
Добавляю немного кросс-языкового кода:
//go:build android && cgo package main /* #include <NetdClient.h> */ import "C" func setNetworkForSocket(socket uintptr, networkId uint) { C.setNetworkForSocket(C.uint(networkId), C.int(socket)) }
и вызываю этот метод в Go после создания сокета:
const networkId = 269 // current netId of the cellular network on my phone func createDialer() *net.Dialer { dialerControl := func(network, address string, conn syscall.RawConn) error { controlFunc := func(socket uintptr) { log.Println("Dialer control:", socket, network, address) setNetworkForSocket(socket, networkId) } return conn.Control(controlFunc) } resolverDialer := &net.Dialer{Control: dialerControl} resolverDial := func(ctx context.Context, network, addr string) (net.Conn, error) { log.Println("Resolver dial:", network, addr) return resolverDialer.DialContext(ctx, network, dnsAddress) } resolver := &net.Resolver{PreferGo: true, Dial: resolverDial} return &net.Dialer{ Control: dialerControl, Resolver: resolver, Timeout: 60 * time.Second, } }
Полная версия получившегося Go кода
//go:build android && cgo package main import ( "context" "crypto/tls" "fmt" "io" "log" "net" "net/http" "syscall" "time" ) /* #include <NetdClient.h> */ import "C" func setNetworkForSocket(socket uintptr, networkId uint) { C.setNetworkForSocket(C.uint(networkId), C.int(socket)) } const proxyListenAddress = "0.0.0.0:3128" func main() { proxyServer := http.Server{ Addr: proxyListenAddress, Handler: http.HandlerFunc(connectHandler), TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, } log.Println("Started proxy at:", proxyServer.Addr) if err := proxyServer.ListenAndServe(); err != nil { log.Println("Server failed:", err) } } func connectHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodConnect { log.Println("Method not allowed:", r.Method) w.WriteHeader(http.StatusMethodNotAllowed) return } log.Println("Hijacking connection:", r.RemoteAddr, "->", r.URL.Host) clientConn, _, err := w.(http.Hijacker).Hijack() if err != nil { log.Println("Hijack error:", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } defer clientConn.Close() log.Println("Connecting to:", r.URL.Host) targetConn, err := dialer.Dial("tcp", r.URL.Host) if err != nil { log.Println("Connect error:", err) writeRawResponse(clientConn, http.StatusServiceUnavailable, r) return } defer targetConn.Close() writeRawResponse(clientConn, http.StatusOK, r) log.Println("Transferring:", r.RemoteAddr, "->", r.URL.Host) go func() { io.Copy(targetConn, clientConn) targetConn.Close() }() io.Copy(clientConn, targetConn) log.Println("Done:", r.RemoteAddr, "->", r.URL.Host) } func writeRawResponse(conn net.Conn, statusCode int, r *http.Request) { if _, err := fmt.Fprintf(conn, "HTTP/%d.%d %03d %s\r\n\r\n", r.ProtoMajor, r.ProtoMinor, statusCode, http.StatusText(statusCode)); err != nil { log.Println("Writing response failed:", err) } } const dnsAddress = "8.8.8.8:53" //Google DNS var dialer = createDialer() const networkId = 269 func createDialer() *net.Dialer { dialerControl := func(network, address string, conn syscall.RawConn) error { controlFunc := func(socket uintptr) { log.Println("Dialer control:", socket, network, address) setNetworkForSocket(socket, networkId) } return conn.Control(controlFunc) } resolverDialer := &net.Dialer{Control: dialerControl} resolverDial := func(ctx context.Context, network, addr string) (net.Conn, error) { log.Println("Resolver dial:", network, addr) return resolverDialer.DialContext(ctx, network, dnsAddress) } resolver := &net.Resolver{PreferGo: true, Dial: resolverDial} return &net.Dialer{ Control: dialerControl, Resolver: resolver, Timeout: 60 * time.Second, } }
Теперь нужно собрать Go-бинарник не совсем традиционным способом - используя тулчейн из Android NDK. Его можно скачать напрямую с официального сайта: "https://dl.google.com/android/repository/android-ndk-r26b-linux.zip"
Распаковываю его в "~/android_sdk".
Можно установить то же самое, следуя Android-way, используя команду `sdkmanager "ndk;26.2.11394342"` из Android SDK Command-line Tools.
Ещё мне понадобятся исходники netd. Смартфон, на котором я планирую проверять приложение на Android 7.1, поэтому качаю исходники для моей версии:
git clone --depth 1 --branch android-7.1.2_r39 --single-branch https://android.googlesource.com/platform/system/netd
Складываю их в "~/android_sdk/sources-misc/android-7.1.2_r39".
Вытаскиваю из телефона либу с реализацией netd-клиента и её зависимость, чтобы слинковать их с моим исполняемым файлом:
mkdir ./lib64 adb pull /system/lib64/libnetd_client.so ./lib64/ adb pull /system/lib64/libc++.so ./lib64/
На этот раз сборка чуть сложнее:
#!/usr/bin/env bash TOOLCHAIN=~/android_sdk/ndk/26.2.11394342/toolchains/llvm/prebuilt/linux-x86_64 ANDROID_SRC=~/android_sdk/sources-misc/android-7.1.2_r39 ANDROID_API=25 export GOOS=android export GOARCH=arm64 export CGO_ENABLED=1 export CGO_LDFLAGS="-Llib64 -lnetd_client -lc++" export CGO_CFLAGS="-I$ANDROID_SRC/platform/system/netd/include" export CC=$TOOLCHAIN/bin/aarch64-linux-android$ANDROID_API-clang export CXX=$TOOLCHAIN/bin/aarch64-linux-android$ANDROID_API-clang++ go build -trimpath -o bin/simple-proxy-bind
Запускаю:
adb push bin/simple-proxy-bind /data/local/tmp adb shell "cd /data/local/tmp && chmod u+x simple-proxy-bind && ./simple-proxy-bind"
Проверяю. Работает!
Хоть всё и собрано на коленке, захардкожен netId, статический бинарник собран под конкретную платформу, запускается не самым удобным образом, но это прототип, который делает то, что нужно. И это был, наверное, самый интересный технический момент в проекте.
Дорисовка совы
Дальнейшее описание может напоминать второй шаг из мема "Как нарисовать сову". Но там довольно рутинная работа без особых технических сложностей.
Кратко, что я сделал, чтобы пользоваться приложением в повседневной жизни:
Пересобрал его в виде APK при помощи gomobile, чтобы запускать как обычное андроид-приложение (хотя позже я отказался от `gomobile build` в пользу самостоятельного управления сборкой).
Добавил кеширование DNS-ответов и фильтрацию хостов по спискам whitelist/blacklist.
Сделал на Java минимальную реализацию Android Activity для автоматизации проброса
netIdиз Java-рантайма в Go и заменил еюGoNativeActivity, которую создаёт gomobile. В этой же активити я отрисовываю пару метрик и кнопку перехода в полноценный веб-интерфейс.Сделал интерфейс для управления приложением и просмотра статистики (на встроенном в приложение веб-сервере).
Перепаковал и переподписал APK с этими добавками.
Инструменты, которые я использовал при разработке:
GNU make - для сборки всего
go - для компиляции go-кода
clang из Android NDK - для кросс-компиляции с использованием С-кода Android
javac из OpenJDK 17 - для компиляции Activity на Java
zip и aapt2, apksigner, d8 из Android Platform Tools - для сборки APK
adb - для консольного доступа к телефону
sass и swc - для сборки юая
Разные сценарии сборки позволяют собрать не только Android-приложение, но и обычные консольные программы для Windows или Linux.
UI
С самого начала я решил, что у приложения будет универсальный веб-интерфейс для локального и удалённого управления. Простой и лёгкий. Без фреймворков и сторонних библиотек. Без NodeJS, npm и всех этих бесконечных node_modules.
Но после программирования на TypeScript не особо хочется возвращаться на голый JS. И появление SWC стало тем глотком свежего воздуха, который позволил мне использовать TS, и при этом отказаться от NodeJS (по крайней мере в этом проекте).
В результате юай стал таким легковесным, каким (в моём понимании) он и должен быть.
Использование
Я часто пользуюсь прокси, чтобы заблокировать мусорный трафик от браузера и приложений. Наиболее используемый сценарий немного отличается от того, что был показан на изначальной диаграмме. В этом сценарии я:
Запускаю прокси на телефоне.
Подключаю этот телефон к вайфай сети, в которой нет доступа в интернет.
В настройках вайфай-соединения на телефоне указываю в качестве прокси себя же (127.0.0.1)
После этого трафик от приложений идёт через прокси, фильтруется и передаётся в мобильную сеть.
В статистике становится видно хосты, с которыми связываются приложения, и объём передаваемых данных. Эту информацию можно использовать для пополнения списков блокировки.
В разных версиях Android и у разных производителей свои особенности поведения системы и настройки, которые приходится учитывать.
Скорее всего, есть готовые подобные решения. Но, как я уже говорил - мне просто очень нравится программировать.
Я планирую опубликовать исходники, и, может быть, написать про некоторые другие интересные моменты. История ещё не закончена.
Go?
Вероятно, кто-то скажет: "-А зачем здесь Go? На Java это сделать правильнее и проще, для Android уж точно!"
Да, всё верно. Если делать для Android. Но хочется запускать программу на чём угодно (в разумных пределах), и Go для этого вполне хорош.
На телефоне мне очень не хватает полноценной штатной консоли и `su` или `sudo`. Без них чувство, что тебя на время пустили поиграть в чужую песочницу. Держишь в руках это удобное высокопроизводительное комбо: процессор, память, сетевые интерфейсы; автономное, с дисплеем, да ещё в таком компактном формате, о котором когда-то можно было только мечтать. И программировать для него хочется так же просто, как для привычных систем. На C, Go или старом добром Паскале.
Поэтому, почему бы и не Go.
Хотя да, в результате версия прокси для андроида получилась таким Франкенштейном на смеси Go, Java и C.
Хабру
Я много лет читаю Хабр. Для меня в последнее время он стал тем центром, который постоянно подогревает интерес и не даёт потерять себя в профессиональном плане (что особенно чувствуется в эпоху удалёнки).
Это уникальное место, где можно виртуально пройтись по испанской барахолке, найти решение давней проблемы с радио-модулем, заглянуть в космос, прочитать про детали древнего протокола, узнать как подготавливать древесину для столешницы, вспомнить про старые девайсы и увидеть новые. Всё разнообразно, многогранно, и неизменно интересно.
Хабр, оставайся собой! Хабом знаний, которые будут актуальны даже спустя годы. Не становись блогом с засильем рекламы в статьях-однодневках.
