Yggdrasil как встраиваемая библиотека

от автора

Yggdrasil — это экспериментальная оверлейная IPv6 mesh-сеть, уже неоднократно рассматривавшаяся на хабре (1 2 3). Если кратко, Yggdrasil позволяет поднять “сеть поверх сети” где у каждого узла появляется стабильный IPv6 адрес выведенный из его публичного ключа, не зависящий от того, где он физически находится и какой у него внешний IP. Узлы могут подключаться к публичным пирам, друг к другу напрямую, через локальное обнаружение, а после установления связности обычные TCP/UDP приложения могут общаться так, будто перед ними просто еще одна IPv6 сеть.

В классическом варианте Yggdrasil это демон создающий виртуальный сетевой интерфейс в системе. Но было бы удобно встраивать его в приложения например в matrix клиенты или веб пиложения. Оригинальный yggdrasil-go не очень удобен в такой роли из за подтекающих абстракций и сильного зацепления компонент. Ради более удобного библиотечного использования и поддержки фич которые систематически отклонялись потому что “это не цель yggdrasil”, я организовал свой совместимый с оригиналом форк. Далее рассматривается встраивание его библиотечной части в ваше go приложение.

Что мы будем собирать

В этой статье мы соберем минималистичный сетап:

┌─────────────┐               ┌─────────────┐│ node A      │               │ node B      ││             │   Yggdrasil   │             ││ Core + VTun │ <───────────> │ Core + VTun │└──────┬──────┘               └──────┬──────┘       │                             │       │ обычный TCP/UDP/HTTP        │       │ через userspace IPv6 стек   │       ▼                             ▼  net.Listener                   http.Client

В нем можно выделить две разные сетевые прослойки.

Первая — “carrier network”. Это сеть, поверх которой сами Yggdrasil узлы устанавливают соединения друг с другом. В ygglib используется интерфейс Network за которым в конкретном случае может быть как обычная “нативная” сеть вашей ОС, так и socks прокся или вообще отладочная in-memory эмуляция сети (или даже другая yggdrasil сеть).

Вторая — сама виртуальная IPv6 сеть yggdrasil, которая появляется после того, как узлы поднялись, нашли друг друга и обменялись служебной информацией.

Минимальный узел

Начнем с самого минимального примера: создадим один yggdrasil Core узел, зарегистрируем TCP и TLS транспорты, выведем адрес узла и завершим работу.

