惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

宝玉的分享
宝玉的分享
The GitHub Blog
The GitHub Blog
Vercel News
Vercel News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
酷 壳 – CoolShell
酷 壳 – CoolShell
Last Week in AI
Last Week in AI
F
Fortinet All Blogs
Jina AI
Jina AI
I
InfoQ
T
The Blog of Author Tim Ferriss
P
Proofpoint News Feed
博客园 - 三生石上(FineUI控件)
G
Google Developers Blog
V
Visual Studio Blog
L
LangChain Blog
WordPress大学
WordPress大学
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
T
Tor Project blog
GbyAI
GbyAI
MongoDB | Blog
MongoDB | Blog
V
V2EX
Stack Overflow Blog
Stack Overflow Blog
H
Help Net Security
Recorded Future
Recorded Future
N
News and Events Feed by Topic
云风的 BLOG
云风的 BLOG
Martin Fowler
Martin Fowler
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
罗磊的独立博客
O
OpenAI News
Google DeepMind News
Google DeepMind News
S
Schneier on Security
C
Check Point Blog
N
Netflix TechBlog - Medium
The Register - Security
The Register - Security
aimingoo的专栏
aimingoo的专栏
TaoSecurity Blog
TaoSecurity Blog
T
Tenable Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Hugging Face - Blog
Hugging Face - Blog
Cyberwarzone
Cyberwarzone
月光博客
月光博客
The Last Watchdog
The Last Watchdog
B
Blog
有赞技术团队
有赞技术团队
Blog — PlanetScale
Blog — PlanetScale
T
Tailwind CSS Blog
Hacker News: Ask HN
Hacker News: Ask HN
H
Heimdal Security Blog
美团技术团队

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Приватная Cвязь на Go и Flutter
callsauI · 2026-04-19 · via Все публикации подряд на Хабре

Сложный

8 мин

3.9K

Кейс

От автора

В последнее время очень хочется мессенджер, в котором:

  • Нет центрального сервера

  • Сообщения шифруются end-to-end и не хранятся в открытом виде нигде

  • Любой при необходимости может поднять свой сервер легко и быстро и присоедениться к общей сети

  • Один сетевой стек вместо зоопарка протоколов

На Go есть библиотека libp2p, поддерживает работу с множеством транспортов, имеет встроенную аутентификацию пиров и предоставляет фундамент для децентрализованных P2P-сетей, которую крайне интересно было бы попробовать интегрировать в мобильное приложение в качестве транспорта для звонков и сообщений. Результатами попытки делюсь ниже.

Стек

Flutter отвечает за UI. Вся сетевая логика живёт в бинарнике, который компилируется в .dylib (macOS), .so (Android/Linux) или статическую библиотеку (iOS). Dart общается с Go через FFI (Foreign Function Interface) — прямые вызовы C-функций. Соединение между пирами может устанавливаться двумя путями: напрямую или через промежуточный узел — Circuit Relay v2. Последний необходим для обхода ограничений NAT и брандмауэров, когда прямой коннект между устройствами невозможен.

Главный и самый первый вопрос который встал у меня в начале разработки: как из Flutter-приложения вызывать Go-код? Лучше всего получилось через CGO. Go умеет компилироваться в C-совместимую shared library с экспортируемыми функциями.

Сборка Go → C-shared library

Android (so, нужен NDK):

CGO_ENABLED=1 GOOS=android GOARCH=arm64 \
CC=$ANDROID_NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang \
go build -buildmode=c-shared \
-o libp2p_network.so \
./main.go

iOS (статическая библиотека .a через CGO):

CGO_ENABLED=1 GOOS=ios GOARCH=arm64 \
CC=$(xcrun --sdk iphoneos --find clang) \
CGO_CFLAGS="-isysroot $(xcrun --sdk iphoneos --show-sdk-path) -arch arm64 -miphoneos-version-min=12.0" \
CGO_LDFLAGS="-isysroot $(xcrun --sdk iphoneos --show-sdk-path) -arch arm64 -miphoneos-version-min=12.0" \
go build -buildmode=c-archive \
-o libp2p_network.a \
./main.go

На выходе получаем бинарник с C-функциями, которые Dart может вызывать через dart:ffi.
На Android достаточно положить .so в jniLibs/arm64-v8a/, и Flutter подхватит его автоматически. На iOS — c-archive выдаёт .a + .h, которые линкуются статически в Xcode-проекте. Для универсальной библиотеки (device + simulator) собираются две .a под arm64 и x86_64, затем склеиваются через lipo -create.

FFI мост: Go → Dart

Go-сторона: экспорт C-функций

Каждая функция, которую нужно вызывать из Dart, помечается комментарием //export:

package main

/*
#include <stdlib.h>
*/
import "C"
import (
    "sync"
    "github.com/myapp/p2p"
)

var (
    nodeInstance *p2p.Node
    nodeMu       sync.Mutex
)

//export StartNode
func StartNode(storagePath *C.char) *C.char {
    nodeMu.Lock()
    defer nodeMu.Unlock()

    if nodeInstance != nil {
        return C.CString(nodeInstance.GetPeerID())
    }

    path := C.GoString(storagePath)
    node, err := p2p.NewNode(path)
    if err != nil {
        return C.CString("")
    }

    node.SetMessageHandler(func(msg *p2p.Message) {
        // складываем в буфер для polling
    })

    if err := node.Start(); err != nil {
        return C.CString("")
    }

    nodeInstance = node
    return C.CString(node.GetPeerID())
}

//export SendMessage
func SendMessage(peerID, content, msgType, id *C.char) C.int {
    nodeMu.Lock()
    node := nodeInstance
    nodeMu.Unlock()

    if node == nil {
        return -1
    }

    err := node.SendMessage(
        C.GoString(peerID),
        C.GoString(content),
        C.GoString(msgType),
        C.GoString(id),
    )
    if err != nil {
        return -1
    }
    return 0
}

//export FreeString
func FreeString(s *C.char) {
    C.free(unsafe.Pointer(s))
}

func main() {}

Важные моменты:

- C.GoString() копирует строку из C-памяти в Go — после этого Dart может освободить свою копию

- C.CString() выделяет память в C-хипе — Dart обязан вызвать FreeString после использования, иначе утечка

- Все экспортированные функции должны быть в пакете main

- func main() {} — обязательна, даже если пустая

Dart-сторона: загрузка и вызов

class P2PNode {
  static DynamicLibrary? _lib;

  void _loadLibrary() {
    if (Platform.isAndroid) {
      _lib = DynamicLibrary.open('libp2p_network.so');
    } else if (Platform.isIOS) {
      _lib = DynamicLibrary.process(); // статически слинковано
    } else if (Platform.isMacOS) {
      // ищем dylib в Frameworks бандла
      final appDir = Platform.resolvedExecutable;
      final frameworksDir = '${File(appDir).parent.path}/Frameworks';
      _lib = DynamicLibrary.open('$frameworksDir/libp2p_network.dylib');
    }

    _startNode = _lib!.lookupFunction<
        Pointer<Utf8> Function(Pointer<Utf8>),  // C-сигнатура
        Pointer<Utf8> Function(Pointer<Utf8>)   // Dart-сигнатура
    >('StartNode');

    _sendMessage = _lib!.lookupFunction<
        Int32 Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>),
        int Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>)
    >('SendMessage');

    _freeString = _lib!.lookupFunction<
        Void Function(Pointer<Utf8>),
        void Function(Pointer<Utf8>)
    >('FreeString');
  }
}

Вызов Go-функции из Dart выглядит так:

Future<String> start() async {
  final dir = await getApplicationDocumentsDirectory();
  final pathPtr = dir.path.toNativeUtf8();
  final resultPtr = _startNode(pathPtr);
  final peerId = resultPtr.toDartString();
  _freeString(resultPtr);  // освобождаем C-память
  calloc.free(pathPtr);    // освобождаем Dart-память
  return peerId;
}

Как работает libp2p-нода

Создание ноды — это конфигурирование libp2p хоста с нужными транспортами и протоколами:

func NewNode(storagePath string) (*Node, error) {
    ctx, cancel := context.WithCancel(context.Background())

    // Загружаем или генерируем Ed25519-ключ (это наш PeerID)
    keyPath := filepath.Join(storagePath, "identity.key")
    priv, _ := loadOrCreateKey(keyPath)

    h, err := libp2p.New(
        libp2p.Identity(priv),
        libp2p.ListenAddrStrings(
            "/ip4/0.0.0.0/tcp/0",
            "/ip4/0.0.0.0/udp/0/quic-v1",
        ),
        libp2p.EnableNATService(),
        libp2p.EnableRelay(),
        libp2p.NATPortMap(),
        libp2p.EnableAutoRelayWithStaticRelays(relayAddrs),
    )

    // Kademlia DHT для discovery
    kadDHT, _ := dht.New(ctx, h, dht.Mode(dht.ModeAutoServer))

    node := &Node{host: h, dht: kadDHT, ctx: ctx}

    // Регистрируем обработчик входящих сообщений
    h.SetStreamHandler("/messaging/1.0.0", node.handleStream)

    // Слушаем подключения/отключения пиров
    h.Network().Notify(&network.NotifyBundle{
        ConnectedF:    func(n network.Network, c network.Conn) { ... },
        DisconnectedF: func(n network.Network, c network.Conn) { ... },
    })

    return node, nil
}

Каждое устройство получает уникальный PeerID — это хеш публичного Ed25519-ключа. Ключ генерируется один раз и хранится на устройстве. PeerID — это ваш идентификатор в сети, аналог номера телефона, но без привязки к чему-либо.

Отправка сообщений

func (n *Node) SendMessage(peerIDStr, content, msgType, id string) error {
    msg := Message{
        ID:      id,
        From:    n.host.ID().String(),
        To:      peerIDStr,
        Content: content,
        Type:    msgType,
    }
    data, _ := json.Marshal(msg)

    peerID, _ := peer.Decode(peerIDStr)

    // Открываем stream к пиру (через relay если нужно)
    s, err := n.host.NewStream(ctx, peerID, "/messaging/1.0.0")

    s.Write(data)
    s.Close()
    return nil
}

Если пир онлайн — сообщение доставляется напрямую. Если оффлайн — шифруется и кладётся в хранилище серверной ноды до доставки(данную функцию можно отключить). При следующем подключении пир заберёт все накопленные сообщения.

Звонки

Для P2P-звонков 1 на 1 используется трехуровневая система транспорта, которая обеспечивает минимальную задержку, но гарантирует связь даже за жесткими NAT:

1. Прямой UDP-транспорт (Pion ICE): Основной и самый быстрый канал. При ответе на звонок ноды обмениваются ICE-кандидатами через обычные libp2p-сообщения (без громоздкого SDP). Устанавливается прямой UDP-канал, аудио-фреймы (Opus) шифруются кастомным симметричным ключом (на базе ключей libp2p) и летят напрямую.

2. libp2p DCUtR (Hole Punching): Если чистый UDP не пробивается, срабатывает механизм DCUtR (Direct Connection Upgrade through Relay). Пиры узнают свои внешние IP через Relay и пробивают прямое TCP/QUIC соединение на уровне libp2p.

3. libp2p stream через Relay (Фоллбек): Если оба клиента за симметричными NAT и прямое соединение невозможно, трафик бесшовно идет через серверную ноду по базовому libp2p-стриму (/call/1.0.0).

Отправка аудио

func (n *Node) SendAudio(data []byte) error {
    call := n.activeCall
    if call == nil || call.State != CallStateActive {
        return fmt.Errorf("no active call")
    }

    // Фрейм: [0xFE][Len 2 bytes][Opus data]
    packet := make([]byte, 1+2+len(data))
    packet[0] = 0xFE
    binary.BigEndian.PutUint16(packet[1:], uint16(len(data)))
    copy(packet[3:], data)

    call.Stream.Write(packet)
    return nil
}

Прием аудио

func (n *Node) audioReadLoop() {
    call := n.activeCall
    for {
        // Ждём sync byte
        syncBuf := make([]byte, 1)
        call.Stream.Read(syncBuf)

        switch syncBuf[0] {
        case 0xFE: // аудио
            lenBuf := make([]byte, 2)
            io.ReadFull(call.Stream, lenBuf)
            frameLen := binary.BigEndian.Uint16(lenBuf)

            opusData := make([]byte, frameLen)
            io.ReadFull(call.Stream, opusData)

            // Передаём Opus-фрейм обработчику
            n.audioHandler(opusData)

        }
    }
}

Если устройства оффлайн.

Поскольку нет классического центрального сервера, возникает резонный вопрос: как получить сообщение, если приложение выгружено из памяти или телефон заблокирован?

