Développement API Master

Un petit schéma de ce que ca va donner

Grosso modo, je souhaite qu'un utilisateur (plus tard notre CRM) envoie une requête HTTP à mon API avec certaines données json (le nom, la ville, la techno, etc). Ensuite, c'est cette API qui va contacter toute mon infra en commençant par Netbox puis mon radius puis Centreon/PowerDNS/ZTP Mikrotik.

Le tree de mon projet :

naradmin@devops:~/api-master$ tree
.
├── cmd
│   └── api
│       └── main.go
├── go.mod
├── go.sum
├── internal
│   ├── api
│   │   ├── controllers
│   │   │   ├── customer.go
│   │   │   └── ping.go
│   │   ├── middlewares
│   │   └── routes
│   │       └── router.go
│   ├── app
│   │   ├── application.go
│   │   ├── centreon.go
│   │   ├── dns.go
│   │   ├── netbox.go
│   │   ├── radius.go
│   │   ├── saga.go
│   │   └── ztp.go
│   └── utils
│       └── generatepassword.go
├── models
│   ├── centreon.go
│   ├── customer.go
│   ├── dns.go
│   ├── radius.go
│   └── ztp.go

Netbox

On va appeler l'API de Netbox en go. Pour cela, on doit savoir qu'est ce qu'on va faire avec. J'ai plusieurs besoins :

      • Création du CPE
      • Réservation de l'IPv4 WAN de management et link au device
      • Réservation du subnet VOIP
      • Réservation du subnet v6 data
      • Réservation de l'IP Pub

Au lieu de mettre le code dans la fonction gin, j'ai préféré séparer et faire des fonctions. On va donc appeler l'API netbox avec ces fonctions. Au total, on va avoir 5 fonctions pour Netbox (une pour chaque besoin). Je ne vais pas détailler ligne par ligne car elles sont assez similaires. J'utilise le module go-netbox qui me permet d'appeler l'API en golang directement.

Pour cela, j'utilise 5 fonctions de ce module :

Besoins métiers Fonctions natives du module
Création du device DcimAPI.DcimDevicesCreate(ctx).WritableDeviceWithConfigContextRequest(deviceData)
Création interface DcimAPI.DcimInterfacesCreate(ctx).WritableInterfaceRequest(interfaceData)
Résever l'IPv4 WAN IpamAPI.IpamPrefixesAvailableIpsCreate(ctx, prefixID).IPAddressRequest([]netbox.IPAddressRequest{IPRequest})
Réserver un préfixe IpamAPI.IpamPrefixesAvailablePrefixesCreate(ctx, prefixID).PrefixRequest([]netbox.PrefixRequest{prefixRequest})

