Installation de notre solution de ticketing

Mais laquelle ?

Je voulais un outil opensource, évolutif, simple, avec une API native et qu'il soit user friendly car il devra être partagé aux clients pour qu'ils aient une vue sur leur ticket en cours.

Plusieurs solutions ont cochés toutes les cases mais j'ai décidé de partir sur Zammad (https://github.com/zammad/zammad). Pourquoi ? Bah j'en sais rien au final, juste l'interface était stylé comparé aux autres lol. Je suis quelqu'un d'assez simple au final.

Installation

On commence par installer les prérequis :

naradmin@zammad:~$ sudo apt install curl apt-transport-https gnupg libimlib2

Zammad fonctionne avec elasticsearch pour la recherche de ticket et tout autre traitement. Compatible avec la v7 et le v8. Dans la doc Zammad, la v7 est installée donc bon, partons là dessus. Tout d'abord on ajoute le dépot et la clé GPG dans notre serveur. On peut le download via apt après ces étapes :

naradmin@zammad:~$ sudo  echo "deb [signed-by=/etc/apt/trusted.gpg.d/elasticsearch.gpg] https://artifacts.elastic.co/packages/7.x/apt stable main"| sudo tee -a /etc/apt/sources.list.d/elastic-7.x.list > /dev/null
naradmin@zammad:~$ curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/elasticsearch.gpg> /dev/null && sudo chmod 644 /etc/apt/trusted.gpg.d/elasticsearch.gpg
naradmin@zammad:~$ sudo apt install elasticsearch -y

Il faut aussi installer le plugin ingest-attachment d'elasticsearch :

naradmin@zammad:~$ sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install ingest-attachment

On peut start et enable le service :

naradmin@zammad:~$ sudo systemctl start elasticsearch
naradmin@zammad:~$ sudo systemctl enable elasticsearch

On augmente le nombre maximal de zones mémoire mappées qu'elasticsearch peut utiliser (commun pour tous les processus) :

naradmin@zammad:~$ sudo sysctl -w vm.max_map_count=262144

Ensuite, dans le fichier de configuration /etc/elasticsearch/elasticsearch.yml, on rajoute :

# /etc/elasticsearch/elasticsearch.yml

# Tickets above this size (articles + attachments + metadata)
# may fail to be properly indexed (Default: 100mb).
#
# When Zammad sends tickets to Elasticsearch for indexing,
# it bundles together all the data on each individual ticket
# and issues a single HTTP request for it.
# Payloads exceeding this threshold will be truncated.
#
# Performance may suffer if it is set too high.
http.max_content_length: 400mb

# Allows the engine to generate larger (more complex) search queries.
# Elasticsearch will raise an error or deprecation notice if this value is too low,
# but setting it too high can overload system resources (Default: 1024).
#
# Available in version 6.6+ only.
indices.query.bool.max_clause_count: 2000

Puis on restart elasticsearch :

naradmin@zammad:~$ sudo systemctl restart elasticsearch

On peut enfin installer zammad :

naradmin@zammad:~$ echo "deb [signed-by=/etc/apt/keyrings/pkgr-zammad.gpg] https://dl.packager.io/srv/deb/zammad/zammad/stable/debian 12 main"| sudo tee /etc/apt/sources.list.d/zammad.list > /dev/null
naradmin@zammad:~$ curl -fsSL https://dl.packager.io/srv/zammad/zammad/key | gpg --dearmor | sudo tee /etc/apt/keyrings/pkgr-zammad.gpg> /dev/null && sudo chmod 644 /etc/apt/keyrings/pkgr-zammad.gpg
naradmin@zammad:~$ sudo apt install zammad -y

Par défaut Zammad utilise nginx pour le serveur web. On ajoute donc la conf du nginx tout en supprimant celui par défaut (je reste en HTTP car je passe par un reverse proxy HTTPS to HTTP qui possède mon wildcard) :

naradmin@zammad:~$ sudo cp /opt/zammad/contrib/nginx/zammad.conf /etc/nginx/sites-available/zammad.conf

(il faut changer le servername :p)

naradmin@zammad:~$ sudo rm /etc/nginx/sites-enabled/default
naradmin@zammad:~$ sudo systemctl restart nginx  

Bientôt fini ! Maintenant, on passe par l'interface web via http://IP_SRV :

On crée un compte admin :

Puis notre organisation (vive naruto !!!!) :

Je n'ai pas faite cette partie (flemme) mais si vous voulez pouvoir envoyer des mails via Zammad, il faut soit configurer un client mail sur le serveur soit utiliser O365/Gmail :

Une fois installé, cela ressemble à ça :

Il nous reste plus qu'à finir la configuration d'elasticsearch :

naradmin@zammad:~$ sudo zammad run rails r "Setting.set('es_url', 'http://localhost:9200')"
naradmin@zammad:~$ sudo zammad run rake zammad:searchindex:rebuild

Enfin installé ! Place à la configuration maintenant :p

Configuration Zammad

Avant de commencer à automatiser, il faut créer certains objets. On commence donc par les organisations (sociétés). Dans paramètres, gérer et Organisations :

En tout, j'en ai crée 4. Konoha pour notre société et Customerx pour les clients.

On crée aussi des groupes. J'ai fonctionné avec la logique des niveaux d'escalade au sein d'un support : N1 -> N2 -> N3 :

Pour finir, les utilisateurs !

Allons créer un ticket pour voir comment ca se passe :

On peut donc définir le titre, le client, une description, le groupe et la priorité. Ce sont les 4 choses qui m'intéressent le plus.

Dans la file d'attente du groupe 'Réseau N1', on peut donc voir le ticket apparaître. Il restera dans cette file tant qu'une personne du groupe ne l'a pas pris.

J'ai aussi décidé que le N1 puisse avoir la possibilité de voir les ticket N2 en read only. Ce qui permet de voir le taux de charge du groupe d'en haut (et pour les plus curieux, voir quel type de ticket est traité !)

Ce que je souhaite vraiment c'est automatiser ce support N1. On commence par les coupures des liaisons (75% du taf). Grosso modo, je souhaite qu'un client ne soit pas obligé d'appeler le support pour dire que sa liaison est coupée donc il faut que notre outil de supervision soit relié à notre outil de ticketing.

API Zammad

On crée un ticket pour l'instant via curl. Pour ce faire, j'ai crée un compte centreon et un token :

Un petit test :

curl -X GET http://192.168.10.112/api/v1/tickets -H "Authorization: Token pzseG9t4cP-z_rJFz1WjMLUqhozTcxIs6XaLs9v5piqIW2MAhnBZyEmCrcViNJgI" -H "Content-Type: application/json"

[{"id":5,"group_id":4,"priority_id":2,"state_id":2,"organization_id":3,"number":"51005","title":"test n1","owner_id":1,"customer_id":12,"note":null,"first_response_at":null,"first_response_escalation_at":null,"first_response_in_min":null,"first_response_diff_in_min":null,"close_at":null,"close_escalation_at":null,"close_in_min":null,"close_diff_in_min":null,"update_escalation_at":null,"update_in_min":null,"update_diff_in_min":null,"last_close_at":null,"last_contact_at":"2026-01-29T13:58:44.353Z","last_contact_agent_at":null,"last_contact_customer_at":"2026-01-29T13:58:44.353Z","last_owner_update_at":null,"create_article_type_id":5,"create_article_sender_id":2,"article_count":1,"escalation_at":null,"pending_time":null,"type":null,"time_unit":null,"preferences":{},"updated_by_id":3,"created_by_id":3,"created_at":"2026-01-29T13:58:44.307Z","updated_at":"2026-01-29T13:58:44.418Z","checklist_id":null},{"id":6,"group_id":2,"priority_id":2,"state_id":2,"organization_id":4,"number":"51006","title":"test n2","owner_id":1,"customer_id":10,"note":null,"first_response_at":null,"first_response_escalation_at":null,"first_response_in_min":null,"first_response_diff_in_min":null,"close_at":null,"close_escalation_at":null,"close_in_min":null,"close_diff_in_min":null,"update_escalation_at":null,"update_in_min":null,"update_diff_in_min":null,"last_close_at":null,"last_contact_at":"2026-01-29T13:59:10.695Z","last_contact_agent_at":null,"last_contact_customer_at":"2026-01-29T13:59:10.695Z","last_owner_update_at":null,"create_article_type_id":5,"create_article_sender_id":2,"article_count":1,"escalation_at":null,"pending_time":null,"type":null,"time_unit":null,"preferences":{},"updated_by_id":3,"created_by_id":3,"created_at":"2026-01-29T13:59:10.665Z","updated_at":"2026-01-29T13:59:10.751Z","checklist_id":null},{"id":7,"group_id":4,"priority_id":3,"state_id":2,"organization_id":3,"number":"51007","title":"Coupure totale DATA ftth","owner_id":1,"customer_id":12,"note":null,"first_response_at":null,"first_response_escalation_at":null,"first_response_in_min":null,"first_response_diff_in_min":null,"close_at":null,"close_escalation_at":null,"close_in_min":null,"close_diff_in_min":null,"update_escalation_at":null,"update_in_min":null,"update_diff_in_min":null,"last_close_at":null,"last_contact_at":"2026-01-29T14:20:49.038Z","last_contact_agent_at":null,"last_contact_customer_at":"2026-01-29T14:20:49.038Z","last_owner_update_at":null,"create_article_type_id":5,"create_article_sender_id":2,"article_count":2,"escalation_at":null,"pending_time":null,"type":null,"time_unit":null,"preferences":{},"updated_by_id":5,"created_by_id":3,"created_at":"2026-01-29T14:20:49.005Z","updated_at":"2026-01-29T14:22:50.232Z","checklist_id":null}]

Ok top, l'API fonctionne bien. On peut voir tous les tickets en cours sur Zammad. Maintenant intéressons nous à la création :

curl -X POST http://192.168.10.112/api/v1/tickets -H "Authorization: Token pzseG9t4cP-z_rJFz1WjMLUqhozTcxIs6XaLs9v5piqIW2MAhnBZyEmCrcViNJgI" -H "Content-Type: application/json" -d '{"title": "Test ticket API","group": Réseau N1 ","customer": "centreon@naruto.ninja","priority": "3 high","article": {"subject": "Test","body":"test"}}'

{"id":8,"group_id":4,"priority_id":2,"state_id":1,"organization_id":6,"number":"51008","title":"Test ticket API","owner_id":1,"customer_id":13,"note":null,"first_response_at":null,"first_response_escalation_at":null,"first_response_in_min":null,"first_response_diff_in_min":null,"close_at":null,"close_escalation_at":null,"close_in_min":null,"close_diff_in_min":null,"update_escalation_at":null,"update_in_min":null,"update_diff_in_min":null,"last_close_at":null,"last_contact_at":null,"last_contact_agent_at":null,"last_contact_customer_at":null,"last_owner_update_at":null,"create_article_type_id":10,"create_article_sender_id":1,"article_count":1,"escalation_at":null,"pending_time":null,"type":null,"time_unit":null,"preferences":{},"updated_by_id":13,"created_by_id":13,"created_at":"2026-01-29T14:45:23.051Z","updated_at":"2026-01-29T14:45:23.125Z","checklist_id":null,"referencing_checklist_ids":[],"article_ids":[9],"ticket_time_accounting_ids":[]}

C'est comme si un client/front office avait crée un ticket à la main sauf que là, c'est en CLI.

Plutôt pas mal ! Bon, par contre, ça crée le ticket avec l'organisation Konoha. Après réflexion, je pense que c'est une bonne chose finalement. Tous les tickets en proactif seront traités comme ça. Si le client s'en remarque, il suffira juste de link le customer au ticket :

Il nous reste plus qu'à faire le lien entre cette API, Zammad et notre solution de monitoring.

Intégration Centreon

On va s'intéresser aux Event Handler dans centreon. Dès qu'un hôte est down dans la supervision, l'event va être trigger et centreon va pouvoir exécuter une commande.

Vous vous en doutez, la commande c'est d'interroger Zammad via API afin de créer le ticket avec les arguments de l'hôte.

Pour ce faire, on commence par créer une commande miscellaneous qu'on nomme zammad_ticket :

$CENTREONPLUGINS$/zammad_ticket.sh $HOSTSTATE$ $HOSTSTATETYPE$ $HOSTATTEMPT$ $HOSTNAME$ $HOSTADDRESS$ $HOSTOUTPUT$

Ensuite, on crée sur tous les pollers plusieurs répertoires :

naradmin@centreon-poller1:~$ mkdir /var/lib/centreon/zammad_tickets
naradmin@centreon-poller1:~$ sudo chown centreon-engine:centreon-engine /var/lib/centreon/zammad_ticket

naradmin@centreon-poller1:~$ sudo touch /var/lib/centreon/zammad_tickets/debug.log
naradmin@centreon-poller1:~$ sudo chown centreon-engine:centreon-engine /var/lib/centreon/zammad_tickets/debug.log
naradmin@centreon-poller1:~$ sudo chmod 664 /var/lib/centreon/zammad_tickets/debug.log

