Comment Créer un Moniteur Réseau macOS avec Swift et ICMP

Jérémie Poutrin

Jérémie Poutrin

29 septembre 2025 • il y a 3 jours

Comment Créer un Moniteur Réseau macOS avec Swift et ICMP

Plongez dans les coulisses du développement de "Hey - Network Monitor", une application macOS native de surveillance réseau. Cet article technique explore l'implémentation d'ICMP en Swift, l'utilisation de sockets raw, l'architecture multi-thread optimisée et l'intégration profonde avec macOS. De la gestion des permissions système aux optimisations de performance, découvrez pourquoi créer une app native reste pertinent en 2024.

Quand SwiftUI Rencontre la Programmation Socket Brute

La surveillance réseau est un problème résolu, n'est-ce pas ? Nous avons Nagios, Zabbix, Datadog et d'innombrables autres solutions. Alors pourquoi en créer une autre ? La réponse réside dans une niche spécifique : un monitoring macOS natif et léger qui vit dans votre barre de menu. Voici l'histoire de la création de "Hey - Network Monitor", et les défis techniques surprenants de l'implémentation de quelque chose d'aussi "simple" qu'un ping en Swift moderne.

L'Espace du Problème

Imaginez : vous êtes développeur travaillant avec plusieurs environnements - serveurs de production, environnements de staging, endpoints VPN, et peut-être un lab domestique. Vous devez savoir instantanément quand quelque chose tombe en panne, mais vous ne voulez pas :

  • Exécuter une infrastructure de monitoring lourde
  • Garder une fenêtre de terminal ouverte avec des pings continus
  • Vérifier plusieurs tableaux de bord tout au long de la journée
  • Gérer des fichiers de configuration complexes

Ce que vous voulez, c'est un petit indicateur dans votre barre de menu qui devient rouge quand quelque chose ne va pas. C'est tout. Simple, non ?

La Solution Trompeusement Simple

"Hey - Network Monitor" offre exactement cela - une app de barre de menu qui :

  • Affiche un point vert lorsque tous les hôtes surveillés sont joignables
  • Devient rouge lorsqu'un hôte échoue
  • Envoie des notifications macOS natives pour les changements de statut
  • Organise les hôtes en listes de surveillance (production, staging, maison, etc.)
  • Synchronise la configuration entre appareils via iCloud
  • Gère intelligemment les cycles de veille/réveil

Mais voici où cela devient intéressant : implémenter le ping ICMP en Swift est loin d'être trivial.

Le Défi Technique : ICMP à l'Ère Swift

Pourquoi Ne Pas Simplement Utiliser le Shell ?

L'approche évidente serait d'appeler simplement la commande système ping :

let process = Process()
process.launchPath = "/sbin/ping"
process.arguments = ["-c", "1", hostname]
// ... gérer la sortie

Mais cette approche a de sérieuses limitations :

  • Overhead des processus pour chaque ping
  • Le parsing de sortie texte est fragile
  • Aucun contrôle fin sur le comportement de timeout
  • Difficile de gérer efficacement plusieurs pings concurrents

Le Voyage des Sockets Bruts

Pour implémenter de véritables requêtes d'écho ICMP, nous avons besoin de sockets bruts. C'est là que les développeurs Swift entrent en territoire inconnu :

struct ICMPPacket {
    var type: UInt8         // 8 pour echo request
    var code: UInt8         // 0 pour echo
    var checksum: UInt16    // Calculé sur l'ensemble du paquet
    var identifier: UInt16  // ID du processus généralement
    var sequenceNumber: UInt16
    var seconds: UInt32     // Timestamp
    var microseconds: UInt32
}

Créer ce paquet implique :

  1. Construire la structure avec l'alignement d'octets correct
  2. Calculer la somme de contrôle (complément à un de la somme en complément à un)
  3. Convertir en ordre d'octets réseau
  4. Envoyer via socket brut

Voici une version simplifiée du calcul de checksum :

func calculateChecksum(_ data: Data) -> UInt16 {
    var sum: UInt32 = 0
    var index = 0

    // Somme tous les mots de 16 bits
    while index < data.count - 1 {
        let word = UInt32(data[index]) << 8 + UInt32(data[index + 1])
        sum += word
        index += 2
    }

    // Ajouter l'octet restant s'il y en a un
    if index < data.count {
        sum += UInt32(data[index]) << 8
    }

    // Plier la somme 32 bits en 16 bits
    while (sum >> 16) != 0 {
        sum = (sum & 0xFFFF) + (sum >> 16)
    }

    return UInt16(~sum)  // Complément à un
}

L'Architecture Qui a Émergé

Après plusieurs itérations, l'architecture s'est cristallisée en trois composants clés :

1. Gestionnaire de Socket Partagé

Au lieu de créer un socket par hôte, l'app utilise un seul socket ICMP partagé :

class ICMPSocketManager {
    static var _socket: CFSocket?
    static var pingManagers = [UUID: PingPongManager]()
    private static var backgroundThread: Thread?

    private class func filterAndPrepareData(_ socket: CFSocket?,
                                           _ type: CFSocketCallBackType,
                                           _ address: CFData?,
                                           _ data: CFData) {
        // Router les réponses vers le PingPongManager approprié
        if let addressData = address as Data? {
            // Extraire l'adresse IP
            // Trouver le PingPongManager correspondant
            // Livrer la réponse
        }
    }
}

Cette conception :

  • Réduit l'utilisation des ressources système
  • Simplifie la gestion du cycle de vie des sockets
  • Permet un routage efficace des réponses

2. Thread Réseau en Arrière-Plan

Toutes les opérations socket s'exécutent sur un thread d'arrière-plan dédié :

static func initialize() {
    backgroundThread = Thread {
        createSocket()
        source = CFRunLoopSourceCreate(nil, 0, &context)
        CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .defaultMode)
        CFRunLoopRun()  // Cela bloque, exécutant la boucle d'événements
    }
    backgroundThread?.start()
}

Cela empêche le blocage de l'UI et assure des performances réseau cohérentes.

3. Gestionnaires de Ping par Hôte

Chaque hôte surveillé obtient son propre PingPongManager :

class PingPongManager {
    private var identifier: UInt16
    private var currentSequenceNumber: UInt16 = 0
    private var responseTimer: DispatchSourceTimer?

    func sendPing() {
        let packet = createICMPPacket()
        let address = createSocketAddress(for: ipAddress)

        // Envoyer via socket partagé
        ICMPSocketManager.send(packet, to: address)

        // Démarrer le timer de timeout
        startTimeoutTimer()
    }
}

La Couche d'Intégration macOS

Présence dans la Barre de Menu

Créer une app de barre de menu réactive nécessite une gestion d'état soigneuse :

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusBarItem: NSStatusItem!

    func applicationDidFinishLaunching(_ notification: Notification) {
        statusBarItem = NSStatusBar.system.statusItem(
            withLength: NSStatusItem.variableLength
        )

        // Vue personnalisée pour gérer clic gauche/droit
        let statusBarView = CustomStatusBarView()
        statusBarView.leftClickAction = { self.showPopover() }
        statusBarView.rightClickMenu = self.createContextMenu()
    }
}

Gestion des Cycles Veille/Réveil

La surveillance réseau doit gérer gracieusement la veille système :

class SleepWakeNotifier {
    init() {
        let notificationCenter = NSWorkspace.shared.notificationCenter

        notificationCenter.addObserver(
            self,
            selector: #selector(sleepNotification),
            name: NSWorkspace.willSleepNotification,
            object: nil
        )

        notificationCenter.addObserver(
            self,
            selector: #selector(wakeNotification),
            name: NSWorkspace.didWakeNotification,
            object: nil
        )
    }
}

Cela empêche les faux positifs lorsque le Mac sort de veille.

Notifications Natives

L'intégration avec le Centre de Notifications macOS fournit des alertes discrètes :

func scheduleNotification(host: Host) {
    let content = UNMutableNotificationContent()
    content.title = "\(host.name) est hors ligne"
    content.body = "Impossible de joindre \(host.address)"
    content.sound = .default
    content.interruptionLevel = .timeSensitive

    let request = UNNotificationRequest(
        identifier: UUID().uuidString,
        content: content,
        trigger: UNTimeIntervalNotificationTrigger(
            timeInterval: 1,
            repeats: false
        )
    )

    UNUserNotificationCenter.current().add(request)
}

Défis Réels et Solutions

Défi 1 : Gestion des Permissions

Les sockets bruts nécessitent des permissions élevées sur macOS. L'app gère cela gracieusement :

if errno == EPERM {
    // Afficher un message convivial sur les exigences de permission
    showPermissionDialog()
}

Défi 2 : Résolution DNS

Les hôtes peuvent être spécifiés comme IPs ou noms d'hôtes. L'app résout le DNS efficacement :

func resolveHost(host: String) -> HostResolutionResponse {
    // Vérifier si c'est déjà une adresse IP
    if isIPAddress(host) {
        return HostResolutionResponse(resolution:
            HostResolution(host: host, ipAddress: host))
    }

    // Effectuer la résolution DNS
    var hints = addrinfo(
        ai_flags: AI_PASSIVE,
        ai_family: AF_INET,
        ai_socktype: SOCK_STREAM,
        ai_protocol: 0,
        // ...
    )

    let status = getaddrinfo(host, nil, &hints, &res)
    // ... gérer la résolution
}

Défi 3 : Gestion des Timeouts

Les timeouts réseau doivent être gérés soigneusement pour éviter les fuites de ressources :

private func startTimeoutTimer() {
    responseTimer?.cancel()
    responseTimer = DispatchSource.makeTimerSource()
    responseTimer?.schedule(deadline: .now() + timeout)
    responseTimer?.setEventHandler { [weak self] in
        self?.handleTimeout()
    }
    responseTimer?.activate()
}

Optimisations de Performance

Avantages du Socket Partagé

L'utilisation d'un seul socket pour tous les hôtes offre des avantages mesurables :

  • Mémoire : ~200Ko pour 50 hôtes vs ~2Mo avec des sockets individuels
  • CPU : Une seule RunLoop vs plusieurs contextes de threads
  • Latence : Routage de réponse constant sous la milliseconde

Ordonnancement Intelligent

L'app implémente un backoff exponentiel pour les hôtes en échec :

func calculateNextRetry(failures: Int) -> TimeInterval {
    let baseInterval: TimeInterval = 5.0
    let maxInterval: TimeInterval = 300.0  // 5 minutes

    let interval = baseInterval * pow(2.0, Double(min(failures, 8)))
    return min(interval, maxInterval)
}

Cela réduit le trafic réseau inutile tout en maintenant la réactivité.

Leçons Apprises

1. Créer des Ponts Réfléchis avec les APIs Système

Les fonctionnalités de sécurité de Swift ne s'harmonisent pas naturellement avec les APIs système basées sur C. Créer des wrappers sûrs est essentiel :

extension CFSocket {
    func safeSend(_ data: Data, to address: sockaddr_in) -> Bool {
        var addr = address
        return withUnsafeBytes(of: &addr) { addressBytes in
            data.withUnsafeBytes { dataBytes in
                let result = CFSocketSendData(
                    self,
                    addressBytes as CFData,
                    dataBytes as CFData,
                    0
                )
                return result == .success
            }
        }
    }
}

2. Respecter les Ressources Système

Les apps de barre de menu s'exécutent en continu. L'efficacité des ressources n'est pas optionnelle :

  • Utiliser des ressources partagées lorsque possible
  • Implémenter un nettoyage approprié dans deinit
  • Gérer correctement la veille/réveil système
  • Ne pas poller quand on peut utiliser des callbacks

3. SwiftUI et la Programmation Système Peuvent Coexister

L'app prouve que vous pouvez avoir :

  • Une UI moderne et déclarative avec SwiftUI
  • Une programmation système bas niveau
  • Une architecture propre de bout en bout

La clé est une séparation appropriée des préoccupations et des frontières claires entre les couches.

4. L'Expérience Utilisateur dans les Utilitaires Système

Même les utilitaires système ont besoin d'une bonne UX :

  • Retour visuel : Le statut codé par couleur est instantanément reconnaissable
  • Non-intrusif : Vit dans la barre de menu, hors du chemin
  • Notifications intelligentes : N'alerte que sur les changements d'état
  • Synchronisation de configuration : La synchro iCloud signifie configurer une fois, utiliser partout

Le Code Qui Fait Fonctionner le Tout

Voici l'implémentation core du ping qui rassemble tout :

func performPing() {
    guard tryResolveIp() else {
        responseCallback?(self, PingResponse(
            error: PingPongError.ipResolutionError(reason: "Impossible de résoudre l'hôte")
        ))
        return
    }

    // Créer le paquet ICMP
    let sequenceNumber = getNextSequenceNumber()
    let packet = createICMPEchoRequest(
        identifier: identifier,
        sequenceNumber: sequenceNumber
    )

    // Enregistrer le temps d'envoi pour le calcul de latence
    context = CurrentPingContext(
        seqNumber: sequenceNumber,
        sentAt: Date(),
        hasTimedout: false
    )

    // Envoyer le paquet
    guard let ipAddress = ipAddress,
          let address = createSockaddrIn(from: ipAddress, port: 0) else {
        handleError(.connectionError(reason: "Adresse invalide"))
        return
    }

    sendPacket(packet, to: address)

    // Démarrer le timer de timeout
    startTimeoutTimer()
}

func handlePingResponse(_ data: CFData) {
    let responseData = data as Data

    // Valider la réponse ICMP
    guard let (type, code, identifier, sequenceNumber) =
        parseICMPResponse(responseData) else {
        return
    }

    // Vérifier si cette réponse est pour nous
    guard identifier == self.identifier,
          sequenceNumber == context.seqNumber else {
        return  // Réponse pour un ping différent
    }

    // Calculer la latence
    let latency = Date().timeIntervalSince(context.sentAt ?? Date()) * 1000

    // Annuler le timer de timeout
    responseTimer?.cancel()

    // Notifier le callback
    responseCallback?(self, PingResponse(
        r: PingSequenceResponse(
            host: hostname,
            hostIpAddress: ipAddress ?? "",
            latency: latency,
            seq: sequenceNumber
        )
    ))
}

Conclusion : La Puissance du Natif

"Hey - Network Monitor" démontre que le développement macOS natif a toujours des avantages uniques :

  • Intégration système profonde : Barre de menu, notifications, gestion veille/réveil
  • Efficacité : Quelques Mo de RAM surveillent des dizaines d'hôtes
  • Réactivité : Le code natif fournit un retour instantané
  • Confiance utilisateur : Aucune dépendance externe, aucune collecte de données

Le projet prouve que même en 2024, il y a de la valeur à comprendre la programmation au niveau système. Oui, vous pourriez construire une app Electron multiplateforme qui appelle ping en shell. Mais en allant natif et en relevant la complexité des sockets bruts, nous avons réalisé quelque chose de mieux : un outil qui se sent comme une extension naturelle de macOS.

Construire cette app a renforcé un principe important : parfois la "voie difficile" est la bonne voie. La complexité de l'implémentation ICMP from scratch a payé en performance, efficacité, et une compréhension profonde de comment la surveillance réseau fonctionne réellement.

La prochaine fois que vous verrez ce petit point vert dans votre barre de menu devenir rouge, vous saurez qu'il se passe plus de choses sous le capot qu'il n'y paraît - des threads gérant des sockets, des paquets volant à travers le réseau, et du code Swift faisant des choses pour lesquelles il n'a pas été conçu à l'origine, mais les faisant bien.

Ressources Techniques


Hey - Network Monitor est disponible sur le Mac App Store. Le code source est disponible pour ceux qui veulent apprendre ou contribuer au projet.

Publié le 29 septembre 2025

Mis à jour le 2 octobre 2025