Développement API Freeradius

💡
Toutes les configurations sont commit sur mon github : https://github.com/Nathan0510/api-freeradius

Seulement celle ci ?

Et la réponse est toute simple : c'est le seul outil qu'on va utilisé qui ne possède pas d'API intégré. Centreon, PowerDNS en ont une par exemple.

Ainsi, il faut donc la développer from stratch. Bien sûr, beaucoup de projet ont vu mais j'aime bien être indépendant vis à vis de mon code. Le fait que tout un projet peut être stopper à cause d'un code plus maintenu ... Avec cette API, je serai autonome sur la création/suppresion/modification des NAS et des utilisateurs PPP.

Ducoup, je vais la développer en go avec cette structure :

root@devops:~/radius# tree
.
|-- README.md
|-- cmd
|   `-- api
|       `-- main.go
|-- db
|   `-- database.go
|-- docs
|   |-- docs.go
|   |-- swagger.json
|   `-- swagger.yaml
|-- go.mod
|-- go.sum
|-- internal
|   |-- api
|   |   |-- controllers
|   |   |   |-- nas.go
|   |   |   `-- user.go
|   |   |-- middleware
|   |   `-- routes
|   |       `-- router.go
|   `-- app
|       |-- nas.go
|       `-- user.go
`-- models
    |-- nas.go
    `-- user.go

Cet épisode ne tendra pas à expliquer ligne par ligne le code mais plutôt de montrer comment je code un petit projet !

MODELS

Tout d'abord on va créer nos models. En réalité, c'est assez simple car je prends la table SQL de ce que je veux traiter :

#nas.go
package models

type Nas struct {
    ID          int    `gorm:"primaryKey;autoIncrement"`
    Nasname     string `json:"Nasname"`
    Shortname   string `json:"Shortname"`
    Secret      string `json:"Secret"`
    Type        string `json:"Type"`
    Ports       int    `json:"Ports"`
    Server      string `json:"Server"`
    Community   string `json:"Community"`
    Description string `json:"Description"`
}

func (Nas) TableName() string {
    return "nas"
}


#user.go
package models

type Radcheck struct {
    ID        int    `gorm:"primaryKey;autoIncrement"`
    Username  string `json:"Username"`
    Attribute string `json:"Attribute"`
    Op        string `json:"-"`
    Value     string `json:"Value"`
    Options []Radreply `json:"Options" gorm:"foreignKey:Username;references:Username"`
}

func (Radcheck) TableName() string {
    return "radcheck"
}

type Radreply struct {
    ID        uint   `gorm:"primaryKey;autoIncrement"`
    Username  string `json:"-"`
    Attribute string `json:"Attribute"`
    Op        string `json:"-"`
    Value     string `json:"Value"`
}

func (Radreply) TableName() string {
    return "radreply"
}

Je suis obligé de créer un fonction Nas/Radreply/Radcheck car GORM (ORM SQL golang) pluralise les noms des tables ... Je ne sais toujours pas pourquoi. J'ai trouvé un fix qui doit être dégelasse mais ca fonctionne !

DB

Vu qu'on va beaucoup jouer avec des injections SQL, on ne va pas initialiser la database dans chaque fichier mais plutôt d'en un et on va importer ce fichier :

#db.go
package db

import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var DB *gorm.DB

func Connect(dsn string) error {
    var err error
    DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    return err
}

APP

C'est dans ces fichiers qu'on va créer les commandes pour agir sur notre postgresql. Ainsi, on va avoir CreateNas, GetAllNas, DeleteUser, etc.

On commence par les nas :

#nas.go
package app

import (
    "api-freeradius/models"
    "api-freeradius/db"
)

func CreateNas(nasname, shortname, secret string) error {
    nas := &models.Nas{
        Nasname:  nasname,
        Shortname: shortname,
        Secret:     secret,
    }

    return db.DB.Create(nas).Error
}

func GetAllNas() ([]models.Nas, error) {
    var nasList []models.Nas
    return nasList, db.DB.Find(&nasList).Error
}

func GetNas(nasname string) (*models.Nas, error) {
    var nas models.Nas
    return &nas, db.DB. Where("nasname = ?", nasname).First(&nas).Error
}

func DeleteNas(nasname string) error {
    return db.DB.Where("nasname = ?", nasname).Delete(&models.Nas{}).Error
}

func UpdateNas(nasname string, updates *models.Nas) error {
    return db.DB.Model(&models.Nas{}).Where("nasname = ?", nasname).Updates(updates).Error
}

On va faire un petit aparté sur ce code. Par exemple, pour créer un Nas, je crée une variable models.Nas (struct crée dans models/nas.go) avec les informations comme l'IP, le nom et le secret. Ensuite, il suffit d'invoquer la commande db.DB.Create(nas). Pourquoi db ? Car on importe db/db.go pour initialiser la connexion à la database.

Pour les Get, on crée une liste si on veut récupérer tous les Nas sinon juste une variable. Pour le delete et le patch, pas grand chose de compliqué hormis de trouver la bonne commande gorm !

Pour le fichier user :

#user.go
package app

import (
    "api-freeradius/models"
    "api-freeradius/db"
    "gorm.io/gorm"
)

func CreateUser(user *models.Radcheck) error {
    user.Op = ":="
    for i := range user.Options {
        user.Options[i].Username = user.Username
        user.Options[i].Op = ":="
    }
    return db.DB.Create(user).Error
}

func GetAllUsers() ([]models.Radcheck, error) {
    var users []models.Radcheck
    return users, db.DB.Preload("Options").Find(&users).Error
}

func GetUser(username string) (*models.Radcheck, error) {
    var user models.Radcheck
    return &user, db.DB.Preload("Options").Where("username = ?", username).First(&user).Error
}

func DeleteUser(username string) error {
    return db.DB.Transaction(func(tx *gorm.DB) error {
        if err := tx.Where("username = ?", username).Delete(&models.Radcheck{}).Error; err != nil {
            return err
        }
        if err := tx.Where("username = ?", username).Delete(&models.Radreply{}).Error; err != nil {
            return err
        }
        return nil
    })
}

func UpdateUser(username string, updates *models.Radcheck) error {
    return db.DB.Transaction(func(tx *gorm.DB) error {
        if updates.Attribute != "" && updates.Value != "" {
            if err := tx.Model(&models.Radcheck{}).
                Where("username = ?", username).
                Updates(map[string]interface{}{
                    "Attribute": updates.Attribute,
                    "Value":     updates.Value,
                }).Error; err != nil {
                return err
            }
        }

        for _, option := range updates.Options {
            option.Username = username
            option.Op = ":="

            res := tx.Model(&models.Radreply{}).
                Where("username = ? AND attribute = ?", username, option.Attribute).
                Updates(map[string]interface{}{"value": option.Value})

            if res.Error != nil {
                return res.Error
            }
        }

        return nil
    })
}
func DeleteUserOption(username, attribute, value string) error {
    return db.DB.Where("username = ? AND attribute = ? AND value = ?", username, attribute, value,).Delete(&models.Radreply{}).Error
}

Pour la création d'un utilisateur, je met en dure l'op de la database. En effet, si on veut mettre un password par exemple, cela ressemble à ça :

radius=> select * from radcheck;
 id |            username             |     attribute      | op |    value
----+---------------------------------+--------------------+----+--------------
  1 | testuser                        | Cleartext-Password | := | testpassword

Vu que c'est toujours :=, j'ai décidé de le mettre directement dans le code. Ensuite, pour les options :

 id |            username             |     attribute     | op |      value
----+---------------------------------+-------------------+----+------------------
 21 | testuser                        | Framed-IP-Address | := | 100.64.0.3
 22 | testuser                        | Mikrotik-Group    | := | Profile-Internet

L'username est dans l'entrée hors dans le json que j'envoie, je n'ai pas envie de remettre l'username à chaque option ... Cela ferait doublon ! Ainsi, je dit que pour toutes les options, l'username de l'option est l'username de l'utilisateur.

Le reste est ressemblant au fichier nas.go sauf le delete car on doit aller taper dans deux tables (radcheck et radreply) donc il faut faire deux requêtes.

Pour l'UpdateUser, je vais être honnête : je n'ai jamais réussi à comprendre la syntaxe pour update ... Du coup, je l'ai généré et ca fonctionne.

Controllers

Et c'est là que rentre dans le game l'API. J'utilise depuis le début de mes codes, le framework gin !

#nas.go
package controllers

import (
  "net/http"
  "github.com/gin-gonic/gin"
  "api-freeradius/models"
  "api-freeradius/internal/app"
)


// @Router /nas [post]
func CreateNas(c *gin.Context){

  var json models.Nas

  if err := c.ShouldBindJSON(&json); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }

  if err := app.CreateNas(json.Nasname, json.Shortname, json.Secret); err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"message": "Error Nas " + json.Nasname + " not added successfully"})
  return
  }

  c.JSON(http.StatusOK, gin.H{"message": "Nas "  + json.Nasname + " added successfully"})

}


// @Router /nas [get]
func GetAllNas(c *gin.Context) {
  nasList, err := app.GetAllNas()
  if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  c.JSON(http.StatusOK, nasList)
}


// @Router /nas/{username} [get]
func GetNas(c *gin.Context) {
  nasname := c.Param("username")
  nas, err := app.GetNas(nasname)
  if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  c.JSON(http.StatusOK, nas)
}

  
// @Router /nas/{username} [delete]
func DeleteNas(c *gin.Context) {
  nasname := c.Param("username")
  if err := app.DeleteNas(nasname); err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": "Error Nas " + nasname + " not deleted successfully"})
    return
  }
  c.JSON(http.StatusOK, gin.H{"message": "Nas " + nasname + " deleted successfully"})
}


// @Router /nas/{username} [patch]
func UpdateNas(c *gin.Context) {
  nasname := c.Param("username")
  var json models.Nas

  if err := c.ShouldBindJSON(&json); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  if err := app.UpdateNas(nasname, &json) ;err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": "Nas " + nasname + " not updated successfully"})
    return
  }
  c.JSON(http.StatusOK, gin.H{"message": "Nas " + nasname + " updated successfully"})
}

J'ai donc 2 méthodes GET, 1 POST, 1 DELETE et 1 PATCH. Dans chaque fonction, soit je récupère un attribut dans l'url (example : /nas/1.1.1.1) ou dans le json (exemple : '{'nasname':'1.1.1.1'}'). Ainsi, pour chaque appel API, on appelle la bonne commande app (injection sql).

Pour la partie user, c'est à peu près la même chose :

#user.go
package controllers

import (
  "net/http"
  "github.com/gin-gonic/gin"
  "api-freeradius/models"
  "api-freeradius/internal/app"
)


// @Router /users [post]
func CreateUser(c *gin.Context){

  var json models.Radcheck

  if err := c.ShouldBindJSON(&json); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }


    if err := app.CreateUser(&json); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

  c.JSON(http.StatusOK, gin.H{"message": "User "  + json.Username + " added successfully"})

}


// @Router /users [get]
func GetAllUsers(c *gin.Context) {
  users, err := app.GetAllUsers()
  if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  c.JSON(http.StatusOK, users)
}


// @Router /users/{username} [get]
func GetUser(c *gin.Context) {
  username := c.Param("username")
  user, err := app.GetUser(username)
  if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  c.JSON(http.StatusOK, user)
}


// @Router /users/{username} [delete]
func DeleteUser(c *gin.Context) {
  username := c.Param("username")
  err := app.DeleteUser(username)
  if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  c.JSON(http.StatusOK, gin.H{"message": "User " + username + " deleted successfully"})
}


// @Router /users/{username} [patch]
func UpdateUser(c *gin.Context) {
    username := c.Param("username")
    var json models.Radcheck

    if err := c.ShouldBindJSON(&json); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    if err := app.UpdateUser(username, &json); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "User " + username + " updated successfully"})
}


// @Router /users/option/{username} [delete]
func DeleteUserOption(c *gin.Context) {
  username := c.Param("username")
  var json models.Radreply

  if err := c.ShouldBindJSON(&json); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }

  if err := app.DeleteUserOption(username, json.Attribute, json.Value); err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
    c.JSON(http.StatusOK, gin.H{"message": "Option " + json.Attribute +  "value " + json.Value + "user " + username + " deleted successfully"})
}

Swagger

J'ai rajouté la documentation de mon API :

Test curl

Un bon code est un code qui fonctionne ! Testons :

Post Nas :
curl -X POST http://192.168.1.240:8080/api/nas -H "Content-Type: application/json" -d '{"nasname": "1.1.1.1","shortname": "LNS1","secret": "naruto"}'
{"message":"Nas 1.1.1.1 added successfully"}


Get all Nas :
curl http://192.168.1.240:8080/api/nas
[{"ID":14,"Nasname":"1.1.1.1","Shortname":"LNS1","Secret":"naruto","Type":"","Ports":0,"Server":"","Community":"","Description":""},{"ID":15,"Nasname":"2.2.2.2","Shortname":"LNS2","Secret":"naruto","Type":"","Ports":0,"Server":"","Community":"","Description":""}]


Get Nas :
curl http://192.168.1.240:8080/api/nas/1.1.1.1
{"ID":14,"Nasname":"1.1.1.1","Shortname":"LNS1","Secret":"naruto","Type":"","Ports":0,"Server":"","Community":"","Description":""}


Patch Nas :
curl -X PATCH http://192.168.1.240:8080/api/nas/1.1.1.1 -H "Content-Type: application/json" -d '{"secret": "sasuke"}'
{"message":"Nas 1.1.1.1 updated successfully"}


Delete Nas :
curl -X DELETE http://192.168.1.240:8080/api/nas/1.1.1.1
{"message":"Nas 1.1.1.1 deleted successfully"}

Plutôt pas mal ! Et pour les users :

Post user :
curl -X POST http://192.168.1.240:8080/api/users -H "Content-Type: application/json" -d '{"Username":"nathan@naruto.ninja","Attribute": "Cleartext-Password","Value":"beaugoss","Options":[{"Attribute": "Framed-IP-Address","Value": "100.127.0.1"},{"Attribute": "Profile-Mikrotik","Value": "Profile-Internet"}]}'
{"message":"User nathan@naruto.ninja added successfully"}


Get all user :
curl http://192.168.1.240:8080/api/users
[{"ID":1,"Username":"minato@naruto.ninja","Attribute":"Cleartext-Password","Value":"surcote","Options":[]},{"ID":2,"Username":"nathan@naruto.ninja","Attribute":"Cleartext-Password","Value":"beaugoss","Options":[{"Attribute":"Framed-IP-Address","Value":"100.127.0.1"},{"Attribute":"Mikrotik-Group","Value":"Profile-Internet"}]}]


Get user :
curl http://192.168.1.240:8080/api/users/nathan@naruto.ninja
{"ID":27,"Username":"nathan@naruto.ninja","Attribute":"Cleartext-Password","Value":"sasuke2","Options":[{"Attribute":"Framed-IP-Address","Value":"100.64.0.10"},{"Attribute":"Framed-IP-Address","Value":"100.127.0.1"},{"Attribute":"Profile-Mikrotik","Value":"Profile-Internet"},{"Attribute":"Profile-Mikrotik","Value":"Profile-Internet"}]}


Patch user :
curl -X PATCH http://192.168.1.240:8080/api/users/nathan@naruto.ninja -H "Content-Type: application/json" -d '{"Options":[{"Attribute": "Framed-IP-Address","Value": "100.127.0.10"}]}'                                     {"message":"User nathan@naruto.ninja updated successfully"}


Delete option user :
curl -X DELETE http://192.168.1.240:8080/api/users/option/nathan@naruto.ninja -H "Content-Type: application/json" -d '{"Attribute": "Framed-IP-Address","Value": "100.127.0.10"}'
{"message":"Option Framed-IP-Address value 100.127.0.10 user nathan@naruto.ninja deleted successfully"}


Delete user :
curl -X DELETE http://192.168.1.240:8080/api/users/naruto@naruto.ninja
{"message":"User naruto@naruto.ninja deleted successfully"}

Conclusion

Petit épisode pas vraiment utile si ce n'est exposer la code. Il est dispo sur https://github.com/Nathan0510 de toute façon.

Je voulais juste écrire cet épisode pour me souvenir ce que j'ai fait lol. J'ai du mal à développer de 0, j'aime bien avoir des exemples (juste pour la syntaxe ...). Donc si ca peut aider une personne !

Avec cette API, on peut enfin commercer notre automatisation d'un client ! Prochain épisode, on s'y attaque.

Je termine sur un mot de fin : Erwan, tu seras mon ingénieur NetOps