Le premier va servir à stocker les tickets. En effet, quand l'hôte remonte, il faut bien pouvoir clôturer le ticket (et par conséquent supprimer l'état de la création du ticket).
Pour le second, il va permettre de stocker les logs du script (appel, heure, etc).

Et le script ? Au lieu de passer 1 heure ou 2, un petit prompt à Claude (mon dieu quel gain de temps mdrrr). Dans /usr/lib/centreon/plugins/zammad_ticket.sh :

#!/bin/bash

LOG_FILE="/var/lib/centreon/zammad_tickets/debug.log"
exec 2>> "$LOG_FILE"
echo "=== Script démarré à $(date) ===" >> "$LOG_FILE"
echo "Args: $@" >> "$LOG_FILE"


# Configuration Zammad
ZAMMAD_URL="http://192.168.10.112"
ZAMMAD_TOKEN="pzseG9t4cP-z_rJFz1WjMLUqhozTcxIs6XaLs9v5piqIW2MAhnBZyEmCrcViNJgI"
ZAMMAD_CUSTOMER="centreon@naruto.ninja"
ZAMMAD_GROUP="Réseau N1"

# Arguments passés par Centreon
HOSTSTATE=$1
HOSTSTATETYPE=$2
HOSTATTEMPT=$3
HOSTNAME=$4
HOSTADDRESS=$5
HOSTOUTPUT=$6

# Fichier pour éviter les doublons
TICKET_FILE="/var/lib/centreon/zammad_tickets/${HOSTNAME}.ticket"

# Créer le répertoire si nécessaire
mkdir -p /var/lib/centreon/zammad_tickets

# Fonction : Créer un ticket
create_ticket() {
    local RESPONSE=$(curl -s -X POST "${ZAMMAD_URL}/api/v1/tickets" \
      -H "Authorization: Token ${ZAMMAD_TOKEN}" \
      -H "Content-Type: application/json" \
      -d "{
        \"title\": \"[CENTREON] Host DOWN - ${HOSTNAME}\",
        \"group\": \"${ZAMMAD_GROUP}\",
        \"customer\": \"${ZAMMAD_CUSTOMER}\",
        \"priority\": \"3 high\",
        \"article\": {
          \"subject\": \"Alerte Centreon - Host DOWN\",
          \"body\": \"=== ALERTE AUTOMATIQUE CENTREON ===\\n\\nHôte: ${HOSTNAME}\\nAdresse IP: ${HOSTADDRESS}\\nStatut: ${HOSTSTATE}\\nType: ${HOSTSTATETYPE}\\nTentative: ${HOSTATTEMPT}\\nOutput: ${HOSTOUTPUT}\\n\\nDate: $(date '+%Y-%m-%d %H:%M:%S')\"
        }
      }")

    # Extraire l'ID du ticket
    TICKET_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)

    if [ -n "$TICKET_ID" ]; then
        echo "$TICKET_ID" > "$TICKET_FILE"
        logger -t zammad "Ticket créé #${TICKET_ID} pour ${HOSTNAME}"
        return 0
    else
        logger -t zammad "ERREUR création ticket pour ${HOSTNAME}: $RESPONSE"
        return 1
    fi
}

# Fonction : Fermer un ticket
close_ticket() {
    if [ ! -f "$TICKET_FILE" ]; then
        logger -t zammad "Pas de ticket à fermer pour ${HOSTNAME}"
        return 0
    fi

    TICKET_ID=$(cat "$TICKET_FILE")

    curl -s -X PUT "${ZAMMAD_URL}/api/v1/tickets/${TICKET_ID}" \
      -H "Authorization: Token ${ZAMMAD_TOKEN}" \
      -H "Content-Type: application/json" \
      -d "{
        \"state\": \"closed\",
        \"article\": {
          \"subject\": \"Host UP - Fermeture automatique\",
          \"body\": \"L'hôte ${HOSTNAME} est revenu UP.\\n\\nDate: $(date '+%Y-%m-%d %H:%M:%S')\\n\\nFermeture automatique du ticket.\",
          \"internal\": false
        }
      }"

    rm -f "$TICKET_FILE"
    logger -t zammad "Ticket #${TICKET_ID} fermé pour ${HOSTNAME}"
}

