Medowar 1 місяць тому
батько
коміт
ee0402f381
1 змінених файлів з 414 додано та 0 видалено
  1. 414 0
      trace.php

+ 414 - 0
trace.php

@@ -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>