Une fois ces informations connues (dieu seul sait combien ça a été long car Netbox ne fournit pas de documentation sur l'API lol donc faut aller chercher dans le code source issou), il nous reste plus qu'une variable à connaitre : le contenu de la requête.

Par exemple, pour créer une interface, on a besoin de créer un modèle WritableInterfaceRequest qui lui contient les informations de la requête. Regardons dans le code source :

type WritableInterfaceRequest struct {
	Device BriefInterfaceRequestDevice      `json:"device"`
	Vdcs   []int32                          `json:"vdcs,omitempty"`
	Module NullableConsolePortRequestModule `json:"module,omitempty"`
	Name   string                           `json:"name"`
	// Physical label
	Label             *string                                   `json:"label,omitempty"`
	Type              InterfaceTypeValue                        `json:"type"`
	Enabled           *bool                                     `json:"enabled,omitempty"`
	Parent            NullableInt32                             `json:"parent,omitempty"`
	Bridge            NullableInt32                             `json:"bridge,omitempty"`
	Lag               NullableInt32                             `json:"lag,omitempty"`
	Mtu               NullableInt32                             `json:"mtu,omitempty"`
	PrimaryMacAddress NullableInterfaceRequestPrimaryMacAddress `json:"primary_mac_address,omitempty"`
	Speed             NullableInt32                             `json:"speed,omitempty"`
	Duplex            NullableInterfaceRequestDuplex            `json:"duplex,omitempty"`
	Wwn               NullableString                            `json:"wwn,omitempty"`
	// This interface is used only for out-of-band management
	MgmtOnly    *bool                                       `json:"mgmt_only,omitempty"`
	Description *string                                     `json:"description,omitempty"`
	Mode        NullablePatchedWritableInterfaceRequestMode `json:"mode,omitempty"`
	RfRole      NullableWirelessRole                        `json:"rf_role,omitempty"`
	RfChannel   NullableWirelessChannel                     `json:"rf_channel,omitempty"`
	PoeMode     NullableInterfaceTemplateRequestPoeMode     `json:"poe_mode,omitempty"`
	PoeType     NullableInterfaceTemplateRequestPoeType     `json:"poe_type,omitempty"`
	// Populated by selected channel (if set)
	RfChannelFrequency NullableFloat64 `json:"rf_channel_frequency,omitempty"`
	// Populated by selected channel (if set)
	RfChannelWidth        NullableFloat64                               `json:"rf_channel_width,omitempty"`
	TxPower               NullableInt32                                 `json:"tx_power,omitempty"`
	UntaggedVlan          NullableInterfaceRequestUntaggedVlan          `json:"untagged_vlan,omitempty"`
	TaggedVlans           []int32                                       `json:"tagged_vlans,omitempty"`
	QinqSvlan             NullableInterfaceRequestUntaggedVlan          `json:"qinq_svlan,omitempty"`
	VlanTranslationPolicy NullableInterfaceRequestVlanTranslationPolicy `json:"vlan_translation_policy,omitempty"`
	// Treat as if a cable is connected
	MarkConnected        *bool                       `json:"mark_connected,omitempty"`
	WirelessLans         []int32                     `json:"wireless_lans,omitempty"`
	Vrf                  NullableIPAddressRequestVrf `json:"vrf,omitempty"`
	Tags                 []NestedTagRequest          `json:"tags,omitempty"`
	CustomFields         map[string]interface{}      `json:"custom_fields,omitempty"`
	AdditionalProperties map[string]interface{}
}

Parmi tout ça, seul la variable Device (qui est une struct BriefInterfaceRequestDevice), le name et le type sont obligatoires. Allons donc regarder le contenu de BriefInterfaceRequestDevice :

type BriefInterfaceRequestDevice struct {
	BriefDeviceRequest *BriefDeviceRequest
	Int32              *int32
}

On a donc le choix ici : soit on précise l'ID en int32 du device soit on renseigne BriefDeviceRequest qui est une structure :

type BriefDeviceRequest struct {
	Name                 NullableString `json:"name,omitempty"`
	Description          *string        `json:"description,omitempty"`
	AdditionalProperties map[string]interface{}
}

Personnellement, pour ce choix, j'ai préféré prendre l'ID du device car je le return dans ma fonction CreateDevice 😄

Ainsi, ca ressemble à ça :

interfaceData := netbox.WritableInterfaceRequest{
	Device: netbox.BriefInterfaceRequestDevice{
 		Int32: &deviceID,
 	},
	Name: interfaceName,
	Type: "virtual",
}

On peut donc créer notre fonction qui attend en argument le ctx, le client netbox (qui sont nécessaires pour lancer l'appel API), le deviceID en int32 et le nom de l'interface en string. Je return donc deux informations : l'ID de l'interface (pour pouvoir linker avec l'IPv4 WAN) et error si jamais :

func CreateNetboxInterface(ctx context.Context, client *netbox.APIClient, deviceID int32, interfaceName string) (int32, error) {
	interfaceData := netbox.WritableInterfaceRequest{
		Device: netbox.BriefInterfaceRequestDevice{
			Int32: &deviceID,
		},
		Name: interfaceName,
		Type: "virtual",
	}

	resi, _, err := client.DcimAPI.DcimInterfacesCreate(ctx).WritableInterfaceRequest(interfaceData).Execute()
	if err != nil {
		return 0, fmt.Errorf("error creating interface: %w", err)
	}
	return resi.Id, nil
}

Bien sûr cette fonction ne sert pas à grand chose si personne l'appelle ... Donc dans mon fichier où j'ai mes routes API, j'appelle la fonction :

	ctx := context.Background()
        client := netbox.NewAPIClientFor(URL_NETBOX, TOKEN_NETBOX)

	interfacename := "Interface-" + username
	idInterface, err := app.CreateNetboxInterface(ctx, client, idCPE, interfacename)
	if err != nil {
		fmt.Println("Erreur création interface via fonction: ", err)
		return
	}
	fmt.Println("ID interface: ", idInterface)

Toutes mes fonctions Netbox ressemblent à cela ! Le plus compliqué reste donc de comprendre comment appeler l'API ! Un petit tableau pour le nom des modèles :

Data requête Modèles
deviceData WritableDeviceWithConfigContextRequest
interfaceData WritableInterfaceRequest
IPRequest IPAddressRequest
prefixRequest PrefixRequest

Et pour chercher dans le code source, il suffit de rechercher le model_xxxx.go dans l'arborescence du repo :

La seule particularité présente est dans ma fonction pour réserver une IP publique. En effet, vu qu'on dispose de plusieurs subnet publiques, il faut récupérer l'ID de chaque préfix et loop dessus :

	idpublic, _, err := client.IpamAPI.IpamPrefixesList(ctx).Tag([]string{"prefix_public"}).Execute()
	if err != nil {
		return nil, fmt.Errorf("Erreur récupération du préfixe public: %w", err)
	}
	var prefixIDs []int32
	for _, result := range idpublic.Results {
		prefixIDs = append(prefixIDs, result.Id)
	}

	IPRequest := netbox.IPAddressRequest{
		Description: &description,
	}

	for _, prefixID := range prefixIDs {
		resa, _, err := client.IpamAPI.IpamPrefixesAvailableIpsCreate(ctx, prefixID).IPAddressRequest([]netbox.IPAddressRequest{IPRequest}).Execute()
		if err != nil {
			return nil, fmt.Errorf("Erreur création de l'addresse dans le préfixe %d: %w", prefixID, err)
		}
		if len(resa) > 0 {
			ip, _, _ := net.ParseCIDR(resa[0].Address)
		return ip, nil
		}
	}

Sur netbox, on peut mettre un tag sur des préfixes. J'en ai donc crée un prefix_public. On récupère donc tous les ID des préfix publiques via l'appel client.IpamAPI.IpamPrefixesList(ctx).Tag([]string{"prefix_public"}).Execute() ensuite, on les mets dans un tableau et on loop sur ce tableau en faisant la requête client.IpamAPI.IpamPrefixesAvailableIpsCreate(ctx, prefixID).IPAddressRequest([]netbox.IPAddressRequest{IPRequest}).Execute()

Radius

Comme vu dans le précédent épisode, j'ai codé cette API. Il n'y a donc pas d'outil intégré en go comme avec netbox lol

Donc je vais faire mes requêtes à coup de POST ! Tout d'abord, on définit un modèle radius.go :

package models

type Request struct {
	Username  string   `json:"Username"`
	Attribute string   `json:"Attribute"`
	Value  string    `json:"Value"`
	Options   []Option `json:"Options"`
}

type Option struct {
	Attribute string `json:"Attribute"`
	Value     string `json:"Value"`
} 

Ensuite dans la fonction CreateRadiusUser, je définis la variable requestData :

	requestData := models.Request{
		Username: username + "@naruto.ninja",
		Attribute: "Cleartext-Password",
		Value: password,
		Options: []models.Option{
			{
				Attribute: "Framed-IP-Address",
				Value:     ip.String(),
			},
			{
				Attribute: "Mikrotik-Group",
				Value:     "Profile-" + client,
			},
		},
	}

Puis, on parse requestData en JSON :

	jsonData, err := json.Marshal(requestData)
	if err != nil {
		return "", "", fmt.Errof("Erreur parsage JSON: %w", err)
	} 

On émet la requête en POST avec l'header json :

	resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
	if err != nil {
		return "", "", fmt.Errorf("Erreur création utilisateur RADIUS: %w", err)
	}

Et dans notre fichier où est définit nos routes API (on return l'username PPP et le password pour la création de la configuration du mikrotik) :

	username, password, err := app.CreateRadiusUser(ctx, username, IpMgmtv4, "INTERNET")
	if err != nil {
		fmt.Println("Erreur création user PPP: ", err)
		return
	}

Centreon & PowerDNS & ZTP Mikrotik

Je regroupe ces trois services car seul l'URL change 😄 (Vous me direz le radius aussi ? Et bah non, car j'ai pas besoin de token pour émettre la requête comparé à ces deux ci).

Pour commencer, on définit les modèles :

#dns.go
package models

type PatchZoneRequest struct {
	RRSets []RRSet `json:"rrsets"`
}

type RRSet struct {
	Name       string   `json:"name"`
	Type       string   `json:"type"`
	TTL        int      `json:"ttl"`
	ChangeType string   `json:"changetype"`
	Records    []Record `json:"records"`
}

type Record struct {
	Content  string `json:"content"`
	Disabled bool   `json:"disabled"`
}


#centreon.go
package	models

type Centreon struct {
	MonitoringServerID int    `json:"monitoring_server_id"`
	Name               string `json:"name"`
	Address            string `json:"address"`
	CheckCommandID     int    `json:"check_command_id"`
	Templates          []int  `json:"templates"`
}

type CreateHostResponse struct {
	ID int `json:"id"`
}

Le type CreateHostResponse c'est pour récupérer l'ID du host 😄

Ensuite, on peut commencer à créer les fonctions :

#dns.go
func CreateRecordDNS(ctx context.Context, name string, ip net.IP) (int, error)


#centreon.go
func CreateCentreonHost(ctx context.Context, name string) (int, int, error)
func CreateCentreonService(ctx context.Context, hostID int) (int, error)
func ReloadCentreonPoller(ctx context.Context, monitoringServerID int) (int, error)

Prenons exemple de CreateRecordDNS. On définit l'URL et le contenu des données JSON :

	pdnsURL := "http://192.168.10.107:8081/api/v1/servers/localhost/zones/naruto.ninja."

	rrset := models.RRSet{
		Name:       name + ".naruto.ninja.",
		Type:       "A",
		TTL:        3600,
		ChangeType: "REPLACE",
		Records: []models.Record{
			{
				Content:  ip.String(),
				Disabled: false,
			},
		},
	}

	patchReq := models.PatchZoneRequest{
		RRSets: []models.RRSet{rrset},
	}

On parse en JSON la variable :

	jsonPatchData, err := json.Marshal(patchReq)
	if err != nil {
		return 0, fmt.Errorf("Erreur lors du marshal JSON pour PowerDNS: %w", err)
	}

Ensuite, au lieu d'utiliser la fonction http.Post, je vais utiliser http.NewRequest qui me permet de mettre des headers plus poussés que seulement le content-type :

	reqPDNS, err := http.NewRequest("PATCH", pdnsURL, bytes.NewReader(jsonPatchData))
	if err != nil {
		return 0, fmt.Errorf("Erreur lors de la création de la requête HTTP pour PowerDNS: %w", err)
	}	

	reqPDNS.Header.Set("Content-Type", "application/json")
	reqPDNS.Header.Set("X-API-Key", TOKEN)
	clientPDNS := &http.Client{}
	respPDNS, err := clientPDNS.Do(reqPDNS)
	if err != nil {
		return 0, fmt.Errorf("Erreur lors de la requête HTTP pour PowerDNS: %w", err)
	}
	defer respPDNS.Body.Close()

Puis on return le statusCode de la requête (200/400/etc) :

return respPDNS.StatusCode, nil

Je fais exactement la même chose pour centreon.go et ztp.go, seul l'URL et les données JSON changent !

Ca fonctionne ?

On teste avec un petit curl :

curl -X POST http://192.168.10.108:8082/api/customers -H "Content-Type: application/json" -d '{"name":"Ichiraku","ville":"Konoha","serialnumber":"SN123456789","fai":"orange","techno":"ftth","modele":"rb4011","constructeur":"mikrotik","archi":"mpls"}'

{"message":"Client bien crée bg"}

Ca à l'air d'être pas mal ! On regarde :

Netbox :

Radius :

curl http://192.168.1.240:8080/api/users/Ichiraku-Konoha-ftth@naruto.ninja
{"ID":91,"Username":"Ichiraku-Konoha-ftth@naruto.ninja","Attribute":"Cleartext-Password","Value":"4HceCja16tjX","Options":[{"ID":183,"Attribute":"Framed-IP-Address","Value":"100.64.0.1"},{"ID":184,"Attribute":"Mikrotik-Group","Value":"Profile-INTERNET"}]}

PowerDNS :

Centreon :

ZTP Mikrotik :

Au top ! Mon client a donc été produit en un clic. Trop la classe ? Adieu la production N1, N2 ?????

Suites et évolutions

Mon code est bien sûr évolutif. Il serait intéressant de terminer les templates (default ou custom), d'utiliser des goroutines et de pouvoir rollback si un appel API foire. Et bien sûr des test unitaires pour ma CI/CD;

Bon, en réalité, j'ai déjà fait tout ça mais je ne sais pas si c'est utile d'en faire un épisode qui serait un peu rébarbatif d'autant que je commit pas le code (trop spécifique, je pense pas que ça servirait à quelqu'un. En plus il est déjà commit sur mon gitlab).

Donc au final, ce développement a permis d'automatiser toute production CPE (qu'importe l'architecture). Il serait donc intéressant par la suite de produire les autres équipements (firewall, etc). Malheureusement, je ne vais pas pouvoir étant donné que les constructeurs s'en foutent des lab lol (cc forti) mais si j'étais opérateur, j'aurai bien sûr automatisé cela. Même si au final, fortimanager est déjà mal (peut-être juste créer les devices automatiquement via call api fmg 😄).

Prochain épisode, installation de notre CRM et développement d'un module spécifique pour l'automatisation (merci claude, flemme d'apprendre le python).

Je termine sur un mot de fin : Au poste d'admin réseau N2, Victor je te choisis.