Реализация простого 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.
Хабру
Я много лет читаю Хабр. Для меня в последнее время он стал тем центром, который постоянно подогревает интерес и не даёт потерять себя в профессиональном плане (что особенно чувствуется в эпоху удалёнки).
Это уникальное место, где можно виртуально пройтись по испанской барахолке, найти решение давней проблемы с радио-модулем, заглянуть в космос, прочитать про детали древнего протокола, узнать как подготавливать древесину для столешницы, вспомнить про старые девайсы и увидеть новые. Всё разнообразно, многогранно, и неизменно интересно.
Хабр, оставайся собой! Хабом знаний, которые будут актуальны даже спустя годы. Не становись блогом с засильем рекламы в статьях-однодневках.