Yggdrasil как встраиваемая библиотека
- воскресенье, 10 мая 2026 г. в 00:00:14
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 main import ( "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 main import ( "context" "net/url" "sync/atomic" "github.com/asciimoth/ygg/ygglib/transport" ) type meteredTransport struct { // Вся настоящая работа будет делегироваться обычному TCP транспорту. base transport.Transport // Счетчики нужны только для демонстрации. dials atomic.Uint64 listens 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 := *u clone.Scheme = scheme return &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.
Обычный 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 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 <- err return } defer conn.Close() buf := make([]byte, 64) n, err := conn.Read(buf) if err != nil { serverErr <- err return } // Отвечаем тем же 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 схема почти такая же, только сервер использует 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 <- err return } _, 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.
Раз 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
До этого мы соединяли узлы вручную: один слушает, второй вызывает 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, так что выбранные пиры становятся постоянными и будут переподключаться после разрывов.
Кроме публичных пиров есть еще авто-обнаружение в локальной сети. Этим занимается пакет 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 демона.