package mainimport ("fmt""github.com/asciimoth/gonnect/native""github.com/asciimoth/ygg/ygglib/config""github.com/asciimoth/ygg/ygglib/core"ygglogger "github.com/asciimoth/ygg/ygglib/logger""github.com/asciimoth/ygg/ygglib/transport")func main() {// Конфиг содержит идентичность узла.// Для примера генерируем новый self-signed сертификат на каждый запуск.cfg := config.GenerateConfig()if err := cfg.GenerateSelfSignedCertificate(); err != nil {panic(err)}// native.Network - обычная сеть операционной системы.// Через нее будут открываться carrier-соединения к другим пирам.network := &native.Network{}if err := network.Up(); err != nil {panic(err)}defer network.Down()// Transport manager регистрирует реализации транспортов (tcp, tls, ws, etc).// И ассоциации между адресами и тем через какую carrier-сеть к ним подключаться.manager := transport.NewManager(network)// Обычный tcp:// транспорт.if err := manager.RegisterTransport(transport.NewTCPTransport()); err != nil {panic(err)}// tls:// транспорт использует сертификат нашего узла.tlsConfig, err := core.GenerateTLSConfig(cfg.Certificate)if err != nil {panic(err)}if err := manager.RegisterTransport(transport.NewTLSTransport(tlsConfig)); err != nil {panic(err)}// Создаем сам Core.// Логгер для простоты выключен.node, err := core.New(cfg.Certificate,ygglogger.Discard(),core.TransportManager{Manager: manager},)if err != nil {panic(err)}defer node.Stop()// Это IPv6 адрес узла внутри Yggdrasil сети.fmt.Println(node.Address())}

Транспорты

Транспорт в ygglib регистрируют за собой одну или несколько url схем и предоставляет методы для открытия исходящих и слушания входящих соединений. Транспорты регистрируются в конкретном экземпляры ноды в рантайме. В библиотечной части встроенны транспорты для tcp://... и tls://..., но демон также реализует quic, ws/wss, unix.

Транспорты можно писать и свои. Для демонстрации обернем существующий транспорт и добавим немного поведения вокруг него. Например, посчитаем количество dial/listen операций и назовем нашу схему metered+tcp.

package mainimport ("context""net/url""sync/atomic""github.com/asciimoth/ygg/ygglib/transport")type meteredTransport struct {// Вся настоящая работа будет делегироваться обычному TCP транспорту.base transport.Transport// Счетчики нужны только для демонстрации.dials   atomic.Uint64listens atomic.Uint64}func (t *meteredTransport) Schemes() []string {// Теперь manager сможет обслуживать URL вида metered+tcp://127.0.0.1:1234.return []string{"metered+tcp"}}func (t *meteredTransport) Dial(ctx context.Context,network transport.Network,u *url.URL,opts transport.Options,) (transport.Conn, error) {t.dials.Add(1)// Нашу metered+tcp схему базовый TCP транспорт не понимает// поэтому перед делегированием меняем ее на tcp.return t.base.Dial(ctx, network, rewriteScheme(u, "tcp"), opts)}func (t *meteredTransport) Listen(ctx context.Context,network transport.Network,u *url.URL,opts transport.Options,) (transport.Listener, error) {t.listens.Add(1)return t.base.Listen(ctx, network, rewriteScheme(u, "tcp"), opts)}func (t *meteredTransport) Dials() uint64 {return t.dials.Load()}func (t *meteredTransport) Listens() uint64 {return t.listens.Load()}func rewriteScheme(u *url.URL, scheme string) *url.URL {clone := *uclone.Scheme = schemereturn &clone}

Регистрируется такой транспорт точно так же, как встроенные:

manager := transport.NewManager(nil)metered := &meteredTransport{base: transport.NewTCPTransport(),}if err := manager.RegisterTransport(metered); err != nil {return err}

Маппинг сетей

transport.Manager может использует одну сеть по умолчанию:

manager := transport.NewManager(defaultNetwork)

Но можно также явно описать к, каким host’ам через какую сеть подключаться.

// Все соединения к 127.0.0.1 будут идти через нашу loopback/native сеть.if err := manager.MapNetwork("127.0.0.1", localNetwork); err != nil {return err}

Это позволяет развести соединения с внешним миром по разным carrier-сетям:

manager.SetDefaultNetwork(nativeNetwork)// Адреса Tor можно отправлять через SOCKS сеть._ = manager.MapNetwork("*.onion", torNetwork)// I2P аналогично._ = manager.MapNetwork("*.i2p", i2pNetwork)// А какие-то зоны можно явно запретить._ = manager.MapNetwork("*.loki", nil)

nil в маппинге означает, что совпавшие адреса должны быть заблокрованны.

Еще одна важная деталь: изменения маппингов живые. Если поменять сеть для какого-то хоста, manager закроет затронутые слушатели и соединения, чтобы новые пошли уже через новую сеть.

Два узла в одном процессе

Один узел сам по себе не очень интересен. Создадим два Core’а и соединим их друг с другом.

Для начала удобно вынести создание узла в функцию:

func newCore(manager *transport.Manager) (*core.Core, error) {// В реальном приложении ключ лучше сохранять между запусками.// Здесь генерируем новый, чтобы пример был самодостаточным.cfg := config.GenerateConfig()if err := cfg.GenerateSelfSignedCertificate(); err != nil {return nil, err}return core.New(cfg.Certificate,ygglogger.Discard(),core.TransportManager{Manager: manager},)}

Теперь поднимем server и client:

network := loopback.NewLoopbackNetwok()metered := &meteredTransport{base: transport.NewTCPTransport(),}manager := transport.NewManager(nil)if err := manager.MapNetwork("127.0.0.1", network); err != nil {return err}if err := manager.RegisterTransport(metered); err != nil {return err}serverCore, err := newCore(manager)if err != nil {return err}defer serverCore.Stop()clientCore, err := newCore(manager)if err != nil {return err}defer clientCore.Stop()

В этом примере оба узла используют один и тот же manager и одну loopback сеть. В реальном приложении каждый узел обычно будет жить в своем процессе, со своим manager’ом и своей сетью.

Чтобы один Core мог принять соединение от другого, надо открыть listener:

listenURL, err := url.Parse("metered+tcp://127.0.0.1:0")if err != nil {return err}listener, err := serverCore.Listen(listenURL, "")if err != nil {return err}

Порт 0 здесь означает выбор произвольного не занятого порта.

Теперь клиент может подключиться серверу:

peerURL, err := url.Parse("metered+tcp://" + listener.Addr().String())if err != nil {return err}if err := clientCore.CallPeer(peerURL, ""); err != nil {return err}

CallPeer открывает единичное подключение к пиру. Если нужна постоянная связь с переподключением после обрыва, вместо него стоит использовать AddPeer.

После этого два Core’а уже связаны. Но писать обычный http.Client поверх core.Core напрямую не выйдет. Core маршрутизирует Yggdrasil пакеты, а не предоставляет привычный net.Listener/net.Conn интерфейс для пользовательских TCP соединений.

Для этого нужен tun.

VTun

Обычный Yggdrasil демон поднимает системный TUN интерфейс. Но для встраиваемой библиотеки мы хотим держать все внутри процесса.

В ygg это делается через встраиваемый TCP/IP стек в юзерспейсе. Core дает поток IPv6 пакетов (L3), а VTun превращает его в L4 интерфейс, с которым можно работать почти как с обычной сетью из Go.

Создадим VTun для одного Core:

import ("fmt""net/netip""github.com/asciimoth/gonnect-netstack/helpers""github.com/asciimoth/gonnect-netstack/vtun""github.com/asciimoth/ygg/ygglib/core""github.com/asciimoth/ygg/ygglib/ipv6rwc"ygglogger "github.com/asciimoth/ygg/ygglib/logger"yggtun "github.com/asciimoth/ygg/ygglib/tun")func newVTun(name string, coreNode *core.Core) (*vtun.VTun, *yggtun.TunAdapter, error) {// ipv6rwc адаптирует core.Core к io.ReadWriteCloser-подобному интерфейсу// для чтения и записи IPv6 пакетов.rwc := ipv6rwc.NewReadWriteCloser(coreNode)// TunAdapter соединяет Yggdrasil Core и конкретную TUN/VTun реализацию.adapter, err := yggtun.New(rwc,ygglogger.Discard(),yggtun.InterfaceMTU(1500),)if err != nil {_ = rwc.Close()return nil, nil, err}// Адрес Core - это IPv6 адрес узла внутри Yggdrasil сети.addr, ok := netip.AddrFromSlice(coreNode.Address())if !ok {_ = adapter.Stop()_ = rwc.Close()return nil, nil, fmt.Errorf("invalid core address")}// VTun живет в памяти процесса и предоставляет Dial/Listen/ListenPacket.vt, err := (&vtun.Opts{Name:           name,LocalAddrs:     []netip.Addr{addr},NoLoopbackAddr: true,NetStackOpts: &helpers.Opts{MTU: 1500,},}).Build()if err != nil {_ = adapter.Stop()_ = rwc.Close()return nil, nil, err}// Подключаем VTun к потоку пакетов Core.if err := adapter.Attach(vt, yggtun.AttachmentType("vtun")); err != nil {_ = vt.Close()_ = adapter.Stop()_ = rwc.Close()return nil, nil, err}return vt, adapter, nil}

Теперь для каждого Core можно сделать свой VTun:

serverVT, serverAdapter, err := newVTun("server", serverCore)if err != nil {return err}defer serverAdapter.Stop()defer serverVT.Close()clientVT, clientAdapter, err := newVTun("client", clientCore)if err != nil {return err}defer clientAdapter.Stop()defer clientVT.Close()

После этого у нас уже есть две in-process IPv6 сети, соединенные через Yggdrasil Core. И с ними можно работать обычными сетевыми примитивами.

TCP поверх VTun

Начнем с простого TCP echo-подобного обмена. Сервер слушает на своем Yggdrasil IPv6 адресе, клиент подключается через свой VTun.

func tcpPing(clientVT, serverVT *vtun.VTun, serverCore *core.Core) (string, error) {// Слушаем TCP внутри Yggdrasil сети.// Адрес берем у serverCore, порт просим выбрать автоматически.listener, err := serverVT.Listen(context.Background(),"tcp6",net.JoinHostPort(serverCore.Address().String(), "0"),)if err != nil {return "", err}defer listener.Close()serverErr := make(chan error, 1)go func() {conn, err := listener.Accept()if err != nil {serverErr <- errreturn}defer conn.Close()buf := make([]byte, 64)n, err := conn.Read(buf)if err != nil {serverErr <- errreturn}// Отвечаем тем же payload'ом, но с префиксом._, err = conn.Write([]byte("tcp:" + string(buf[:n])))serverErr <- err}()// Клиент подключается к адресу listener'а через свой VTun.conn, err := clientVT.Dial(context.Background(), "tcp6", listener.Addr().String())if err != nil {return "", err}defer conn.Close()_ = conn.SetDeadline(time.Now().Add(10 * time.Second))if _, err := conn.Write([]byte("ping")); err != nil {return "", err}buf := make([]byte, 64)n, err := conn.Read(buf)if err != nil {return "", err}if err := <-serverErr; err != nil {return "", err}return string(buf[:n]), nil}

На выходе получим строку:

tcp:ping

Снаружи это выглядит почти как обычный TCP код. Главное отличие в том, что Dial и Listen берутся не из модуля стандартной библиотекиnet, а из VTun объекта.

UDP поверх VTun

С UDP схема почти такая же, только сервер использует ListenPacket.

func udpPing(clientVT, serverVT *vtun.VTun, serverCore *core.Core) (string, error) {packetConn, err := serverVT.ListenPacket(context.Background(),"udp6",net.JoinHostPort(serverCore.Address().String(), "0"),)if err != nil {return "", err}defer packetConn.Close()serverErr := make(chan error, 1)go func() {buf := make([]byte, 64)// Для UDP нам нужен адрес отправителя,// чтобы послать ответ обратно.n, addr, err := packetConn.ReadFrom(buf)if err != nil {serverErr <- errreturn}_, err = packetConn.WriteTo([]byte("udp:"+string(buf[:n])), addr)serverErr <- err}()conn, err := clientVT.Dial(context.Background(),"udp6",packetConn.LocalAddr().String(),)if err != nil {return "", err}defer conn.Close()_ = conn.SetDeadline(time.Now().Add(10 * time.Second))if _, err := conn.Write([]byte("ping")); err != nil {return "", err}buf := make([]byte, 64)n, err := conn.Read(buf)if err != nil {return "", err}if err := <-serverErr; err != nil {return "", err}return string(buf[:n]), nil}

Результат:

udp:ping

То есть для обычного прикладного кода Yggdrasil особо не вносит изменений. Мы просто используем другой Dial/ListenPacket, а дальше работаем со стандартными net.Conn и net.PacketConn.

HTTP поверх VTun

Раз TCP работает, HTTP тоже не требует ничего особенного. Серверу нужен listener из serverVT, клиенту — http.Transport, у которого DialContext указывает на clientVT.Dial.

func httpPing(clientVT, serverVT *vtun.VTun, serverCore *core.Core) (string, error) {listener, err := serverVT.Listen(context.Background(),"tcp6",net.JoinHostPort(serverCore.Address().String(), "0"),)if err != nil {return "", err}server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {_, _ = io.WriteString(w, "http:pong")}),ReadHeaderTimeout: 10 * time.Second,}go func() {// http.ErrServerClosed при Shutdown - нормальная ситуация,// поэтому в минимальном примере его можно отдельно не логировать._ = server.Serve(listener)}()defer func() {ctx, cancel := context.WithTimeout(context.Background(), time.Second)defer cancel()_ = server.Shutdown(ctx)}()_, port, err := net.SplitHostPort(listener.Addr().String())if err != nil {return "", err}// IPv6 адрес в URL обязательно берется в квадратные скобки.target := fmt.Sprintf("http://[%s]:%s", serverCore.Address().String(), port)client := http.Client{Transport: &http.Transport{// HTTP клиент открывает TCP соединения// не через net.Dialer, а через наш VTun.DialContext: clientVT.Dial,},Timeout: 10 * time.Second,}resp, err := client.Get(target)if err != nil {return "", err}defer resp.Body.Close()body, err := io.ReadAll(resp.Body)if err != nil {return "", err}return string(body), nil}

Результат:

http:pong

Autopeering

До этого мы соединяли узлы вручную: один слушает, второй вызывает CallPeer. Для тестов этого достаточно, но обычному приложению желательно автоматическое подключаться к глобальнйо сети.

Для этого есть autopeer.Manager. Он подтягивает списки публичных пиров, фильтрует результаты и добавляет подходящие адреса в Core.

func configurePublicAutopeering(coreNode *core.Core, network transport.Network) *autopeer.Manager {// Fetcher умеет получать списки публичных пиров.// BUILTIN - встроенный список, без отдельного.fetcher := autopeer.NewFetcher(ygglogger.Discard(), time.Hour)fetcher.SetDefaultNetwork(network)fetcher.SetSources([]string{autopeer.BuiltinSource})manager := autopeer.NewManager(fetcher)manager.SetPeerManager(coreNode)manager.SetConfig(autopeer.ManagerConfig{CheckInterval: time.Minute,// Если подключенных пиров меньше двух,// manager будет пытаться добавить новых.MinimumConnected: 2,// Для примера ограничимся несколькими странами.Countries: []string{"germany","france","netherlands",},// И только такими транспортными схемами.TransportSchemes: []string{"tcp", "tls"},})return manager}

Запускается manager явно:

autopeering := configurePublicAutopeering(coreNode, nativeNetwork)autopeering.Start()defer autopeering.Close()

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

Внутри используется core.AddPeer, а не CallPeer, так что выбранные пиры становятся постоянными и будут переподключаться после разрывов.

Link-local autopeering

Кроме публичных пиров есть еще авто-обнаружение в локальной сети. Этим занимается пакет ygglib/multicast. Он слушает локальные широковещательные рассылки и вызывает core.CallPeer для найденных узлов.

Но здесь есть ограничение: link-local autopeering требует настоящую сеть. In-memory loopback, socks клиенты и прочие реализации не имеют требуемых низкоуровневых OS-интерфейсов.

Минимальный запуск выглядит так:

func startLinkLocalAutopeering(coreNode *core.Core,network transport.Network,ifacePattern string,) (*multicast.Multicast, error) {if network == nil || !network.IsNative() {return nil, fmt.Errorf("link-local autopeering requires a native carrier network")}return multicast.New(coreNode,ygglogger.Discard(),multicast.ProtocolVersion{Major: core.ProtocolVersionMajor,Minor: core.ProtocolVersionMinor,},multicast.MulticastInterface{// Например: ^(eth|en|wlan|wl).*Regex: regexp.MustCompile(ifacePattern),Beacon: true,Listen: true,Port:   0,},)}

Использование:

mc, err := startLinkLocalAutopeering(coreNode,nativeNetwork,"^(eth|en|wlan|wl).*",)if err != nil {return err}defer mc.Stop()

Заключение

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

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