|
@@ -0,0 +1,414 @@
|
|
|
|
|
+<?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>
|