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.