Compare commits

..

No commits in common. "032201d631c4680d2b1a69e21900549fd879d620" and "63fc3057364fbad1b16bfd9f63c19d3cbd6b6f7e" have entirely different histories.

29 changed files with 170 additions and 1191 deletions

View file

@ -349,10 +349,8 @@ 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, public_ip] enum: [vxlan, bridge]
default: vxlan default: vxlan
example: vxlan example: vxlan
vxlan_id: vxlan_id:
@ -375,20 +373,9 @@ components:
default_route: default_route:
type: boolean type: boolean
description: > description: >
A default route is always advertised via DHCP; this flag only chooses its next-hop. If true, advertise a default route via DHCP. For vxlan mode the gateway is the interface IP.
When false, the next-hop is the subnet interface_ip. When true, it is the `gateway` For bridge mode the gateway is read from the host routing table.
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
@ -405,7 +392,7 @@ components:
example: vpc1 example: vpc1
mode: mode:
type: string type: string
enum: [vxlan, bridge, public_ip] enum: [vxlan, bridge]
example: vxlan example: vxlan
vxlan_id: vxlan_id:
type: integer type: integer
@ -424,9 +411,6 @@ 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
@ -443,8 +427,11 @@ components:
type: integer type: integer
description: Number of vCPUs (default 1) description: Number of vCPUs (default 1)
example: 2 example: 2
metadata: password:
$ref: "#/components/schemas/VMMetadata" type: string
sshkey:
type: string
example: "ssh-ed25519 AAAA..."
interfaces: interfaces:
type: array type: array
minItems: 1 minItems: 1
@ -460,37 +447,6 @@ components:
description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS). description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS).
example: false example: false
VMMetadata:
type: object
description: >
cloud-init inputs for this VM. Every field is optional. Documents not
supplied here fall back to the agent's built-in templates; a document
supplied as an empty string is served empty, which is not the same
thing.
properties:
password:
type: string
description: >
Password *hash* for the syonad account, as expected by the
cloud-config `passwd` key (e.g. `$6$...`) — not a plaintext
password. When omitted, the account is created locked
(`lock_passwd: true`); when neither password nor sshkey is given,
no account is created at all.
example: "$6$rounds=4096$saltsalt$hash..."
sshkey:
type: string
description: Public SSH key added to the syonad account. Sent as-is, not encoded.
example: "ssh-ed25519 AAAA..."
user_data:
type: string
format: byte
description: >
cloud-init user-data, **base64-encoded**. Encoding keeps multi-line
documents out of JSON escaping and allows gzip+base64 payloads.
Invalid base64 is rejected with 400 rather than silently serving an
empty document. The agent never interprets this content.
example: "I2Nsb3VkLWNvbmZpZwpwYWNrYWdlczoKICAtIG5naW54Cg=="
VMInterface: VMInterface:
type: object type: object
required: [subnet, ip, primary] required: [subnet, ip, primary]

View file

@ -20,7 +20,6 @@ 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 {
@ -33,7 +32,6 @@ 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 {
@ -47,18 +45,13 @@ type VMStorage struct {
Dev string `json:"dev"` Dev string `json:"dev"`
} }
type VMMetadata struct {
Password string `json:"password"`
SSHKey string `json:"sshkey"`
UserData string `json:"user_data"`
}
type VMCreateRequest struct { type VMCreateRequest struct {
Name string `json:"name"` Name string `json:"name"`
Memory int `json:"memory"` Memory int `json:"memory"`
CPUs int `json:"cpus"` CPUs int `json:"cpus"`
UEFI bool `json:"uefi"` UEFI bool `json:"uefi"`
Metadata VMMetadata `json:"metadata"` Password string `json:"password"`
SSHKey string `json:"sshkey"`
Interfaces []VMInterface `json:"interfaces"` Interfaces []VMInterface `json:"interfaces"`
Storage []VMStorage `json:"storage"` Storage []VMStorage `json:"storage"`
} }

View file

@ -59,8 +59,6 @@ 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)

View file

@ -306,34 +306,3 @@ 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)
}
}

View file