В этом случае на помощь приходят Push-уведомления (APNs для iOS, FCM для Android), но с важнейшей оговоркой ради сохранения E2EE и приватности: в самом пуше не передаётся ничего важного. В нём нет ни текста сообщения, ни ключей, ни даже реального отправителя. Это просто "слепой" триггер (silent/data push), который служит только для одной цели — разбудить устройство.

Механика работы выглядит так:

  1. На телефон прилетает пуш-сигнал.

  2. Операционная система на короткое время будит приложение в фоновом режиме.

  3. В фоне стартует наша Go-нода.

  4. Нода подключается к сети и скачивает все накопившиеся зашифрованные пакеты.

  5. Расшифровка происходит строго локально, после чего приложение само формирует и показывает пользователю полноценное локальное уведомление с текстом сообщения.

Таким образом, получаем удобство классических мессенджеров, не компрометируя безопасность передаваемых данных.

Шифрование: E2EE как в Signal и WhatsApp

Безопасность — это фундамент любого современного мессенджера. Не стал(да и не смог бы быстро) изобретать велосипед (свою криптографию) или ограничиваться простым статичным шифрованием. В проекте реализовано полноценное End-to-End шифрование (E2EE) с Perfect Forward Secrecy (PFS) и Post-Compromise Security (PCS).

Архитектура шифрования разделена на два современных стандарта: один для личных чатов, другой — для групповых.

1. Личные чаты (1 на 1): Double Ratchet

Для приватных переписок используется алгоритм Double Ratchet (тот самый, что лежит в основе протокола Signal). Использую реализацию status-im/doubleratchet.

Как это работает:

1. Инициализация (X3DH): При первом контакте пиры используют свои статические ключи libp2p (Ed25519 конвертируются в X25519) для выполнения Diffie-Hellman и получения общего Root Key.

2. Симметричный храповик (Symmetric Ratchet): Каждое отправленное сообщение прокручивает цепочку ключей (KDF) через хеш-функцию. Ключ от каждого сообщения уникален. Если хакер перехватит ключ от сообщения №5, он не сможет прочитать сообщения №1–4 (Forward Secrecy).

3. Асимметричный храповик (DH Ratchet): Периодически к сообщениям прикрепляются новые эфемерные публичные ключи (Diffie-Hellman). При получении такого ключа генерируется новый Root Key. Это значит, что если устройство было скомпрометировано (ключи утекли), но потом хакер потерял к нему доступ — после пары новых сообщений ключи обновятся, и хакер снова не сможет читать переписку (Post-Compromise Security).

Даже если сообщение доставляется в оффлайне (через Relay-сервер), оно зашифровано уникальным ключом сессии. Relay видит только нечитаемый бинарный мусор.

2. Групповые чаты: Messaging Layer Security (MLS)

Double Ratchet отлично работает для двух человек, но в группах он превращается в кошмар: чтобы отправить сообщение в группу из 50 человек, нужно зашифровать его 50 раз разными ключами (Sender Keys). Это убивает батарею и сеть.

Поэтому для групп внедрил MLS (Messaging Layer Security, RFC 9420) — новейший стандарт IETF для группового E2EE. Использую библиотеку mls-go.

В чем магия MLS:

Вместо того чтобы шифровать сообщение для каждого участника отдельно, MLS строит бинарное дерево ключей (Ratchet Tree).

* Группа имеет один общий симметричный ключ для шифрования сообщений.

* При добавлении или удалении участника дерево перестраивается (отправляется Commit и Welcome сообщения), и генерируется новая эпоха (Epoch) с новым общим ключом.

* Вычислительная сложность добавления/удаления участника и обновления ключей логарифмическая O(log N), а не линейная O(N).

Итог: Группы на сотни человек шифруются так же быстро и с такими же гарантиями безопасности (PFS и PCS), как и личные чаты. Relay-сервер просто рассылает (fan-out) один зашифрованный пакет всем участникам группы, не имея доступа к ключам дерева.

Что дальше

Если увижу заинтересованность сообщества, планирую развивать проект. В первую очередь:

  • Федерация серверных нод — любой сможет поднять свою, DHT для автоматического обнаружения. В таком сценарии можно полностью изолировать свои сообщения, как в Matrix.

  • Полный P2P режим. Как у Jami и ему подобных

  • Групповые звонки.

  • Open-source — клиентский пакет (Go + Dart)

Если хотите попробовать результата данного эксперимента - App Store. Чуть позже выложу в гугл маркет. Всем спасибо за внимание!