# Logique principale
if [ "$HOSTSTATETYPE" = "HARD" ]; then
    if [ "$HOSTSTATE" = "DOWN" ] || [ "$HOSTSTATE" = "UNREACHABLE" ]; then
        # Host DOWN : créer ticket si pas déjà existant
        if [ ! -f "$TICKET_FILE" ]; then
            create_ticket
        fi
    elif [ "$HOSTSTATE" = "UP" ]; then
        # Host UP : fermer le ticket s'il existe
        close_ticket
    fi
fi

exit 0

Le script permet donc de créer un ticket quand le statuts de l'hôte passe en HARD DOWN ou HARD UNREACHABLE. Il crée aussi un fichier $HOSTNAME.ticket dans le répertoire /var/lib/centreon/zammad_tickets qui permet de savoir si un ticket est en cours. En effet, quand le client va remonter (UP), il va clôturer le ticket.

Ensuite, dans la configuration de l'hôte, dans Data Processing, on rajoute la commande pour l'event handler (tout en bas) :

Allez un petit test ? On crée un hôte et on simule le fait qu'il soit down :

Il se passe quoi dans les logs ?

=== Script démarré à jeu. 29 janv. 2026 22:07:24 CET ===
Args: DOWN SOFT 1 test-zammad 192.168.10.66 CRITICAL - 192.168.10.66: Host unreachable @ 192.168.10.106. rta nan, lost 100%
=== Script démarré à jeu. 29 janv. 2026 22:07:29 CET ===
Args: DOWN SOFT 2 test-zammad 192.168.10.66 CRITICAL - 192.168.10.66: Host unreachable @ 192.168.10.106. rta nan, lost 100%
=== Script démarré à jeu. 29 janv. 2026 22:08:29 CET ===
Args: DOWN HARD 3 test-zammad 192.168.10.66 CRITICAL - 192.168.10.66: Host unreachable @ 192.168.10.106. rta nan, lost 100%

C'est à partir du moment du DOWN HARD que centreon envoie une requête à zammad ! Allons regarder dans notre outil de ticketing :

Plutôt pas mal ? Et en description, on a quoi ?

Parfait, on a donc le nom de l'hôte, son IP et la raison du down (bon vu que j'utilise la commande base_host_alive en commande de vérification, ça sera toujours un problème de ping mais on peut changer si par exemple on supervise un serveur).

Que se passe-t-il si l'hôte remonte ?

Dans zammad ?

Quel beaugoss !!!!! J'aurai bien aimé aller plus loin avec la création automatique des tickets sur les extranet des opérateurs tiers mais malheureusement, cela va être compliqué dans le cadre d'un lab lol

Mon idée aurait été de créer un bouton 'ouvrir ticket OI' dans l'onglet Fiche Technique dans notre Odoo. Après vérification avec le client qu'il s'agisse bien d'une panne et non d'un faux positif, on pourrait créer un ticket opérateur en un seul clic.

Conclusion

C'est sur cette épisode que se clôture notre infrastructure NetOps. En effet, mon but était d'automatiser toute production réseau et support N1.

Avec l'intégration d'Odoo et de mon API-Master, la production est automatisée. Pour le support, cet épisode a permit d'y voir plus clair sur le fonctionnement de ce que je veux faire. J'ai du m'arrêter à la création automatique de ticket mais comme vu plus haut, il aurait été intéressant que la personne qui traite le ticket puisse en ouvrir un en un seul clic vers l'OI. Le plus dur a été fait (manque plus que d'avoir les endpoints API des extranets opérateurs tiers et de faire un bouton dans le module Odoo).

Au final, tous les outils ont été montés pour assurer un opérateur (Supervision / Radius / IPAM / CRM / Ticketing / DNS / Repo). On pourrait rajouter d'autres outils mais ceux là suffisent !!!! Quand on y regarde de plus près, il en faut pas tant que ça 😄 (j'estime qu'il me suffit de développer les derniers outils manquant via mes propres modules Odoo pour être à 100% autonome).

Donc au total, cela nous fait 3 infras de montées ! On se rapproche du but ultime : le fonctionnement complet d'un opérateur télécom BtoB.

Il va falloir enfin commencer la dernière, l'ultime, la plus redoutée (en tout cas pour moi lol), celle de la téléphonie. Prochain épisode, on monte notre PBX multitenant !!!!!!

Je termine sur un mot de fin : Ah mon Louison, tu croyais que je t'avais oublié ??? Hé bah nan, je te choisis en tant que responsable production <3