Encore la même question : Quelle solution ?
Et encore une fois, il en existe pas mal et de surcroit opensource ! La plus célèbre étant kamailio (qui est la base de pratiquement tout SBC). J'ai décidé de partir sur une solution un peu plus KISS (Keep It Simple, Stupid) qu'un fichier de configuration en brut.
La solution s'appelle PKS, développée par un gars chez Wazo sur ton temps libre. Elle se veut minimaliste, facile à installer et à utiliser (https://github.com/mwolff44/pk-sbc).
Installation
PKS est basé sur docker. En effet, il vient conteneuriser chaque fonction VoIP (Kamailio, Redis, PostgreSQL, RTPEngine, WebGUI et le reverse proxy). Il faut donc installer docker sur la VM :
root@sbc:~# apt install ca-certificates curl
root@sbc:~# install -m 0755 -d /etc/apt/keyrings
root@sbc:~# curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
root@sbc:~# chmod a+r /etc/apt/keyrings/docker.asc
root@sbc:~# echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
root@sbc:~# apt update
root@sbc:~# apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
root@sbc:~# docker --version
Docker version 29.2.1, build a5c7197
Ensuite, le script d'installation est pété (?), il encode en dur une version 4.2.0 mais qui n'existe pas. Donc pour download le project, je le git clone dans le répértoire /srv et je change la version dans le script :
root@sbc:/srv# git clone https://github.com/mwolff44/pk-sbc.git
root@sbc:/srv# cd pk-sbc/
root@sbc:/srv# ls -l src/pks
root@sbc:/srv# chmod +x src/pks
root@sbc:/srv# ln -sf /srv/pk-sbc/src/pks /usr/local/bin/pks
root@sbc:/srv# sed -i 's|v4.1.4|v.4.2.0|g' /srv/pk-sbc/src/pks
Ensuite, on lance l'installation avec un pks start sauf que :
root@sbc:/srv# pks start
✘ Image mwolff44w/pks-sipproxy:v4.2.0 Error failed to resolve reference "docker.io/mwolff44w/pks-sipproxy:v4.2.0": docker.io/mwolff44w/pks-sipproxy:v4.2.0: not found
Il y a une erreur sur la version du conteneur sipproxy dans le docker-compose (qui est téléchargé grâce à pks start). Pour corriger cela, il faut modifier la version encodé en brut par latest dans le .yml :
root@sbc:/srv# sed -i 's|mwolff44w/pks-sipproxy:v4.2.0|mwolff44w/pks-sipproxy:latest|g' /srv/pks/docker-compose.yml
root@sbc:/srv# pks start
root@sbc:/srv/pks# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f6e4d98f78cd mwolff44w/pks-sipproxy:latest "/etc/kamailio/boots…" 5 minutes ago Up 5 minutes (healthy) pks-sip
3989c99f3caf mwolff44w/pks-admin:v1.6.0 "/bin/server" 5 minutes ago Up 5 minutes pks-admin
19ec682d4ef0 postgres:16 "docker-entrypoint.s…" 5 minutes ago Up 5 minutes (healthy) pks-db
78b8fd570377 redis:7-alpine "docker-entrypoint.s…" 5 minutes ago Up 5 minutes pks-redis
002cfa76df90 caddy "caddy run --config …" 5 minutes ago Up 5 minutes pks-proxy
6bf460787534 mwolff44w/pks-rtpengine:v1.0.2 "/entrypoint.sh rtpe…" 5 minutes ago Up 5 minutes (healthy) pks-rtp
Au top, tous les conteneurs sont UP !
Configuration
Tout d'abord, il faut comprendre le vocabulaire de PKS :
- Gateway : Une destination SIP. Peut être un IPBX ou un transit
- Tenant : Même idée que sur Wazo. Permet d'isoler un client
- Routing Rules : Table de routage des SDA (dialplan)
L'interface WEB :

Gateway
Tout d'abord, on crée notre gateway vers notre IPBX Wazo. On renseigne donc le fait qu'il soit multitenant :

