Utiliser socket pair et passfd

J'ai toujours eu en tête cette fonctionnalité de passfd disponible sous Linux mais je n'avais
jamais eu l'occasion de vraiment m'en servir. Au gré de tests d'architecture,
j'ai pu expérimenté ça, et je trouve que ça ouvre plein de perspectives, je vous parle de tout ça.
Socket pair
Rien de bien compliqué avec socketpair ça permet en un appel système d'avoir 2 sockets inter-connectées:
quand on envoie des octets sur une socket ou peut les lire sur l'autre:
#include <sys/socket.h> int socketpair(int domain, int type, int protocol, int sv[2]);
Ça reste quand même plus pratique que les appels systèmes pipe ou pipe2, qui fournissent un file
descriptor pour lire et un autre pour écrire.
Même sous windows dans de nombreux projets, j'ai codé des équivalents de socketpair tellement c'est
pratique. En gros on créé une socket en écoute, on fait un bind(), puis un listen() ensuite on accept(), et en parallèle
on se connect sur cette même socket en écoute, et ça nous donne les deux sockets interconnectées, et on peut arrêter la socket
en écoute (si on ne veut pas faire de thread on peut même mettre la socket en non-bloquant et lancer accept et connect
en parallèle, ça fonctionne).
En pratique on utilise souvent ça pour communiquer en bi-directionnel avec un processus enfant. Donc par exemple:
- on appelle
socketpair(AF_UNIX, SOCK_STREAM, 0, &socks), on va gardersocks[0]coté processus père et se servir desocks[1]coté enfant; - on
fork()donc tous les file descriptors sont hérités dans le processus fils. Coté parent, on peut fermersocks[1](close(socks[1])), elle ne sert plus, et coté enfant on peut fermersocks[0]; - désormais les deux processus peuvent communiquer en se servant de leur socket, on peut mettre ces sockets dans nos boucles de polling pour lire les messages venant de l'autre processus.
C'est une manière assez classique pour faire communiquer un processus "principal" avec ses workers. Par exemple, on a un serveur qui écoute en RDP et qui va lancer un processus fils pour traiter une connexion entrante. Mais on souhaite quand même garder une liaison entre le processus principal et le "processus session", pour échanger des informations (ACL, ou autre) ou pour se passer des ordres (arrête toi !).
Passfd
Passfd n'est pas un appel système c'est plutôt un moyen de passer des file descriptors comme OOB (Out-Of-Band) data à travers des appels sockets. En gros, on peut envoyer les octets de d'habitude et en plus passer d'autres informations en données annexes, dont des descripteurs de fichiers.
La première fois que je l'ai vu en action c'est dans wayland: le compositeur et
un client wayland communique entre eux à travers une socket AF_UNIX locale et à un moment se pose la question de
la disposition du clavier. Pour que le client sache quoi utiliser, le compositeur ouvre le fichier XKB, et envoie ce file descriptor au
client wayland avec un passfd, et il pourra lire à partir de ce fichier tous les détails de la disposition du clavier. Il y a un coté un peu magique
à l'opération: on ouvre un fichier, on envoie le handle au processus à l'autre bout, et il peut s'en servir directement.
struct msghdr { void *msg_name; /* Optional address */ socklen_t msg_namelen; /* Size of address */ struct iovec *msg_iov; /* Scatter/gather array */ size_t msg_iovlen; /* # elements in msg_iov */ void *msg_control; /* Ancillary data, see below */ size_t msg_controllen; /* Ancillary data buffer len */ int msg_flags; /* Flags on received message */ };
En pratique quand on veut envoyer ou recevoir on va utiliser un struct msghdr lors des appels à
recvmsg() ou sendmsg(). D'ailleurs ce msghdr est intéressant car en plus de permettre d'héberger les
données auxiliaires (msg_control et msg_controllen), il permet aussi d'envoyer des données coupées en chunks. Donc en théorie, plus
besoin que l'applicatif "s'embête" à fabriquer un beau buffer linéaire pour faire un gros send() lors d'un envoie de paquets, il peut
juste fournir la liste des bouts de paquets à envoyer et le système fera de son mieux pour rassembler les bouts et envoyer le tout.
Dans certaines situations ça peut éviter des copies inutiles, car le kernel va pouvoir se servir dans la liste de chunks pour remplir
les paquets qu'il peut envoyer tout de suite, et il fera la copie du reste. Potentiellement on peut donc économiser une partie de l'étape de
linéarisation du buffer (c'est ce que fait libevent par exemple). Par exemple dans le RDP, souvent on construit un payload incrémentalement en appelant
une après l'autre les couches internes du protocole. Une fois qu'on a le payload il faut mettre une entête, mais comme sa taille dépend de la taille du contenu,
on ne peut pas réserver de la place. On pourrait réserver la taille maximum des entêtes mais faire ça risque de forcer une copie pour la couche suivante du protocole.
Alors qu'avec le contenu de msg_iov / msg_iovlen, on peut fabriquer une liste de chunks dans lesquels on mettra les payloads et les headers et le
kernel se débrouillera pour transmettre tout ça en un block.
Mais je digresse.
Les file descriptors sont passés en données annexes avec le cmsg_level à SOL_SOCKET et le cmsg_type à SCM_RIGHTS, il s'agit juste
d'un tableau d'entiers contenant les numéro de handle.
Combinaison des deux
Et si on combine les deux ça permet un schéma assez intéressant: un processus père qui orchestre, il lance deux processus fils avec lesquels il garde un canal de communication via une socketpair. Il peut créer une autre socketpair, et en passe un bout à chaque fils en utilisant passfd:

Et donc on a 2 processus fils qui récupèrent, de manière parfaitement sécurisée, un canal de communication entre eux à travers le processus père. Dans cet exemple en python, entre le père et les fils on a un protocol de communication basique: si le premier octet est 0 c'est qu'on transmet une chaîne, sinon si c'est 1 c'est qu'on transmet un bout de la socketpair:
import socket import os import sys import time import array def doProcess(no, s): print("{}: child running".format(no)) while True: (data, ancdata, msg_flags, address) = s.recvmsg(4096, 4096) if data[0] == 0: print('{}: echo {}'.format(no, data[1:])) elif data[0] == 1: fds = array.array("i") for cmsg_level, cmsg_type, cmsg_data in ancdata: if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS: # Append data, ignoring any truncated integers at the end. fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)]) print("{}: got {} fds".format(no, len(fds))) sock = socket.fromfd(fds[0], socket.AF_UNIX, socket.SOCK_STREAM) if no == 1: sock.send(b'coucou') print("{}: coucou sent".format(no)) elif no == 2: print("{}: received={}".format(no, sock.recv(4096))) break sys.exit(0) if __name__ == '__main__': c1, c1sub = socket.socketpair(socket.AF_UNIX) c1sub.set_inheritable(True) if os.fork() == 0: doProcess(1, c1sub) c2, c2sub = socket.socketpair(socket.AF_UNIX) c2sub.set_inheritable(True) if os.fork() == 0: doProcess(2, c2sub) # Test that father/son communication works c1.send(b'\0hello 1') c2.send(b'\0hello 2') time.sleep(0.500) # then send an end of the socket pair to each child process s1, s2 = socket.socketpair(socket.AF_UNIX) socket.send_fds(c1, buffers=[b'\1tutu'], fds=[s1.fileno()] ) socket.send_fds(c2, buffers=[b'\1tutu'], fds=[s2.fileno()] ) time.sleep(10)
A l'exécution on obtiens cette sortie, donc les deux processus fils communiquent bien entre eux:
1: child running 1: echo b'hello 1' 2: child running 2: echo b'hello 2' 1: got 1 fds 2: got 1 fds 1: coucou sent 2: received=b'coucou'
Conclusion
Bref, ces appels systèmes ouvrent plein de possibilités d'architecture système très intéressantes.