VXLAN : poser l'adresse VTEP locale — EVPN inopérant entre deux hyperviseurs two #51

Open
opened 2026-10-04 17:14:24 +00:00 by nicolas.boufideline · 1 comment

Constat

Entre deux hyperviseurs gérés par two, le trafic d'un subnet vxlan ne passe pas : les VM d'un même
subnet ne se joignent pas d'un hyperviseur à l'autre.

two crée ses VXLAN (internal/netif.CreateVxlan, appelé par subnet.setupVxlanHost) avec le
périphérique parent (dev <local_iface>, VtepDevIndex) mais sans adresse source (local) :

vxlan id 1010 dev br-000000 srcport 0 0 dstport 4789 nolearning ttl auto ...

FRR voit bien la VNI (advertise-all-vni), mais sans adresse VTEP locale :

VNI: 100
 Local VTEP IP: (null)          # FRR 10.7.1 ; FRR 10.5.0 affiche 0.0.0.0
 No remote VTEPs known for this VNI

bgpd ne compte alors aucune L2 VNI (Number of L2 VNIs: 0) et n'annonce aucune route EVPN pour
cet hyperviseur. Avec nolearning, la FDB du VXLAN n'est remplie que par EVPN : sans annonce, aucun
VTEP distant n'est connu, l'ARP ne traverse pas.

Mesuré

Lab #50 (Scaleway, 2026-10-04) — deux hyperviseurs Debian 12, release 0.2.0rc002, FRR 10.7.1,
même VPC et même subnet vxlan sur les deux, une VM de chaque côté :

VXLAN tel que créé par two ip link set vxlan-<id> type vxlan local <IP> des deux côtés patch ci-dessous
Local VTEP IP (null) renseignée renseignée, posée par two
routes EVPN type 3 aucune échangées via le route reflector échangées
VTEP distant / entrée d'inondation aucun appris, 00:00:00:00:00:00 dst <autre VTEP> appris
ping VM ↔ VM entre hyperviseurs 0 % 100 % 100 %, MTU 1500 (ping -M do -s 1472)

lab1 — le ping fonctionne alors que son VXLAN n'a pas de local (Local VTEP IP: 0.0.0.0) :
l'autre extrémité est le routeur de test (192.168.14.1), qui a une adresse VTEP et l'annonce.
lab1 l'apprend par EVPN et envoie vers lui, le noyau choisissant l'adresse source par la table de
routage. Un seul VTEP correct suffit donc dans ce cas ; deux hyperviseurs gérés par two ne se
voient pas.
(Au passage, la session EVPN de lab1 n'était pas établie depuis environ six semaines :
sa configuration FRR était en AS 66000 au lieu de 64600 — sans lien avec ce ticket, corrigé.)

Décision

L'adresse VTEP locale est l'adresse IPv4 primaire, de portée globale, du local_iface du
subnet
— le périphérique déjà passé au VXLAN. Pas de loopback, pas d'option de configuration :
l'hyperviseur reste le plus simple possible, une adresse sur le réseau des VM. Un bridge ou une
interface supplémentaire (pour un VPC d'administration, par exemple) ne change rien : on prend
l'adresse du local_iface du subnet concerné.

  • Adresse primaire : le noyau marque les adresses secondaires (IFA_F_SECONDARY) ; elles sont
    écartées, comme les adresses de portée lien.
  • Aucune adresse utilisable sur le local_iface : la création du VXLAN échoue avec un message
    explicite, plutôt que de créer un VXLAN muet.

Patch proposé

internal/netif/vxlan.go :

@@ -1,14 +1,31 @@
 package netif
 
 import (
+	"fmt"
+	"net"
+	"syscall"
+
 	"github.com/vishvananda/netlink"
 )
 
+const (
+	scopeUniverse    = 0
+	addressSecondary = 0x01
+)
+
 func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error {
 	link, err := netlink.LinkByName(localIface)
 	if err != nil {
 		return err
 	}
+	addrs, err := netlink.AddrList(link, syscall.AF_INET)
+	if err != nil {
+		return err
+	}
+	local, err := vtepAddress(localIface, addrs)
+	if err != nil {
+		return err
+	}
 	vxlan := &netlink.Vxlan{
 		LinkAttrs: netlink.LinkAttrs{
 			Name: name,
@@ -17,7 +34,18 @@ func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error {
 		VxlanId:      vxlanID,
 		Port:         4789,
 		VtepDevIndex: link.Attrs().Index,
+		SrcAddr:      local,
 		Learning:     false,
 	}
 	return netlink.LinkAdd(vxlan)
 }
+
+func vtepAddress(iface string, addrs []netlink.Addr) (net.IP, error) {
+	for _, a := range addrs {
+		if a.IPNet == nil || a.IP.To4() == nil || a.Scope != scopeUniverse || a.Flags&addressSecondary != 0 {
+			continue
+		}
+		return a.IP.To4(), nil
+	}
+	return nil, fmt.Errorf("no primary global IPv4 address on %s for the VXLAN local endpoint", iface)
+}

AF_INET vient de syscall et les deux valeurs du noyau (RT_SCOPE_UNIVERSE = 0,
IFA_F_SECONDARY = 0x01) sont des constantes du paquet : unix.RT_SCOPE_UNIVERSE,
unix.IFA_F_SECONDARY et netlink.FAMILY_V4 n'existent que sous Linux, et le paquet doit compiler
sur macOS.

Le choix de l'adresse est une fonction pure (vtepAddress), testée sur macOS
(internal/netif/vxlan_test.go, 4 tests) : adresse primaire globale retenue ; secondaires et
portée lien écartées ; interface sans adresse utilisable refusée (aucune adresse, seulement des
secondaires, seulement de portée lien, seulement de l'IPv6) ; adresse rendue sur 4 octets.

Validé sur le lab #50 : agent compilé avec ce patch sur les deux hyperviseurs, nouvelle VPC et
nouveau subnet vxlan sans aucune retouche manuelle — local posé par two, VTEP distant appris par
EVPN, ping et MTU 1500 VM ↔ VM entre hyperviseurs.

Reste à trancher

  • Subnets existants : leurs VXLAN n'ont pas de local, et un redémarrage de l'agent ne les
    recrée pas. Soit les recréer, soit une migration au démarrage de l'agent — ip link set vxlan-<id> type vxlan local <IP> s'applique à chaud, sans recréer l'interface (vérifié dans le
    lab et sur lab1). À décider selon les subnets vxlan en service.
  • Changement d'adresse de l'hyperviseur : l'adresse est lue à la création du VXLAN ; si celle
    du local_iface change ensuite, le VXLAN garde l'ancienne.

Analyse de risque

  • Comportement modifié : un subnet vxlan dont le local_iface n'a pas d'adresse IPv4
    primaire était créé (et ne fonctionnait pas entre hyperviseurs) ; il est désormais refusé à la
    création (Execute en erreur, état error). Choix délibéré : un échec visible plutôt qu'un VXLAN
    muet.
  • Sécurité : aucune surface nouvelle ; le VXLAN émettait déjà depuis cette adresse, choisie par
    la table de routage — elle devient explicite.
  • MTU : sans lien avec ce patch, mais à surveiller : sur un underlay à 1500 (lab1), le noyau
    rabaisse le VXLAN à 1450 alors que les VM sont à 1500 — les trames pleine taille sont perdues.
    L'underlay doit être à 1550 au moins.
  • Code assisté par IA : revue technique, tests et traçabilité avant intégration, conformément aux
    règles internes.

Trouvé pendant #50 (lab multi-nœud), qui en dépend pour ses scénarios EVPN (E5).

## Constat Entre deux hyperviseurs gérés par two, le trafic d'un subnet `vxlan` ne passe pas : les VM d'un même subnet ne se joignent pas d'un hyperviseur à l'autre. two crée ses VXLAN (`internal/netif.CreateVxlan`, appelé par `subnet.setupVxlanHost`) avec le périphérique parent (`dev <local_iface>`, `VtepDevIndex`) mais **sans adresse source** (`local`) : ``` vxlan id 1010 dev br-000000 srcport 0 0 dstport 4789 nolearning ttl auto ... ``` FRR voit bien la VNI (`advertise-all-vni`), mais sans adresse VTEP locale : ``` VNI: 100 Local VTEP IP: (null) # FRR 10.7.1 ; FRR 10.5.0 affiche 0.0.0.0 No remote VTEPs known for this VNI ``` bgpd ne compte alors aucune L2 VNI (`Number of L2 VNIs: 0`) et n'annonce **aucune route EVPN** pour cet hyperviseur. Avec `nolearning`, la FDB du VXLAN n'est remplie que par EVPN : sans annonce, aucun VTEP distant n'est connu, l'ARP ne traverse pas. ## Mesuré **Lab #50 (Scaleway, 2026-10-04)** — deux hyperviseurs Debian 12, release `0.2.0rc002`, FRR 10.7.1, même VPC et même subnet `vxlan` sur les deux, une VM de chaque côté : | | VXLAN tel que créé par two | `ip link set vxlan-<id> type vxlan local <IP>` des deux côtés | patch ci-dessous | |---|---|---|---| | `Local VTEP IP` | `(null)` | renseignée | renseignée, posée par two | | routes EVPN type 3 | aucune | échangées via le route reflector | échangées | | VTEP distant / entrée d'inondation | aucun | appris, `00:00:00:00:00:00 dst <autre VTEP>` | appris | | ping VM ↔ VM entre hyperviseurs | **0 %** | **100 %** | **100 %**, MTU 1500 (`ping -M do -s 1472`) | **lab1** — le ping fonctionne alors que son VXLAN n'a pas de `local` (`Local VTEP IP: 0.0.0.0`) : l'autre extrémité est le routeur de test (`192.168.14.1`), qui **a** une adresse VTEP et l'annonce. lab1 l'apprend par EVPN et envoie vers lui, le noyau choisissant l'adresse source par la table de routage. Un seul VTEP correct suffit donc dans ce cas ; **deux hyperviseurs gérés par two ne se voient pas.** (Au passage, la session EVPN de lab1 n'était pas établie depuis environ six semaines : sa configuration FRR était en AS 66000 au lieu de 64600 — sans lien avec ce ticket, corrigé.) ## Décision L'adresse VTEP locale est **l'adresse IPv4 primaire, de portée globale, du `local_iface` du subnet** — le périphérique déjà passé au VXLAN. Pas de loopback, pas d'option de configuration : l'hyperviseur reste le plus simple possible, une adresse sur le réseau des VM. Un bridge ou une interface supplémentaire (pour un VPC d'administration, par exemple) ne change rien : on prend l'adresse du `local_iface` du subnet concerné. - **Adresse primaire** : le noyau marque les adresses secondaires (`IFA_F_SECONDARY`) ; elles sont écartées, comme les adresses de portée lien. - **Aucune adresse utilisable** sur le `local_iface` : la création du VXLAN échoue avec un message explicite, plutôt que de créer un VXLAN muet. ## Patch proposé `internal/netif/vxlan.go` : ```diff @@ -1,14 +1,31 @@ package netif import ( + "fmt" + "net" + "syscall" + "github.com/vishvananda/netlink" ) +const ( + scopeUniverse = 0 + addressSecondary = 0x01 +) + func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { link, err := netlink.LinkByName(localIface) if err != nil { return err } + addrs, err := netlink.AddrList(link, syscall.AF_INET) + if err != nil { + return err + } + local, err := vtepAddress(localIface, addrs) + if err != nil { + return err + } vxlan := &netlink.Vxlan{ LinkAttrs: netlink.LinkAttrs{ Name: name, @@ -17,7 +34,18 @@ func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { VxlanId: vxlanID, Port: 4789, VtepDevIndex: link.Attrs().Index, + SrcAddr: local, Learning: false, } return netlink.LinkAdd(vxlan) } + +func vtepAddress(iface string, addrs []netlink.Addr) (net.IP, error) { + for _, a := range addrs { + if a.IPNet == nil || a.IP.To4() == nil || a.Scope != scopeUniverse || a.Flags&addressSecondary != 0 { + continue + } + return a.IP.To4(), nil + } + return nil, fmt.Errorf("no primary global IPv4 address on %s for the VXLAN local endpoint", iface) +} ``` `AF_INET` vient de `syscall` et les deux valeurs du noyau (`RT_SCOPE_UNIVERSE = 0`, `IFA_F_SECONDARY = 0x01`) sont des constantes du paquet : `unix.RT_SCOPE_UNIVERSE`, `unix.IFA_F_SECONDARY` et `netlink.FAMILY_V4` n'existent que sous Linux, et le paquet doit compiler sur macOS. Le choix de l'adresse est une fonction pure (`vtepAddress`), testée sur macOS (`internal/netif/vxlan_test.go`, 4 tests) : adresse primaire globale retenue ; secondaires et portée lien écartées ; interface sans adresse utilisable refusée (aucune adresse, seulement des secondaires, seulement de portée lien, seulement de l'IPv6) ; adresse rendue sur 4 octets. Validé sur le lab #50 : agent compilé avec ce patch sur les deux hyperviseurs, nouvelle VPC et nouveau subnet `vxlan` sans aucune retouche manuelle — `local` posé par two, VTEP distant appris par EVPN, ping et MTU 1500 VM ↔ VM entre hyperviseurs. ## Reste à trancher - **Subnets existants** : leurs VXLAN n'ont pas de `local`, et un redémarrage de l'agent ne les recrée pas. Soit les recréer, soit une migration au démarrage de l'agent — `ip link set vxlan-<id> type vxlan local <IP>` s'applique à chaud, sans recréer l'interface (vérifié dans le lab et sur lab1). À décider selon les subnets `vxlan` en service. - **Changement d'adresse de l'hyperviseur** : l'adresse est lue à la création du VXLAN ; si celle du `local_iface` change ensuite, le VXLAN garde l'ancienne. ## Analyse de risque - **Comportement modifié** : un subnet `vxlan` dont le `local_iface` n'a pas d'adresse IPv4 primaire était créé (et ne fonctionnait pas entre hyperviseurs) ; il est désormais **refusé** à la création (`Execute` en erreur, état `error`). Choix délibéré : un échec visible plutôt qu'un VXLAN muet. - **Sécurité** : aucune surface nouvelle ; le VXLAN émettait déjà depuis cette adresse, choisie par la table de routage — elle devient explicite. - **MTU** : sans lien avec ce patch, mais à surveiller : sur un underlay à 1500 (lab1), le noyau rabaisse le VXLAN à 1450 alors que les VM sont à 1500 — les trames pleine taille sont perdues. L'underlay doit être à 1550 au moins. - Code assisté par IA : revue technique, tests et traçabilité avant intégration, conformément aux règles internes. Trouvé pendant #50 (lab multi-nœud), qui en dépend pour ses scénarios EVPN (E5).
Author
Owner

Tests du patch — nouveau fichier internal/netif/vxlan_test.go, exécutables sur macOS (go test ./internal/netif) :

package netif

import (
	"net"
	"strings"
	"testing"

	"github.com/vishvananda/netlink"
)

func addr(cidr string, scope, flags int) netlink.Addr {
	ip, network, err := net.ParseCIDR(cidr)
	if err != nil {
		panic(err)
	}
	network.IP = ip
	return netlink.Addr{IPNet: network, Scope: scope, Flags: flags}
}

func TestVtepAddress_PicksThePrimaryGlobalIPv4(t *testing.T) {
	addrs := []netlink.Addr{
		addr("127.0.0.1/8", 254, 0),
		addr("169.254.0.3/28", 0, 0x01),
		addr("192.168.14.11/24", 0, 0x80),
		addr("192.168.14.99/24", 0, 0x01),
	}
	got, err := vtepAddress("br-000000", addrs)
	if err != nil || got.String() != "192.168.14.11" {
		t.Errorf("vtepAddress = %v, %v, want 192.168.14.11", got, err)
	}
}

func TestVtepAddress_SkipsLinkScopeAndSecondaryAddresses(t *testing.T) {
	addrs := []netlink.Addr{
		addr("169.254.0.3/28", 253, 0),
		addr("192.168.14.99/24", 0, 0x01),
		addr("192.168.14.12/24", 0, 0),
	}
	got, err := vtepAddress("br-000000", addrs)
	if err != nil || got.String() != "192.168.14.12" {
		t.Errorf("vtepAddress = %v, %v, want 192.168.14.12", got, err)
	}
}

func TestVtepAddress_RefusesAnInterfaceWithoutUsableAddress(t *testing.T) {
	for name, addrs := range map[string][]netlink.Addr{
		"no address":     nil,
		"only secondary": {addr("192.168.14.99/24", 0, 0x01)},
		"only link":      {addr("169.254.0.3/28", 253, 0)},
		"only IPv6":      {addr("fd00::11/64", 0, 0)},
	} {
		_, err := vtepAddress("br-000000", addrs)
		if err == nil || !strings.Contains(err.Error(), "no primary global IPv4 address on br-000000") {
			t.Errorf("%s: error = %v", name, err)
		}
	}
}

func TestVtepAddress_ReturnsAFourByteAddress(t *testing.T) {
	got, err := vtepAddress("br-000000", []netlink.Addr{addr("192.168.14.11/24", 0, 0)})
	if err != nil || len(got) != 4 {
		t.Errorf("vtepAddress = %v (%d bytes), %v", got, len(got), err)
	}
}
Tests du patch — nouveau fichier `internal/netif/vxlan_test.go`, exécutables sur macOS (`go test ./internal/netif`) : ```go package netif import ( "net" "strings" "testing" "github.com/vishvananda/netlink" ) func addr(cidr string, scope, flags int) netlink.Addr { ip, network, err := net.ParseCIDR(cidr) if err != nil { panic(err) } network.IP = ip return netlink.Addr{IPNet: network, Scope: scope, Flags: flags} } func TestVtepAddress_PicksThePrimaryGlobalIPv4(t *testing.T) { addrs := []netlink.Addr{ addr("127.0.0.1/8", 254, 0), addr("169.254.0.3/28", 0, 0x01), addr("192.168.14.11/24", 0, 0x80), addr("192.168.14.99/24", 0, 0x01), } got, err := vtepAddress("br-000000", addrs) if err != nil || got.String() != "192.168.14.11" { t.Errorf("vtepAddress = %v, %v, want 192.168.14.11", got, err) } } func TestVtepAddress_SkipsLinkScopeAndSecondaryAddresses(t *testing.T) { addrs := []netlink.Addr{ addr("169.254.0.3/28", 253, 0), addr("192.168.14.99/24", 0, 0x01), addr("192.168.14.12/24", 0, 0), } got, err := vtepAddress("br-000000", addrs) if err != nil || got.String() != "192.168.14.12" { t.Errorf("vtepAddress = %v, %v, want 192.168.14.12", got, err) } } func TestVtepAddress_RefusesAnInterfaceWithoutUsableAddress(t *testing.T) { for name, addrs := range map[string][]netlink.Addr{ "no address": nil, "only secondary": {addr("192.168.14.99/24", 0, 0x01)}, "only link": {addr("169.254.0.3/28", 253, 0)}, "only IPv6": {addr("fd00::11/64", 0, 0)}, } { _, err := vtepAddress("br-000000", addrs) if err == nil || !strings.Contains(err.Error(), "no primary global IPv4 address on br-000000") { t.Errorf("%s: error = %v", name, err) } } } func TestVtepAddress_ReturnsAFourByteAddress(t *testing.T) { got, err := vtepAddress("br-000000", []netlink.Addr{addr("192.168.14.11/24", 0, 0)}) if err != nil || len(got) != 4 { t.Errorf("vtepAddress = %v (%d bytes), %v", got, len(got), err) } } ```
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
syonad/two#51
No description provided.