Ainsi que notre transit. En l'occurrence, je n'ai, finalement, pas monté de trunk vers OVH donc j'ai virtualisé mon propre transit VoIP :

Destination & Tenant
Il faut que notre SBC puisse différencier le trafic des différents tenants de notre IPBX. PKS est compatible avec ce mode d'architecture par contre il faut bien les créer. Je vais en créer deux : Customer1 et Customer2 !



PKS identifie un tenant grâce à un champ SIP X-CUSTOMER-ID qui sera rajouté via le trunk SIP du tenant Wazo vers ce SBC !
Il nous manque plus que les destinations à créer avant de procéder au routage des SDA. Il nous en faut donc trois : le transit et les deux tenants :



Routing rules
Il nous manque plus qu'à créer la table de routage des SDA. Il faut savoir qu'en VoIP, on aime bien jouer avec les regex ! Je compare ça aux masques IP, cela permet, d'au lieu de router des /32, de router un ensemble d'IP.
Exemple pour Customer1 :


Les SDA attribuées au Customer1 sont 0123456700 à 0123456709 et pour Customer2 0123456720 à 01234567209.
Pour la regex du transit, elle permet de router toutes les autres SDA (route par défaut, merci chatgpt je suis A CHIER sur les regex).
Bon c'est plutôt pas trop mal. Maintenant attaquons nous à nos tenants sur notre IPBX !
Wazo
Trunk
Comme déjà évoqué dans un précédent épisode, tous les tenants seront connectés au SBC via un trunk SIP. Comparé au SBC et le fait qu'on puisse utiliser qu'un seul trunk, WAZO nécessite un trunk par tenant. Il sera donc différencié par un entête SIP X-CUSTOMER-ID !
Allez, on configure tout ca :




Il faudrait vraiment que je résume toutes les options possibles pour un trunk dans un épisode mais pour l'instant ...
Les seules informations différentes que dans l'épisode trunk SIP, c'est l'ajout de l'header SIP via PJSIP_HEADER. Ce champ va insérer l'entête dans le sens aller.
Concernant le match_header, c'est plus dans le sens retour.
On fait exactement la même chose pour le deuxième tenant (on change customer1 par customer2 bien sûr)
root@wazo:~# asterisk -rx "pjsip show endpoints"
Endpoint: pqsg15te Not in use 0 of inf
Aor: pqsg15te 10
Contact: pqsg15te/sip:192.168.1.211:5060 e32e6dd392 Avail 1.295
Transport: transport-udp udp 0 0 0.0.0.0:5060
Identify: pqsg15te/pqsg15te
Match: 192.168.1.211/32
Header: X-CUSTOMER-ID: customer1
Endpoint: v7r24no0 Not in use 0 of inf
Aor: v7r24no0 10
Contact: v7r24no0/sip:192.168.1.211:5060 e32e6dd392 Avail 1.042
Transport: transport-udp udp 0 0 0.0.0.0:5060
Identify: v7r24no0/v7r24no0
Match: 192.168.1.211/32
Header: X-CUSTOMER-ID: customer2
Les deux trunk sont bien montés !
Appel entrant/sortant
Il faut indiquer à Wazo quelles SDA sont portées par le tenant. Il faut donc aller dans sa configuration, contextes, customer1-from-extern et renseigner la range :

Il faut aussi définir une route par défaut qui va envoyer tous les appels vers le trunk de sortie (hors extension) :

Puis on va attribuer des SDA aux utilisateurs finaux du tenant. Pour l'exemple, j'ai pris deux users (un téléphone et un webrtc) :


Tests d'appels
A des fin de lab, je ne pourrai donc pas appeler de vraies SDA publiques donc j'ai simulé un transit avec un autre WAZO (qui porte les SDA 012345671[0-9]).
Customer 1 vers Transit :

Transit vers Customer 1 :

Customer 2 vers Transit :

Transit vers Customer 2 :

