Tous les textes

~/textes/goscan-one-socket-subnet

Débogage de systèmes
5 min de lecture

Un seul socket pour tout le sous-réseau

Un scanner d'hôtes qui ouvrait un socket brut et une goroutine par hôte s'écroulait sur tout ce qui dépassait un /24. La réécriture utilise un seul socket et deux goroutines peu importe le nombre d'hôtes, avec un chemin rapide ARP et un repli sans privilèges.

goscan fait une seule chose: trouver les hôtes vivants sur chaque sous-réseau de chaque interface, vite, et les afficher d'une façon qu'on peut rediriger vers la commande suivante. La forme utile, c'est goscan alive -i eth0 | xargs -I{} ssh root@{} uptime.

La première version marchait et ne montait pas à l'échelle. Elle ouvrait un pinger, et un socket brut, par hôte, et lançait à peu près une goroutine par hôte pour aller avec. Sur un /24 ça fait 254 sockets et 254 goroutines, ce qui est laid mais survit. Pointe-la vers un /16 et tu demandes au noyau 65 000 sockets bruts d'un coup. Ça se passe mal.

J'ai donc réécrit le coeur autour d'un seul socket partagé.

probe.go
// Host discovery via a single shared ICMP raw socket per scan, with an
// optional ARP fast-path for local subnets. The old design opened one
// Pinger (and one raw socket) per host and spawned ~N goroutines; this
// one uses two goroutines (sender + receiver) and one socket regardless of N.

Deux goroutines, n'importe quel nombre d'hôtes

Tout le balayage, c'est un seul socket brut ICMP, une goroutine qui envoie, et une goroutine qui reçoit. L'astuce qui rend un seul socket suffisant, c'est le numéro de séquence de l'écho ICMP: je le règle à l'index de l'hôte dans la liste, donc une réponse identifie son expéditeur sans que j'aie à suivre un état par hôte.

L'émetteur parcourt la liste d'hôtes, tire un écho par hôte avec Seq réglé à l'index, et se cadence avec un petit délai pour ne pas saturer la carte réseau:

probe.go (the sender)
payload := []byte("goscan/v1")
gap := time.Duration(icmpSendGapNsec) // 200 microseconds
for _, idx := range pending {
    msg := icmp.Message{
        Type: ipv4.ICMPTypeEcho,
        Body: &icmp.Echo{ID: int(id), Seq: idx, Data: payload},
    }
    b, _ := msg.Marshal(nil)
    var dst net.Addr = &net.IPAddr{IP: hosts[idx].AsSlice()}
    if !privileged {
        dst = &net.UDPAddr{IP: hosts[idx].AsSlice()}
    }
    conn.WriteTo(b, dst)
    time.Sleep(gap)
}

Le récepteur lit les réponses et fait correspondre chacune par numéro de séquence directement à l'index de l'hôte, avec un compare-and-swap atomique pour qu'un hôte qui répond deux fois ne soit compté qu'une seule fois:

probe.go (the receiver)
// Primary match: sequence number == host index.
if seq := echo.Seq; seq >= 0 && seq < len(hosts) {
    if replied[seq].CompareAndSwap(false, true) {
        emit(seq, true)
    }
    continue
}
// Fallback: match by source IP (covers the rare seq mismatch
// in unprivileged mode).
if addr, ok := srcAddr(src); ok {
    if idx, ok := addrToIdx[addr]; ok && replied[idx].CompareAndSwap(false, true) {
        emit(idx, true)
    }
}

Le numéro de séquence fait 16 bits, donc un seul balayage plafonne à 65 535 hôtes. C'est l'unique limite dure (icmpMaxSubnet = 0xFFFF), et un /16 se loge juste en dessous. Plus gros que ça et le scan refuse au lieu de boucler l'index en silence.

ARP arrive le premier

Pour un sous-réseau local, ICMP est la façon lente de demander "es-tu là." Les machines de ton propre domaine de diffusion répondent à l'ARP, et l'ARP n'a besoin d'aucun privilège de socket brut ni d'aucun aller-retour à travers la pile IP. Donc pour tout sous-réseau de /24 ou plus petit, goscan fait d'abord une passe ARP, avec un bassin de travailleurs, et ne retombe sur le balayage ICMP que pour les adresses que l'ARP n'a pas déjà confirmées.

probe.go
// Stage 1: ARP fast path for local subnets (/24 or smaller).
if bits >= 24 && supportsARP() {
    // j-keck/arping uses AF_PACKET on Linux and BPF on the BSDs/darwin.
    // arpWorkers = 64
}

Travailler sans sudo

L'ICMP brut veut normalement dire root. C'est une mauvaise exigence pour un outil qu'on veut rediriger dans une boucle shell, alors goscan tente le chemin privilégié et redescend discrètement vers celui sans privilèges s'il ne peut pas l'avoir. Linux expose l'ICMP datagramme sans privilèges, contrôlé par net.ipv4.ping_group_range, et ça suffit pour faire un ping sans socket brut.

probe.go
func openICMPSocket() (*icmp.PacketConn, bool, error) {
    if c, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0"); err == nil {
        return c, true, nil // privileged raw ICMP
    } else if runtime.GOOS == "windows" {
        return nil, false, err
    }
    if c, err := icmp.ListenPacket("udp4", "0.0.0.0"); err == nil {
        return c, false, nil // unprivileged datagram ICMP
    }
    return nil, false, fmt.Errorf("try sudo, or setcap cap_net_raw+ep")
}

Le booléen qu'il renvoie, c'est la raison pour laquelle le récepteur a ce repli sur l'IP source. Sur le socket datagramme sans privilèges, le noyau possède l'id ICMP et peut le réécrire, donc l'astuce de la séquence-comme-index est un peu moins fiable là, et faire correspondre sur l'adresse source ramasse les retardataires.

Ce qu'il ne fait pas

Il n'énumère pas IPv6. Les gens le demandent, et la réponse est qu'on ne peut pas balayer un /64. Il y a dix-huit trillions d'adresses dans un seul; tu ne finirais jamais, et rien de réel ne vit de toute façon à un décalage qu'on pourrait deviner par force brute. La découverte d'hôtes par balayage est une idée d'IPv4. En v6 on trouve les hôtes en écoutant, pas en cognant à chaque porte, donc goscan reste un outil IPv4 et le dit franchement.

Le résultat, c'est un scanner dont l'empreinte ne grossit pas avec le sous-réseau: un seul socket, deux goroutines, et un numéro de séquence qui fait la comptabilité qui prenait avant des milliers de sockets.

Le code est sur GitHub.