<?php
declare(strict_types=1);
namespace Adawolfa\ICMP;
use React;
use RuntimeException, Exception, InvalidArgumentException;
* ICMP ping for ReactPHP.
*
* @author Adam Klvač <adam@klva.cz>
*/
final class Ping
{
private $loop;
private $resolver;
private $precision;
private $socket;
private $timer;
private $id = 0;
private $stack = [];
private static $hrTime = null;
* Ping constructor.
* @param float $precision response precision - interval between response collections
*/
public function __construct(React\EventLoop\LoopInterface $loop, React\Dns\Resolver\ResolverInterface $resolver, float $precision = .1)
{
if ($precision < .0) {
throw new InvalidArgumentException('Precision must be a non-negative number.');
}
if (!function_exists('socket_create')) {
throw new RuntimeException("Missing extension 'sockets'.");
}
$this->loop = $loop;
$this->resolver = $resolver;
$this->precision = $precision;
if (self::$hrTime === null) {
self::$hrTime = function_exists('hrtime');
}
}
* Sends a single ICMP echo request and resolves whenever a reply is returned or the time limit hits.
*
* @param string $host remote host, either an IP address or a hostname
* @param float $timeout reply time limit
*/
public function ping(string $host, float $timeout = 5.0): React\Promise\PromiseInterface
{
if ($timeout < .0) {
throw new InvalidArgumentException('Timeout must be a non-negative number.');
}
return new React\Promise\Promise(function(callable $resolve, callable $reject) use($host, $timeout): void {
$this->resolve($host)->then(function($host) use($timeout, $resolve, $reject): void {
$this->send($host, $timeout, $resolve, $reject);
}, $reject);
});
}
* Pings a host periodically and whenever a reply is returned or the time limit hits, calls the given function.
*
* @param string $host remote host, either an IP address or a hostname
* @param callable $handler will be called for every ping with time and an Exception in the second argument, if there's one
* @param float|null $interval interval between individual echo requests
* @param float $timeout reply time limit
* @return callable call this function in order to interrupt the pinging
*/
public function periodic(string $host, callable $handler, float $interval = 1.0, float $timeout = 5.0): callable
{
if ($interval < .0) {
throw new InvalidArgumentException('Interval must be a non-negative number.');
}
if ($timeout < .0) {
throw new InvalidArgumentException('Timeout must be a non-negative number.');
}
$time = self::microtime();
$stop = false;
$ping = function() use(&$time, $host, $timeout, &$callback, &$error): void {
$time = self::microtime();
$this->ping($host, $timeout)
->then($callback, $error);
};
$callback = function(float $time, Exception $exception = null) use(&$stop, $handler, $ping, $interval): void {
if ($stop) {
return;
}
if ($time > $interval) {
$interval = .0;
}
$this->loop->addTimer($interval, $ping);
$handler($time, $exception);
};
$error = function(Exception $exception) use($callback, &$time): void {
$callback(self::microtime() - $time, $exception);
};
$ping();
return function() use(&$stop, $error): void {
$error(new Exception('Interrupted.'));
$stop = true;
};
}
* Builds and sends the ICMP packet to the resolved address.
*/
private function send(string $host, float $timeout, callable $resolve, callable $reject): void
{
$id = $this->id++;
if ($id === 0x7ffffffe) {
$this->id = 0;
}
$packet
= chr(0x8) . chr(0x0)
. chr(0x0) . chr(0x0)
. chr(($id >> (8 * 3)) & 0xff)
. chr(($id >> (8 * 2)) & 0xff)
. chr(($id >> (8 * 1)) & 0xff)
. chr(($id >> (8 * 0)) & 0xff)
. 'ping';
self::checksum($packet);
try {
$sent = $this->call(Exception::class, 'socket_sendto', $this->socket(), $packet, strlen($packet), 0, $host, 0);
if ($sent !== strlen($packet)) {
throw new Exception('socket_sendto() buffer truncated.');
}
$timer = $this->loop->addTimer($timeout, function() use($host, $id): void {
$this->clear($host, $id, new Exception('Request timed out.'));
});
$this->stack[$host][$id] = [self::microtime(), $resolve, $reject, $timer];
$this->schedule();
} catch (Exception $exception) {
$reject($exception);
}
}
* Handles ICMP responses.
*/
private function collect(): void
{
for (;;) {
$size = @socket_recvfrom($this->socket, $buffer, 20 + 12, 0, $host, $_);
if ($size === false) {
break;
}
if ($size !== 20 + 12) {
continue;
}
$buffer = substr($buffer, 20);
$id = ord($buffer[4]) << 24 | ord($buffer[5]) << 16 | ord($buffer[6]) << 8 | ord($buffer[7]);
$message = $buffer;
self::checksum($message);
if ($message !== $buffer) {
$this->clear($host, $id, new Exception('Response checksum mismatch.'));
continue;
}
if (substr($buffer, 8) !== 'ping') {
$this->clear($host, $id, new Exception('Data mismatch.'));
continue;
}
$this->clear($host, $id);
}
}
* Dispatches the result.
*/
private function clear(string $host, int $id, Exception $exception = null): void
{
if (empty($this->stack[$host][$id])) {
return;
}
[$started, $resolve, $reject, $timer] = $this->stack[$host][$id];
unset($this->stack[$host][$id]);
if (empty($this->stack[$host])) {
unset($this->stack[$host]);
}
$this->loop->cancelTimer($timer);
$this->schedule();
if ($exception === null) {
$resolve(self::microtime() - $started);
} else {
$reject($exception);
}
}
* Binds and returns the socket.
* @return resource
*/
private function socket()
{
if ($this->socket !== null) {
return $this->socket;
}
$socket = $this->call(
RuntimeException::class,
'socket_create',
AF_INET, SOCK_RAW,
getprotobyname('icmp')
);
$this->call(RuntimeException::class, 'socket_set_nonblock', $socket);
return $this->socket = $socket;
}
* Configures timer.
*/
private function schedule(): void
{
$stop = empty($this->stack);
if ($stop === ($this->timer === null)) {
return;
}
if ($stop) {
$this->loop->cancelTimer($this->timer);
$this->timer = null;
return;
}
$this->timer = $this->loop->addPeriodicTimer($this->precision, function(): void {
$this->collect();
$this->schedule();
});
}
* Calculates checksum for a message.
*/
private static function checksum(string &$message): void
{
$message[2] = chr(0);
$message[3] = chr(0);
for ($i = 0, $checksum = 0x0; $i < strlen($message); $i += 2) {
$checksum += ord($message[$i]) << 8 | ord($message[$i + 1] ?? 0);
}
$checksum = (~$checksum) & 0xffff;
$message[2] = chr($checksum >> 8);
$message[3] = chr($checksum & 0xff);
}
* Calls a socket function.
*/
private function call(string $exception, callable $function, ...$arguments)
{
$result = @call_user_func($function, ...$arguments);
if ($result === false) {
$errno = $this->socket === null ? socket_last_error() : socket_last_error($this->socket);
throw new $exception(sprintf('%s() failed: %s', $function, socket_strerror($errno)), $errno);
}
return $result;
}
* Hostname resolution.
*/
private function resolve(string $host): React\Promise\PromiseInterface
{
if (filter_var($host, FILTER_VALIDATE_IP) === $host) {
return new React\Promise\FulfilledPromise($host);
}
return new React\Promise\Promise(function(callable $resolve, callable $reject) use($host): void {
$this->resolver->resolve($host)->then($resolve, $reject);
});
}
* Returns precise time.
*/
private static function microtime(): float
{
return self::$hrTime ? hrtime(true) / 1e9 : microtime(true);
}
* Closes the underlying socket.
*/
public function __destruct()
{
if ($this->socket !== null) {
socket_close($this->socket);
}
}
}