golang

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

  • воскресенье, 10 мая 2026 г. в 00:00:14
https://habr.com/ru/articles/1033236/

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.

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 <- 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 поверх 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.

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 демона.