dimanche 17 août 2014

Port d’écoute TCP ou UDP restant ouvert après l’arrêt du processus : Process.Start et l’héritage de handles


Dans ce post nous allons nous pencher sur une des raisons de fond parmi les moins évidentes pour lesquelles une exception System.Net.Sockets.SocketException peut être levée avec le message "Only one usage of each socket address (protocol/network address/port) is normally permitted" / "Une seule utilisation de chaque adresse de socket (protocole/adresse réseau/port) est habituellement autorisée".
Cette exception peut être reçue directement, comme par exemple lors de l'utilisation des classes TcpListener, UdpClient ou Socket, ou à un niveau plus ou moins profond de la chaine d’innerexceptions suivant la technologie manipulant le socket sous-jacent : dans le cas de WCF elle sera probablement enveloppée dans une instance de System.ServiceModel.AddressAlreadyInUseException avec un message du type "There is already a listener on IP endpoint [IP]:[Port]. This could happen if there is another application already listening on this endpoint or if you have multiple service endpoints in your service host with the same IP endpoint but with incompatible binding configurations." / "Il existe déjà un écouteur sur le point de terminaison IP [IP]:[Port]. Vérifiez que vous n'essayez pas d'utiliser ce point de terminaison plusieurs fois dans votre application et qu'aucune autre application n'écoute sur ce point de terminaison.".
A noter : dans ce post nous parlons bien d'un cas où cette exception est levée avec ce message pour une tentative d'ouverture d'un port d'écoute et non pas le cas où elle est levée avec ce même message lors d'une tentative d'établissement d'une connexion sortante, comme ça peut être le cas lors d'un incident d'épuisement de la plage de ports dynamiques utilisée pour ces connexions (incident bien connu sous le nom "TCP/IP port exhaustion").

Contexte technique de rédaction de ce post : Windows 7 x64 et .NET 4.5.1, mais observé sur d'autres configurations telles que Windows Server 2008 R2 et .NET 4.0 (les tests n'ont pas été réalisés sous Windows 8 / 2012 mais je ne vois à priori pas de raison pour laquelle ce serait différent sur ces plateformes).


Contexte de l'incident

Un service Windows ou une simple application s'exécutant dans une session utilisateur, et pas un service hébergé dans IIS, a été redémarré(e) et n'arrive plus à se positionner en écoute sur le port TCP 12345 (pour notre exemple) alors qu'il/elle le pouvait avant redémarrage.
Il est certain que l'application s'est arrêtée correctement et qu'aucun incident matériel ou réseau n'a eu lieu : toutes les connexions ont été terminées correctement.


Par quel processus est ouvert le port ?

Plusieurs outils permettent de vérifier si un port est ouvert, et par quel processus :
  • netstat, en ligne de commande, fourni avec Windows
  • TCPView, GUI, membre de la suite Sysinternals (une version en ligne de commande est aussi fournie : Tcpvcon).
  • CurrPorts, GUI, par Nirsoft (utilisable aussi en ligne de commande)
Il vaut mieux lancer ces outils avec les privilèges administrateur même si ce n'est pas absolument requis (sauf pour netstat qui ne fonctionnera pas du tout sans ces privilèges avec les paramètres que j'utilise).
Si ces outils sont lancés après l'arrêt du processus de notre application, ils affichent ce qui suit :
TCPView
Capture d'écran de TCPView montrant le port TCP 12345 ouvert en état LISTENING par un processus n'existant plus.
netstat
C:\>netstat -abnop TCP
Active Connections
  Proto  Local Address          Foreign Address        State           PID
[...]
  TCP    0.0.0.0:12345          0.0.0.0:0              LISTENING       5868
 [System]
[...]
CurrPorts (qui permet de filtrer les connexions, donc nous utilisons ce filtre : include:local:tcp:12345)
Capture d'écran de CurrPorts montrant le port TCP 12345 ouvert en état LISTENING soi-disant par le processus System mais dont le PID n'est ni 4, ni 0 : ce n'est donc pas vrai.
Les 3 outils ne sont pas totalement d'accord entres eux :
  • TCPView montre le port TCP 12345 ouvert en état LISTENING par un processus inexistant (PID 5868).
  • netstat et CurrPorts montrent le port TCP 12345 ouvert en état LISTENING soi-disant par le processus System mais dont le PID (5868) n'est ni 4, ni 0 : ce n'est donc pas vrai.
(A noter que s'ils sont lancés avant l'arrêt du processus, TCPView et CurrPorts afficheront peut-être encore le nom du processus qui s'est terminé depuis, au lieu de "System" ou "<non-existent>".)
Aucun processus de PID 5868 ne tourne sur mon système donc nous ne sommes pas très avancés...


Tentative de résolution temporaire du problème : forçage de la fermeture de la connexion

TCPView et CurrPorts permettent de forcer la fermeture de la connexion via le menu contextuel disponible sur cette dernière : "Close Connection" pour TCPView, "Close Selected TCP Connections" pour CurrPorts :
Capture d'écran de l'élément "Close Connection" dans TCPView
Capture d'écran de l'élément "Close Selected TCP Connections" dans CurrPorts
Je ne suis pas forcément fan de cette pratique que je n'encourage pas, on ne sait jamais comment peuvent réagir les applications utilisant la ressource, mais ça peut permettre de débloquer une situation en attendant de corriger le problème de fond.
Là nous avons une connexion associée à un processus inexistant, donc tentons la manipulation.
Echec depuis les 2 outils : TCPView ne dit rien mais la connexion reste ouverte, et CurrPorts affiche un message d'erreur : "Failed to close one or more TCP connections. Be aware that you must run this tool as admin in order to close TCP connections." (j'avais pourtant bien lancé cet outil avec les privilèges administrateur comme je l'ai conseillé plus haut).
Nous verrons plus bas qu'il est en réalité possible, dans certains cas, d'obtenir la fermeture de cette connexion sans pour autant terminer le processus qui la tient ouverte.


Cause de fond du problème : l'héritage de handles

En examinant la liste des processus s'exécutant sur la machine j'en ai vu un que je savais être un processus esclave de celui qui écoutait sur le port 12345 et ce n'est qu'à ce moment-là que je me suis souvenu d'un mécanisme disponible sur le lancement de processus sous certaines conditions : l'héritage de handles.
Il faut savoir que, au moment où je rédige cet article (.NET 4.5.1), la classe Process dispose de 2 moyens de lancer un nouveau processus lors de l'appel à la méthode Start :
Une seule de ces 3 fonctions permet d'activer le mécanisme d'héritage de handles : CreateProcess
BOOL WINAPI CreateProcess(
  _In_opt_     LPCTSTR lpApplicationName,
  _Inout_opt_  LPTSTR lpCommandLine,
  _In_opt_     LPSECURITY_ATTRIBUTES lpProcessAttributes,
  _In_opt_     LPSECURITY_ATTRIBUTES lpThreadAttributes,
  _In_         BOOL bInheritHandles,
  _In_         DWORD dwCreationFlags,
  _In_opt_     LPVOID lpEnvironment,
  _In_opt_     LPCTSTR lpCurrentDirectory,
  _In_         LPSTARTUPINFO lpStartupInfo,
  _Out_        LPPROCESS_INFORMATION lpProcessInformation
);

[...]
bInheritHandles [in]
If this parameter TRUE, each inheritable handle in the calling process is inherited by the new process. If the parameter is FALSE, the handles are not inherited. Note that inherited handles have the same value and access rights as the original handles.
[...]
Et justement si on examine le code de la classe Process dans .NET 4.5.1, disponible au travers du programme Reference Source, on s'aperçoit que l'appel à CreateProcess est bel et bien effectué en passant true pour le paramètre bInheritHandles :
Extrait du code de la méthode StartWithCreateProcess qui nous intéresse  :
retVal = NativeMethods.CreateProcess (
       null,               // we don't need this since all the info is in commandLine
       commandLine,        // pointer to the command line string
       null,               // pointer to process security attributes, we don't need to inheriat the handle
       null,               // pointer to thread security attributes
       true,               // handle inheritance flag
       creationFlags,      // creation flags
       environmentPtr,     // pointer to new environment block
       workingDirectory,   // pointer to current directory name
       startupInfo,        // pointer to STARTUPINFO
       processInfo         // pointer to PROCESS_INFORMATION
   );


La classe Process utilise ShellExecuteEx pour lancer le nouveau processus tant que la propriété UseShellExecute de l'instance de ProcessStartInfo passée à ou créée par la méthode Start vaut true.
Dès que UseShellExecute vaut false, la classe utilise CreateProcess si aucun username/password n'a été spécifié, ou CreateProcessWithLogonW dans le cas contraire.
Donc dans le cas où le processus esclave est lancé via CreateProcess, le port d'écoute ne sera libéré que quand à la fois le processus maitre et le processus esclave auront terminé leur exécution (ou simplement fermé le handle vers le socket ouvert).
Si vous vous retrouvez dans cette situation, que votre processus esclave doit absolument terminer son travail avant d'être arrêté et que vous êtes certains que le handle du socket qui a été hérité n'est pas utilisé dans le processus esclave vous pouvez peut-être vous en sortir en forçant la fermeture du handle (avec Process Explorer ou Process Hacker), avec les mêmes réserves que pour le forçage de fermeture de la connexion plus tôt dans ce post. Le handle à chercher est typé File et nommé "\Device\Afd". Si notre processus en possède plusieurs, ça va être compliqué : je ne connais pas de méthode permettant de retrouver les informations du socket en partant du handle.

Autre point à noter : la documentation de CreateProcess dit ceci pour le paramètre bInheritHandles : "Note that inherited handles have the same value and access rights as the original handles."
Autrement dit : si le processus esclave en lance un troisième selon les mêmes modalités alors le handle du socket sera aussi transmis à ce nouveau processus, et ainsi de suite.


Peut-on éviter l'héritage du handle de socket quand Process.Start passe par CreateProcess ?

A ma connaissance il n'existe pas de moyen fiable d'empêcher cet héritage de handle lors de l'appel à CreateProcess par la classe Process.
J'ai bien vu quelques informations concernant l'utilisation de SetHandeInformation sur le handle du socket (accessible via la propriété Handle) mais j'ai aussi lu que la présence de certains Layered Service Providers (LSP) pouvait rendre ce changement inefficace, entrainant un héritage systématique des handles de sockets par les processus enfants dès lors que le paramètre bInheritHandles vaut true.
Il vaut donc mieux avoir un design prenant en compte et compensant ce problème d'héritage du handle de socket, ou permettant de s'assurer que UseShellExecute pourra conserver une valeur true.

Aucun commentaire:

Enregistrer un commentaire