Networking con PHP

Networking con PHP

PHP viene spesso associato esclusivamente al ciclo richiesta/risposta HTTP gestito da un web server, ma il linguaggio offre un insieme completo di strumenti per lavorare direttamente con la rete: socket TCP e UDP, risoluzione DNS, connessioni cifrate con TLS e multiplexing di più connessioni con stream_select(). In questo articolo vedremo come usare l'API degli stream di PHP per costruire client e server di rete affidabili, con particolare attenzione a timeout, gestione degli errori e framing dei messaggi.

Stream o estensione sockets?

PHP mette a disposizione due API distinte per lavorare con i socket:

  • L'API degli stream (stream_socket_client(), stream_socket_server(), stream_select()): è sempre disponibile, si integra con le normali funzioni di I/O come fread() e fwrite() e supporta nativamente TLS tramite gli stream context.
  • L'estensione sockets (socket_create(), socket_bind(), socket_recvfrom()): è un wrapper molto vicino all'API BSD in C, utile quando servono opzioni di basso livello, ma va abilitata esplicitamente e non gestisce TLS.

Nella grande maggioranza dei casi l'API degli stream è la scelta più pratica, ed è quella che useremo come riferimento principale.

Un client TCP con timeout

Il primo errore che si commette scrivendo un client di rete è dimenticare i timeout. Senza un limite esplicito, una connessione verso un host che non risponde può bloccare il processo per decine di secondi. PHP distingue tra il timeout di connessione, passato a stream_socket_client(), e il timeout di lettura e scrittura, impostato con stream_set_timeout().

<?php

declare(strict_types=1);

final class TcpClient
{
    /** @var resource|null */
    private $stream = null;

    public function __construct(
        private readonly string $host,
        private readonly int $port,
        private readonly float $connectTimeout = 3.0,
        private readonly int $ioTimeout = 5,
    ) {
    }

    public function connect(): void
    {
        $address = sprintf('tcp://%s:%d', $this->host, $this->port);

        // Apertura della connessione con timeout esplicito
        $stream = @stream_socket_client(
            $address,
            $errorCode,
            $errorMessage,
            $this->connectTimeout,
            STREAM_CLIENT_CONNECT
        );

        if ($stream === false) {
            throw new RuntimeException(
                sprintf('Connessione a %s fallita: [%d] %s', $address, $errorCode, $errorMessage)
            );
        }

        // Timeout per le singole operazioni di lettura e scrittura
        stream_set_timeout($stream, $this->ioTimeout);

        $this->stream = $stream;
    }

    public function send(string $payload): void
    {
        $this->assertConnected();

        $total = strlen($payload);
        $written = 0;

        // fwrite può scrivere solo una parte dei dati: si cicla fino al completamento
        while ($written < $total) {
            $bytes = fwrite($this->stream, substr($payload, $written));

            if ($bytes === false || $bytes === 0) {
                throw new RuntimeException('Scrittura sul socket fallita');
            }

            $written += $bytes;
        }
    }

    public function readLine(int $maxLength = 8192): string
    {
        $this->assertConnected();

        $line = fgets($this->stream, $maxLength);
        $meta = stream_get_meta_data($this->stream);

        // Il flag timed_out indica che il timeout di lettura è scaduto
        if ($meta['timed_out']) {
            throw new RuntimeException('Timeout in lettura');
        }

        if ($line === false) {
            throw new RuntimeException('Connessione chiusa dal peer');
        }

        return rtrim($line, "\r\n");
    }

    public function close(): void
    {
        if ($this->stream !== null) {
            fclose($this->stream);
            $this->stream = null;
        }
    }

    private function assertConnected(): void
    {
        if ($this->stream === null) {
            throw new LogicException('Il client non è connesso');
        }
    }
}

Due dettagli meritano attenzione. Il primo è il ciclo di scrittura: fwrite() su un socket non garantisce di inviare tutti i byte in una sola chiamata, soprattutto con payload di grandi dimensioni. Il secondo è il controllo di timed_out: quando il timeout di lettura scade, fgets() restituisce false esattamente come quando il peer chiude la connessione, e solo i metadati dello stream permettono di distinguere i due casi.

Un esempio d'uso verso un server SMTP, che risponde con un banner testuale appena si apre la connessione:

<?php

$client = new TcpClient('smtp.example.com', 25);

try {
    $client->connect();

    // Il server SMTP invia il banner di benvenuto
    echo $client->readLine(), PHP_EOL;

    $client->send("EHLO client.example.com\r\n");
    echo $client->readLine(), PHP_EOL;

    $client->send("QUIT\r\n");
} catch (RuntimeException $e) {
    fwrite(STDERR, $e->getMessage() . PHP_EOL);
} finally {
    $client->close();
}

Framing dei messaggi

TCP è un protocollo orientato al flusso: non conserva i confini dei messaggi. Se il client invia due messaggi da 100 byte, il server può riceverne uno da 200, oppure quattro da 50. Per questo ogni protocollo applicativo deve definire un meccanismo di framing. Le due strategie più comuni sono il delimitatore (tipicamente il ritorno a capo, come in SMTP o Redis) e il prefisso di lunghezza.

Il prefisso di lunghezza è più robusto quando il payload può contenere qualsiasi byte. Con pack() e unpack() è semplice codificare la lunghezza come intero a 32 bit in big-endian (network byte order):

<?php

declare(strict_types=1);

final class LengthPrefixedCodec
{
    private const HEADER_SIZE = 4;
    private const MAX_FRAME_SIZE = 16 * 1024 * 1024;

    public static function encode(string $payload): string
    {
        // 'N' = intero senza segno a 32 bit, big-endian
        return pack('N', strlen($payload)) . $payload;
    }

    /**
     * @param resource $stream
     */
    public static function readFrame($stream): string
    {
        $header = self::readExactly($stream, self::HEADER_SIZE);
        $length = unpack('Nlength', $header)['length'];

        // Protezione contro frame malevoli o corrotti
        if ($length > self::MAX_FRAME_SIZE) {
            throw new RuntimeException(sprintf('Frame troppo grande: %d byte', $length));
        }

        return $length === 0 ? '' : self::readExactly($stream, $length);
    }

    /**
     * @param resource $stream
     */
    private static function readExactly($stream, int $length): string
    {
        $buffer = '';

        // fread può restituire meno byte di quelli richiesti
        while (strlen($buffer) < $length) {
            $chunk = fread($stream, $length - strlen($buffer));

            if ($chunk === false || $chunk === '') {
                if (feof($stream)) {
                    throw new RuntimeException('Connessione chiusa durante la lettura del frame');
                }

                $meta = stream_get_meta_data($stream);

                if ($meta['timed_out']) {
                    throw new RuntimeException('Timeout durante la lettura del frame');
                }

                continue;
            }

            $buffer .= $chunk;
        }

        return $buffer;
    }
}

Il limite MAX_FRAME_SIZE non è un dettaglio: senza di esso, un client malevolo potrebbe dichiarare un frame da 4 GB e costringere il server ad allocare memoria fino all'esaurimento.

Un server TCP concorrente con stream_select()

PHP non dispone di thread nativi nell'esecuzione standard, ma può gestire molte connessioni contemporanee in un singolo processo grazie al multiplexing dell'I/O. La funzione stream_select() riceve un array di stream e restituisce quelli pronti per la lettura, evitando di bloccarsi su un singolo client.

<?php

declare(strict_types=1);

final class EchoServer
{
    /** @var resource */
    private $server;

    /** @var array<int, resource> */
    private array $clients = [];

    /** @var array<int, string> */
    private array $buffers = [];

    public function __construct(private readonly string $address = 'tcp://0.0.0.0:9000')
    {
    }

    public function run(): void
    {
        $server = stream_socket_server($this->address, $errorCode, $errorMessage);

        if ($server === false) {
            throw new RuntimeException(sprintf('Impossibile avviare il server: [%d] %s', $errorCode, $errorMessage));
        }

        // Il socket di ascolto non deve bloccare il ciclo principale
        stream_set_blocking($server, false);
        $this->server = $server;

        printf("Server in ascolto su %s\n", $this->address);

        while (true) {
            $read = [$this->server, ...array_values($this->clients)];
            $write = null;
            $except = null;

            // Attesa fino a 1 secondo di attività su uno qualsiasi degli stream
            $ready = stream_select($read, $write, $except, 1);

            if ($ready === false) {
                break;
            }

            foreach ($read as $stream) {
                if ($stream === $this->server) {
                    $this->acceptClient();
                    continue;
                }

                $this->handleClient($stream);
            }
        }
    }

    private function acceptClient(): void
    {
        $client = @stream_socket_accept($this->server, 0, $peerName);

        if ($client === false) {
            return;
        }

        stream_set_blocking($client, false);

        $id = (int) $client;
        $this->clients[$id] = $client;
        $this->buffers[$id] = '';

        printf("Nuovo client: %s (id %d)\n", $peerName, $id);
    }

    /**
     * @param resource $client
     */
    private function handleClient($client): void
    {
        $id = (int) $client;
        $data = fread($client, 8192);

        // Stringa vuota con EOF significa che il client ha chiuso la connessione
        if ($data === '' || $data === false) {
            if (feof($client)) {
                $this->disconnect($id);
            }

            return;
        }

        $this->buffers[$id] .= $data;

        // Si elaborano tutte le righe complete presenti nel buffer
        while (($position = strpos($this->buffers[$id], "\n")) !== false) {
            $line = substr($this->buffers[$id], 0, $position);
            $this->buffers[$id] = substr($this->buffers[$id], $position + 1);

            fwrite($client, 'echo: ' . rtrim($line, "\r") . "\n");
        }
    }

    private function disconnect(int $id): void
    {
        fclose($this->clients[$id]);
        unset($this->clients[$id], $this->buffers[$id]);

        printf("Client %d disconnesso\n", $id);
    }
}

(new EchoServer())->run();

Il buffer per ogni client è indispensabile: con stream non bloccanti, una singola fread() può restituire mezza riga, oppure tre righe e mezzo. Il server accumula i dati e processa solo le righe complete, lasciando il resto nel buffer in attesa della lettura successiva.

Si può provare il server con nc:

nc 127.0.0.1 9000

UDP con l'API degli stream

UDP è un protocollo senza connessione: ogni datagramma è indipendente, può arrivare in ordine diverso da quello di invio oppure non arrivare affatto. In compenso conserva i confini dei messaggi, quindi non serve alcun framing. Con gli stream si usano stream_socket_recvfrom() e stream_socket_sendto():

<?php

declare(strict_types=1);

// Server UDP: il flag STREAM_SERVER_BIND è sufficiente, non esiste listen()
$socket = stream_socket_server('udp://0.0.0.0:9001', $errorCode, $errorMessage, STREAM_SERVER_BIND);

if ($socket === false) {
    throw new RuntimeException("Bind UDP fallito: $errorMessage");
}

echo "Server UDP in ascolto sulla porta 9001\n";

while (true) {
    // $peer viene valorizzato con l'indirizzo del mittente
    $message = stream_socket_recvfrom($socket, 1500, 0, $peer);

    if ($message === false) {
        continue;
    }

    printf("Ricevuto da %s: %s\n", $peer, trim($message));

    stream_socket_sendto($socket, strtoupper($message), 0, $peer);
}

Il client UDP deve implementare autonomamente timeout e ritrasmissioni, perché il protocollo non garantisce la consegna:

<?php

declare(strict_types=1);

function udpRequest(string $address, string $message, int $retries = 3, int $timeoutSeconds = 1): string
{
    $socket = stream_socket_client($address, $errorCode, $errorMessage);

    if ($socket === false) {
        throw new RuntimeException("Creazione socket UDP fallita: $errorMessage");
    }

    stream_set_timeout($socket, $timeoutSeconds);

    try {
        for ($attempt = 1; $attempt <= $retries; $attempt++) {
            fwrite($socket, $message);

            $response = fread($socket, 1500);
            $meta = stream_get_meta_data($socket);

            if ($response !== false && $response !== '' && !$meta['timed_out']) {
                return $response;
            }

            // Nessuna risposta entro il timeout: si ritenta
            fprintf(STDERR, "Tentativo %d senza risposta\n", $attempt);
        }
    } finally {
        fclose($socket);
    }

    throw new RuntimeException("Nessuna risposta dopo $retries tentativi");
}

echo udpRequest('udp://127.0.0.1:9001', 'ping'), PHP_EOL;

Risoluzione DNS

PHP espone diverse funzioni per interrogare il DNS. gethostbynamel() restituisce solo indirizzi IPv4, mentre dns_get_record() permette di interrogare qualsiasi tipo di record, inclusi AAAA, MX, TXT e PTR.

<?php

declare(strict_types=1);

final class DnsInspector
{
    /**
     * @return list<string>
     */
    public function resolveAddresses(string $hostname): array
    {
        $records = @dns_get_record($hostname, DNS_A | DNS_AAAA);

        if ($records === false) {
            throw new RuntimeException("Risoluzione di $hostname fallita");
        }

        $addresses = [];

        foreach ($records as $record) {
            // I record A espongono la chiave 'ip', i record AAAA la chiave 'ipv6'
            $addresses[] = $record['ip'] ?? $record['ipv6'];
        }

        return $addresses;
    }

    /**
     * @return list<array{host: string, priority: int}>
     */
    public function mailExchangers(string $domain): array
    {
        $records = @dns_get_record($domain, DNS_MX) ?: [];

        $exchangers = array_map(
            static fn (array $record): array => ['host' => $record['target'], 'priority' => $record['pri']],
            $records
        );

        // Priorità più bassa = server preferito
        usort($exchangers, static fn (array $a, array $b): int => $a['priority'] <=> $b['priority']);

        return $exchangers;
    }

    public function reverseLookup(string $ip): ?string
    {
        if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
            throw new InvalidArgumentException("Indirizzo IP non valido: $ip");
        }

        $hostname = gethostbyaddr($ip);

        // In caso di fallimento gethostbyaddr restituisce l'IP stesso
        return ($hostname === false || $hostname === $ip) ? null : $hostname;
    }
}

$dns = new DnsInspector();

print_r($dns->resolveAddresses('example.com'));
print_r($dns->mailExchangers('gmail.com'));
var_dump($dns->reverseLookup('8.8.8.8'));

Va tenuto presente che queste funzioni usano il resolver di sistema e non permettono di impostare un timeout: in contesti critici conviene delegare la risoluzione a una libreria dedicata o impostare timeout ragionevoli in /etc/resolv.conf.

Connessioni TLS con gli stream context

Uno dei vantaggi principali dell'API degli stream è il supporto integrato a TLS. Basta usare lo schema tls:// e configurare la verifica del certificato tramite uno stream context:

<?php

declare(strict_types=1);

function fetchCertificateInfo(string $host, int $port = 443): array
{
    $context = stream_context_create([
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
            'peer_name' => $host,
            'capture_peer_cert' => true,
            // Versione minima di TLS accettata
            'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT,
        ],
    ]);

    $stream = @stream_socket_client(
        "tls://$host:$port",
        $errorCode,
        $errorMessage,
        5,
        STREAM_CLIENT_CONNECT,
        $context
    );

    if ($stream === false) {
        throw new RuntimeException("Handshake TLS con $host fallito: $errorMessage");
    }

    $params = stream_context_get_params($stream);
    $certificate = openssl_x509_parse($params['options']['ssl']['peer_certificate']);

    fclose($stream);

    return [
        'subject' => $certificate['subject']['CN'] ?? null,
        'issuer' => $certificate['issuer']['O'] ?? null,
        'valid_to' => date(DATE_ATOM, $certificate['validTo_time_t']),
        'days_left' => (int) floor(($certificate['validTo_time_t'] - time()) / 86400),
    ];
}

print_r(fetchCertificateInfo('www.php.net'));

Questa funzione è la base per un semplice monitor di scadenza dei certificati: eseguita periodicamente su un elenco di domini, permette di ricevere un avviso quando days_left scende sotto una soglia.

Richieste HTTP senza dipendenze

Gli stream wrapper di PHP permettono anche di effettuare richieste HTTP con file_get_contents(), configurando metodo, header e timeout nel context. È una soluzione adatta a script semplici, mentre per applicazioni complesse è preferibile cURL o un client come Guzzle.

<?php

declare(strict_types=1);

function postJson(string $url, array $data, float $timeout = 5.0): array
{
    $context = stream_context_create([
        'http' => [
            'method' => 'POST',
            'header' => implode("\r\n", [
                'Content-Type: application/json',
                'Accept: application/json',
            ]),
            'content' => json_encode($data, JSON_THROW_ON_ERROR),
            'timeout' => $timeout,
            // Permette di leggere il corpo anche con status 4xx e 5xx
            'ignore_errors' => true,
        ],
    ]);

    $body = @file_get_contents($url, false, $context);

    if ($body === false) {
        throw new RuntimeException("Richiesta verso $url fallita");
    }

    // La prima riga degli header di risposta contiene lo status
    $statusLine = $http_response_header[0] ?? '';
    preg_match('/\s(\d{3})\s/', $statusLine, $matches);
    $status = (int) ($matches[1] ?? 0);

    return [
        'status' => $status,
        'body' => json_decode($body, true, 512, JSON_THROW_ON_ERROR),
    ];
}

print_r(postJson('https://httpbin.org/post', ['message' => 'ciao']));

Un port scanner concorrente

Mettendo insieme quanto visto finora, possiamo costruire un piccolo strumento che verifica in parallelo quali porte di un host sono aperte. Il trucco consiste nell'aprire le connessioni in modalità asincrona con il flag STREAM_CLIENT_ASYNC_CONNECT e usare stream_select() sull'array di scrittura: un socket diventa scrivibile quando la connessione è stata stabilita.

<?php

declare(strict_types=1);

/**
 * @param list<int> $ports
 * @return array<int, bool>
 */
function scanPorts(string $host, array $ports, float $timeout = 2.0): array
{
    $pending = [];
    $results = array_fill_keys($ports, false);

    foreach ($ports as $port) {
        // La connessione viene avviata senza attenderne il completamento
        $stream = @stream_socket_client(
            "tcp://$host:$port",
            $errorCode,
            $errorMessage,
            $timeout,
            STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
        );

        if ($stream !== false) {
            $pending[$port] = $stream;
        }
    }

    $deadline = microtime(true) + $timeout;

    while ($pending !== [] && microtime(true) < $deadline) {
        $read = null;
        $write = array_values($pending);
        $except = null;

        $remaining = max(0, $deadline - microtime(true));
        $seconds = (int) $remaining;
        $microseconds = (int) (($remaining - $seconds) * 1_000_000);

        if (stream_select($read, $write, $except, $seconds, $microseconds) === false) {
            break;
        }

        foreach ($write as $stream) {
            $port = array_search($stream, $pending, true);

            // Se il peer è risolvibile la connessione è effettivamente riuscita
            $results[$port] = stream_socket_get_name($stream, true) !== false;

            fclose($stream);
            unset($pending[$port]);
        }
    }

    // Le connessioni ancora pendenti hanno superato il timeout
    foreach ($pending as $stream) {
        fclose($stream);
    }

    return $results;
}

$results = scanPorts('127.0.0.1', [22, 80, 443, 3306, 5432, 6379, 9000]);

foreach ($results as $port => $open) {
    printf("%5d  %s\n", $port, $open ? 'aperta' : 'chiusa o filtrata');
}

La chiamata a stream_socket_get_name() con il secondo argomento a true serve a distinguere una connessione riuscita da una rifiutata: anche un socket la cui connessione è fallita può risultare "pronto" per stream_select(), ma in quel caso il nome del peer non è disponibile.

Buone pratiche

  • Impostare sempre i timeout, sia di connessione sia di I/O. Un valore predefinito ragionevole evita processi bloccati indefinitamente.
  • Non dare per scontato che una lettura o una scrittura siano complete: ciclare finché non si sono trasferiti tutti i byte attesi.
  • Definire un framing esplicito per i protocolli su TCP e imporre una dimensione massima dei messaggi.
  • Non disabilitare mai verify_peer in produzione: aggirare la verifica del certificato annulla la protezione offerta da TLS.
  • Per server di lunga durata valutare librerie event-driven come ReactPHP o AMPHP, oppure runtime come Swoole, che costruiscono sugli stessi concetti visti qui offrendo un'astrazione più ricca.

Conclusioni

L'API degli stream rende PHP uno strumento sorprendentemente capace per la programmazione di rete. Con poche funzioni è possibile scrivere client e server TCP, gestire UDP, interrogare il DNS, ispezionare certificati TLS e gestire decine di connessioni contemporanee in un singolo processo. I principi alla base, ovvero timeout espliciti, framing dei messaggi e I/O non bloccante, sono gli stessi che ritroveremo in qualsiasi altro linguaggio.