Customer1 vers Customer2 :
Error
Customer2 vers Customer1 :
Error
Mes communications sortantes/entrantes des tenants et Internet fonctionnent bien. Cependant, je n'ai aucune communication inter tenant ce qui est problématique si mes clients appellent un autre de mes clients !
Mais Jammy, pourquoi diable cela ne fonctionne-t-il pas ?
En regardant les logs de PKS, j'observe :
14(36) INFO: {1 29968 INVITE 026f92ae-b4db-483e-b2bd-3f6649321e39} script>: INFO | No internal routing rule found for GW (IP:192.168.1.210:5060) associated with '0123456700'
14(36) ERROR: {1 29968 INVITE 026f92ae-b4db-483e-b2bd-3f6649321e39} script>: ALERT | Error finding internal routing rule for GW (IP:192.168.1.210:5060) associated with '0123456700'
14(36) INFO: {1 29968 INVITE 026f92ae-b4db-483e-b2bd-3f6649321e39} script>: INFO | The internal routing rule for GW (IP:192.168.1.210:5060) associated with '0123456700' are ''
14(36) INFO: {1 29968 INVITE 026f92ae-b4db-483e-b2bd-3f6649321e39} script>: INFO | The external routing rule for GW (IP:192.168.1.210:5060) associated with '0123456700' are '13'
Pour décortiquer ce message d'erreur, il faut comprendre les types des routing rules dans PKS :
- External : Vers un transit
- Internal : Vers un IPBX
- DID : SDA publique
- Emergency : Appels d'urgence (15/17/18/112)
Donc il serait naturel de router les SDA publiques de nos clients via le type Internal et la route par défaut en External.
On va aller regarder dans le code de kamailio pour comprendre un peu comment tout cela fonctionne :
route[AUTH] {
if(!is_method("REGISTER")) {
$var(group) = allow_source_address_group();
switch ($var(group)) {
case -1:
xlog("L_ALERT","ALERT | Request from unknown GW (IP:$si:$sp)\n");
exit;
case 1:
setflag(FLAG_FROM_CUST);
xlog("L_INFO", "INFO | Call from IPBX/customer - GW_ID : $avp(i:707) - Source IP ($si:$sp) allowed\n");
if ($avp(i:707) == 0) {
route(TENANTAUTH);
}
return;
case 2:
setflag(FLAG_FROM_PROV);
xlog("L_INFO", "Call from provider - GW_ID : $avp(i:707) - Source IP ($si:$sp)\n");
if ($avp(i:707) == 0) {
route(TENANTAUTH);
}
return;
}
}
}
Bon ca parait un peu chinois mais c'est assez simple à comprendre !
$var(group) = allow_source_address_group() permet de regarder l'IP source du paquet, de regarder la table address dans la bdd et de return la valeur grp.
pks=# select * from address;
id | grp | ip_addr | mask | port | tag | gateway_name
----+-----+---------------+------+------+-----+--------------
1 | 2 | 192.168.1.220 | 32 | 5060 | 3 | To_Transit
2 | 1 | 192.168.1.210 | 32 | 5060 | 0 | To_WAZO
Ensuite, en fonction du groupe, kamailio va set un flag qui permettra d'identifier la source du paquet. Ce flag est utilisé dans la route dispatch :
route[DISPATCH] {
xinfo("route dispatch\n");
# select the GW list from $rU
if(isflagset(FLAG_FROM_CUST)) {
$fsn = "public";
# first, check if the R-URI matches a rule in internal topic (3)
# second, if no internal match, then check if the R_URI matches a rule in external topic (2)
$var(rule3) = dp_match("3", "$rU");
switch ($var(rule3)) {
case -2:
xinfo("INFO | No internal routing rule found for GW (IP:$si:$sp) associated with '$rU'\n");
case -1:
xerr("ALERT | Error finding internal routing rule for GW (IP:$si:$sp) associated with '$rU'\n");
case 1:
xinfo("INFO | The internal routing rule for GW (IP:$si:$sp) associated with '$rU' are '$avp(s:dest)'\n");
}
if (!$avp(s:dest)) {
$var(rule2) = dp_match("2", "$rU");
switch ($var(rule2)) {
case -2:
xwarn("INFO | No external routing rule found for GW (IP:$si:$sp) associated with '$rU'\n");
case -1:
xerr("ALERT | Error finding external routing rule for GW (IP:$si:$sp) associated with '$rU'\n");
case 1:
xinfo("INFO | The external routing rule for GW (IP:$si:$sp) associated with '$rU' are '$avp(s:dest)'\n");
}
}
} else if(isflagset(FLAG_FROM_PROV)) {
$fsn = "private";
$var(rule1) = dp_match("1", "$rU");
switch ($var(rule1)) {
case -2:
xwarn("INFO | No DID routing rule found for GW (IP:$si:$sp) associated with '$rU'\n");
case -1:
xerr("ALERT | Error finding DID routing rule for GW (IP:$si:$sp) associated with '$rU'\n");
case 1:
xinfo("INFO | The DID routing rule for GW (IP:$si:$sp) associated with '$rU' are '$avp(s:dest)'\n");
}
} else {
xwarn("WARN | No routing rule found for GW (IP:$si:$sp)\n");
send_reply("404", "No destination");
exit;
}
if (!$avp(s:dest)) {
xwarn("WARN | No routing rule found for GW (IP:$si:$sp)\n");
send_reply("404", "No destination");
exit;
}
}
Cette fonction permet de router les paquets SIP ! Tout d'abord, on regarde si y'a un flag FROM_CUST (donc de notre IPBX).
Il faut regarder les lignes $var(rule3) = dp_match("3", "$rU") où 3 est une valeur dans la table dialplan et $rU le numéro appelé.
Si l'appel est émis depuis un client, kamailio va regarder dans la table dialplan les routes avec un dpid 3 (interne) puis 2 (externe).
Si l'appel est émis depuis internet, kamalio va regarder dans la table dialplan les routes avec un dpid 1 (did).
pks=# select * from dialplan;
id | dpid | pr | match_op | match_exp | match_len | subst_exp | repl_exp | attrs
----+------+----+----------+------------------------------+-----------+------------------------------+----------+-------
1 | 2 | 1 | 1 | ^(\+33|0033|0)[1-9][0-9]{8}$ | 0 | ^(\+33|0033|0)[1-9][0-9]{8}$ | \1 | 13
2 | 1 | 1 | 1 | ^(012345670[0-9])$ | 0 | ^(012345670[0-9])$ | \1 | 5
3 | 1 | 1 | 1 | ^(012345672[0-9])$ | 0 | ^(012345672[0-9])$ | \1 | 14
Donc résumons, si je place une route avec un type internal, la communication inter tenant et les appels sortants fonctionneraient. Cependant, pas les appels entrants car il n'y a pas de switch qui dit que si le paquet est flag FROM_PROV, kamailio doit check les routes avec un type internal.
Donc on est peu coincé. Je suppose que le développeur avait pensé à un système de téléphonique où les appels inter tenant n'auraient pas lieu d'être ou qu'il y ait un serveur VoIP qui fasse déjà ce travail en amont ! Sauf que dans notre architecture, ce n'est pas le cas.
De ce fait, il m'ait paru évidemment de mettre le type en did sur mes routes pour tester les appels mais la situation n'est pas optimale ! Donc comment régler ce problème ?
Evolution
Plusieurs solutions s'offrent à nous :
- Adapter le code pour implémenter le fait que kamailio puisse aller taper dans les routes internal quand l'appel vient de l'extérieur
- Adapter le code pour mutualiser les types internal et did. Internal semble plutôt réservé à des numéros extensions or mon SBC ne routera que des SDA publiques
- Développer son propre SBC
La réponse me parait claire ... Je vais, bien sûr, me taper un développement complet d'une class 4 !
Prochain épisode, on commence à décortiquer une class 4 en se basant sur des solutions opensource et on commence à monter l'archi (ce n'est pas qu'un simple kamailio).
PKS est un projet très sympathique mais j'adore être créateur de ma propre infra ! De plus, étant débutant en téléphonie, ce projet me permettra de récupérer mon retard et de mieux comprendre la VoIP !
Je termine sur un mot de fin : Lisez Boruto je vous en supplie