@ -87,7 +87,6 @@ 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 {

View file

@ -2,16 +2,13 @@ package agentapi
import ( import (
"bytes" "bytes"
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sort" "sort"
"strings"
"testing" "testing"
"git.g3e.fr/syonad/two/pkg/db/kv" "git.g3e.fr/syonad/two/pkg/db/kv"
"github.com/dgraph-io/badger/v4"
) )
// --- vmFromDB --- // --- vmFromDB ---
@ -159,106 +156,3 @@ func TestStartVM_StorageReturnedInResponse(t *testing.T) {
t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0]) t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0])
} }
} }
// --- POST /vms : objet metadata ---
func postVM(t *testing.T, name string, meta VMMetadata) (*httptest.ResponseRecorder, *badger.DB) {
t.Helper()
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
body, _ := json.Marshal(VMCreateRequest{
Name: name,
Interfaces: []VMInterface{{Subnet: "sn-1", IP: "10.0.0.20", Primary: true}},
Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}},
Memory: 1024,
CPUs: 2,
Metadata: meta,
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
return w, db
}
func TestStartVM_UserDataIsDecodedFromBase64(t *testing.T) {
plain := "#cloud-config\npackages:\n - nginx\n"
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
w, db := postVM(t, "vm-md1", VMMetadata{UserData: encoded})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
got, err := kv.GetFromDB(db, "vm/vm-md1/metadata/user-data")
if err != nil {
t.Fatalf("user-data absent en DB : %v", err)
}
if got != plain {
t.Errorf("user-data décodé attendu %q, obtenu %q", plain, got)
}
}
func TestStartVM_InvalidBase64IsRejected(t *testing.T) {
w, db := postVM(t, "vm-md2", VMMetadata{UserData: "ceci n'est pas du base64 !!"})
if w.Code != http.StatusBadRequest {
t.Fatalf("un base64 invalide doit être refusé en 400, obtenu %d : %s", w.Code, w.Body.String())
}
if _, err := kv.GetFromDB(db, "vm/vm-md2/state"); err == nil {
t.Error("aucune VM ne doit être créée quand la requête est refusée")
}
}
func TestStartVM_InvalidBase64ErrorNamesTheField(t *testing.T) {
w, _ := postVM(t, "vm-md3", VMMetadata{UserData: "@@@"})
if !strings.Contains(w.Body.String(), "user_data") {
t.Errorf("le message doit nommer le champ fautif : %s", w.Body.String())
}
}
func TestStartVM_NoUserDataWritesNoDocument(t *testing.T) {
w, db := postVM(t, "vm-md4", VMMetadata{SSHKey: "ssh-ed25519 AAAA user@host"})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
entries, err := kv.ListByPrefix(db, "vm/vm-md4/metadata/")
if err != nil {
t.Fatalf("ListByPrefix : %v", err)
}
if len(entries) != 0 {
t.Errorf("sans user_data, aucun document ne doit être stocké : %v", entries)
}
}
func TestStartVM_PasswordAndSSHKeyComeFromMetadata(t *testing.T) {
w, db := postVM(t, "vm-md5", VMMetadata{
Password: "$6$rounds$hash",
SSHKey: "ssh-ed25519 AAAA user@host",
})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
if got, _ := kv.GetFromDB(db, "vm/vm-md5/password"); got != "$6$rounds$hash" {
t.Errorf("password attendu depuis metadata, obtenu %q", got)
}
if got, _ := kv.GetFromDB(db, "vm/vm-md5/sshkey"); got != "ssh-ed25519 AAAA user@host" {
t.Errorf("sshkey attendu depuis metadata, obtenu %q", got)
}
}
func TestStartVM_EmptyBase64MeansNoDocument(t *testing.T) {
w, db := postVM(t, "vm-md6", VMMetadata{UserData: base64.StdEncoding.EncodeToString([]byte(""))})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
entries, _ := kv.ListByPrefix(db, "vm/vm-md6/metadata/")
if len(entries) != 0 {
t.Errorf("un base64 vide est indiscernable d'un champ absent : %v", entries)
}
}

View file

@ -1,14 +1,11 @@
package agentapi package agentapi
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings" "strings"
dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent"
"git.g3e.fr/syonad/two/internal/metadata"
"git.g3e.fr/syonad/two/pkg/db/kv" "git.g3e.fr/syonad/two/pkg/db/kv"
) )
@ -85,13 +82,6 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev} disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev}
} }
documents, err := decodeDocuments(req.Metadata)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
return
}
cmd := dispatcher.StartVMCommand{ cmd := dispatcher.StartVMCommand{
Name: req.Name, Name: req.Name,
Subnet: primary.Subnet, Subnet: primary.Subnet,
@ -100,9 +90,8 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
Memory: req.Memory, Memory: req.Memory,
CPUs: req.CPUs, CPUs: req.CPUs,
UEFI: req.UEFI, UEFI: req.UEFI,
Password: req.Metadata.Password, Password: req.Password,
SSHKey: req.Metadata.SSHKey, SSHKey: req.SSHKey,
Documents: documents,
} }
if err := s.dispatcher.Prepare(cmd); err != nil { if err := s.dispatcher.Prepare(cmd); err != nil {
@ -126,14 +115,3 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(vm) json.NewEncoder(w).Encode(vm)
} }
func decodeDocuments(m VMMetadata) (map[string]string, error) {
if m.UserData == "" {
return nil, nil
}
content, err := base64.StdEncoding.DecodeString(m.UserData)
if err != nil {
return nil, fmt.Errorf("metadata.user_data is not valid base64: %w", err)
}
return map[string]string{metadata.DocUserData: string(content)}, nil
}

View file

