trace.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. <?php
  2. declare(strict_types=1);
  3. const PING_COUNT = 4; // ICMP echo requests per ping run
  4. const PING_WAIT = 2; // seconds to wait for each reply
  5. const TRACE_MAX_HOPS = 30; // RFC-ish practical ceiling, matches traceroute's default
  6. const TRACE_QUERIES = 2; // probes per hop — 3 is the default, 2 keeps the page snappy
  7. const TRACE_WAIT = 2; // seconds to wait for a hop to answer
  8. const CMD_TIMEOUT = 25; // hard wall-clock cap so a request can never hang the worker
  9. /** Record types offered for the DNS mode. PTR is handled via dig -x. */
  10. const DNS_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'CAA', 'SRV', 'DS', 'DNSKEY', 'PTR', 'ANY'];
  11. /**
  12. * Runs a command under `timeout` and returns its combined output.
  13. * Every argument is escaped individually — nothing user-supplied ever reaches a shell
  14. * as syntax, only as a single argv entry.
  15. *
  16. * @param string[] $args
  17. * @return array{command:string,output:string,code:int}
  18. */
  19. function runCommand(string $binary, array $args): array
  20. {
  21. $command = 'timeout ' . CMD_TIMEOUT . ' ' . escapeshellarg($binary);
  22. foreach ($args as $arg) {
  23. $command .= ' ' . escapeshellarg((string) $arg);
  24. }
  25. $output = [];
  26. $code = 0;
  27. exec($command . ' 2>&1', $output, $code);
  28. $text = implode("\n", $output);
  29. if ($code === 124) {
  30. $text .= ($text === '' ? '' : "\n") . '--- aborted: exceeded the ' . CMD_TIMEOUT . 's time limit ---';
  31. } elseif ($code === 127) {
  32. $text = 'The "' . $binary . '" command is not installed on this server.';
  33. }
  34. // Display the command without the timeout wrapper — that is plumbing, not diagnostics.
  35. $display = $binary;
  36. foreach ($args as $arg) {
  37. $display .= ' ' . (string) $arg;
  38. }
  39. return ['command' => $display, 'output' => $text, 'code' => $code];
  40. }
  41. function performPing(string $target): array
  42. {
  43. return runCommand('ping', ['-c', (string) PING_COUNT, '-W', (string) PING_WAIT, $target]);
  44. }
  45. function performTraceroute(string $target): array
  46. {
  47. return runCommand('traceroute', [
  48. '-m', (string) TRACE_MAX_HOPS,
  49. '-q', (string) TRACE_QUERIES,
  50. '-w', (string) TRACE_WAIT,
  51. $target,
  52. ]);
  53. }
  54. function performDns(string $target, string $type, string $resolver): array
  55. {
  56. $args = [];
  57. if ($resolver !== '') {
  58. $args[] = '@' . $resolver;
  59. }
  60. if ($type === 'PTR' && filter_var($target, FILTER_VALIDATE_IP)) {
  61. $args[] = '-x';
  62. $args[] = $target;
  63. } else {
  64. $args[] = $target;
  65. $args[] = $type;
  66. }
  67. $args[] = '+timeout=3';
  68. $args[] = '+tries=2';
  69. return runCommand('dig', $args);
  70. }
  71. /**
  72. * Pulls the responding IP out of each traceroute hop line so the hops can be
  73. * enriched the same way spf-check.php and mail-delivery-check.php enrich theirs.
  74. *
  75. * @return array<int,array{hop:string,host:string,ip:string}>
  76. */
  77. function parseTracerouteHops(string $output): array
  78. {
  79. $hops = [];
  80. foreach (explode("\n", $output) as $line) {
  81. if (!preg_match('/^\s*(\d+)\s+(.*)$/', $line, $m)) {
  82. continue;
  83. }
  84. $rest = trim($m[2]);
  85. $first = explode(' ', $rest)[0];
  86. // A hop reads either "host (ip) 1.2 ms" or "ip 1.2 ms", or "* * *" when it stays silent.
  87. if (preg_match('/\(([0-9a-fA-F:.]+)\)/', $rest, $paren)) {
  88. $ip = $paren[1];
  89. } else {
  90. $ip = $first;
  91. }
  92. if (!filter_var($ip, FILTER_VALIDATE_IP)) {
  93. continue;
  94. }
  95. $hops[] = ['hop' => $m[1], 'host' => $first === $ip ? '' : $first, 'ip' => $ip];
  96. }
  97. return $hops;
  98. }
  99. /**
  100. * Enriches IPs with ASN / country / company via ip-api.com's free batch endpoint.
  101. * @param string[] $ips
  102. * @return array<string,array>
  103. */
  104. function lookupIpInfo(array $ips): array
  105. {
  106. $out = [];
  107. $ips = array_values(array_unique(array_filter($ips)));
  108. if (empty($ips)) {
  109. return $out;
  110. }
  111. $fields = 'query,status,message,country,countryCode,as,asname,isp,org,city,regionName,reverse';
  112. foreach (array_chunk($ips, 100) as $chunk) {
  113. $payload = array_map(static fn($ip) => ['query' => $ip, 'fields' => $fields], $chunk);
  114. $ch = curl_init('http://ip-api.com/batch');
  115. curl_setopt_array($ch, [
  116. CURLOPT_POST => true,
  117. CURLOPT_POSTFIELDS => json_encode($payload),
  118. CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
  119. CURLOPT_RETURNTRANSFER => true,
  120. CURLOPT_TIMEOUT => 20,
  121. ]);
  122. $resp = curl_exec($ch);
  123. curl_close($ch);
  124. foreach ((json_decode((string) $resp, true) ?: []) as $item) {
  125. if (isset($item['query'])) {
  126. $out[$item['query']] = $item;
  127. }
  128. }
  129. }
  130. return $out;
  131. }
  132. /** True for addresses that are not routable on the public internet. */
  133. function isReservedIp(string $ip): bool
  134. {
  135. return filter_var(
  136. $ip,
  137. FILTER_VALIDATE_IP,
  138. FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
  139. ) === false;
  140. }
  141. /** @return string[] */
  142. function resolveHost(string $host): array
  143. {
  144. $ips = [];
  145. foreach ((@dns_get_record($host, DNS_A) ?: []) as $r) {
  146. if (!empty($r['ip'])) {
  147. $ips[] = $r['ip'];
  148. }
  149. }
  150. foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) {
  151. if (!empty($r['ipv6'])) {
  152. $ips[] = $r['ipv6'];
  153. }
  154. }
  155. return $ips;
  156. }
  157. $target = trim((string) ($_GET['target'] ?? ''));
  158. $action = (string) ($_GET['action'] ?? 'ping');
  159. $dnsType = strtoupper(trim((string) ($_GET['type'] ?? 'A')));
  160. $resolver = strtolower(trim((string) ($_GET['resolver'] ?? '')));
  161. $inputError = null;
  162. $result = null;
  163. $hops = [];
  164. $hopInfo = [];
  165. if (!in_array($action, ['ping', 'traceroute', 'dns'], true)) {
  166. $action = 'ping';
  167. }
  168. if (!in_array($dnsType, DNS_TYPES, true)) {
  169. $dnsType = 'A';
  170. }
  171. if ($target !== '') {
  172. $target = strtolower(rtrim($target, '.'));
  173. $isIp = (bool) filter_var($target, FILTER_VALIDATE_IP);
  174. if (!$isIp && !preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $target)) {
  175. $inputError = 'Please enter a valid hostname (e.g. example.com) or IP address (e.g. 8.8.8.8).';
  176. } elseif ($resolver !== ''
  177. && !filter_var($resolver, FILTER_VALIDATE_IP)
  178. && !preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $resolver)) {
  179. $inputError = 'The resolver must be an IP address (e.g. 9.9.9.9) or a hostname.';
  180. } elseif ($isIp && isReservedIp($target)) {
  181. $inputError = 'Private, loopback and reserved addresses cannot be probed from this tool.';
  182. } elseif ($resolver !== '' && filter_var($resolver, FILTER_VALIDATE_IP) && isReservedIp($resolver)) {
  183. $inputError = 'Private, loopback and reserved addresses cannot be used as a resolver.';
  184. } elseif ($action === 'dns' && $dnsType === 'PTR' && !$isIp) {
  185. $inputError = 'A PTR lookup needs an IP address as the target.';
  186. }
  187. // A hostname that only resolves into reserved space is the same problem one step removed.
  188. if ($inputError === null && !$isIp) {
  189. $resolved = resolveHost($target);
  190. if (!empty($resolved) && count(array_filter($resolved, 'isReservedIp')) === count($resolved)) {
  191. $inputError = 'That hostname only resolves to private or reserved addresses, which cannot be probed.';
  192. }
  193. }
  194. if ($inputError === null) {
  195. if ($action === 'ping') {
  196. $result = performPing($target);
  197. } elseif ($action === 'traceroute') {
  198. $result = performTraceroute($target);
  199. $hops = parseTracerouteHops($result['output']);
  200. $hopInfo = lookupIpInfo(array_column($hops, 'ip'));
  201. } else {
  202. $result = performDns($target, $dnsType, $resolver);
  203. }
  204. }
  205. }
  206. $clientIp = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
  207. $clientInfo = $clientIp !== '' && !isReservedIp($clientIp) ? (lookupIpInfo([$clientIp])[$clientIp] ?? null) : null;
  208. ?>
  209. <!DOCTYPE html>
  210. <html lang="en">
  211. <head>
  212. <meta charset="UTF-8">
  213. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  214. <title>Network Trace</title>
  215. <style>
  216. body {
  217. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  218. max-width: 1200px;
  219. margin: 40px auto;
  220. padding: 0 20px;
  221. background: #f5f5f5;
  222. color: #333;
  223. }
  224. h1 { color: #333; }
  225. h2 { color: #333; margin-top: 30px; }
  226. .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
  227. form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
  228. input[type=text] {
  229. flex: 1; min-width: 240px; padding: 10px; font-size: 15px;
  230. border: 1px solid #ccc; border-radius: 5px;
  231. }
  232. select {
  233. padding: 10px; font-size: 15px; border: 1px solid #ccc;
  234. border-radius: 5px; background: white;
  235. }
  236. .btn {
  237. display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
  238. text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
  239. }
  240. .btn:hover { background: #1976D2; }
  241. .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
  242. .warning {
  243. background: #fff8e1; color: #e65100; padding: 12px 15px; border-radius: 5px;
  244. margin: 10px 0; border-left: 5px solid #ff9800;
  245. }
  246. .output {
  247. background: #263238; color: #aed581; padding: 15px; border-radius: 4px;
  248. font-family: monospace; font-size: 13px; white-space: pre-wrap;
  249. word-break: break-all; margin: 10px 0; line-height: 1.5;
  250. }
  251. .command {
  252. font-family: monospace; font-size: 12px; color: #888; margin-bottom: 4px;
  253. }
  254. table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
  255. th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ddd; font-size: 13px; }
  256. th { background: #2196F3; color: white; }
  257. tr:hover { background: #f5f5f5; }
  258. td.ip { font-family: monospace; }
  259. .empty { text-align: center; color: #999; padding: 40px; }
  260. .muted { color: #888; font-size: 12px; }
  261. .self { background: white; padding: 12px 15px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
  262. .self strong { color: #333; }
  263. .hidden { display: none; }
  264. </style>
  265. </head>
  266. <body>
  267. <h1>📡 Network Trace</h1>
  268. <div class="info">
  269. Runs <strong>ping</strong>, <strong>traceroute</strong> and <strong>DNS</strong> lookups from this server
  270. against a public host, and enriches every traceroute hop with its
  271. <strong>ASN, country and company</strong>. Probes are capped at
  272. <?= CMD_TIMEOUT ?>s and <?= TRACE_MAX_HOPS ?> hops.
  273. </div>
  274. <form class="lookup" method="GET">
  275. <input type="text" name="target" placeholder="example.com or 8.8.8.8"
  276. value="<?= htmlspecialchars($target) ?>" autofocus>
  277. <select name="action" id="action">
  278. <option value="ping" <?= $action === 'ping' ? 'selected' : '' ?>>Ping</option>
  279. <option value="traceroute" <?= $action === 'traceroute' ? 'selected' : '' ?>>Traceroute</option>
  280. <option value="dns" <?= $action === 'dns' ? 'selected' : '' ?>>DNS Query</option>
  281. </select>
  282. <select name="type" id="type" class="<?= $action === 'dns' ? '' : 'hidden' ?>">
  283. <?php foreach (DNS_TYPES as $t): ?>
  284. <option value="<?= $t ?>" <?= $dnsType === $t ? 'selected' : '' ?>><?= $t ?></option>
  285. <?php endforeach; ?>
  286. </select>
  287. <input type="text" name="resolver" id="resolver" class="<?= $action === 'dns' ? '' : 'hidden' ?>"
  288. style="flex: 0 1 160px; min-width: 120px;" placeholder="resolver (optional)"
  289. value="<?= htmlspecialchars($resolver) ?>">
  290. <button type="submit" class="btn">🔍 Run</button>
  291. </form>
  292. <?php if ($inputError): ?>
  293. <div class="error"><?= htmlspecialchars($inputError) ?></div>
  294. <?php endif; ?>
  295. <?php if ($result !== null): ?>
  296. <h2>
  297. <?= $action === 'dns' ? htmlspecialchars($dnsType) . ' lookup' : ucfirst($action) ?>
  298. — <?= htmlspecialchars($target) ?>
  299. </h2>
  300. <div class="command"><?= htmlspecialchars($result['command']) ?></div>
  301. <div class="output"><?= htmlspecialchars($result['output'] !== '' ? $result['output'] : '(no output)') ?></div>
  302. <?php if ($result['code'] !== 0 && $result['code'] !== 124): ?>
  303. <div class="warning">⚠️ The command exited with code <?= (int) $result['code'] ?>.</div>
  304. <?php endif; ?>
  305. <?php if ($action === 'traceroute' && !empty($hops)): ?>
  306. <h2>Hops (<?= count($hops) ?>)</h2>
  307. <table>
  308. <thead>
  309. <tr>
  310. <th>#</th>
  311. <th>IP</th>
  312. <th>Reverse DNS</th>
  313. <th>ASN</th>
  314. <th>Company / ISP</th>
  315. <th>Country</th>
  316. </tr>
  317. </thead>
  318. <tbody>
  319. <?php foreach ($hops as $hop): ?>
  320. <?php
  321. $info = $hopInfo[$hop['ip']] ?? null;
  322. $ok = $info && ($info['status'] ?? '') === 'success';
  323. $asn = $ok ? ($info['as'] ?: '—') : '—';
  324. $company = $ok ? ($info['org'] ?: ($info['isp'] ?? '') ?: ($info['asname'] ?? '')) : '';
  325. $country = $ok ? trim(($info['country'] ?? '') . ' ' . ($info['countryCode'] ?? '')) : '';
  326. $rdns = $hop['host'] !== '' ? $hop['host'] : ($ok ? ($info['reverse'] ?? '') : '');
  327. ?>
  328. <tr>
  329. <td><?= htmlspecialchars($hop['hop']) ?></td>
  330. <td class="ip"><?= htmlspecialchars($hop['ip']) ?></td>
  331. <td><?= htmlspecialchars($rdns !== '' ? $rdns : '—') ?></td>
  332. <td><?= htmlspecialchars($asn) ?></td>
  333. <td><?= htmlspecialchars($company !== '' ? $company : '—') ?></td>
  334. <td><?= htmlspecialchars($country !== '' ? $country : '—') ?></td>
  335. </tr>
  336. <?php endforeach; ?>
  337. </tbody>
  338. </table>
  339. <p class="muted" style="margin-top: 10px;">
  340. Hops answering with <code>* * *</code> are omitted from this table.
  341. IP intelligence via ip-api.com (free tier).
  342. </p>
  343. <?php endif; ?>
  344. <?php elseif ($inputError === null): ?>
  345. <div class="empty">Enter a hostname or IP address above to run a probe.</div>
  346. <?php endif; ?>
  347. <h2>Your connection</h2>
  348. <div class="self">
  349. <p><strong>IP address:</strong> <?= htmlspecialchars($clientIp !== '' ? $clientIp : 'unknown') ?></p>
  350. <?php if ($clientInfo && ($clientInfo['status'] ?? '') === 'success'): ?>
  351. <p><strong>ISP:</strong> <?= htmlspecialchars($clientInfo['isp'] ?: '—') ?></p>
  352. <?php if (!empty($clientInfo['as'])): ?>
  353. <p><strong>ASN:</strong> <?= htmlspecialchars($clientInfo['as']) ?></p>
  354. <?php endif; ?>
  355. <?php
  356. $location = implode(', ', array_filter([
  357. $clientInfo['city'] ?? '',
  358. $clientInfo['regionName'] ?? '',
  359. $clientInfo['country'] ?? '',
  360. ]));
  361. ?>
  362. <?php if ($location !== ''): ?>
  363. <p><strong>Location:</strong> <?= htmlspecialchars($location) ?></p>
  364. <?php endif; ?>
  365. <?php else: ?>
  366. <p class="muted">No public IP intelligence available for this address.</p>
  367. <?php endif; ?>
  368. </div>
  369. <script>
  370. // Show the DNS-only controls when the DNS mode is selected.
  371. var action = document.getElementById('action');
  372. function syncDnsFields() {
  373. var isDns = action.value === 'dns';
  374. document.getElementById('type').classList.toggle('hidden', !isDns);
  375. document.getElementById('resolver').classList.toggle('hidden', !isDns);
  376. }
  377. action.addEventListener('change', syncDnsFields);
  378. syncDnsFields();
  379. </script>
  380. </body>
  381. </html>