C’est un problème qui impacte tous les bidouilleurs à domicile. C’est déjà suffisamment complexe de configurer sa Bidule Box, mais un nouveau problème intervient. À la maison, vous avez sans doute une adresse IP dynamique. C’est-à-dire que si vous débranchez votre routeur et que vous le rebranchez, vous obtiendrez une nouvelle adresse IP. Sur ma connexion Escrorange, toutefois, il semble que ce ne soit le cas que pour IPv4. Malheureusement, cela ne change rien au problème finalement : lorsque mon ordi démarre, il y a un court laps de temps pendant lequel le réseau est initialisé, mais je n’ai pas encore d’adresse IP. Ou alors, je ne les ai pas encore toutes.

Si mon service réseau décide de démarrer à ce moment-là, je me retrouve bien embarassé : il ne sera tout simplement pas accessible (ou seulement partiellement), et le problème ne sera peut-être jamais détecté.

La solution retenue par systemd consiste à définir une cible network-online.target, qui ne sera activée que quand la négociation des adresses sera terminée. Comme noté sur le site de Red Hat, cette solution n’est utile que pour le démarrage du système. En cas de panne temporaire du réseau sans redémarrage de la machine, il n’y aura pas de redémarrage du service. Malgré cette limitation, la solution s’applique à tous les services nécessitant de se lier au réseau. Mais peut-être est-il possible de concevoir mieux chaque service, afin que le problème ne se pose pas ?

Nous allons aujourd’hui voir comment il est possible de concevoir le service réseau de sorte à ce qu’il puisse réagir à la présence d’une nouvelle adresse IP à lier, ou à la disparition d’une adresse utilisée.

1. Dites bonjour :)

Nous allons concevoir un serveur très simple : il lui est donné comme configuration un nom de domaine et un nom de service, et il se liera à toutes les adresses connues de ce domaine, selon le protocole TCP, dont le port correspond au service. Lorsqu’un client se connectera, on enverra "Bonjour :)" et on terminera la connexion.

Comme on s’attend à ce qu’il y ait plusieurs adresses IP à lier, il y aura donc plusieurs sockets à surveiller en même temps. Il y a de bonnes API pour faire cela, comme par exemple epoll, mais nous allons nous contenter de la fonction select car elle est disponible directement en guile.