@ -55,7 +55,7 @@ func newConf(t *testing.T, cidr string) Config {
gw := net.ParseIP("192.168.1.1").To4() gw := net.ParseIP("192.168.1.1").To4()
return Config{ return Config{
Network: network, Network: network,
InterfaceIP: net.ParseIP("192.168.1.254").To4(), VPCGateway: gw,
VPCRoute: vpcNet, VPCRoute: vpcNet,
DefaultGateway: gw, DefaultGateway: gw,
Name: "test", Name: "test",
@ -120,10 +120,7 @@ func TestGenerateConfig_NoDefaultGatewaySuppressesRouterOption(t *testing.T) {
} }
} }
func TestGenerateConfig_NilDefaultGatewayEmitsNoRoute(t *testing.T) { func TestGenerateConfig_VxlanEmitsNoRouterOption(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
@ -131,27 +128,31 @@ func TestGenerateConfig_NilDefaultGatewayEmitsNoRoute(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 toujours attendue, ne serait-ce que pour la route metadata :\n%s", content) t.Fatalf("dhcp-option=121 attendue pour un subnet vxlan :\n%s", content)
} }
if strings.Contains(string(content), "dhcp-option=3,") { if strings.Contains(string(content), "dhcp-option=3,") {
t.Errorf("DefaultGateway nulle : aucune route par défaut ne doit être émise\n%s", content) t.Errorf("un subnet vxlan est privé : aucune route par défaut ne doit être émise\n%s", content)
} }
} }
func TestGenerateConfig_ContainsVPCRoute(t *testing.T) { func TestGenerateConfig_ContainsVPCRoute(t *testing.T) {
routes := route121(t, confLines(t, newConf(t, "192.168.1.0/29"))) conf := newConf(t, "192.168.1.0/29")
if !strings.Contains(routes, "10.0.0.0/16,192.168.1.254") { path, _, _ := GenerateConfig(conf)
t.Errorf("route VPC absente, ou next-hop autre que l'interface_ip du subnet :\n%s", routes) 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)
} }
} }
func TestGenerateConfig_NoVPCRoute(t *testing.T) { func TestGenerateConfig_NoVPCRoute(t *testing.T) {
conf := newConf(t, "192.168.1.0/29") conf := newConf(t, "192.168.1.0/29")
conf.VPCRoute = nil conf.VPCRoute = nil
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
routes := route121(t, confLines(t, conf)) if strings.Contains(string(content), "dhcp-option=121,") {
if strings.Contains(routes, "10.0.0.0/16") { t.Errorf("dhcp-option=121 présente alors que VPCRoute=nil :\n%s", content)
t.Errorf("route VPC présente alors que VPCRoute=nil :\n%s", routes)
} }
} }
@ -159,7 +160,6 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) {
_, network, _ := net.ParseCIDR("10.10.0.0/24") _, network, _ := net.ParseCIDR("10.10.0.0/24")
conf := Config{ conf := Config{
Network: network, Network: network,
InterfaceIP: net.ParseIP("10.10.0.1").To4(),
Name: "vpc1", Name: "vpc1",
ConfDir: t.TempDir(), ConfDir: t.TempDir(),
} }
@ -205,7 +205,6 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) {
_, network, _ := net.ParseCIDR("10.0.0.0/30") _, network, _ := net.ParseCIDR("10.0.0.0/30")
conf := Config{ conf := Config{
Network: network, Network: network,
InterfaceIP: net.ParseIP("10.0.0.1").To4(),
Name: "net", Name: "net",
ConfDir: dir, ConfDir: dir,
} }
@ -216,79 +215,3 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) {
t.Errorf("répertoire %q non créé", dir) 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")
}
}

View file

@ -6,20 +6,17 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"git.g3e.fr/syonad/two/internal/metadata"
) )
func GenerateConfig(c Config) (string, map[string]string, error) { 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]) 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 var sb strings.Builder
fmt.Fprintf(&sb, "no-resolv\n") fmt.Fprintf(&sb, "no-resolv\n")
fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask) fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask)
fmt.Fprintf(&sb, "dhcp-option=121,%s\n", strings.Join(classlessRoutes(c), ",")) if c.VPCRoute != nil {
fmt.Fprintf(&sb, "dhcp-option=121,%s,%s\n", c.VPCRoute.String(), c.VPCGateway.String())
}
if c.DefaultGateway != nil { if c.DefaultGateway != nil {
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String()) fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String())
} else { } else {
@ -43,19 +40,6 @@ func GenerateConfig(c Config) (string, map[string]string, error) {
return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) 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) { func incrementIP(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- { for j := len(ip) - 1; j >= 0; j-- {
ip[j]++ ip[j]++

View file

@ -8,9 +8,9 @@ const DefaultConfDir = "/etc/dnsmasq.d"
type Config struct { type Config struct {
Network *net.IPNet Network *net.IPNet
InterfaceIP net.IP // subnet gateway; next-hop for the metadata and VPC routes VPCGateway net.IP // next-hop for VPCRoute (option 121)
VPCRoute *net.IPNet // if non-nil, routed via InterfaceIP in option 121 VPCRoute *net.IPNet // if non-nil, emit dhcp-option=121,VPCRoute,VPCGateway
DefaultGateway net.IP // if non-nil, default route via option 3 and 0.0.0.0/0 in option 121 DefaultGateway net.IP // if non-nil, emit dhcp-option=3,DefaultGateway; if nil, emit a bare dhcp-option=3 to suppress the dnsmasq default
Name string Name string
ConfDir string ConfDir string
} }

View file

@ -21,16 +21,15 @@ 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 = subnet.ModeVxlan c.Mode = "vxlan"
} }
if !subnet.ValidMode(c.Mode) { if c.Mode != "vxlan" && c.Mode != "bridge" {
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 {
@ -54,14 +53,9 @@ 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 == subnet.ModeVxlan { if c.Mode == "vxlan" {
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
} }

View file

@ -5,7 +5,6 @@ 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 {
@ -256,74 +255,3 @@ 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é")
}
}

View file

@ -29,7 +29,6 @@ type StartVMCommand struct {
UEFI bool UEFI bool
Password string Password string
SSHKey string SSHKey string
Documents map[string]string
} }
func (c StartVMCommand) Key() string { return "vm/" + c.Name } func (c StartVMCommand) Key() string { return "vm/" + c.Name }
@ -67,11 +66,6 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error {
if c.SSHKey != "" { if c.SSHKey != "" {
kv.AddInDB(db, "vm/"+c.Name+"/sshkey", c.SSHKey) kv.AddInDB(db, "vm/"+c.Name+"/sshkey", c.SSHKey)
} }
for doc, content := range c.Documents {
if err := kv.AddInDB(db, "vm/"+c.Name+"/metadata/"+doc, content); err != nil {
return fmt.Errorf("store metadata %s: %w", doc, err)
}
}
return nil return nil
} }

View file

@ -4,7 +4,6 @@ import (
"testing" "testing"
"git.g3e.fr/syonad/two/pkg/db/kv" "git.g3e.fr/syonad/two/pkg/db/kv"
"github.com/dgraph-io/badger/v4"
) )
// --- StartVMCommand.Prepare : écriture des disques en DB --- // --- StartVMCommand.Prepare : écriture des disques en DB ---
@ -174,98 +173,3 @@ func TestStartVMCommand_Prepare_Duplicate(t *testing.T) {
t.Error("Prepare devrait échouer si la VM existe déjà") t.Error("Prepare devrait échouer si la VM existe déjà")
} }
} }
// --- StartVMCommand.Prepare : documents cloud-init ---
func prepareWithDocuments(t *testing.T, docs map[string]string) *badger.DB {
t.Helper()
_, db := newTestDispatcher(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
cmd := StartVMCommand{
Name: "vm-doc",
Subnet: "sn-1",
IP: "10.0.0.5",
Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "vda"}},
Documents: docs,
}
if err := cmd.Prepare(db, nil); err != nil {
t.Fatalf("Prepare a échoué : %v", err)
}
return db
}
func TestStartVMCommand_Prepare_StoresDocuments(t *testing.T) {
userData := "#cloud-config\npackages:\n - nginx\n"
db := prepareWithDocuments(t, map[string]string{"user-data": userData})
got, err := kv.GetFromDB(db, "vm/vm-doc/metadata/user-data")
if err != nil {
t.Fatalf("clé metadata/user-data absente : %v", err)
}
if got != userData {
t.Errorf("user-data attendu %q, obtenu %q", userData, got)
}
}
func TestStartVMCommand_Prepare_NoDocumentsWritesNoKey(t *testing.T) {
db := prepareWithDocuments(t, nil)
entries, err := kv.ListByPrefix(db, "vm/vm-doc/metadata/")
if err != nil {
t.Fatalf("ListByPrefix : %v", err)
}
if len(entries) != 0 {
t.Errorf("aucun document fourni, aucune clé ne doit être écrite : %v", entries)
}
}
func TestStartVMCommand_Prepare_EmptyDocumentIsStored(t *testing.T) {
db := prepareWithDocuments(t, map[string]string{"vendor-data": ""})
entries, err := kv.ListByPrefix(db, "vm/vm-doc/metadata/")
if err != nil {
t.Fatalf("ListByPrefix : %v", err)
}
if _, ok := entries["vm/vm-doc/metadata/vendor-data"]; !ok {
t.Error("un document explicitement vide doit être stocké : c'est une demande de ne rien servir, pas une absence de demande")
}
}
func TestStartVMCommand_Prepare_AllDocumentKinds(t *testing.T) {
docs := map[string]string{
"user-data": "u",
"meta-data": "m",
"network-config": "n",
"vendor-data": "v",
}
db := prepareWithDocuments(t, docs)
for doc, want := range docs {
got, err := kv.GetFromDB(db, "vm/vm-doc/metadata/"+doc)
if err != nil {
t.Errorf("clé metadata/%s absente : %v", doc, err)
continue
}
if got != want {
t.Errorf("%s attendu %q, obtenu %q", doc, want, got)
}
}
}
func TestDeleteInDB_RemovesMetadataDocuments(t *testing.T) {
db := prepareWithDocuments(t, map[string]string{"user-data": "u", "vendor-data": "v"})
if err := kv.DeleteInDB(db, "vm/vm-doc"); err != nil {
t.Fatalf("DeleteInDB : %v", err)
}
entries, err := kv.ListByPrefix(db, "vm/vm-doc/")
if err != nil {
t.Fatalf("ListByPrefix : %v", err)
}
if len(entries) != 0 {
t.Errorf("la suppression de la VM doit emporter ses documents : %v", entries)
}
}

View file

@ -3,8 +3,6 @@ package iptables
import ( import (
"fmt" "fmt"
"os/exec" "os/exec"
"git.g3e.fr/syonad/two/internal/metadata"
) )
func addRule(args ...string) error { func addRule(args ...string) error {
@ -18,7 +16,7 @@ func deleteRule(args ...string) error {
func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
if err := addRule("PREROUTING", if err := addRule("PREROUTING",
"-s", vmIP+"/32", "-s", vmIP+"/32",
"-d", metadata.ServiceIP+"/32", "-d", "169.254.169.254/32",
"-p", "tcp", "-m", "tcp", "-p", "tcp", "-m", "tcp",
"--dport", "80", "--dport", "80",
"-j", "DNAT", "-j", "DNAT",
@ -32,7 +30,7 @@ func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
if err := deleteRule("PREROUTING", if err := deleteRule("PREROUTING",
"-s", vmIP+"/32", "-s", vmIP+"/32",
"-d", metadata.ServiceIP+"/32", "-d", "169.254.169.254/32",
"-p", "tcp", "-m", "tcp", "-p", "tcp", "-m", "tcp",
"--dport", "80", "--dport", "80",
"-j", "DNAT", "-j", "DNAT",

View file

@ -8,37 +8,33 @@ import (
) )
func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error { func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error {
if err := WriteNoCloudFiles(config, cfg.Metadata.RunDir); err != nil {
return fmt.Errorf("write nocloud files for %s: %w", config.Name, err)
}
if dryrun {
return nil
}
service, err := systemd.New() service, err := systemd.New()
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to systemd: %w", err) return fmt.Errorf("failed to connect to systemd: %w", err)
} }
defer service.Close() defer service.Close()
LoadNcCloudInDB(config, cfg.Metadata.RunDir)
if !dryrun {
if err := service.Start("metadata@" + config.Name + ".service"); err != nil { if err := service.Start("metadata@" + config.Name + ".service"); err != nil {
return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err) return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err)
} }
}
return nil return nil
} }
func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error { func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error {
if !dryrun {
service, err := systemd.New() service, err := systemd.New()
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to systemd: %w", err) return fmt.Errorf("failed to connect to systemd: %w", err)
} }
defer service.Close() defer service.Close()
UnLoadNoCloudInDB(vmName, cfg.Metadata.RunDir)
if !dryrun {
if err := service.Stop("metadata@" + vmName + ".service"); err != nil { if err := service.Stop("metadata@" + vmName + ".service"); err != nil {
return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err) return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err)
} }
} }
return nil
return RemoveNoCloudFiles(vmName, cfg.Metadata.RunDir)
} }

View file

@ -25,21 +25,6 @@ func useTestDir(t *testing.T) string {
return t.TempDir() return t.TempDir()
} }
func mustWrite(t *testing.T, cfg NoCloudConfig, dir string) {
t.Helper()
if err := WriteNoCloudFiles(cfg, dir); err != nil {
t.Fatalf("WriteNoCloudFiles : %v", err)
}
}
func mustRemove(t *testing.T, vmName, dir string) {
t.Helper()
if err := RemoveNoCloudFiles(vmName, dir); err != nil {
t.Fatalf("RemoveNoCloudFiles : %v", err)
}
}
// --- RenderConfig --- // --- RenderConfig ---
func TestRenderConfig_MetaData(t *testing.T) { func TestRenderConfig_MetaData(t *testing.T) {
@ -89,23 +74,14 @@ func TestRenderConfig_NetworkConfig(t *testing.T) {
} }
} }
func TestRenderConfig_UserData_DefaultIsEmpty(t *testing.T) { func TestRenderConfig_UserData(t *testing.T) {
doc, err := renderDocument(DocUserData, newCfg()) cfg := newCfg()
out, err := RenderConfig("templates/user-data.tmpl", cfg)
if err != nil { if err != nil {
t.Fatalf("renderDocument user-data : %v", err) t.Fatalf("RenderConfig user-data : %v", err)
} }
if doc != "" { if !strings.Contains(out, "passwd -d root") {
t.Errorf("le user-data par défaut doit être vide, obtenu :\n%q", doc) t.Errorf("user-data inattendu :\n%s", out)
}
}
func TestRenderConfig_UserData_NeverTouchesRootPassword(t *testing.T) {
doc, err := renderDocument(DocUserData, newCfg())
if err != nil {
t.Fatalf("renderDocument user-data : %v", err)
}
if strings.Contains(doc, "passwd -d root") {
t.Errorf("l'agent ne doit imposer aucune modification du compte root :\n%s", doc)
} }
} }
@ -128,21 +104,21 @@ func TestRenderConfig_SpecialCharsInName(t *testing.T) {
} }
} }
// --- WriteNoCloudFiles / RemoveNoCloudFiles --- // --- LoadNcCloudInDB / UnLoadNoCloudInDB ---
func readTestFile(t *testing.T, dir, vmName, name string) string { func readTestFile(t *testing.T, dir, vmName, name string) string {
t.Helper() t.Helper()
b, err := os.ReadFile(filepath.Join(dir, vmName, name)) b, err := os.ReadFile(filepath.Join(dir, vmName, name))
if err != nil { if err != nil {
t.Errorf("fichier %q absent après WriteNoCloudFiles : %v", name, err) t.Errorf("fichier %q absent après LoadNcCloudInDB : %v", name, err)
return "" return ""
} }
return string(b) return string(b)
} }
func TestWriteNoCloudFiles_StoresAllFiles(t *testing.T) { func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
mustWrite(t, newCfg(), dir) LoadNcCloudInDB(newCfg(), dir)
files := []string{"meta-data", "user-data", "network-config", "vendor-data", "vpc", "bind_ip", "bind_port"} files := []string{"meta-data", "user-data", "network-config", "vendor-data", "vpc", "bind_ip", "bind_port"}
for _, f := range files { for _, f := range files {
@ -153,9 +129,9 @@ func TestWriteNoCloudFiles_StoresAllFiles(t *testing.T) {
} }
} }
func TestWriteNoCloudFiles_VpcAndBindValues(t *testing.T) { func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
mustWrite(t, newCfg(), dir) LoadNcCloudInDB(newCfg(), dir)
if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" { if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" {
t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc) t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc)
@ -168,26 +144,26 @@ func TestWriteNoCloudFiles_VpcAndBindValues(t *testing.T) {
} }
} }
func TestRemoveNoCloudFiles_RemovesAllFiles(t *testing.T) { func TestUnLoadNoCloudInDB_RemovesAllFiles(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
mustWrite(t, newCfg(), dir) LoadNcCloudInDB(newCfg(), dir)
mustRemove(t, "vm1", dir) UnLoadNoCloudInDB("vm1", dir)
if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) {
t.Error("répertoire vm1 devrait être supprimé après RemoveNoCloudFiles") t.Error("répertoire vm1 devrait être supprimé après UnLoadNoCloudInDB")
} }
} }
func TestRemoveNoCloudFiles_DoesNotAffectOtherVMs(t *testing.T) { func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
cfg1 := newCfg() cfg1 := newCfg()
cfg2 := newCfg() cfg2 := newCfg()
cfg2.Name = "vm2" cfg2.Name = "vm2"
mustWrite(t, cfg1, dir) LoadNcCloudInDB(cfg1, dir)
mustWrite(t, cfg2, dir) LoadNcCloudInDB(cfg2, dir)
mustRemove(t, "vm1", dir) UnLoadNoCloudInDB("vm1", dir)
if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil { if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil {
t.Errorf("vm2 ne devrait pas être supprimée : %v", err) t.Errorf("vm2 ne devrait pas être supprimée : %v", err)
@ -288,138 +264,3 @@ func TestRootHandler_ContentType(t *testing.T) {
t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct) t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct)
} }
} }
// --- vendor-data : blocs conditionnels ---
func vendorData(t *testing.T, password, sshkey string) string {
t.Helper()
cfg := newCfg()
cfg.Password = password
cfg.SSHKEY = sshkey
out, err := renderDocument(DocVendorData, cfg)
if err != nil {
t.Fatalf("renderDocument vendor-data : %v", err)
}
return out
}
func TestVendorData_NoCredentials_EmitsNothing(t *testing.T) {
out := vendorData(t, "", "")
if out != "" {
t.Errorf("sans mot de passe ni clé, aucun compte ne doit être créé :\n%s", out)
}
}
func TestVendorData_NoCredentials_NeverEmitsEmptyValues(t *testing.T) {
out := vendorData(t, "", "")
for _, forbidden := range []string{`passwd: ""`, `- ""`, "lock_passwd: false"} {
if strings.Contains(out, forbidden) {
t.Errorf("valeur vide %q émise :\n%s", forbidden, out)
}
}
}
func TestVendorData_PasswordOnly(t *testing.T) {
out := vendorData(t, "$6$rounds$hash", "")
if !strings.Contains(out, `passwd: "$6$rounds$hash"`) {
t.Errorf("hash absent :\n%s", out)
}
if !strings.Contains(out, "lock_passwd: false") {
t.Errorf("un compte avec mot de passe doit être déverrouillé :\n%s", out)
}
if strings.Contains(out, "ssh_authorized_keys") {
t.Errorf("aucune clé fournie, le bloc ne doit pas apparaître :\n%s", out)
}
}
func TestVendorData_SSHKeyOnly_LocksPassword(t *testing.T) {
out := vendorData(t, "", "ssh-ed25519 AAAA user@host")
if !strings.Contains(out, "ssh-ed25519 AAAA user@host") {
t.Errorf("clé absente :\n%s", out)
}
if strings.Contains(out, "\n passwd:") {
t.Errorf("aucun mot de passe fourni, le champ ne doit pas apparaître :\n%s", out)
}
if !strings.Contains(out, "lock_passwd: true") {
t.Errorf("sans mot de passe le compte doit rester verrouillé, sinon il devient un compte sudo sans mot de passe :\n%s", out)
}
}
func TestVendorData_BothCredentials(t *testing.T) {
out := vendorData(t, "$6$hash", "ssh-ed25519 AAAA user@host")
for _, expected := range []string{"#cloud-config", "name: syonad", `passwd: "$6$hash"`, "lock_passwd: false", "ssh-ed25519 AAAA user@host"} {
if !strings.Contains(out, expected) {
t.Errorf("%q absent :\n%s", expected, out)
}
}
}
func TestVendorData_CloudConfigHeaderIsFirstLine(t *testing.T) {
out := vendorData(t, "$6$hash", "")
if !strings.HasPrefix(out, "#cloud-config\n") {
t.Errorf("cloud-init exige #cloud-config en première ligne :\n%q", out)
}
}
// --- documents fournis par l'appelant ---
func TestRenderDocument_VerbatimOverridesTemplate(t *testing.T) {
cfg := newCfg()
supplied := "#cloud-config\npackages:\n - nginx\n"
cfg.Documents = map[string]string{DocUserData: supplied}
out, err := renderDocument(DocUserData, cfg)
if err != nil {
t.Fatalf("renderDocument : %v", err)
}
if out != supplied {
t.Errorf("le document fourni doit être écrit verbatim :\nattendu %q\nobtenu %q", supplied, out)
}
}
func TestRenderDocument_EmptySuppliedDocumentIsHonoured(t *testing.T) {
cfg := newCfg()
cfg.Documents = map[string]string{DocVendorData: ""}
out, err := renderDocument(DocVendorData, cfg)
if err != nil {
t.Fatalf("renderDocument : %v", err)
}
if out != "" {
t.Errorf("un document explicitement vide ne doit pas retomber sur le template :\n%s", out)
}
}
func TestWriteNoCloudFiles_WritesSuppliedDocument(t *testing.T) {
dir := useTestDir(t)
cfg := newCfg()
cfg.Documents = map[string]string{DocUserData: "#cloud-config\nruncmd:\n - [ls]\n"}
mustWrite(t, cfg, dir)
if got := readTestFile(t, dir, "vm1", "user-data"); got != cfg.Documents[DocUserData] {
t.Errorf("user-data servi différent de celui fourni :\n%q", got)
}
}
// --- remontée des erreurs ---
func TestWriteNoCloudFiles_ReturnsErrorOnUnwritableDir(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignore les permissions de fichiers")
}
dir := useTestDir(t)
if err := os.Chmod(dir, 0500); err != nil {
t.Fatalf("chmod : %v", err)
}
t.Cleanup(func() { os.Chmod(dir, 0700) })
if err := WriteNoCloudFiles(newCfg(), dir); err == nil {
t.Error("une écriture impossible doit remonter une erreur : une VM ne doit jamais démarrer sans métadonnées en silence")
}
}
func TestRemoveNoCloudFiles_AbsentDirIsNotAnError(t *testing.T) {
if err := RemoveNoCloudFiles("jamais-creee", useTestDir(t)); err != nil {
t.Errorf("supprimer une VM sans fichiers ne doit pas échouer : %v", err)
}
}

View file

@ -3,27 +3,14 @@ package metadata
import ( import (
"bytes" "bytes"
"embed" "embed"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"text/template" "text/template"
) )
//go:embed templates/*.tmpl //go:embed templates/*.tmpl
var templateFS embed.FS var templateFS embed.FS
const (
DocMetaData = "meta-data"
DocUserData = "user-data"
DocNetworkConfig = "network-config"
DocVendorData = "vendor-data"
)
func Documents() []string {
return []string{DocMetaData, DocUserData, DocNetworkConfig, DocVendorData}
}
func RenderConfig(path string, cfg NoCloudConfig) (string, error) { func RenderConfig(path string, cfg NoCloudConfig) (string, error) {
tpl, err := template.ParseFS(templateFS, path) tpl, err := template.ParseFS(templateFS, path)
if err != nil { if err != nil {
@ -38,55 +25,25 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) {
return buf.String(), nil return buf.String(), nil
} }
func renderDocument(name string, cfg NoCloudConfig) (string, error) { func LoadNcCloudInDB(config NoCloudConfig, runDir string) {
if doc, ok := cfg.Documents[name]; ok { meta_data, _ := RenderConfig("templates/meta-data.tmpl", config)
return doc, nil user_data, _ := RenderConfig("templates/user-data.tmpl", config)
} network_config, _ := RenderConfig("templates/network-config.tmpl", config)
vendor_data, _ := RenderConfig("templates/vendor-data.tmpl", config)
out, err := RenderConfig("templates/"+name+".tmpl", cfg)
if err != nil {
return "", fmt.Errorf("render %s: %w", name, err)
}
out = strings.TrimSpace(out)
if out == "" {
return "", nil
}
return out + "\n", nil
}
func WriteNoCloudFiles(config NoCloudConfig, runDir string) error {
dir := filepath.Join(runDir, config.Name) dir := filepath.Join(runDir, config.Name)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create %s: %w", dir, err) return
}
os.WriteFile(filepath.Join(dir, "meta-data"), []byte(meta_data), 0644)
os.WriteFile(filepath.Join(dir, "user-data"), []byte(user_data), 0644)
os.WriteFile(filepath.Join(dir, "network-config"), []byte(network_config), 0644)
os.WriteFile(filepath.Join(dir, "vendor-data"), []byte(vendor_data), 0644)
os.WriteFile(filepath.Join(dir, "vpc"), []byte(config.VpcName), 0644)
os.WriteFile(filepath.Join(dir, "bind_ip"), []byte(config.BindIP), 0644)
os.WriteFile(filepath.Join(dir, "bind_port"), []byte(config.BindPort), 0644)
} }
files := map[string]string{ func UnLoadNoCloudInDB(vmName string, runDir string) {
"vpc": config.VpcName, os.RemoveAll(filepath.Join(runDir, vmName))
"bind_ip": config.BindIP,
"bind_port": config.BindPort,
}
for _, name := range Documents() {
doc, err := renderDocument(name, config)
if err != nil {
return err
}
files[name] = doc
}
for name, content := range files {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
return nil
}
func RemoveNoCloudFiles(vmName string, runDir string) error {
dir := filepath.Join(runDir, vmName)
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove %s: %w", dir, err)
}
return nil
} }

View file

@ -1,10 +1,5 @@
package metadata 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 { type NoCloudData struct {
MetaData string MetaData string
UserData string UserData string
@ -27,5 +22,4 @@ type NoCloudConfig struct {
Name string Name string
Password string Password string
SSHKEY string SSHKEY string
Documents map[string]string
} }

View file

@ -0,0 +1,3 @@
#!/bin/sh
passwd -d root

View file

@ -1,17 +1,9 @@
{{- if or .Password .SSHKEY -}}
#cloud-config #cloud-config
users: users:
- name: syonad - name: syonad
lock_passwd: false
sudo: ["ALL=(ALL) NOPASSWD:ALL"] sudo: ["ALL=(ALL) NOPASSWD:ALL"]
shell: /bin/bash shell: /bin/bash
{{- if .Password }}
lock_passwd: false
passwd: "{{ .Password }}" passwd: "{{ .Password }}"
{{- else }}
lock_passwd: true
{{- end }}
{{- if .SSHKEY }}
ssh_authorized_keys: ssh_authorized_keys:
- "{{ .SSHKEY }}" - "{{ .SSHKEY }}"
{{- end }}
{{- end }}

View file

@ -43,13 +43,11 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
} }
switch d.mode { switch d.mode {
case ModeVxlan: case "vxlan":
if err := setupVxlanHost(d, vethE); err != nil { if err := setupVxlanHost(d, vethE); err != nil {
return err return err
} }
case ModePublicIP: case "bridge":
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,14 +139,20 @@ func startDHCP(db *badger.DB, subnetName string, d subnetData) error {
Network: d.cidr, Network: d.cidr,
Name: d.vpc + "_" + d.bridge, Name: d.vpc + "_" + d.bridge,
ConfDir: dhcp.DefaultConfDir, ConfDir: dhcp.DefaultConfDir,
InterfaceIP: d.interfaceIP,
} }
defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway) switch d.mode {
case "vxlan":
conf.VPCGateway = d.interfaceIP
conf.VPCRoute = d.vpcCIDR
case "bridge":
if d.defaultRoute {
gw, err := netif.GetDefaultGateway()
if err != nil { if err != nil {
return err 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)

View file

@ -21,7 +21,6 @@ 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) {
@ -86,14 +85,6 @@ 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)

View file

@ -1,15 +0,0 @@
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
}

View file

@ -1,34 +0,0 @@
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
}

View file

@ -1,158 +0,0 @@
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)
}
}
}

View file

@ -48,7 +48,6 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
BindPort: d.metadataPort, BindPort: d.metadataPort,
Password: d.password, Password: d.password,
SSHKEY: d.sshkey, SSHKEY: d.sshkey,
Documents: d.documents,
}, cfg, false); err != nil { }, cfg, false); err != nil {
return fmt.Errorf("start metadata: %w", err) return fmt.Errorf("start metadata: %w", err)
} }

View file

@ -31,7 +31,6 @@ type vmData struct {
uefi bool uefi bool
password string password string
sshkey string sshkey string
documents map[string]string
} }
func loadVM(db *badger.DB, name string) (vmData, error) { func loadVM(db *badger.DB, name string) (vmData, error) {
@ -125,17 +124,5 @@ func loadVM(db *badger.DB, name string) (vmData, error) {
d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password") d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password")
d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey") d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey")
docPrefix := "vm/" + name + "/metadata/"
docEntries, err := kv.ListByPrefix(db, docPrefix)
if err != nil {
return d, fmt.Errorf("list metadata documents: %w", err)
}
if len(docEntries) > 0 {
d.documents = make(map[string]string, len(docEntries))
for key, content := range docEntries {
d.documents[strings.TrimPrefix(key, docPrefix)] = content
}
}
return d, nil return d, nil
} }

View file

@ -1,90 +0,0 @@
package vm
import (
"testing"
"git.g3e.fr/syonad/two/pkg/db/kv"
"github.com/dgraph-io/badger/v4"
)
func newVMInDB(t *testing.T) *badger.DB {
t.Helper()
db := kv.InitDB(kv.Config{Path: t.TempDir()}, false)
t.Cleanup(func() { db.Close() })
kv.AddInDB(db, "vm/vm-1/subnet", "sn-000001")
kv.AddInDB(db, "subnet/sn-000001/vpc", "vp-admin")
kv.AddInDB(db, "subnet/sn-000001/interface_ip", "10.1.1.1")
kv.AddInDB(db, "subnet/sn-000001/dhcp/10.1.1.2", "00:22:33:00:01:02")
kv.AddInDB(db, "vm/vm-1/ip", "10.1.1.2")
kv.AddInDB(db, "vm/vm-1/metadata_port", "8081")
kv.AddInDB(db, "vm/vm-1/disk/vda", "/data/root.qcow2")
kv.AddInDB(db, "vm/vm-1/memory", "2048")
kv.AddInDB(db, "vm/vm-1/cpus", "2")
return db
}
func TestLoadVM_NoDocuments(t *testing.T) {
db := newVMInDB(t)
d, err := loadVM(db, "vm-1")
if err != nil {
t.Fatalf("loadVM : %v", err)
}
if d.documents != nil {
t.Errorf("aucun document en base, la map doit rester nil : %v", d.documents)
}
}
func TestLoadVM_ReadsDocuments(t *testing.T) {
db := newVMInDB(t)
userData := "#cloud-config\npackages:\n - nginx\n"
kv.AddInDB(db, "vm/vm-1/metadata/user-data", userData)
kv.AddInDB(db, "vm/vm-1/metadata/network-config", "version: 2\n")
d, err := loadVM(db, "vm-1")
if err != nil {
t.Fatalf("loadVM : %v", err)
}
if len(d.documents) != 2 {
t.Fatalf("2 documents attendus, obtenu %d : %v", len(d.documents), d.documents)
}
if d.documents["user-data"] != userData {
t.Errorf("user-data altéré au passage en base :\nattendu %q\nobtenu %q", userData, d.documents["user-data"])
}
if d.documents["network-config"] != "version: 2\n" {
t.Errorf("network-config inattendu : %q", d.documents["network-config"])
}
}
func TestLoadVM_DocumentKeysAreStrippedOfPrefix(t *testing.T) {
db := newVMInDB(t)
kv.AddInDB(db, "vm/vm-1/metadata/vendor-data", "v")
d, err := loadVM(db, "vm-1")
if err != nil {
t.Fatalf("loadVM : %v", err)
}
for key := range d.documents {
if key != "vendor-data" {
t.Errorf("clé de document attendue %q, obtenue %q — le préfixe doit être retiré", "vendor-data", key)
}
}
}
func TestLoadVM_EmptyDocumentIsPreserved(t *testing.T) {
db := newVMInDB(t)
kv.AddInDB(db, "vm/vm-1/metadata/user-data", "")
d, err := loadVM(db, "vm-1")
if err != nil {
t.Fatalf("loadVM : %v", err)
}
content, ok := d.documents["user-data"]
if !ok {
t.Fatal("un document vide doit survivre au passage en base : c'est une demande de ne rien servir")
}
if content != "" {
t.Errorf("contenu attendu vide, obtenu %q", content)
}
}