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


























