diff --git a/api/agent.yaml b/api/agent.yaml index b9bebf5..9fcd5d0 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -349,8 +349,10 @@ components: description: > Subnet mode. "vxlan" (default): creates a VXLAN tunnel and a host bridge. "bridge": attaches directly to an existing bridge resolved from iface_type in the agent config. + "public_ip": accepted and routed like vxlan for DHCP purposes, but its host network + setup is not implemented yet — creating such a subnet currently fails in Execute. "vlan" is reserved for future use. - enum: [vxlan, bridge] + enum: [vxlan, bridge, public_ip] default: vxlan example: vxlan vxlan_id: @@ -373,9 +375,20 @@ components: default_route: type: boolean description: > - If true, advertise a default route via DHCP. For vxlan mode the gateway is the interface IP. - For bridge mode the gateway is read from the host routing table. + A default route is always advertised via DHCP; this flag only chooses its next-hop. + When false, the next-hop is the subnet interface_ip. When true, it is the `gateway` + field if supplied, otherwise the gateway read from the host routing table. + The route to the VPC CIDR always keeps interface_ip as its next-hop (except in bridge + mode, which has no VPC route), so VPC traffic never leaves through a public gateway. default: false + gateway: + type: string + format: ipv4 + description: > + Optional next-hop for the default route. Only used when default_route is true; + supplied with default_route false, it is ignored. Not validated by the agent: + reachability and coherence with the subnet CIDR are the caller's responsibility. + example: "10.10.10.254" Subnet: type: object @@ -392,7 +405,7 @@ components: example: vpc1 mode: type: string - enum: [vxlan, bridge] + enum: [vxlan, bridge, public_ip] example: vxlan vxlan_id: type: integer @@ -411,6 +424,9 @@ components: default_route: type: boolean example: false + gateway: + type: string + example: "10.10.10.254" VMCreateRequest: type: object diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index a7114c2..f30ddf0 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -20,6 +20,7 @@ type SubnetCreateRequest struct { InterfaceIP string `json:"interface_ip"` CIDR string `json:"cidr"` DefaultRoute bool `json:"default_route"` + Gateway string `json:"gateway"` } type Subnet struct { @@ -32,6 +33,7 @@ type Subnet struct { InterfaceIP string `json:"interface_ip"` CIDR string `json:"cidr"` DefaultRoute bool `json:"default_route"` + Gateway string `json:"gateway"` } type VMInterface struct { diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index f0671f0..4473489 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -59,6 +59,8 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string) sub.CIDR = value case "default_route": sub.DefaultRoute = value == "true" + case "gateway": + sub.Gateway = value } } w.WriteHeader(http.StatusOK) diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go index 77ab5e4..6cf4176 100644 --- a/internal/api/agent/subnet_test.go +++ b/internal/api/agent/subnet_test.go @@ -57,11 +57,11 @@ func TestPostSubnet_Created(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-new", - VPC: "vpc-1", - IfaceType: "vms", + Name: "sn-new", + VPC: "vpc-1", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -93,10 +93,10 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-opt", - VPC: "vpc-1", + Name: "sn-opt", + VPC: "vpc-1", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", // IfaceType omis — doit utiliser default_interface } body, _ := json.Marshal(req) @@ -110,11 +110,11 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) { func TestPostSubnet_VPCNotFound(t *testing.T) { s, _ := newTestServer(t) req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-inexistant", - IfaceType: "vms", + Name: "sn-1", + VPC: "vpc-inexistant", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -129,11 +129,11 @@ func TestPostSubnet_Duplicate(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "running") kv.AddInDB(db, "subnet/sn-exist/state", "running") req := SubnetCreateRequest{ - Name: "sn-exist", - VPC: "vpc-1", - IfaceType: "vms", + Name: "sn-exist", + VPC: "vpc-1", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -147,11 +147,11 @@ func TestPostSubnet_VPCDeleting(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-dying", - IfaceType: "vms", + Name: "sn-1", + VPC: "vpc-dying", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -165,12 +165,12 @@ func TestPostSubnet_BridgeMode_Success(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-br", - VPC: "vpc-1", - Mode: "bridge", - IfaceType: "vms", + Name: "sn-br", + VPC: "vpc-1", + Mode: "bridge", + IfaceType: "vms", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -192,11 +192,11 @@ func TestPostSubnet_UnknownMode(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-1/state", "running") req := SubnetCreateRequest{ - Name: "sn-1", - VPC: "vpc-1", - Mode: "vlan", + Name: "sn-1", + VPC: "vpc-1", + Mode: "vlan", InterfaceIP: "10.0.0.1", - CIDR: "10.0.0.0/24", + CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) w := httptest.NewRecorder() @@ -306,3 +306,34 @@ func TestSubnetByName_InvalidMethod(t *testing.T) { t.Errorf("attendu 405, obtenu %d", w.Code) } } + +func TestCreateSubnet_GatewayRoundTrip(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "running") + + body, _ := json.Marshal(SubnetCreateRequest{ + Name: "sn-gw", VPC: "vpc-1", Mode: "public_ip", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", + DefaultRoute: true, Gateway: "203.0.113.1", + }) + + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) + } + + if gw, _ := kv.GetFromDB(db, "subnet/sn-gw/gateway"); gw != "203.0.113.1" { + t.Errorf("gateway attendue en DB, obtenu %q", gw) + } + + w = httptest.NewRecorder() + s.SubnetByNameHandler(w, httptest.NewRequest(http.MethodGet, "/subnets/sn-gw", nil)) + var got Subnet + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("réponse illisible : %v", err) + } + if got.Gateway != "203.0.113.1" { + t.Errorf("gateway absente de la réponse GET : %+v", got) + } +} diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index 3cea247..efa480b 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -87,6 +87,7 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { InterfaceIP: req.InterfaceIP, CIDR: req.CIDR, DefaultRoute: req.DefaultRoute, + Gateway: req.Gateway, } if err := s.dispatcher.Prepare(cmd); err != nil { if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil { diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index a13d1a2..2597457 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -120,7 +120,10 @@ func TestGenerateConfig_NoDefaultGatewaySuppressesRouterOption(t *testing.T) { } } -func TestGenerateConfig_VxlanEmitsNoRouterOption(t *testing.T) { +func TestGenerateConfig_NilDefaultGatewayEmitsNoRoute(t *testing.T) { + // Contrat du paquet, pas politique de subnet : depuis 2026-08-24 `startDHCP` + // renseigne toujours DefaultGateway, donc ce chemin n'est plus emprunté en + // production. Il reste valide — le générateur ne doit rien inventer. conf := newConf(t, "192.168.1.0/29") conf.DefaultGateway = nil @@ -128,10 +131,10 @@ func TestGenerateConfig_VxlanEmitsNoRouterOption(t *testing.T) { content, _ := os.ReadFile(path) if !strings.Contains(string(content), "dhcp-option=121,") { - t.Fatalf("dhcp-option=121 attendue pour un subnet vxlan :\n%s", content) + t.Fatalf("dhcp-option=121 toujours attendue, ne serait-ce que pour la route metadata :\n%s", content) } if strings.Contains(string(content), "dhcp-option=3,") { - t.Errorf("un subnet vxlan est privé : aucune route par défaut ne doit être émise\n%s", content) + t.Errorf("DefaultGateway nulle : aucune route par défaut ne doit être émise\n%s", content) } } diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index 6033a40..8d71f0c 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -21,15 +21,16 @@ type CreateSubnetCommand struct { InterfaceIP string CIDR string DefaultRoute bool + Gateway string } func (c CreateSubnetCommand) Key() string { return "subnet/" + c.Name } func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error { if c.Mode == "" { - c.Mode = "vxlan" + c.Mode = subnet.ModeVxlan } - if c.Mode != "vxlan" && c.Mode != "bridge" { + if !subnet.ValidMode(c.Mode) { return fmt.Errorf("unknown subnet mode %q", c.Mode) } if _, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err == nil { @@ -53,9 +54,14 @@ func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) e kv.AddInDB(db, "subnet/"+c.Name+"/interface_ip", c.InterfaceIP) kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR) kv.AddInDB(db, "subnet/"+c.Name+"/default_route", strconv.FormatBool(c.DefaultRoute)) - if c.Mode == "vxlan" { + if c.Mode == subnet.ModeVxlan { kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) } + if c.Gateway != "" { + if err := kv.AddInDB(db, "subnet/"+c.Name+"/gateway", c.Gateway); err != nil { + return fmt.Errorf("store gateway: %w", err) + } + } return nil } diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go index 645f620..d94f5e4 100644 --- a/internal/dispatcher/agent/subnet_commands_test.go +++ b/internal/dispatcher/agent/subnet_commands_test.go @@ -5,6 +5,7 @@ import ( configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" ) func testCfg() *configuration.Config { @@ -255,3 +256,74 @@ func TestDeleteSubnetCommand_Prepare_NotFound(t *testing.T) { t.Error("Prepare devrait échouer si le subnet n'existe pas") } } + +// --- gateway optionnelle et mode public_ip --- + +func prepareSubnet(t *testing.T, cmd CreateSubnetCommand) (*badger.DB, error) { + t.Helper() + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "running") + if cmd.VPC == "" { + cmd.VPC = "vpc-1" + } + return db, cmd.Prepare(db, testCfg()) +} + +func TestCreateSubnetCommand_Prepare_StoresGateway(t *testing.T) { + db, err := prepareSubnet(t, CreateSubnetCommand{ + Name: "sn-gw", VxlanID: 100, IfaceType: "vms", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", + DefaultRoute: true, Gateway: "10.0.0.254", + }) + if err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + gw, err := kv.GetFromDB(db, "subnet/sn-gw/gateway") + if err != nil { + t.Fatalf("clé gateway absente : %v", err) + } + if gw != "10.0.0.254" { + t.Errorf("gateway attendue 10.0.0.254, obtenu %q", gw) + } +} + +func TestCreateSubnetCommand_Prepare_NoGatewayWritesNoKey(t *testing.T) { + db, err := prepareSubnet(t, CreateSubnetCommand{ + Name: "sn-nogw", VxlanID: 100, IfaceType: "vms", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", + }) + if err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + if _, err := kv.GetFromDB(db, "subnet/sn-nogw/gateway"); err == nil { + t.Error("aucune gateway fournie, aucune clé ne doit être écrite") + } +} + +func TestCreateSubnetCommand_Prepare_AcceptsPublicIPMode(t *testing.T) { + db, err := prepareSubnet(t, CreateSubnetCommand{ + Name: "sn-pub", Mode: "public_ip", IfaceType: "vms", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", + DefaultRoute: true, Gateway: "203.0.113.1", + }) + if err != nil { + t.Fatalf("le mode public_ip doit être accepté : %v", err) + } + mode, _ := kv.GetFromDB(db, "subnet/sn-pub/mode") + if mode != "public_ip" { + t.Errorf("mode attendu public_ip, obtenu %q", mode) + } + if _, err := kv.GetFromDB(db, "subnet/sn-pub/vxlan_id"); err == nil { + t.Error("vxlan_id ne doit être écrit que pour le mode vxlan") + } +} + +func TestCreateSubnetCommand_Prepare_RejectsUnknownMode(t *testing.T) { + _, err := prepareSubnet(t, CreateSubnetCommand{ + Name: "sn-bad", Mode: "public", IfaceType: "vms", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", + }) + if err == nil { + t.Error("un mode inconnu doit être refusé") + } +} diff --git a/internal/subnet/create.go b/internal/subnet/create.go index a42a1a9..e37b865 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -43,11 +43,13 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { } switch d.mode { - case "vxlan": + case ModeVxlan: if err := setupVxlanHost(d, vethE); err != nil { return err } - case "bridge": + case ModePublicIP: + return fmt.Errorf("subnet mode %q: host network setup is not implemented yet", d.mode) + case ModeBridge: if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil { return fmt.Errorf("add veth-e to bridge: %w", err) } @@ -141,18 +143,12 @@ func startDHCP(db *badger.DB, subnetName string, d subnetData) error { ConfDir: dhcp.DefaultConfDir, InterfaceIP: d.interfaceIP, } - switch d.mode { - case "vxlan": - conf.VPCRoute = d.vpcCIDR - case "bridge": - if d.defaultRoute { - gw, err := netif.GetDefaultGateway() - if err != nil { - return fmt.Errorf("get default gateway: %w", err) - } - conf.DefaultGateway = gw - } + defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway) + if err != nil { + return err } + conf.DefaultGateway = defaultGateway + conf.VPCRoute = vpcRoute _, entries, err := dhcp.GenerateConfig(conf) if err != nil { return fmt.Errorf("generate dhcp config: %w", err) diff --git a/internal/subnet/data.go b/internal/subnet/data.go index 0f1f62a..6f2d302 100644 --- a/internal/subnet/data.go +++ b/internal/subnet/data.go @@ -21,6 +21,7 @@ type subnetData struct { cidr *net.IPNet vpcCIDR *net.IPNet defaultRoute bool + gateway net.IP } func loadSubnet(db *badger.DB, name string) (subnetData, error) { @@ -85,6 +86,14 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) { } d.defaultRoute = defaultRouteStr == "true" + if gatewayStr, err := kv.GetFromDB(db, "subnet/"+name+"/gateway"); err == nil && gatewayStr != "" { + gateway := net.ParseIP(gatewayStr) + if gateway == nil { + return d, fmt.Errorf("invalid gateway: %s", gatewayStr) + } + d.gateway = gateway + } + vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr") if err != nil { return d, fmt.Errorf("get vpc cidr: %w", err) diff --git a/internal/subnet/mode.go b/internal/subnet/mode.go new file mode 100644 index 0000000..cfc61dd --- /dev/null +++ b/internal/subnet/mode.go @@ -0,0 +1,15 @@ +package subnet + +const ( + ModeVxlan = "vxlan" + ModeBridge = "bridge" + ModePublicIP = "public_ip" +) + +func ValidMode(mode string) bool { + switch mode { + case ModeVxlan, ModeBridge, ModePublicIP: + return true + } + return false +} diff --git a/internal/subnet/routing.go b/internal/subnet/routing.go new file mode 100644 index 0000000..722acb8 --- /dev/null +++ b/internal/subnet/routing.go @@ -0,0 +1,34 @@ +package subnet + +import ( + "fmt" + "net" +) + +// dhcpRouting resolves what the DHCP server advertises to the guests of a subnet. +// +// The default route always points at the subnet gateway (interface_ip); default_route +// swaps that next-hop for the supplied gateway, or for the deduced one when none was +// supplied. The VPC route keeps interface_ip as its next-hop in every mode but bridge, +// so that traffic to the VPC ranges never leaves through a public gateway. +func dhcpRouting(d subnetData, deduceGateway func() (net.IP, error)) (net.IP, *net.IPNet, error) { + defaultGateway := d.interfaceIP + if d.defaultRoute { + if d.gateway != nil { + defaultGateway = d.gateway + } else { + deduced, err := deduceGateway() + if err != nil { + return nil, nil, fmt.Errorf("get default gateway: %w", err) + } + defaultGateway = deduced + } + } + + var vpcRoute *net.IPNet + if d.mode != ModeBridge { + vpcRoute = d.vpcCIDR + } + + return defaultGateway, vpcRoute, nil +} diff --git a/internal/subnet/routing_test.go b/internal/subnet/routing_test.go new file mode 100644 index 0000000..8ad0440 --- /dev/null +++ b/internal/subnet/routing_test.go @@ -0,0 +1,158 @@ +package subnet + +import ( + "errors" + "net" + "testing" +) + +func cidr(t *testing.T, s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("ParseCIDR(%q) : %v", s, err) + } + return n +} + +func baseSubnet(t *testing.T, mode string) subnetData { + t.Helper() + return subnetData{ + mode: mode, + interfaceIP: net.ParseIP("10.1.1.1").To4(), + cidr: cidr(t, "10.1.0.0/23"), + vpcCIDR: cidr(t, "192.168.0.0/16"), + } +} + +func deduced(ip string) func() (net.IP, error) { + return func() (net.IP, error) { return net.ParseIP(ip).To4(), nil } +} + +func neverDeduced(t *testing.T) func() (net.IP, error) { + t.Helper() + return func() (net.IP, error) { + t.Error("la gateway de l'host ne doit pas être interrogée dans ce cas") + return nil, nil + } +} + +// --- next-hop de la route par défaut --- + +func TestDhcpRouting_DefaultRouteUsesInterfaceIP(t *testing.T) { + d := baseSubnet(t, ModeVxlan) + + gw, _, err := dhcpRouting(d, neverDeduced(t)) + if err != nil { + t.Fatalf("dhcpRouting : %v", err) + } + if gw.String() != "10.1.1.1" { + t.Errorf("sans default_route le next-hop doit être l'interface_ip, obtenu %s", gw) + } +} + +func TestDhcpRouting_DefaultRouteUsesSuppliedGateway(t *testing.T) { + d := baseSubnet(t, ModeVxlan) + d.defaultRoute = true + d.gateway = net.ParseIP("10.1.1.254").To4() + + gw, _, err := dhcpRouting(d, neverDeduced(t)) + if err != nil { + t.Fatalf("dhcpRouting : %v", err) + } + if gw.String() != "10.1.1.254" { + t.Errorf("gateway fournie attendue, obtenu %s", gw) + } +} + +func TestDhcpRouting_DefaultRouteFallsBackToDeducedGateway(t *testing.T) { + d := baseSubnet(t, ModeBridge) + d.defaultRoute = true + + gw, _, err := dhcpRouting(d, deduced("192.0.2.1")) + if err != nil { + t.Fatalf("dhcpRouting : %v", err) + } + if gw.String() != "192.0.2.1" { + t.Errorf("gateway déduite attendue, obtenu %s", gw) + } +} + +func TestDhcpRouting_SuppliedGatewayIgnoredWithoutDefaultRoute(t *testing.T) { + d := baseSubnet(t, ModeVxlan) + d.gateway = net.ParseIP("10.1.1.254").To4() + + gw, _, err := dhcpRouting(d, neverDeduced(t)) + if err != nil { + t.Fatalf("dhcpRouting : %v", err) + } + if gw.String() != "10.1.1.1" { + t.Errorf("gateway fournie sans default_route : ignorée en silence, next-hop attendu 10.1.1.1, obtenu %s", gw) + } +} + +func TestDhcpRouting_DeductionFailureIsReported(t *testing.T) { + d := baseSubnet(t, ModeBridge) + d.defaultRoute = true + + _, _, err := dhcpRouting(d, func() (net.IP, error) { return nil, errors.New("pas de route") }) + if err == nil { + t.Error("l'échec de déduction de la gateway doit remonter, pas produire une route muette") + } +} + +// --- route VPC --- + +func TestDhcpRouting_VPCRouteKeptInVxlan(t *testing.T) { + _, route, err := dhcpRouting(baseSubnet(t, ModeVxlan), neverDeduced(t)) + if err != nil { + t.Fatalf("dhcpRouting : %v", err) + } + if route == nil || route.String() != "192.168.0.0/16" { + t.Errorf("route VPC attendue, obtenu %v", route) + } +} + +func TestDhcpRouting_VPCRouteKeptInPublicIP(t *testing.T) { + d := baseSubnet(t, ModePublicIP) + d.defaultRoute = true + d.gateway = net.ParseIP("203.0.113.1").To4() + + gw, route, err := dhcpRouting(d, neverDeduced(t)) + if err != nil { + t.Fatalf("dhcpRouting : %v", err) + } + if route == nil || route.String() != "192.168.0.0/16" { + t.Fatalf("route VPC attendue sur un subnet public, obtenu %v", route) + } + if gw.String() != "203.0.113.1" { + t.Errorf("next-hop par défaut attendu 203.0.113.1, obtenu %s", gw) + } + // C'est tout l'intérêt du mode : la route VPC garde interface_ip comme next-hop, + // donc le trafic interne ne sort jamais par la gateway publique. +} + +func TestDhcpRouting_NoVPCRouteInBridge(t *testing.T) { + _, route, err := dhcpRouting(baseSubnet(t, ModeBridge), neverDeduced(t)) + if err != nil { + t.Fatalf("dhcpRouting : %v", err) + } + if route != nil { + t.Errorf("le mode bridge n'a pas de route VPC, obtenu %v", route) + } +} + +// --- modes --- + +func TestValidMode(t *testing.T) { + for _, m := range []string{ModeVxlan, ModeBridge, ModePublicIP} { + if !ValidMode(m) { + t.Errorf("%q devrait être un mode valide", m) + } + } + for _, m := range []string{"", "public", "vxlan ", "VXLAN"} { + if ValidMode(m) { + t.Errorf("%q ne devrait pas être un mode valide", m) + } + } +}