(define-class <serveur> ()
  (sockets #:init-value '())
  (hôte #:init-keyword #:hôte #:init-thunk gethostname)
  (service #:init-keyword #:service #:init-value "12345"))

Le nom de l’hôte et le nom du service sont des constantes pour cette classe. En effet, il serait trop complexe de gérer plusieurs noms d’hôtes différents. La valeur par défaut du nom d’hôte est le nom de l’ordinateur (que l’on considère comme dynamique, même s’il serait assez incongru qu’il change sans que les adresses IP associées suivent). La valeur par défaut du service, en revanche, est arbitraire, et c’est au développeur de la fixer.

2. Relier le serveur en cas de besoin

Nous pouvons définir une méthode, relier, qui s’occupera de récupérer la liste des adresses IP pour l’hôte, afin de fermer les sockets obsolètes et d’en lier de nouvelles pour les nouvelles adresses. Il ne faudrait pas fermer les sockets qui sont toujours valides, autrement les connexions en attente seraient rejetées.

Par choix, nous allons éviter de faire trop de mutations dans notre API. La fonction retournera donc deux valeurs : un nouveau serveur, et la liste des sockets obsolètes. Charge à l’utilisateur de l’API de les fermer.

(define-method (relier (serveur <serveur>))
  (define (index adresse)
    ;; retourne une valeur d’index pour adresse, comparable avec
    ;; equal?
    (list
     (sockaddr:fam adresse)
     (sockaddr:path adresse)
     (sockaddr:addr adresse)
     (sockaddr:port adresse)))
  (define tcp
    (protoent:proto (getprotobyname "tcp")))
  (let ((sockets-existantes
         (make-hash-table))
        (nouveau-serveur (shallow-clone serveur)))
    (for-each
     (lambda (socket)
       (let ((adresse (getsockname socket)))
         (hash-set! sockets-existantes
                    ;; On ne peut pas utiliser les adresses telles
                    ;; quelles, sinon equal? ne fonctionnera pas.
                    (index adresse)
                    socket)))
     (slot-ref serveur 'sockets))
    (slot-set!
     nouveau-serveur 'sockets
     (map
      (lambda (info-adresse)
        (let ((adresse (addrinfo:addr info-adresse)))
          (let ((existante
                 (hash-ref sockets-existantes
                           (index adresse))))
            (if existante
                (begin
                  (hash-remove! sockets-existantes (index adresse))
                  existante)
                ;; Nouvelle adresse
                (let ((s (socket (addrinfo:fam info-adresse)
                                 (addrinfo:socktype info-adresse)
                                 (addrinfo:protocol info-adresse))))
                  (bind s adresse)
                  (listen s 10)
                  s)))))
      (getaddrinfo (slot-ref serveur 'hôte)
                   (slot-ref serveur 'service)
                   (logior AI_PASSIVE)
                   0 ;; Famille
                   SOCK_STREAM ;; Avec connexion
                   tcp)))
    (values nouveau-serveur
            (map cdr (hash-map->list cons sockets-existantes)))))

La fonction construit une table de hachage qui retient la liste des sockets, indexée par l’adresse de la socket. Puisque les adresses IP sont converties en nombre par guile, la fonction de comparaison equal? permet de retrouver les adresses nécessaires pour satisfaire getaddrinfo.

Par souci de facilité, nous allons appeler directement la fonction relier dans la méthode d’initialisation de la classe de serveur.

(define-method (initialize (object <serveur>) initargs)
  (next-method)
  (receive (serveur _)
      (relier object)
    (slot-set! object 'sockets
               (slot-ref serveur 'sockets))))

On commence bien sûr par appeler (next-method), de sorte à définir les champs hôte et service grâce à la méthode d’initialisation par défaut, qui ne se fonde que sur la définition des slots. Il est possible de détourner cette garantie en utilisant de l’héritage multiple, mais nous supposerons que l’initialisation pour la classe fille d’un héritage multiple ne fait pas directement appel à (next-method), ce qui fera disparaître le problème.

3. Utilisons select

Comme nous allons utiliser la fonction select, nous devons être en mesure d’obtenir la liste des ports ou descripteurs de fichiers à surveiller. On peut simplement retourner la valeur du champ sockets ici.

(define-method (ports (serveur <serveur>))
  (values
   ;; Liste des ports dont on doit surveiller la lecture
   (slot-ref serveur 'sockets)
   ;; Liste des ports dont on attend l’écriture
   '()
   ;; Liste des ports dont on attend une erreur
   '()))

Enfin, lorsque la fonction select aura indiqué un port disponible en lecture, nous devons agir. En l’occurence, il faut accepter le nouveau client, lui envoyer le bonjour, et fermer la socket du client. En revanche, si la socket ne fait pas partie du serveur, il ne faut rien faire.

(define-method (prête (serveur <serveur>) socket direction)
  (when (port? socket)
    (set! socket (port->fdes socket)))
  (for-each
   (lambda (socket-serveur)
     (when (and (eq? direction 'lire)
                (eqv? (port->fdes socket-serveur) socket))
       (let ((client (accept socket-serveur)))
         (let ((port (car client))
               (adresse (cdr client)))
           (format port "Bonjour ~a :)\n"
                   (inet-ntop (sockaddr:fam adresse)
                              (sockaddr:addr adresse)))
           (close-port port)))))
   (slot-ref serveur 'sockets))
  serveur)

4. Le programme à exécuter

Nous pouvons maintenant exécuter ../../../code/bonjour.scm :

(use-modules (oop goops) (ice-9 receive) (ice-9 match)
             (srfi srfi-19))

<<classe-serveur>>
<<fonction-relier>>
<<initialisation-serveur>>
<<fonction-ports>>
<<fonction-prête>>

(define (main serveur)
  (format #t "~a : le serveur lie les adresses suivantes : ~a\n"
          (date->string (current-date))
          (map
           (lambda (socket)
             (let ((adresse (getsockname socket)))
               (let ((adresse
                      (inet-ntop (sockaddr:fam adresse) (sockaddr:addr adresse)))
                     (port
                      (sockaddr:port adresse)))
                 (format #f "[~a]:~a" adresse port))))
           (slot-ref serveur 'sockets)))
  (receive (read write except) (ports serveur)
    (match (select read write except 60)
      ((read write except)
       (let traiter ((serveur serveur)
                     (tâches
                      (append
                          (map (lambda (socket)
                                 `(,socket lire))
                               read)
                          (map (lambda (socket)
                                 `(,socket écrire))
                               write)
                          (map (lambda (socket)
                                 `(,socket exception))
                               except))))
         (match tâches
           (()
            (receive (serveur à-fermer) (relier serveur)
              (for-each close-port à-fermer)
              (main serveur)))
           (((socket direction) tâches-restantes ...)
            (traiter (prête serveur socket direction)
                     tâches-restantes))))))))

(main (make <serveur>))

Ce programme vérifie toutes les 60 secondes (dans le pire des cas) que les adresses à lier sont à jour, et sert tous les clients qui se présentent en leur répondant « Bonjour :) ».

Il y a beaucoup de choses à améliorer. Pour commencer, il faudrait utiliser autre chose que select, qui a une complexité temporelle linéaire et qui ne gère qu’un nombre limité de clients. Nous avons aussi une complexité linéaire pour vérifier si la socket est rattachée au serveur, et donc quadratique pour un appel récursif de main. Il faudrait utiliser epoll, ou une bibliothèque de gestion du réseau plus haut niveau comme la GLib, et revoir notre code. Il faudrait également utiliser getopt-long (en internationalisant les noms longs d’options) pour choisir le nom d’hôte à lier. Enfin, exécuter getaddrinfo à chaque fois que l’on sert un groupe de clients n’est pas très pertinent, et attendre 60 secondes peut être long. Pour détecter les situations où nous devrions relier le serveur, il va falloir utiliser l’API Netlink, ce que nous ferons une prochaine fois.