f-34: route: add default gateway full gestion
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
d15454d35b
commit
46596f8142
13 changed files with 396 additions and 51 deletions
|
|
@ -349,8 +349,10 @@ components:
|
||||||
description: >
|
description: >
|
||||||
Subnet mode. "vxlan" (default): creates a VXLAN tunnel and a host bridge.
|
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.
|
"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.
|
"vlan" is reserved for future use.
|
||||||
enum: [vxlan, bridge]
|
enum: [vxlan, bridge, public_ip]
|
||||||
default: vxlan
|
default: vxlan
|
||||||
example: vxlan
|
example: vxlan
|
||||||
vxlan_id:
|
vxlan_id:
|
||||||
|
|
@ -373,9 +375,20 @@ components:
|
||||||
default_route:
|
default_route:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: >
|
description: >
|
||||||
If true, advertise a default route via DHCP. For vxlan mode the gateway is the interface IP.
|
A default route is always advertised via DHCP; this flag only chooses its next-hop.
|
||||||
For bridge mode the gateway is read from the host routing table.
|
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
|
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:
|
Subnet:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -392,7 +405,7 @@ components:
|
||||||
example: vpc1
|
example: vpc1
|
||||||
mode:
|
mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [vxlan, bridge]
|
enum: [vxlan, bridge, public_ip]
|
||||||
example: vxlan
|
example: vxlan
|
||||||
vxlan_id:
|
vxlan_id:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
@ -411,6 +424,9 @@ components:
|
||||||
default_route:
|
default_route:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
|
gateway:
|
||||||
|
type: string
|
||||||
|
example: "10.10.10.254"
|
||||||
|
|
||||||
VMCreateRequest:
|
VMCreateRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ type SubnetCreateRequest struct {
|
||||||
InterfaceIP string `json:"interface_ip"`
|
InterfaceIP string `json:"interface_ip"`
|
||||||
CIDR string `json:"cidr"`
|
CIDR string `json:"cidr"`
|
||||||
DefaultRoute bool `json:"default_route"`
|
DefaultRoute bool `json:"default_route"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subnet struct {
|
type Subnet struct {
|
||||||
|
|
@ -32,6 +33,7 @@ type Subnet struct {
|
||||||
InterfaceIP string `json:"interface_ip"`
|
InterfaceIP string `json:"interface_ip"`
|
||||||
CIDR string `json:"cidr"`
|
CIDR string `json:"cidr"`
|
||||||
DefaultRoute bool `json:"default_route"`
|
DefaultRoute bool `json:"default_route"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VMInterface struct {
|
type VMInterface struct {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string)
|
||||||
sub.CIDR = value
|
sub.CIDR = value
|
||||||
case "default_route":
|
case "default_route":
|
||||||
sub.DefaultRoute = value == "true"
|
sub.DefaultRoute = value == "true"
|
||||||
|
case "gateway":
|
||||||
|
sub.Gateway = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,11 @@ func TestPostSubnet_Created(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
||||||
req := SubnetCreateRequest{
|
req := SubnetCreateRequest{
|
||||||
Name: "sn-new",
|
Name: "sn-new",
|
||||||
VPC: "vpc-1",
|
VPC: "vpc-1",
|
||||||
IfaceType: "vms",
|
IfaceType: "vms",
|
||||||
InterfaceIP: "10.0.0.1",
|
InterfaceIP: "10.0.0.1",
|
||||||
CIDR: "10.0.0.0/24",
|
CIDR: "10.0.0.0/24",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -93,10 +93,10 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
||||||
req := SubnetCreateRequest{
|
req := SubnetCreateRequest{
|
||||||
Name: "sn-opt",
|
Name: "sn-opt",
|
||||||
VPC: "vpc-1",
|
VPC: "vpc-1",
|
||||||
InterfaceIP: "10.0.0.1",
|
InterfaceIP: "10.0.0.1",
|
||||||
CIDR: "10.0.0.0/24",
|
CIDR: "10.0.0.0/24",
|
||||||
// IfaceType omis — doit utiliser default_interface
|
// IfaceType omis — doit utiliser default_interface
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
|
|
@ -110,11 +110,11 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) {
|
||||||
func TestPostSubnet_VPCNotFound(t *testing.T) {
|
func TestPostSubnet_VPCNotFound(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
req := SubnetCreateRequest{
|
req := SubnetCreateRequest{
|
||||||
Name: "sn-1",
|
Name: "sn-1",
|
||||||
VPC: "vpc-inexistant",
|
VPC: "vpc-inexistant",
|
||||||
IfaceType: "vms",
|
IfaceType: "vms",
|
||||||
InterfaceIP: "10.0.0.1",
|
InterfaceIP: "10.0.0.1",
|
||||||
CIDR: "10.0.0.0/24",
|
CIDR: "10.0.0.0/24",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -129,11 +129,11 @@ func TestPostSubnet_Duplicate(t *testing.T) {
|
||||||
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
||||||
kv.AddInDB(db, "subnet/sn-exist/state", "running")
|
kv.AddInDB(db, "subnet/sn-exist/state", "running")
|
||||||
req := SubnetCreateRequest{
|
req := SubnetCreateRequest{
|
||||||
Name: "sn-exist",
|
Name: "sn-exist",
|
||||||
VPC: "vpc-1",
|
VPC: "vpc-1",
|
||||||
IfaceType: "vms",
|
IfaceType: "vms",
|
||||||
InterfaceIP: "10.0.0.1",
|
InterfaceIP: "10.0.0.1",
|
||||||
CIDR: "10.0.0.0/24",
|
CIDR: "10.0.0.0/24",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -147,11 +147,11 @@ func TestPostSubnet_VPCDeleting(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
kv.AddInDB(db, "vpc/vpc-dying/state", "deleting")
|
kv.AddInDB(db, "vpc/vpc-dying/state", "deleting")
|
||||||
req := SubnetCreateRequest{
|
req := SubnetCreateRequest{
|
||||||
Name: "sn-1",
|
Name: "sn-1",
|
||||||
VPC: "vpc-dying",
|
VPC: "vpc-dying",
|
||||||
IfaceType: "vms",
|
IfaceType: "vms",
|
||||||
InterfaceIP: "10.0.0.1",
|
InterfaceIP: "10.0.0.1",
|
||||||
CIDR: "10.0.0.0/24",
|
CIDR: "10.0.0.0/24",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -165,12 +165,12 @@ func TestPostSubnet_BridgeMode_Success(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
||||||
req := SubnetCreateRequest{
|
req := SubnetCreateRequest{
|
||||||
Name: "sn-br",
|
Name: "sn-br",
|
||||||
VPC: "vpc-1",
|
VPC: "vpc-1",
|
||||||
Mode: "bridge",
|
Mode: "bridge",
|
||||||
IfaceType: "vms",
|
IfaceType: "vms",
|
||||||
InterfaceIP: "10.0.0.1",
|
InterfaceIP: "10.0.0.1",
|
||||||
CIDR: "10.0.0.0/24",
|
CIDR: "10.0.0.0/24",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -192,11 +192,11 @@ func TestPostSubnet_UnknownMode(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
||||||
req := SubnetCreateRequest{
|
req := SubnetCreateRequest{
|
||||||
Name: "sn-1",
|
Name: "sn-1",
|
||||||
VPC: "vpc-1",
|
VPC: "vpc-1",
|
||||||
Mode: "vlan",
|
Mode: "vlan",
|
||||||
InterfaceIP: "10.0.0.1",
|
InterfaceIP: "10.0.0.1",
|
||||||
CIDR: "10.0.0.0/24",
|
CIDR: "10.0.0.0/24",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -306,3 +306,34 @@ func TestSubnetByName_InvalidMethod(t *testing.T) {
|
||||||
t.Errorf("attendu 405, obtenu %d", w.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) {
|
||||||
InterfaceIP: req.InterfaceIP,
|
InterfaceIP: req.InterfaceIP,
|
||||||
CIDR: req.CIDR,
|
CIDR: req.CIDR,
|
||||||
DefaultRoute: req.DefaultRoute,
|
DefaultRoute: req.DefaultRoute,
|
||||||
|
Gateway: req.Gateway,
|
||||||
}
|
}
|
||||||
if err := s.dispatcher.Prepare(cmd); err != nil {
|
if err := s.dispatcher.Prepare(cmd); err != nil {
|
||||||
if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil {
|
if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil {
|
||||||
|
|
|
||||||
|
|
@ -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 := newConf(t, "192.168.1.0/29")
|
||||||
conf.DefaultGateway = nil
|
conf.DefaultGateway = nil
|
||||||
|
|
||||||
|
|
@ -128,10 +131,10 @@ func TestGenerateConfig_VxlanEmitsNoRouterOption(t *testing.T) {
|
||||||
content, _ := os.ReadFile(path)
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
if !strings.Contains(string(content), "dhcp-option=121,") {
|
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,") {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,16 @@ type CreateSubnetCommand struct {
|
||||||
InterfaceIP string
|
InterfaceIP string
|
||||||
CIDR string
|
CIDR string
|
||||||
DefaultRoute bool
|
DefaultRoute bool
|
||||||
|
Gateway string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c CreateSubnetCommand) Key() string { return "subnet/" + c.Name }
|
func (c CreateSubnetCommand) Key() string { return "subnet/" + c.Name }
|
||||||
|
|
||||||
func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error {
|
func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error {
|
||||||
if c.Mode == "" {
|
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)
|
return fmt.Errorf("unknown subnet mode %q", c.Mode)
|
||||||
}
|
}
|
||||||
if _, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err == nil {
|
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+"/interface_ip", c.InterfaceIP)
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR)
|
kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR)
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/default_route", strconv.FormatBool(c.DefaultRoute))
|
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))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCfg() *configuration.Config {
|
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")
|
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é")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch d.mode {
|
switch d.mode {
|
||||||
case "vxlan":
|
case ModeVxlan:
|
||||||
if err := setupVxlanHost(d, vethE); err != nil {
|
if err := setupVxlanHost(d, vethE); err != nil {
|
||||||
return err
|
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 {
|
if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil {
|
||||||
return fmt.Errorf("add veth-e to bridge: %w", err)
|
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,
|
ConfDir: dhcp.DefaultConfDir,
|
||||||
InterfaceIP: d.interfaceIP,
|
InterfaceIP: d.interfaceIP,
|
||||||
}
|
}
|
||||||
switch d.mode {
|
defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway)
|
||||||
case "vxlan":
|
if err != nil {
|
||||||
conf.VPCRoute = d.vpcCIDR
|
return err
|
||||||
case "bridge":
|
|
||||||
if d.defaultRoute {
|
|
||||||
gw, err := netif.GetDefaultGateway()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get default gateway: %w", err)
|
|
||||||
}
|
|
||||||
conf.DefaultGateway = gw
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
conf.DefaultGateway = defaultGateway
|
||||||
|
conf.VPCRoute = vpcRoute
|
||||||
_, entries, err := dhcp.GenerateConfig(conf)
|
_, entries, err := dhcp.GenerateConfig(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generate dhcp config: %w", err)
|
return fmt.Errorf("generate dhcp config: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type subnetData struct {
|
||||||
cidr *net.IPNet
|
cidr *net.IPNet
|
||||||
vpcCIDR *net.IPNet
|
vpcCIDR *net.IPNet
|
||||||
defaultRoute bool
|
defaultRoute bool
|
||||||
|
gateway net.IP
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSubnet(db *badger.DB, name string) (subnetData, error) {
|
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"
|
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")
|
vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return d, fmt.Errorf("get vpc cidr: %w", err)
|
return d, fmt.Errorf("get vpc cidr: %w", err)
|
||||||
|
|
|
||||||
15
internal/subnet/mode.go
Normal file
15
internal/subnet/mode.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
34
internal/subnet/routing.go
Normal file
34
internal/subnet/routing.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
158
internal/subnet/routing_test.go
Normal file
158
internal/subnet/routing_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue