Прокси-сервер для Android на Go

от автора

Реализация простого 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.

Хабру

Я много лет читаю Хабр. Для меня в последнее время он стал тем центром, который постоянно подогревает интерес и не даёт потерять себя в профессиональном плане (что особенно чувствуется в эпоху удалёнки).

Это уникальное место, где можно виртуально пройтись по испанской барахолке, найти решение давней проблемы с радио-модулем, заглянуть в космос, прочитать про детали древнего протокола, узнать как подготавливать древесину для столешницы, вспомнить про старые девайсы и увидеть новые. Всё разнообразно, многогранно, и неизменно интересно.

Хабр, оставайся собой! Хабом знаний, которые будут актуальны даже спустя годы. Не становись блогом с засильем рекламы в статьях-однодневках.


ссылка на оригинал статьи https://habr.com/ru/articles/796105/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *