| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- <?php
- declare(strict_types=1);
- const PING_COUNT = 4; // ICMP echo requests per ping run
- const PING_WAIT = 2; // seconds to wait for each reply
- const TRACE_MAX_HOPS = 30; // RFC-ish practical ceiling, matches traceroute's default
- const TRACE_QUERIES = 2; // probes per hop — 3 is the default, 2 keeps the page snappy
- const TRACE_WAIT = 2; // seconds to wait for a hop to answer
- const CMD_TIMEOUT = 25; // hard wall-clock cap so a request can never hang the worker
- /** Record types offered for the DNS mode. PTR is handled via dig -x. */
- const DNS_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'CAA', 'SRV', 'DS', 'DNSKEY', 'PTR', 'ANY'];
- /**
- * Runs a command under `timeout` and returns its combined output.
- * Every argument is escaped individually — nothing user-supplied ever reaches a shell
- * as syntax, only as a single argv entry.
- *
- * @param string[] $args
- * @return array{command:string,output:string,code:int}
- */
- function runCommand(string $binary, array $args): array
- {
- $command = 'timeout ' . CMD_TIMEOUT . ' ' . escapeshellarg($binary);
- foreach ($args as $arg) {
- $command .= ' ' . escapeshellarg((string) $arg);
- }
- $output = [];
- $code = 0;
- exec($command . ' 2>&1', $output, $code);
- $text = implode("\n", $output);
- if ($code === 124) {
- $text .= ($text === '' ? '' : "\n") . '--- aborted: exceeded the ' . CMD_TIMEOUT . 's time limit ---';
- } elseif ($code === 127) {
- $text = 'The "' . $binary . '" command is not installed on this server.';
- }
- // Display the command without the timeout wrapper — that is plumbing, not diagnostics.
- $display = $binary;
- foreach ($args as $arg) {
- $display .= ' ' . (string) $arg;
- }
- return ['command' => $display, 'output' => $text, 'code' => $code];
- }
- function performPing(string $target): array
- {
- return runCommand('ping', ['-c', (string) PING_COUNT, '-W', (string) PING_WAIT, $target]);
- }
- function performTraceroute(string $target): array
- {
- return runCommand('traceroute', [
- '-m', (string) TRACE_MAX_HOPS,
- '-q', (string) TRACE_QUERIES,
- '-w', (string) TRACE_WAIT,
- $target,
- ]);
- }
- function performDns(string $target, string $type, string $resolver): array
- {
- $args = [];
- if ($resolver !== '') {
- $args[] = '@' . $resolver;
- }
- if ($type === 'PTR' && filter_var($target, FILTER_VALIDATE_IP)) {
- $args[] = '-x';
- $args[] = $target;
- } else {
- $args[] = $target;
- $args[] = $type;
- }
- $args[] = '+timeout=3';
- $args[] = '+tries=2';
- return runCommand('dig', $args);
- }
- /**
- * Pulls the responding IP out of each traceroute hop line so the hops can be
- * enriched the same way spf-check.php and mail-delivery-check.php enrich theirs.
- *
- * @return array<int,array{hop:string,host:string,ip:string}>
- */
- function parseTracerouteHops(string $output): array
- {
- $hops = [];
- foreach (explode("\n", $output) as $line) {
- if (!preg_match('/^\s*(\d+)\s+(.*)$/', $line, $m)) {
- continue;
- }
- $rest = trim($m[2]);
- $first = explode(' ', $rest)[0];
- // A hop reads either "host (ip) 1.2 ms" or "ip 1.2 ms", or "* * *" when it stays silent.
- if (preg_match('/\(([0-9a-fA-F:.]+)\)/', $rest, $paren)) {
- $ip = $paren[1];
- } else {
- $ip = $first;
- }
- if (!filter_var($ip, FILTER_VALIDATE_IP)) {
- continue;
- }
- $hops[] = ['hop' => $m[1], 'host' => $first === $ip ? '' : $first, 'ip' => $ip];
- }
- return $hops;
- }
- /**
- * Enriches IPs with ASN / country / company via ip-api.com's free batch endpoint.
- * @param string[] $ips
- * @return array<string,array>
- */
- function lookupIpInfo(array $ips): array
- {
- $out = [];
- $ips = array_values(array_unique(array_filter($ips)));
- if (empty($ips)) {
- return $out;
- }
- $fields = 'query,status,message,country,countryCode,as,asname,isp,org,city,regionName,reverse';
- foreach (array_chunk($ips, 100) as $chunk) {
- $payload = array_map(static fn($ip) => ['query' => $ip, 'fields' => $fields], $chunk);
- $ch = curl_init('http://ip-api.com/batch');
- curl_setopt_array($ch, [
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => json_encode($payload),
- CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_TIMEOUT => 20,
- ]);
- $resp = curl_exec($ch);
- curl_close($ch);
- foreach ((json_decode((string) $resp, true) ?: []) as $item) {
- if (isset($item['query'])) {
- $out[$item['query']] = $item;
- }
- }
- }
- return $out;
- }
- /** True for addresses that are not routable on the public internet. */
- function isReservedIp(string $ip): bool
- {
- return filter_var(
- $ip,
- FILTER_VALIDATE_IP,
- FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
- ) === false;
- }
- /** @return string[] */
- function resolveHost(string $host): array
- {
- $ips = [];
- foreach ((@dns_get_record($host, DNS_A) ?: []) as $r) {
- if (!empty($r['ip'])) {
- $ips[] = $r['ip'];
- }
- }
- foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) {
- if (!empty($r['ipv6'])) {
- $ips[] = $r['ipv6'];
- }
- }
- return $ips;
- }
- $target = trim((string) ($_GET['target'] ?? ''));
- $action = (string) ($_GET['action'] ?? 'ping');
- $dnsType = strtoupper(trim((string) ($_GET['type'] ?? 'A')));
- $resolver = strtolower(trim((string) ($_GET['resolver'] ?? '')));
- $inputError = null;
- $result = null;
- $hops = [];
- $hopInfo = [];
- if (!in_array($action, ['ping', 'traceroute', 'dns'], true)) {
- $action = 'ping';
- }
- if (!in_array($dnsType, DNS_TYPES, true)) {
- $dnsType = 'A';
- }
- if ($target !== '') {
- $target = strtolower(rtrim($target, '.'));
- $isIp = (bool) filter_var($target, FILTER_VALIDATE_IP);
- if (!$isIp && !preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $target)) {
- $inputError = 'Please enter a valid hostname (e.g. example.com) or IP address (e.g. 8.8.8.8).';
- } elseif ($resolver !== ''
- && !filter_var($resolver, FILTER_VALIDATE_IP)
- && !preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $resolver)) {
- $inputError = 'The resolver must be an IP address (e.g. 9.9.9.9) or a hostname.';
- } elseif ($isIp && isReservedIp($target)) {
- $inputError = 'Private, loopback and reserved addresses cannot be probed from this tool.';
- } elseif ($resolver !== '' && filter_var($resolver, FILTER_VALIDATE_IP) && isReservedIp($resolver)) {
- $inputError = 'Private, loopback and reserved addresses cannot be used as a resolver.';
- } elseif ($action === 'dns' && $dnsType === 'PTR' && !$isIp) {
- $inputError = 'A PTR lookup needs an IP address as the target.';
- }
- // A hostname that only resolves into reserved space is the same problem one step removed.
- if ($inputError === null && !$isIp) {
- $resolved = resolveHost($target);
- if (!empty($resolved) && count(array_filter($resolved, 'isReservedIp')) === count($resolved)) {
- $inputError = 'That hostname only resolves to private or reserved addresses, which cannot be probed.';
- }
- }
- if ($inputError === null) {
- if ($action === 'ping') {
- $result = performPing($target);
- } elseif ($action === 'traceroute') {
- $result = performTraceroute($target);
- $hops = parseTracerouteHops($result['output']);
- $hopInfo = lookupIpInfo(array_column($hops, 'ip'));
- } else {
- $result = performDns($target, $dnsType, $resolver);
- }
- }
- }
- $clientIp = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
- $clientInfo = $clientIp !== '' && !isReservedIp($clientIp) ? (lookupIpInfo([$clientIp])[$clientIp] ?? null) : null;
- ?>
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Network Trace</title>
- <style>
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- max-width: 1200px;
- margin: 40px auto;
- padding: 0 20px;
- background: #f5f5f5;
- color: #333;
- }
- h1 { color: #333; }
- h2 { color: #333; margin-top: 30px; }
- .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
- form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
- input[type=text] {
- flex: 1; min-width: 240px; padding: 10px; font-size: 15px;
- border: 1px solid #ccc; border-radius: 5px;
- }
- select {
- padding: 10px; font-size: 15px; border: 1px solid #ccc;
- border-radius: 5px; background: white;
- }
- .btn {
- display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
- text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
- }
- .btn:hover { background: #1976D2; }
- .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
- .warning {
- background: #fff8e1; color: #e65100; padding: 12px 15px; border-radius: 5px;
- margin: 10px 0; border-left: 5px solid #ff9800;
- }
- .output {
- background: #263238; color: #aed581; padding: 15px; border-radius: 4px;
- font-family: monospace; font-size: 13px; white-space: pre-wrap;
- word-break: break-all; margin: 10px 0; line-height: 1.5;
- }
- .command {
- font-family: monospace; font-size: 12px; color: #888; margin-bottom: 4px;
- }
- table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
- th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ddd; font-size: 13px; }
- th { background: #2196F3; color: white; }
- tr:hover { background: #f5f5f5; }
- td.ip { font-family: monospace; }
- .empty { text-align: center; color: #999; padding: 40px; }
- .muted { color: #888; font-size: 12px; }
- .self { background: white; padding: 12px 15px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
- .self strong { color: #333; }
- .hidden { display: none; }
- </style>
- </head>
- <body>
- <h1>📡 Network Trace</h1>
- <div class="info">
- Runs <strong>ping</strong>, <strong>traceroute</strong> and <strong>DNS</strong> lookups from this server
- against a public host, and enriches every traceroute hop with its
- <strong>ASN, country and company</strong>. Probes are capped at
- <?= CMD_TIMEOUT ?>s and <?= TRACE_MAX_HOPS ?> hops.
- </div>
- <form class="lookup" method="GET">
- <input type="text" name="target" placeholder="example.com or 8.8.8.8"
- value="<?= htmlspecialchars($target) ?>" autofocus>
- <select name="action" id="action">
- <option value="ping" <?= $action === 'ping' ? 'selected' : '' ?>>Ping</option>
- <option value="traceroute" <?= $action === 'traceroute' ? 'selected' : '' ?>>Traceroute</option>
- <option value="dns" <?= $action === 'dns' ? 'selected' : '' ?>>DNS Query</option>
- </select>
- <select name="type" id="type" class="<?= $action === 'dns' ? '' : 'hidden' ?>">
- <?php foreach (DNS_TYPES as $t): ?>
- <option value="<?= $t ?>" <?= $dnsType === $t ? 'selected' : '' ?>><?= $t ?></option>
- <?php endforeach; ?>
- </select>
- <input type="text" name="resolver" id="resolver" class="<?= $action === 'dns' ? '' : 'hidden' ?>"
- style="flex: 0 1 160px; min-width: 120px;" placeholder="resolver (optional)"
- value="<?= htmlspecialchars($resolver) ?>">
- <button type="submit" class="btn">🔍 Run</button>
- </form>
- <?php if ($inputError): ?>
- <div class="error"><?= htmlspecialchars($inputError) ?></div>
- <?php endif; ?>
- <?php if ($result !== null): ?>
- <h2>
- <?= $action === 'dns' ? htmlspecialchars($dnsType) . ' lookup' : ucfirst($action) ?>
- — <?= htmlspecialchars($target) ?>
- </h2>
- <div class="command"><?= htmlspecialchars($result['command']) ?></div>
- <div class="output"><?= htmlspecialchars($result['output'] !== '' ? $result['output'] : '(no output)') ?></div>
- <?php if ($result['code'] !== 0 && $result['code'] !== 124): ?>
- <div class="warning">⚠️ The command exited with code <?= (int) $result['code'] ?>.</div>
- <?php endif; ?>
- <?php if ($action === 'traceroute' && !empty($hops)): ?>
- <h2>Hops (<?= count($hops) ?>)</h2>
- <table>
- <thead>
- <tr>
- <th>#</th>
- <th>IP</th>
- <th>Reverse DNS</th>
- <th>ASN</th>
- <th>Company / ISP</th>
- <th>Country</th>
- </tr>
- </thead>
- <tbody>
- <?php foreach ($hops as $hop): ?>
- <?php
- $info = $hopInfo[$hop['ip']] ?? null;
- $ok = $info && ($info['status'] ?? '') === 'success';
- $asn = $ok ? ($info['as'] ?: '—') : '—';
- $company = $ok ? ($info['org'] ?: ($info['isp'] ?? '') ?: ($info['asname'] ?? '')) : '';
- $country = $ok ? trim(($info['country'] ?? '') . ' ' . ($info['countryCode'] ?? '')) : '';
- $rdns = $hop['host'] !== '' ? $hop['host'] : ($ok ? ($info['reverse'] ?? '') : '');
- ?>
- <tr>
- <td><?= htmlspecialchars($hop['hop']) ?></td>
- <td class="ip"><?= htmlspecialchars($hop['ip']) ?></td>
- <td><?= htmlspecialchars($rdns !== '' ? $rdns : '—') ?></td>
- <td><?= htmlspecialchars($asn) ?></td>
- <td><?= htmlspecialchars($company !== '' ? $company : '—') ?></td>
- <td><?= htmlspecialchars($country !== '' ? $country : '—') ?></td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- <p class="muted" style="margin-top: 10px;">
- Hops answering with <code>* * *</code> are omitted from this table.
- IP intelligence via ip-api.com (free tier).
- </p>
- <?php endif; ?>
- <?php elseif ($inputError === null): ?>
- <div class="empty">Enter a hostname or IP address above to run a probe.</div>
- <?php endif; ?>
- <h2>Your connection</h2>
- <div class="self">
- <p><strong>IP address:</strong> <?= htmlspecialchars($clientIp !== '' ? $clientIp : 'unknown') ?></p>
- <?php if ($clientInfo && ($clientInfo['status'] ?? '') === 'success'): ?>
- <p><strong>ISP:</strong> <?= htmlspecialchars($clientInfo['isp'] ?: '—') ?></p>
- <?php if (!empty($clientInfo['as'])): ?>
- <p><strong>ASN:</strong> <?= htmlspecialchars($clientInfo['as']) ?></p>
- <?php endif; ?>
- <?php
- $location = implode(', ', array_filter([
- $clientInfo['city'] ?? '',
- $clientInfo['regionName'] ?? '',
- $clientInfo['country'] ?? '',
- ]));
- ?>
- <?php if ($location !== ''): ?>
- <p><strong>Location:</strong> <?= htmlspecialchars($location) ?></p>
- <?php endif; ?>
- <?php else: ?>
- <p class="muted">No public IP intelligence available for this address.</p>
- <?php endif; ?>
- </div>
- <script>
- // Show the DNS-only controls when the DNS mode is selected.
- var action = document.getElementById('action');
- function syncDnsFields() {
- var isDns = action.value === 'dns';
- document.getElementById('type').classList.toggle('hidden', !isDns);
- document.getElementById('resolver').classList.toggle('hidden', !isDns);
- }
- action.addEventListener('change', syncDnsFields);
- syncDnsFields();
- </script>
- </body>
- </html>
|