From a2170fff0889fa3193f521bd9fed2a6db29c8fc1 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 24 Aug 2026 22:57:03 +0200 Subject: [PATCH 1/2] f-34: route: fix missing route for metadata #34 Signed-off-by: GnomeZworc --- internal/dhcp/generate.go | 22 +++++++++++++++++++--- internal/dhcp/struct.go | 6 +++--- internal/iptables/iptables.go | 6 ++++-- internal/metadata/struct.go | 2 ++ internal/subnet/create.go | 8 ++++---- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index 40250f6..ebcb914 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -6,17 +6,20 @@ import ( "os" "path/filepath" "strings" + + "git.g3e.fr/syonad/two/internal/metadata" ) func GenerateConfig(c Config) (string, map[string]string, error) { + if c.InterfaceIP == nil { + return "", nil, fmt.Errorf("interface ip is required: guests would have no route to the metadata server") + } mask := fmt.Sprintf("%d.%d.%d.%d", c.Network.Mask[0], c.Network.Mask[1], c.Network.Mask[2], c.Network.Mask[3]) var sb strings.Builder fmt.Fprintf(&sb, "no-resolv\n") fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask) - if c.VPCRoute != nil { - fmt.Fprintf(&sb, "dhcp-option=121,%s,%s\n", c.VPCRoute.String(), c.VPCGateway.String()) - } + fmt.Fprintf(&sb, "dhcp-option=121,%s\n", strings.Join(classlessRoutes(c), ",")) if c.DefaultGateway != nil { fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String()) } else { @@ -40,6 +43,19 @@ func GenerateConfig(c Config) (string, map[string]string, error) { return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) } +func classlessRoutes(c Config) []string { + nextHop := c.InterfaceIP.String() + + routes := []string{metadata.ServiceIP + "/32," + nextHop} + if c.VPCRoute != nil { + routes = append(routes, c.VPCRoute.String()+","+nextHop) + } + if c.DefaultGateway != nil { + routes = append(routes, "0.0.0.0/0,"+c.DefaultGateway.String()) + } + return routes +} + func incrementIP(ip net.IP) { for j := len(ip) - 1; j >= 0; j-- { ip[j]++ diff --git a/internal/dhcp/struct.go b/internal/dhcp/struct.go index bacb1e0..9df7c1f 100644 --- a/internal/dhcp/struct.go +++ b/internal/dhcp/struct.go @@ -8,9 +8,9 @@ const DefaultConfDir = "/etc/dnsmasq.d" type Config struct { Network *net.IPNet - VPCGateway net.IP // next-hop for VPCRoute (option 121) - VPCRoute *net.IPNet // if non-nil, emit dhcp-option=121,VPCRoute,VPCGateway - DefaultGateway net.IP // if non-nil, emit dhcp-option=3,DefaultGateway; if nil, emit a bare dhcp-option=3 to suppress the dnsmasq default + InterfaceIP net.IP // subnet gateway; next-hop for the metadata and VPC routes + VPCRoute *net.IPNet // if non-nil, routed via InterfaceIP in option 121 + DefaultGateway net.IP // if non-nil, default route via option 3 and 0.0.0.0/0 in option 121 Name string ConfDir string } diff --git a/internal/iptables/iptables.go b/internal/iptables/iptables.go index 1d156a7..5e39e3e 100644 --- a/internal/iptables/iptables.go +++ b/internal/iptables/iptables.go @@ -3,6 +3,8 @@ package iptables import ( "fmt" "os/exec" + + "git.g3e.fr/syonad/two/internal/metadata" ) func addRule(args ...string) error { @@ -16,7 +18,7 @@ func deleteRule(args ...string) error { func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { if err := addRule("PREROUTING", "-s", vmIP+"/32", - "-d", "169.254.169.254/32", + "-d", metadata.ServiceIP+"/32", "-p", "tcp", "-m", "tcp", "--dport", "80", "-j", "DNAT", @@ -30,7 +32,7 @@ func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { if err := deleteRule("PREROUTING", "-s", vmIP+"/32", - "-d", "169.254.169.254/32", + "-d", metadata.ServiceIP+"/32", "-p", "tcp", "-m", "tcp", "--dport", "80", "-j", "DNAT", diff --git a/internal/metadata/struct.go b/internal/metadata/struct.go index 2e94cfc..3c9da1e 100644 --- a/internal/metadata/struct.go +++ b/internal/metadata/struct.go @@ -1,5 +1,7 @@ package metadata +const ServiceIP = "169.254.169.254" + type NoCloudData struct { MetaData string UserData string diff --git a/internal/subnet/create.go b/internal/subnet/create.go index e7b0c70..a42a1a9 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -136,13 +136,13 @@ func setupVxlanHost(d subnetData, vethE string) error { func startDHCP(db *badger.DB, subnetName string, d subnetData) error { conf := dhcp.Config{ - Network: d.cidr, - Name: d.vpc + "_" + d.bridge, - ConfDir: dhcp.DefaultConfDir, + Network: d.cidr, + Name: d.vpc + "_" + d.bridge, + ConfDir: dhcp.DefaultConfDir, + InterfaceIP: d.interfaceIP, } switch d.mode { case "vxlan": - conf.VPCGateway = d.interfaceIP conf.VPCRoute = d.vpcCIDR case "bridge": if d.defaultRoute { From d15454d35b7054f1c88d1cf4f0fe9d1cf68ebd43 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 24 Aug 2026 22:57:30 +0200 Subject: [PATCH 2/2] f-34: route: add test for route metadata #34 Signed-off-by: GnomeZworc --- internal/dhcp/dhcp_test.go | 108 ++++++++++++++++++++++++++++++------ internal/metadata/struct.go | 3 + 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index 94c7d7b..a13d1a2 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -55,7 +55,7 @@ func newConf(t *testing.T, cidr string) Config { gw := net.ParseIP("192.168.1.1").To4() return Config{ Network: network, - VPCGateway: gw, + InterfaceIP: net.ParseIP("192.168.1.254").To4(), VPCRoute: vpcNet, DefaultGateway: gw, Name: "test", @@ -136,32 +136,29 @@ func TestGenerateConfig_VxlanEmitsNoRouterOption(t *testing.T) { } func TestGenerateConfig_ContainsVPCRoute(t *testing.T) { - conf := newConf(t, "192.168.1.0/29") - path, _, _ := GenerateConfig(conf) - content, _ := os.ReadFile(path) - - if !strings.Contains(string(content), "dhcp-option=121,10.0.0.0/16,192.168.1.1") { - t.Errorf("dhcp-option=121 absente ou incorrecte :\n%s", content) + routes := route121(t, confLines(t, newConf(t, "192.168.1.0/29"))) + if !strings.Contains(routes, "10.0.0.0/16,192.168.1.254") { + t.Errorf("route VPC absente, ou next-hop autre que l'interface_ip du subnet :\n%s", routes) } } func TestGenerateConfig_NoVPCRoute(t *testing.T) { conf := newConf(t, "192.168.1.0/29") conf.VPCRoute = nil - path, _, _ := GenerateConfig(conf) - content, _ := os.ReadFile(path) - if strings.Contains(string(content), "dhcp-option=121,") { - t.Errorf("dhcp-option=121 présente alors que VPCRoute=nil :\n%s", content) + routes := route121(t, confLines(t, conf)) + if strings.Contains(routes, "10.0.0.0/16") { + t.Errorf("route VPC présente alors que VPCRoute=nil :\n%s", routes) } } func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { _, network, _ := net.ParseCIDR("10.10.0.0/24") conf := Config{ - Network: network, - Name: "vpc1", - ConfDir: t.TempDir(), + Network: network, + InterfaceIP: net.ParseIP("10.10.0.1").To4(), + Name: "vpc1", + ConfDir: t.TempDir(), } path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) @@ -204,9 +201,10 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "sous", "dossier") _, network, _ := net.ParseCIDR("10.0.0.0/30") conf := Config{ - Network: network, - Name: "net", - ConfDir: dir, + Network: network, + InterfaceIP: net.ParseIP("10.0.0.1").To4(), + Name: "net", + ConfDir: dir, } if _, _, err := GenerateConfig(conf); err != nil { t.Fatalf("GenerateConfig devrait créer les répertoires manquants : %v", err) @@ -215,3 +213,79 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) { t.Errorf("répertoire %q non créé", dir) } } + +// --- option 121 : routes classless --- + +func confLines(t *testing.T, c Config) string { + t.Helper() + path, _, err := GenerateConfig(c) + if err != nil { + t.Fatalf("GenerateConfig : %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("lecture : %v", err) + } + return string(content) +} + +func route121(t *testing.T, content string) string { + t.Helper() + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "dhcp-option=121,") { + return strings.TrimPrefix(line, "dhcp-option=121,") + } + } + t.Fatalf("aucune dhcp-option=121 dans :\n%s", content) + return "" +} + +func TestGenerateConfig_AlwaysRoutesToMetadata(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + conf.VPCRoute = nil + conf.DefaultGateway = nil + + routes := route121(t, confLines(t, conf)) + if !strings.Contains(routes, "169.254.169.254/32,192.168.1.254") { + t.Errorf("sans route vers le serveur de métadonnées, cloud-init échoue et la VM n'est pas provisionnée :\n%s", routes) + } +} + +func TestGenerateConfig_MetadataRouteEvenInBridgeMode(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + conf.VPCRoute = nil + + routes := route121(t, confLines(t, conf)) + if !strings.Contains(routes, "169.254.169.254/32") { + t.Errorf("le mode bridge a besoin de la même route :\n%s", routes) + } +} + +func TestGenerateConfig_DefaultRouteAlsoInOption121(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + conf.VPCRoute = nil + + routes := route121(t, confLines(t, conf)) + if !strings.Contains(routes, "0.0.0.0/0,192.168.1.1") { + t.Errorf("RFC 3442 : un client qui lit l'option 121 ignore l'option 3, la route par défaut doit donc figurer dans la 121 :\n%s", routes) + } +} + +func TestGenerateConfig_NoDefaultRouteMeansNoCatchAllInOption121(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + conf.DefaultGateway = nil + + routes := route121(t, confLines(t, conf)) + if strings.Contains(routes, "0.0.0.0/0") { + t.Errorf("aucune route par défaut demandée, la 121 ne doit pas en contenir :\n%s", routes) + } +} + +func TestGenerateConfig_MissingInterfaceIPIsAnError(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + conf.InterfaceIP = nil + + if _, _, err := GenerateConfig(conf); err == nil { + t.Error("sans interface_ip aucune route metadata n'est possible : il faut échouer, pas écrire une conf muette") + } +} diff --git a/internal/metadata/struct.go b/internal/metadata/struct.go index 3c9da1e..39592ed 100644 --- a/internal/metadata/struct.go +++ b/internal/metadata/struct.go @@ -1,5 +1,8 @@ package metadata +// ServiceIP is the well-known address guests use to reach the metadata server. +// Traffic to it is DNATed to the subnet gateway by internal/iptables, and the +// route to it is advertised by internal/dhcp. const ServiceIP = "169.254.169.254" type NoCloudData struct {