mirror of
https://github.com/LowderPlay/cheburcheck.git
synced 2026-09-22 22:37:59 +03:00
feat: better probing (#81)
* feat: ip probe traceroute * feat: block bogon and rate limit * fix: block badge * fix: control sampling * docs: update probing info * feat: disable traceroute * feat: dns spoofing * docs: dns spoofing * chore: bump version * fix: default rate limit
This commit is contained in:
@@ -21,28 +21,33 @@ jobs:
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Install musl toolchain
|
||||
run: sudo apt-get update && sudo apt-get install --yes musl-tools
|
||||
|
||||
- name: Install cargo-deb
|
||||
run: cargo install cargo-deb
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --package probe --bin cheburprobe
|
||||
run: cargo build --release --package probe --bin cheburprobe --target x86_64-unknown-linux-musl
|
||||
|
||||
- name: Build Debian package
|
||||
run: cargo deb --package probe --no-build -- --bin cheburprobe
|
||||
run: cargo deb --package probe --target x86_64-unknown-linux-musl --no-strip --no-build -- --bin cheburprobe
|
||||
|
||||
- name: Upload Linux amd64 binary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cheburprobe-linux-amd64
|
||||
path: target/release/cheburprobe
|
||||
path: target/x86_64-unknown-linux-musl/release/cheburprobe
|
||||
compression-level: 0
|
||||
|
||||
- name: Upload Linux amd64 Debian package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cheburprobe-linux-amd64-deb
|
||||
path: target/debian/*.deb
|
||||
path: target/x86_64-unknown-linux-musl/debian/*.deb
|
||||
compression-level: 0
|
||||
|
||||
linux-arm64:
|
||||
@@ -61,25 +66,25 @@ jobs:
|
||||
uses: houseabsolute/actions-rust-cross@v1
|
||||
with:
|
||||
command: build
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
target: "aarch64-unknown-linux-musl"
|
||||
args: "--release --package probe --bin cheburprobe"
|
||||
strip: true
|
||||
|
||||
- name: Build Debian package
|
||||
run: cargo deb --package probe --target aarch64-unknown-linux-gnu --no-strip --no-build -- --bin cheburprobe
|
||||
run: cargo deb --package probe --target aarch64-unknown-linux-musl --no-strip --no-build -- --bin cheburprobe
|
||||
|
||||
- name: Upload Linux arm64 binary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cheburprobe-linux-arm64
|
||||
path: target/aarch64-unknown-linux-gnu/release/cheburprobe
|
||||
path: target/aarch64-unknown-linux-musl/release/cheburprobe
|
||||
compression-level: 0
|
||||
|
||||
- name: Upload Linux arm64 Debian package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cheburprobe-linux-arm64-deb
|
||||
path: target/aarch64-unknown-linux-gnu/debian/*.deb
|
||||
path: target/aarch64-unknown-linux-musl/debian/*.deb
|
||||
compression-level: 0
|
||||
|
||||
windows-amd64:
|
||||
|
||||
Generated
+37
-2
@@ -97,6 +97,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
@@ -806,6 +812,15 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "etherparse"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17304d06addb3283cdc4bd528e42dd95e73c8ee2d6492ffce415e93660885449"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -2531,6 +2546,20 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -2603,18 +2632,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "probe"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"etherparse",
|
||||
"futures",
|
||||
"hickory-resolver",
|
||||
"log",
|
||||
"polling",
|
||||
"rand 0.8.5",
|
||||
"reports",
|
||||
"rumqttc 0.25.1",
|
||||
"rustix",
|
||||
"rustls 0.23.35",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
]
|
||||
@@ -4671,7 +4706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "website"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"dotenvy",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"whitespaceSensitivity": "ignore",
|
||||
"whitespaceSensitivity": "css",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
MQTT_ADMIN_TOKEN: "${MQTT_ADMIN_TOKEN}"
|
||||
MQTT_HOST: rmqtt
|
||||
MQTT_PORT: 11883
|
||||
PROBE_TRACEROUTE_ENABLED: "${PROBE_TRACEROUTE_ENABLED:-false}"
|
||||
PROBE_RATE_LIMIT_RPM: "${PROBE_RATE_LIMIT_RPM:-5}"
|
||||
volumes:
|
||||
- database-cache:/var/cache/cheburcheck/databases
|
||||
expose:
|
||||
|
||||
@@ -11,14 +11,43 @@ export type ProbeHostResult = {
|
||||
};
|
||||
};
|
||||
|
||||
export type DnsObservation = {
|
||||
provider: string;
|
||||
protocol: "Udp" | "Tcp" | "Doh" | "Dot";
|
||||
suspected_spoofing: boolean;
|
||||
metadata: {
|
||||
response_codes: string[];
|
||||
ipv4_count: number;
|
||||
ipv6_count: number;
|
||||
};
|
||||
outcome:
|
||||
| { type: "Answer"; addresses: string[] }
|
||||
| { type: "NoRecords" }
|
||||
| { type: "Error"; message: string };
|
||||
};
|
||||
|
||||
export type ProbeResult = {
|
||||
job_id: string;
|
||||
probe_id: string;
|
||||
region?: string | null;
|
||||
provider?: string | null;
|
||||
asn?: string | null;
|
||||
verdict: "uncertain" | "sni_block" | "whitelist" | "ok";
|
||||
host_results: ProbeHostResult[];
|
||||
verdict:
|
||||
| "uncertain"
|
||||
| "dns_spoofing"
|
||||
| "sni_block"
|
||||
| "tspu_block"
|
||||
| "whitelist"
|
||||
| "ok";
|
||||
host_results: ProbeHostResult[] | null;
|
||||
target_hop: number | null;
|
||||
dns: {
|
||||
spoofing_detected: boolean;
|
||||
suspicious_provider_count: number;
|
||||
verdict_threshold: number;
|
||||
samples_per_protocol: number;
|
||||
observations: DnsObservation[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ProbeStatus = {
|
||||
|
||||
@@ -98,16 +98,18 @@ const providerCidrs = (provider: Provider) =>
|
||||
|
||||
{#if result.reverseLookup.length > 0}
|
||||
<DetailRow label="Обратный DNS" icon={EthernetPort}>
|
||||
{#each result.reverseLookup as ptr}
|
||||
<span class={valueClass}>
|
||||
<a
|
||||
href={`/check?target=${ptr}`}
|
||||
class="text-neutral-100 underline decoration-neutral-500 transition-all hover:text-white hover:decoration-neutral-100"
|
||||
>
|
||||
{ptr}
|
||||
</a>
|
||||
</span>
|
||||
{/each}
|
||||
<div class="flex w-full flex-col items-end gap-2">
|
||||
{#each result.reverseLookup as ptr}
|
||||
<span class={valueClass}>
|
||||
<a
|
||||
href={`/check?target=${ptr}`}
|
||||
class="text-neutral-100 underline decoration-neutral-500 transition-all hover:text-white hover:decoration-neutral-100"
|
||||
>
|
||||
{ptr}
|
||||
</a>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailRow>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ kb?.reset();
|
||||
[&_code]:bg-neutral-900/70 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:text-neutral-200
|
||||
[&_h1]:mb-16 [&_h1]:text-5xl [&_h1]:font-black [&_h1]:tracking-tight [&_h1]:text-neutral-100 [&_h1]:uppercase
|
||||
[&_li]:my-3 [&_li]:text-neutral-300
|
||||
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-6
|
||||
[&_p]:my-6 [&_p]:text-lg [&_p]:leading-relaxed [&_p]:text-neutral-300
|
||||
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-6
|
||||
"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
LoaderCircle,
|
||||
ShieldCheck,
|
||||
} from "@lucide/svelte";
|
||||
import type { ProbeResult, ProbeStatus } from "$lib/api/probe";
|
||||
import type { DnsObservation, ProbeResult, ProbeStatus } from "$lib/api/probe";
|
||||
|
||||
let {
|
||||
probes,
|
||||
@@ -27,6 +27,38 @@ function toggleRow(id: string) {
|
||||
expandedRows[id] = !expandedRows[id];
|
||||
}
|
||||
|
||||
function dnsObservationStatus(
|
||||
observation: DnsObservation,
|
||||
): "ok" | "spoofed" | "error" {
|
||||
if (observation.outcome.type === "Error") return "error";
|
||||
return observation.suspected_spoofing ? "spoofed" : "ok";
|
||||
}
|
||||
|
||||
const dnsProtocolLabels: Record<DnsObservation["protocol"], string> = {
|
||||
Udp: "UDP",
|
||||
Tcp: "TCP",
|
||||
Doh: "DoH",
|
||||
Dot: "DoT",
|
||||
};
|
||||
|
||||
const dnsProtocols = ["Udp", "Tcp", "Doh", "Dot"] as const;
|
||||
|
||||
function groupDnsObservations(observations: DnsObservation[]) {
|
||||
const providers = new Map<
|
||||
string,
|
||||
Partial<Record<DnsObservation["protocol"], DnsObservation>>
|
||||
>();
|
||||
for (const observation of observations) {
|
||||
const protocols = providers.get(observation.provider) ?? {};
|
||||
protocols[observation.protocol] = observation;
|
||||
providers.set(observation.provider, protocols);
|
||||
}
|
||||
return [...providers].map(([provider, protocols]) => ({
|
||||
provider,
|
||||
protocols,
|
||||
}));
|
||||
}
|
||||
|
||||
const verdictStyles = {
|
||||
ok: {
|
||||
icon: CircleCheck,
|
||||
@@ -49,9 +81,23 @@ const verdictStyles = {
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/20",
|
||||
},
|
||||
tspu_block: {
|
||||
icon: CircleX,
|
||||
text: "ТСПУ Блок",
|
||||
class: "text-red-500",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/20",
|
||||
},
|
||||
dns_spoofing: {
|
||||
icon: CircleX,
|
||||
text: "Подмена DNS",
|
||||
class: "text-red-500",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/20",
|
||||
},
|
||||
whitelist: {
|
||||
icon: ShieldCheck,
|
||||
text: "Белый список",
|
||||
text: "Исключение для CDN",
|
||||
class: "text-amber-500",
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-amber-500/20",
|
||||
@@ -123,13 +169,12 @@ const verdictStyles = {
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each probes as probe (probe.probe_id)}
|
||||
{@const style = verdictStyles[(isStaticBlocked && probe.verdict === "ok") ? "cdn_block" : probe.verdict]}
|
||||
{@const style = verdictStyles[(isStaticBlocked && probe.host_results?.length !== 0 && probe.verdict === "ok") ? "cdn_block" : probe.verdict]}
|
||||
{@const isExpanded = !!expandedRows[probe.probe_id]}
|
||||
<tr
|
||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/20 transition-colors cursor-pointer select-none"
|
||||
onclick={() => toggleRow(probe.probe_id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && toggleRow(probe.probe_id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<td class="p-3">
|
||||
@@ -142,12 +187,23 @@ const verdictStyles = {
|
||||
<div class="text-xs text-neutral-500">{probe.asn || ""}</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<div
|
||||
class={`inline-flex items-center gap-1.5 px-2 py-1 rounded border ${style.bg} ${style.border} ${style.class} text-xs font-bold`}
|
||||
>
|
||||
<style.icon size={14} />
|
||||
{style.text}
|
||||
</div>
|
||||
{#if probe.verdict === "whitelist"}
|
||||
<a
|
||||
href="/kb/whitelist"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
class={`inline-flex items-center gap-1.5 px-2 py-1 rounded border ${style.bg} ${style.border} ${style.class} text-xs font-bold transition-colors hover:bg-amber-500/20 hover:border-amber-500/50 hover:text-amber-400`}
|
||||
>
|
||||
<style.icon size={14} />
|
||||
{style.text}
|
||||
</a>
|
||||
{:else}
|
||||
<div
|
||||
class={`inline-flex items-center gap-1.5 px-2 py-1 rounded border ${style.bg} ${style.border} ${style.class} text-xs font-bold`}
|
||||
>
|
||||
<style.icon size={14} />
|
||||
{style.text}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-3 text-right">
|
||||
{#if isExpanded}
|
||||
@@ -160,6 +216,110 @@ const verdictStyles = {
|
||||
{#if isExpanded}
|
||||
<tr class="bg-neutral-900/30">
|
||||
<td colspan="4" class="p-4 border-b border-neutral-800/50">
|
||||
<!-- {#if probe.verdict === "tspu_block"}
|
||||
<div
|
||||
class="mb-3 flex items-center gap-2 rounded-md border border-red-500/50 bg-red-500/15 px-3 py-2 text-red-200"
|
||||
>
|
||||
<TriangleAlert size={18} class="shrink-0 text-red-400" />
|
||||
<span class="font-bold">
|
||||
Блокировка ТСПУ обнаружена после
|
||||
{probe.target_hop}
|
||||
прыжка
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-md text-neutral-200 mb-2">
|
||||
Блокировка на ТСПУ <b>не обнаружена</b> после
|
||||
{probe.target_hop}
|
||||
прыжков
|
||||
</div>
|
||||
{/if} -->
|
||||
{#if probe.dns}
|
||||
<div class="mb-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<h4
|
||||
class="text-xs font-bold uppercase tracking-wide text-neutral-300"
|
||||
>
|
||||
DNS-проверка
|
||||
</h4>
|
||||
<span
|
||||
class={`text-xs font-semibold ${probe.dns.spoofing_detected ? 'text-red-400' : probe.dns.suspicious_provider_count > 0 ? 'text-amber-400' : 'text-green-400'}`}
|
||||
>
|
||||
{probe.dns.spoofing_detected
|
||||
? `Возможна подмена (${probe.dns.suspicious_provider_count}/${probe.dns.verdict_threshold})`
|
||||
: probe.dns.suspicious_provider_count > 0
|
||||
? `Недостаточно подтверждений (${probe.dns.suspicious_provider_count}/${probe.dns.verdict_threshold})`
|
||||
: "Подмена не выявлена"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-x-auto rounded border border-neutral-700/50"
|
||||
>
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead class="bg-neutral-800/60 text-neutral-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-semibold">Провайдер</th>
|
||||
{#each dnsProtocols as protocol}
|
||||
<th class="px-3 py-2 font-semibold">
|
||||
{dnsProtocolLabels[protocol]}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each groupDnsObservations(probe.dns.observations) as provider}
|
||||
<tr class="border-t border-neutral-800/80">
|
||||
<td
|
||||
class="px-3 py-2 font-semibold capitalize text-neutral-200"
|
||||
>
|
||||
{provider.provider}
|
||||
</td>
|
||||
{#each dnsProtocols as protocol}
|
||||
{@const observation = provider.protocols[protocol]}
|
||||
<td class="px-3 py-2">
|
||||
{#if observation}
|
||||
{@const dnsStatus = dnsObservationStatus(observation)}
|
||||
<div
|
||||
class={dnsStatus === "spoofed" ? "text-red-400" : dnsStatus === "error" ? "text-amber-400" : "text-green-400"}
|
||||
>
|
||||
<div class="font-semibold">
|
||||
{dnsStatus === "spoofed" ? "Подозрительно" : dnsStatus === "error" ? "Ошибка" : "Норма"}
|
||||
</div>
|
||||
<div
|
||||
class="mt-0.5 whitespace-nowrap font-mono text-[10px] text-neutral-500"
|
||||
>
|
||||
{observation.metadata.response_codes.join(", ") || "—"}
|
||||
·
|
||||
{observation.metadata.ipv4_count}/{observation.metadata.ipv6_count}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-neutral-600">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-2 text-[11px] text-neutral-500">
|
||||
Сравниваются код ответа и количество уникальных
|
||||
IPv4/IPv6. Выполнено по
|
||||
{probe.dns.samples_per_protocol || 1}
|
||||
запроса на протокол. Вердикт требует подтверждения от
|
||||
{probe.dns.verdict_threshold || 2}
|
||||
DNS-провайдеров.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-2 border-t border-neutral-800 pt-4">
|
||||
<h4
|
||||
class="text-xs font-bold uppercase tracking-wide text-neutral-300"
|
||||
>
|
||||
CDN-проверка
|
||||
</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{#each probe.host_results as host}
|
||||
<div
|
||||
@@ -168,7 +328,8 @@ const verdictStyles = {
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-bold text-neutral-400">
|
||||
Сервер {host.host_id}
|
||||
({host.host === "Blacklist" ? "в заблокированных" : "в доступных"} диапазонах)
|
||||
({host.host === "Blacklist" ? "в заблокированных" : "в доступных"}
|
||||
диапазонах)
|
||||
</span>
|
||||
<span class="text-xs text-neutral-200">
|
||||
{#if host.probe_evidence.type === 'Good'}
|
||||
@@ -176,7 +337,9 @@ const verdictStyles = {
|
||||
{:else if host.probe_evidence.type === 'ClientHello'}
|
||||
Блокировка после ClientHello
|
||||
{:else if host.probe_evidence.type === 'DataTimeout'}
|
||||
Таймаут получения данных, получено {host.probe_evidence.bytes} байт
|
||||
Таймаут получения данных, получено
|
||||
{host.probe_evidence.bytes}
|
||||
байт
|
||||
{:else if host.probe_evidence.type === 'ConnectionError'}
|
||||
Ошибка подключения
|
||||
{/if}
|
||||
|
||||
@@ -31,9 +31,7 @@ const checkQuery = createQuery(() => ({
|
||||
}));
|
||||
|
||||
const queryId = $derived(checkQuery.data?.id);
|
||||
const shouldProbe = $derived(
|
||||
!!queryId && checkQuery.data?.targetType === "Домен",
|
||||
);
|
||||
const shouldProbe = $derived(!!queryId);
|
||||
|
||||
function createInitialProbeData(id: string): ProbeQueryData {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import KbArticle from "$lib/components/kb/KbArticle.svelte";
|
||||
import KbCode from "$lib/components/kb/KbCode.svelte";
|
||||
import KbHeading from "$lib/components/kb/KbHeading.svelte";
|
||||
import KbNote from "$lib/components/kb/KbNote.svelte";
|
||||
</script>
|
||||
@@ -48,14 +47,63 @@ import KbNote from "$lib/components/kb/KbNote.svelte";
|
||||
сразу, не дожидаясь завершения всех проверок.
|
||||
</p>
|
||||
<p>
|
||||
Сканер не пытается открыть сайт целиком или подключиться напрямую к серверу.
|
||||
Вместо этого он делает небольшую контрольную загрузку с тестовых хостов.
|
||||
Часть этих хостов используется как условно заблокированные направления,
|
||||
часть — как контрольные доступные направления. Это помогает отличить разные
|
||||
типы поведения: обрыв на этапе начала HTTPS-соединения, недогрузку данных
|
||||
или нормальную передачу.
|
||||
Сканер не загружает целевой сайт целиком. Для домена он делает небольшие
|
||||
контрольные HTTPS-загрузки с тестовых хостов, подставляя проверяемое имя в
|
||||
SNI, а также сравнивает ответы нескольких публичных DNS-сервисов.
|
||||
</p>
|
||||
|
||||
<KbHeading id="методика-сканирования" title="Методика сканирования">
|
||||
Методика сканирования
|
||||
</KbHeading>
|
||||
<p>
|
||||
Сканер подключается к настроенным тестовым хостам, но передаёт проверяемый
|
||||
домен в TLS ClientHello и HTTP Host. Затем он оценивает, удалось ли
|
||||
завершить TLS-обмен и получить минимальный объём данных. Для проверки
|
||||
IP-адреса без домена этот этап пропускается.
|
||||
</p>
|
||||
<!-- <li>
|
||||
Сканер открывает TCP-соединения к разрешённому IP-адресу на порту 443,
|
||||
последовательно увеличивая TTL от 1 до 5. На каждом TTL по умолчанию
|
||||
одновременно отправляются три попытки: это уменьшает влияние потерь
|
||||
пакетов и ограничения частоты ICMP-ответов. Ответ ICMP Time Exceeded
|
||||
переводит проверку к следующему TTL, а TCP RST или успешное соединение
|
||||
завершают трассировку.
|
||||
</li>
|
||||
<li>
|
||||
Из списка известных доступных хостов случайно выбираются не более трёх
|
||||
IP-адресов той же версии протокола, что и цель. Все контрольные
|
||||
трассировки идут параллельно, а для сравнения используется наименьший
|
||||
измеренный контрольный TTL.
|
||||
</li> -->
|
||||
<!-- <p>
|
||||
Если трассировки цели и контроля завершились ответом ICMP Time Exceeded, но
|
||||
ответ для цели пришёл на меньшем TTL, сканер выставляет вердикт «ТСПУ Блок».
|
||||
В результатах показывается номер последнего достигнутого перехода для цели.
|
||||
</p> -->
|
||||
|
||||
<KbHeading id="проверка-dns" title="Проверка DNS"> Проверка DNS </KbHeading>
|
||||
<p>
|
||||
Для доменных имён сканер обращается к Google DNS, Cloudflare, Quad9 и Яндекс
|
||||
DNS. Запросы к каждому сервису выполняются параллельно по UDP, TCP, DNS over
|
||||
HTTPS (DoH) и DNS over TLS (DoT). Каждый вариант запускается несколько раз,
|
||||
чтобы обычная ротация адресов CDN и балансировщиков меньше влияла на
|
||||
результат.
|
||||
</p>
|
||||
<p>
|
||||
Сами IP-адреса могут законно отличаться между запросами, поэтому сканер не
|
||||
требует их точного совпадения. Для сравнения используется форма ответа: код
|
||||
DNS и количество уникальных IPv4- и IPv6-адресов. Ответы DoH и DoT одного
|
||||
провайдера служат зашифрованным ориентиром. UDP или TCP считается
|
||||
подозрительным, когда его форма отличается от совпавших между собой
|
||||
зашифрованных ответов.
|
||||
</p>
|
||||
<KbNote variant="info">
|
||||
Одиночное расхождение ещё не означает подмену. Итоговый вердикт появляется,
|
||||
только когда подозрительный результат независимо подтверждают несколько
|
||||
DNS-провайдеров — обычно не менее двух. Несколько протоколов одного
|
||||
провайдера считаются одним подтверждением.
|
||||
</KbNote>
|
||||
|
||||
<KbHeading id="что-показывает-результат" title="Что показывает результат">
|
||||
Что показывает результат
|
||||
</KbHeading>
|
||||
@@ -66,30 +114,47 @@ import KbNote from "$lib/components/kb/KbNote.svelte";
|
||||
</p>
|
||||
<ul class="list-disc">
|
||||
<li>
|
||||
<b>Доступен</b> – сайт не найден в списках CDN, не блокируется на
|
||||
контрольных хостах, но может блокироваться на зарубежных CDN.
|
||||
<b>Доступен</b>
|
||||
– сайт не найден в списках CDN, не блокируется на контрольных хостах, но
|
||||
может блокироваться на зарубежных CDN.
|
||||
</li>
|
||||
<li>
|
||||
<b>CDN Блок (16-20)</b> – сайт найден в списках CDN, не блокируется на
|
||||
контрольных хостах, но блокируется на зарубежных CDN.
|
||||
<b>CDN Блок (16-20)</b>
|
||||
– сайт найден в списках CDN, не блокируется на контрольных хостах, но
|
||||
блокируется на зарубежных CDN.
|
||||
</li>
|
||||
<li>
|
||||
<b>SNI Блок</b> – похоже на блокировку по домену сайта в начале
|
||||
HTTPS-соединения. На практике это похоже на ситуацию, когда оборудование
|
||||
оператора (ТСПУ) видит имя домена и разрывает соединение до передачи
|
||||
полезных данных.
|
||||
<b>SNI Блок</b>
|
||||
– похоже на блокировку по домену сайта в начале HTTPS-соединения. На
|
||||
практике это похоже на ситуацию, когда оборудование оператора (ТСПУ) видит
|
||||
имя домена и разрывает соединение до передачи полезных данных.
|
||||
</li>
|
||||
<li>
|
||||
<b>Подмена DNS</b>
|
||||
– открытые DNS-запросы по UDP или TCP у нескольких независимых провайдеров
|
||||
отличаются от их ответов по защищённым протоколам DoH и DoT. Если
|
||||
одновременно обнаружена SNI-блокировка, в общем вердикте приоритет
|
||||
получает
|
||||
<b>SNI Блок</b>.
|
||||
</li>
|
||||
<!-- <li>
|
||||
<b>ТСПУ Блок</b>
|
||||
– TCP-трассировка до цели остановилась раньше контрольной трассировки.
|
||||
Обычно означает, что соединение разрывается на оборудовании оператора
|
||||
(ТСПУ) до того как соединение дошло до цели.
|
||||
</li> -->
|
||||
<li>
|
||||
<!-- biome-ignore format: link punctuation -->
|
||||
<a
|
||||
class="underline decoration-dotted underline-offset-2"
|
||||
href="/kb/whitelist"
|
||||
>Белый список</a> – похоже на блокировку по домену сайта в начале
|
||||
HTTPS-соединения. Вероятнее всего, указывает на блокировку сайта на ТСПУ.
|
||||
>Исключение для CDN</a> – данные передаются в полном объеме при
|
||||
подстановке домена в SNI при подключении к заблокированному серверу.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<b>Неясно</b> – данных недостаточно или ответы противоречат друг другу.
|
||||
<b>Неясно</b>
|
||||
– данных недостаточно или ответы противоречат друг другу.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -120,6 +185,13 @@ import KbNote from "$lib/components/kb/KbNote.svelte";
|
||||
большинство ответов и на то, какие именно хосты дали сбой: контрольные,
|
||||
проверочные или все сразу.
|
||||
</p>
|
||||
<p>
|
||||
В раскрытой строке результата DNS-проверка показана отдельно от
|
||||
CDN-проверки. Для каждого DNS-провайдера видны UDP, TCP, DoH и DoT, код
|
||||
ответа, счётчик IPv4/IPv6 и оценка расхождения. Ошибка запроса означает лишь
|
||||
то, что конкретное измерение не удалось, и сама по себе не считается
|
||||
подменой.
|
||||
</p>
|
||||
|
||||
<KbHeading
|
||||
id="почему-результаты-могут-отличаться"
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
target: "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "probe"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license-file = "../LICENSE"
|
||||
description = "Dynamic network probe daemon for Cheburcheck"
|
||||
@@ -25,8 +25,11 @@ assets = [
|
||||
anyhow = "1.0"
|
||||
clap = { version = "4.6.0", features = ["derive", "env"] }
|
||||
env_logger = "0.11.10"
|
||||
etherparse = "0.21"
|
||||
futures = "0.3"
|
||||
log = { workspace = true }
|
||||
polling = "3.11"
|
||||
rustix = { version = "1.1", features = ["net"] }
|
||||
rumqttc = { version = "0.25", features = ["use-rustls", "websocket"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0"
|
||||
@@ -34,3 +37,6 @@ tokio = { workspace = true }
|
||||
reports = { path = "../reports" }
|
||||
rustls = "0.23"
|
||||
tokio-rustls = "0.26"
|
||||
rand = "0.8"
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
hickory-resolver = { version = "0.26.0-beta.3", features = ["tokio", "webpki-roots", "https-aws-lc-rs"] }
|
||||
|
||||
+6
-1
@@ -15,13 +15,18 @@ RUN --mount=type=cache,id=probe-target,target=/build/target \
|
||||
|
||||
FROM docker.io/debian:bookworm-slim
|
||||
|
||||
RUN groupadd --system app && \
|
||||
RUN apt-get update && \
|
||||
apt-get install --yes --no-install-recommends libcap2-bin && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd --system app && \
|
||||
useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /build/probe-bin ./probe
|
||||
|
||||
RUN setcap cap_net_raw=ep ./probe
|
||||
|
||||
USER app
|
||||
|
||||
ENTRYPOINT ["./probe"]
|
||||
|
||||
+7
-3
@@ -104,6 +104,7 @@ cargo run --package probe --bin cheburprobe
|
||||
|
||||
```shell
|
||||
docker run --rm \
|
||||
--cap-add NET_RAW \
|
||||
-e PROBE_ID=1 \
|
||||
-e PROBE_TOKEN=ваш-токен \
|
||||
-e MQTT_HOST=wss://cheburcheck.ru/mqtt \
|
||||
@@ -121,9 +122,12 @@ docker run --rm \
|
||||
| `--probe-id`, `PROBE_ID` | ID сканера. | обязательно |
|
||||
| `--probe-token`, `PROBE_TOKEN` | Секретный токен сканера. | обязательно |
|
||||
| `--max-concurrent-tasks`, `MAX_CONCURRENT_TASKS` | Максимальное количество одновременных заданий. | `8` |
|
||||
| `--traceroute-max-hops`, `TRACEROUTE_MAX_HOPS` | Максимальный TTL для TCP traceroute. | `5` |
|
||||
| `--traceroute-retries`, `TRACEROUTE_RETRIES` | Количество одновременных TCP-попыток на каждом TTL. | `3` |
|
||||
| `--traceroute-control-hosts`, `TRACEROUTE_CONTROL_HOSTS` | Максимальное количество случайных контрольных IP для одновременной трассировки. | `3` |
|
||||
| `RUST_LOG` | Уровень логирования. | `info` |
|
||||
|
||||
`MAX_CONCURRENT_TASKS` должен быть больше нуля.
|
||||
`MAX_CONCURRENT_TASKS`, `TRACEROUTE_MAX_HOPS`, `TRACEROUTE_RETRIES` и `TRACEROUTE_CONTROL_HOSTS` должны быть больше нуля. Для получения ICMP-ответов traceroute процессу требуется capability `CAP_NET_RAW`; systemd unit и Docker-образ настраивают её автоматически.
|
||||
|
||||
## Как работает проверка
|
||||
|
||||
@@ -131,8 +135,8 @@ docker run --rm \
|
||||
|
||||
1. публикует retained-статус `online` в MQTT;
|
||||
2. подписывается на конфигурацию динамического сканирования;
|
||||
3. получает задания на проверку доменов;
|
||||
4. параллельно проверяет домен на настроенных тестовых хостах;
|
||||
3. получает задания на проверку доменов и IP-адресов;
|
||||
4. параллельно запускает SNI-проверки (для доменов), TCP traceroute до цели и контрольный TCP traceroute;
|
||||
5. отправляет результат обратно в Cheburcheck.
|
||||
|
||||
Для каждого тестового хоста сканер открывает TCP-соединение, начинает TLS-handshake с проверяемым доменом в SNI, затем отправляет простой HTTP GET-запрос.
|
||||
|
||||
@@ -10,6 +10,8 @@ ExecStart=/usr/bin/cheburprobe
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
DynamicUser=yes
|
||||
AmbientCapabilities=CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_RAW
|
||||
LimitNOFILE=16384
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
use futures::future::join_all;
|
||||
use hickory_resolver::config::{
|
||||
CLOUDFLARE, GOOGLE, LookupIpStrategy, QUAD9, ResolverConfig, ResolverOpts, ServerGroup,
|
||||
};
|
||||
use hickory_resolver::net::runtime::TokioRuntimeProvider;
|
||||
use hickory_resolver::net::{DnsError, NetError};
|
||||
use reports::probe::{
|
||||
DnsObservation, DnsOutcome, DnsProbeResult, DnsProtocol, DnsResponseMetadata,
|
||||
};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::time::Duration;
|
||||
|
||||
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const YANDEX: ServerGroup<'static> = ServerGroup {
|
||||
ips: &[IpAddr::V4(Ipv4Addr::new(77, 88, 8, 8))],
|
||||
server_name: "common.dot.dns.yandex.net",
|
||||
path: "/dns-query",
|
||||
};
|
||||
|
||||
pub async fn check_dns(
|
||||
domain: Option<&str>,
|
||||
remaining: Duration,
|
||||
samples_per_protocol: u8,
|
||||
spoofing_provider_threshold: u8,
|
||||
) -> Option<DnsProbeResult> {
|
||||
let domain = domain?;
|
||||
let timeout = remaining.min(LOOKUP_TIMEOUT);
|
||||
if timeout.is_zero() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let providers = [
|
||||
("google", &GOOGLE),
|
||||
("cloudflare", &CLOUDFLARE),
|
||||
("quad9", &QUAD9),
|
||||
("yandex", &YANDEX),
|
||||
];
|
||||
let mut observations = join_all(providers.into_iter().flat_map(|(provider, servers)| {
|
||||
[
|
||||
(DnsProtocol::Udp, resolver_config(servers, DnsProtocol::Udp)),
|
||||
(DnsProtocol::Tcp, resolver_config(servers, DnsProtocol::Tcp)),
|
||||
(DnsProtocol::Doh, ResolverConfig::https(servers)),
|
||||
(DnsProtocol::Dot, ResolverConfig::tls(servers)),
|
||||
]
|
||||
.map(|(protocol, config)| {
|
||||
lookup(
|
||||
domain,
|
||||
provider,
|
||||
protocol,
|
||||
config,
|
||||
timeout,
|
||||
samples_per_protocol,
|
||||
)
|
||||
})
|
||||
}))
|
||||
.await;
|
||||
|
||||
mark_suspected_spoofing(&mut observations);
|
||||
let suspicious_provider_count = suspicious_provider_count(&observations);
|
||||
let spoofing_detected = suspicious_provider_count >= spoofing_provider_threshold;
|
||||
|
||||
Some(DnsProbeResult {
|
||||
spoofing_detected,
|
||||
suspicious_provider_count,
|
||||
verdict_threshold: spoofing_provider_threshold,
|
||||
samples_per_protocol,
|
||||
observations,
|
||||
})
|
||||
}
|
||||
|
||||
fn suspicious_provider_count(observations: &[DnsObservation]) -> u8 {
|
||||
["google", "cloudflare", "quad9", "yandex"]
|
||||
.into_iter()
|
||||
.filter(|provider| {
|
||||
observations.iter().any(|observation| {
|
||||
observation.provider == *provider && observation.suspected_spoofing
|
||||
})
|
||||
})
|
||||
.count() as u8
|
||||
}
|
||||
|
||||
fn mark_suspected_spoofing(observations: &mut [DnsObservation]) {
|
||||
for provider in ["google", "cloudflare", "quad9", "yandex"] {
|
||||
let doh = observations
|
||||
.iter()
|
||||
.find(|item| item.provider == provider && matches!(item.protocol, DnsProtocol::Doh))
|
||||
.filter(|item| successful_outcome(item).is_some());
|
||||
let dot = observations
|
||||
.iter()
|
||||
.find(|item| item.provider == provider && matches!(item.protocol, DnsProtocol::Dot))
|
||||
.filter(|item| successful_outcome(item).is_some());
|
||||
let Some(reference) = doh
|
||||
.zip(dot)
|
||||
.and_then(|(doh, dot)| (doh.metadata == dot.metadata).then(|| doh.metadata.clone()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for observation in observations.iter_mut().filter(|item| {
|
||||
item.provider == provider
|
||||
&& matches!(item.protocol, DnsProtocol::Udp | DnsProtocol::Tcp)
|
||||
}) {
|
||||
observation.suspected_spoofing =
|
||||
successful_outcome(observation).is_some() && observation.metadata != reference;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn successful_outcome(observation: &DnsObservation) -> Option<&DnsOutcome> {
|
||||
(!matches!(observation.outcome, DnsOutcome::Error { .. })).then_some(&observation.outcome)
|
||||
}
|
||||
|
||||
fn resolver_config(servers: &ServerGroup<'_>, protocol: DnsProtocol) -> ResolverConfig {
|
||||
let name_servers = match protocol {
|
||||
DnsProtocol::Udp => servers.udp().collect(),
|
||||
DnsProtocol::Tcp => servers.tcp().collect(),
|
||||
DnsProtocol::Doh | DnsProtocol::Dot => unreachable!(),
|
||||
};
|
||||
ResolverConfig::from_parts(None, Vec::new(), name_servers)
|
||||
}
|
||||
|
||||
async fn lookup(
|
||||
domain: &str,
|
||||
provider: &str,
|
||||
protocol: DnsProtocol,
|
||||
config: ResolverConfig,
|
||||
timeout: Duration,
|
||||
samples_per_protocol: u8,
|
||||
) -> DnsObservation {
|
||||
let mut options = ResolverOpts::default();
|
||||
options.ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
|
||||
options.attempts = 1;
|
||||
options.timeout = timeout;
|
||||
options.cache_size = 0;
|
||||
let (outcome, metadata) = match hickory_resolver::Resolver::builder_with_config(
|
||||
config,
|
||||
TokioRuntimeProvider::default(),
|
||||
)
|
||||
.with_options(options)
|
||||
.build()
|
||||
{
|
||||
Ok(resolver) => {
|
||||
let samples = join_all(
|
||||
(0..samples_per_protocol)
|
||||
.map(|_| tokio::time::timeout(timeout, resolver.lookup_ip(domain))),
|
||||
)
|
||||
.await;
|
||||
let mut addresses = Vec::new();
|
||||
let mut no_records = false;
|
||||
let mut errors = Vec::new();
|
||||
let mut response_codes = Vec::new();
|
||||
for sample in samples {
|
||||
match sample {
|
||||
Ok(Ok(lookup)) => {
|
||||
addresses.extend(lookup.iter());
|
||||
response_codes.push("NoError".to_string());
|
||||
}
|
||||
Ok(Err(NetError::Dns(DnsError::NoRecordsFound(no_records_error)))) => {
|
||||
no_records = true;
|
||||
response_codes.push(no_records_error.response_code.to_string());
|
||||
}
|
||||
Ok(Err(NetError::Dns(DnsError::ResponseCode(code)))) => {
|
||||
response_codes.push(code.to_string());
|
||||
errors.push(format!("error response: {code}"));
|
||||
}
|
||||
Ok(Err(error)) => errors.push(error.to_string()),
|
||||
Err(_) => errors.push("lookup timed out".to_string()),
|
||||
}
|
||||
}
|
||||
response_codes.sort();
|
||||
response_codes.dedup();
|
||||
let outcome = if !addresses.is_empty() {
|
||||
addresses.sort_unstable();
|
||||
addresses.dedup();
|
||||
DnsOutcome::Answer {
|
||||
addresses: addresses.clone(),
|
||||
}
|
||||
} else if no_records {
|
||||
DnsOutcome::NoRecords
|
||||
} else {
|
||||
errors.sort();
|
||||
errors.dedup();
|
||||
DnsOutcome::Error {
|
||||
message: errors.join("; "),
|
||||
}
|
||||
};
|
||||
let metadata = DnsResponseMetadata {
|
||||
response_codes,
|
||||
ipv4_count: addresses.iter().filter(|address| address.is_ipv4()).count() as u16,
|
||||
ipv6_count: addresses.iter().filter(|address| address.is_ipv6()).count() as u16,
|
||||
};
|
||||
(outcome, metadata)
|
||||
}
|
||||
Err(error) => (
|
||||
DnsOutcome::Error {
|
||||
message: error.to_string(),
|
||||
},
|
||||
DnsResponseMetadata::default(),
|
||||
),
|
||||
};
|
||||
|
||||
DnsObservation {
|
||||
provider: provider.to_string(),
|
||||
protocol,
|
||||
outcome,
|
||||
suspected_spoofing: false,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{mark_suspected_spoofing, suspicious_provider_count};
|
||||
use reports::probe::{DnsObservation, DnsOutcome, DnsProtocol, DnsResponseMetadata};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
#[test]
|
||||
fn normalized_answers_compare_independent_of_order() {
|
||||
let mut left = vec![
|
||||
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)),
|
||||
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
|
||||
];
|
||||
let right = vec![
|
||||
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)),
|
||||
];
|
||||
left.sort_unstable();
|
||||
assert_eq!(left, right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_rotation_is_not_mistaken_for_spoofing() {
|
||||
let answer = |last| DnsOutcome::Answer {
|
||||
addresses: vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, last))],
|
||||
};
|
||||
let observation = |protocol, outcome| DnsObservation {
|
||||
provider: "quad9".to_string(),
|
||||
protocol,
|
||||
outcome,
|
||||
suspected_spoofing: false,
|
||||
metadata: DnsResponseMetadata {
|
||||
response_codes: vec!["NoError".to_string()],
|
||||
ipv4_count: 1,
|
||||
ipv6_count: 0,
|
||||
},
|
||||
};
|
||||
let mut observations = vec![
|
||||
observation(DnsProtocol::Udp, answer(1)),
|
||||
observation(DnsProtocol::Tcp, answer(2)),
|
||||
observation(DnsProtocol::Doh, answer(1)),
|
||||
observation(DnsProtocol::Dot, answer(2)),
|
||||
];
|
||||
mark_suspected_spoofing(&mut observations);
|
||||
assert!(observations.iter().all(|item| !item.suspected_spoofing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_addresses_with_the_same_metadata_are_not_suspicious() {
|
||||
let answer = |last| DnsOutcome::Answer {
|
||||
addresses: vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, last))],
|
||||
};
|
||||
let observation = |protocol, outcome| DnsObservation {
|
||||
provider: "quad9".to_string(),
|
||||
protocol,
|
||||
outcome,
|
||||
suspected_spoofing: false,
|
||||
metadata: DnsResponseMetadata {
|
||||
response_codes: vec!["NoError".to_string()],
|
||||
ipv4_count: 1,
|
||||
ipv6_count: 0,
|
||||
},
|
||||
};
|
||||
let mut observations = vec![
|
||||
observation(DnsProtocol::Udp, answer(99)),
|
||||
observation(DnsProtocol::Tcp, answer(1)),
|
||||
observation(DnsProtocol::Doh, answer(1)),
|
||||
observation(DnsProtocol::Dot, answer(1)),
|
||||
];
|
||||
mark_suspected_spoofing(&mut observations);
|
||||
assert!(observations.iter().all(|item| !item.suspected_spoofing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_metadata_difference_is_suspicious() {
|
||||
let observation = |protocol, ipv4_count| DnsObservation {
|
||||
provider: "quad9".to_string(),
|
||||
protocol,
|
||||
outcome: DnsOutcome::Answer {
|
||||
addresses: vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))],
|
||||
},
|
||||
suspected_spoofing: false,
|
||||
metadata: DnsResponseMetadata {
|
||||
response_codes: vec!["NoError".to_string()],
|
||||
ipv4_count,
|
||||
ipv6_count: 0,
|
||||
},
|
||||
};
|
||||
let mut observations = vec![
|
||||
observation(DnsProtocol::Udp, 2),
|
||||
observation(DnsProtocol::Tcp, 1),
|
||||
observation(DnsProtocol::Doh, 1),
|
||||
observation(DnsProtocol::Dot, 1),
|
||||
];
|
||||
mark_suspected_spoofing(&mut observations);
|
||||
assert!(observations[0].suspected_spoofing);
|
||||
assert!(
|
||||
observations[1..]
|
||||
.iter()
|
||||
.all(|item| !item.suspected_spoofing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_protocols_from_one_provider_count_as_one_vote() {
|
||||
let observation = |provider: &str, protocol| DnsObservation {
|
||||
provider: provider.to_string(),
|
||||
protocol,
|
||||
outcome: DnsOutcome::NoRecords,
|
||||
suspected_spoofing: true,
|
||||
metadata: DnsResponseMetadata::default(),
|
||||
};
|
||||
let observations = vec![
|
||||
observation("quad9", DnsProtocol::Udp),
|
||||
observation("quad9", DnsProtocol::Tcp),
|
||||
observation("yandex", DnsProtocol::Udp),
|
||||
];
|
||||
assert_eq!(suspicious_provider_count(&observations[..2]), 1);
|
||||
assert_eq!(suspicious_provider_count(&observations), 2);
|
||||
}
|
||||
}
|
||||
+145
-175
@@ -1,24 +1,31 @@
|
||||
mod dns;
|
||||
mod sni;
|
||||
mod traceroute;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use futures::future::join_all;
|
||||
use log::{error, info, warn};
|
||||
use reports::probe::{Host, HostProbeResult, ProbeConfig, ProbeEvidence, ProbeStatus, ProbeTask};
|
||||
use rand::seq::SliceRandom;
|
||||
use reports::probe::{ProbeConfig, ProbeResult, ProbeStatus, ProbeTask, TcpTracerouteOutcome};
|
||||
use rumqttc::{
|
||||
AsyncClient, Event, Incoming, LastWill, MqttOptions, NetworkOptions, QoS, Transport,
|
||||
};
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme};
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
const CONFIG_TOPIC: &str = "probe/config/v1";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LoadedProbeConfig {
|
||||
config: ProbeConfig,
|
||||
control_hosts_v4: Vec<Ipv4Addr>,
|
||||
control_hosts_v6: Vec<Ipv6Addr>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(author, version, about = "Dynamic probing daemon")]
|
||||
struct Args {
|
||||
@@ -39,6 +46,15 @@ struct Args {
|
||||
|
||||
#[arg(long, env = "MAX_CONCURRENT_TASKS", default_value_t = 8)]
|
||||
max_concurrent_tasks: usize,
|
||||
|
||||
#[arg(long, env = "TRACEROUTE_MAX_HOPS", default_value_t = 5)]
|
||||
traceroute_max_hops: u8,
|
||||
|
||||
#[arg(long, env = "TRACEROUTE_RETRIES", default_value_t = 3)]
|
||||
traceroute_retries: u8,
|
||||
|
||||
#[arg(long, env = "TRACEROUTE_CONTROL_HOSTS", default_value_t = 3)]
|
||||
traceroute_control_hosts: usize,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -48,6 +64,15 @@ async fn main() -> Result<()> {
|
||||
if args.max_concurrent_tasks == 0 {
|
||||
bail!("max_concurrent_tasks must be greater than zero");
|
||||
}
|
||||
if args.traceroute_max_hops == 0 {
|
||||
bail!("traceroute_max_hops must be greater than zero");
|
||||
}
|
||||
if args.traceroute_retries == 0 {
|
||||
bail!("traceroute_retries must be greater than zero");
|
||||
}
|
||||
if args.traceroute_control_hosts == 0 {
|
||||
bail!("traceroute_control_hosts must be greater than zero");
|
||||
}
|
||||
|
||||
let status_topic = format!("probe/status/v1/{}", args.probe_id);
|
||||
let offline_status = serde_json::to_vec(&ProbeStatus {
|
||||
@@ -155,9 +180,43 @@ fn mqtt_transport(mqtt_host: &str) -> Result<Transport> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_config(config: &Arc<RwLock<Option<ProbeConfig>>>, payload: &[u8]) -> Result<()> {
|
||||
let value = serde_json::from_slice(payload).context("decode probe config")?;
|
||||
*config.write().await = Some(value);
|
||||
async fn update_config(
|
||||
config: &Arc<RwLock<Option<LoadedProbeConfig>>>,
|
||||
payload: &[u8],
|
||||
) -> Result<()> {
|
||||
let value: ProbeConfig = serde_json::from_slice(payload).context("decode probe config")?;
|
||||
if value.dns_samples_per_protocol == 0 {
|
||||
bail!("dns_samples_per_protocol must be greater than zero");
|
||||
}
|
||||
if !(1..=4).contains(&value.dns_spoofing_provider_threshold) {
|
||||
bail!("dns_spoofing_provider_threshold must be between 1 and 4");
|
||||
}
|
||||
let mut control_hosts_v4 = HashSet::new();
|
||||
let mut control_hosts_v6 = HashSet::new();
|
||||
if value.traceroute_enabled {
|
||||
for domain in &value.control_hosts {
|
||||
match tokio::net::lookup_host((domain.as_str(), 443)).await {
|
||||
Ok(addresses) => {
|
||||
for address in addresses {
|
||||
match address.ip() {
|
||||
IpAddr::V4(address) => {
|
||||
control_hosts_v4.insert(address);
|
||||
}
|
||||
IpAddr::V6(address) => {
|
||||
control_hosts_v6.insert(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => warn!("failed to resolve control host {domain}: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
*config.write().await = Some(LoadedProbeConfig {
|
||||
config: value,
|
||||
control_hosts_v4: control_hosts_v4.into_iter().collect(),
|
||||
control_hosts_v6: control_hosts_v6.into_iter().collect(),
|
||||
});
|
||||
info!("updated retained probe config");
|
||||
Ok(())
|
||||
}
|
||||
@@ -201,7 +260,7 @@ async fn publish_status(
|
||||
async fn handle_task(
|
||||
client: &AsyncClient,
|
||||
args: &Args,
|
||||
config: &Arc<RwLock<Option<ProbeConfig>>>,
|
||||
config: &Arc<RwLock<Option<LoadedProbeConfig>>>,
|
||||
topic: &str,
|
||||
task: ProbeTask<'_>,
|
||||
received_at: Instant,
|
||||
@@ -219,180 +278,91 @@ async fn handle_task(
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let config = config.read().await.clone();
|
||||
let Some(config) = config else {
|
||||
bail!("no config");
|
||||
};
|
||||
let result_topic = format!("probe/results/v1/{job_id}/{}", args.probe_id);
|
||||
let probing = join_all(config.hosts.into_iter().map(|host| {
|
||||
let target = task.target.to_string();
|
||||
async move {
|
||||
let probe_evidence = probe_host(&host, &target).await;
|
||||
HostProbeResult {
|
||||
probe_evidence,
|
||||
host_id: host.id,
|
||||
}
|
||||
let config = config.read().await.clone();
|
||||
let traceroute_enabled = config
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.config.traceroute_enabled);
|
||||
let control_targets = config.as_ref().map_or_else(Vec::new, |config| {
|
||||
if !traceroute_enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
}));
|
||||
let result = match time::timeout(remaining, probing).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"dropping expired task {job_id}: timeout {}ms",
|
||||
task.timeout_ms
|
||||
);
|
||||
return Ok(());
|
||||
let mut rng = rand::thread_rng();
|
||||
match task.ip {
|
||||
IpAddr::V4(_) => config
|
||||
.control_hosts_v4
|
||||
.choose_multiple(&mut rng, args.traceroute_control_hosts)
|
||||
.copied()
|
||||
.map(IpAddr::V4)
|
||||
.collect(),
|
||||
IpAddr::V6(_) => config
|
||||
.control_hosts_v6
|
||||
.choose_multiple(&mut rng, args.traceroute_control_hosts)
|
||||
.copied()
|
||||
.map(IpAddr::V6)
|
||||
.collect(),
|
||||
}
|
||||
});
|
||||
let sni_check = sni::check_sni(
|
||||
config.as_ref().map(|config| &config.config),
|
||||
task.domain,
|
||||
remaining,
|
||||
job_id,
|
||||
task.timeout_ms,
|
||||
);
|
||||
let target_traceroute = async {
|
||||
if traceroute_enabled {
|
||||
traceroute::tcp_traceroute(task.ip, args.traceroute_max_hops, args.traceroute_retries)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let dns_samples_per_protocol = config
|
||||
.as_ref()
|
||||
.map_or_else(reports::probe::default_dns_samples_per_protocol, |config| {
|
||||
config.config.dns_samples_per_protocol
|
||||
});
|
||||
let dns_spoofing_provider_threshold = config.as_ref().map_or_else(
|
||||
reports::probe::default_dns_spoofing_provider_threshold,
|
||||
|config| config.config.dns_spoofing_provider_threshold,
|
||||
);
|
||||
let dns_check = dns::check_dns(
|
||||
task.domain,
|
||||
remaining,
|
||||
dns_samples_per_protocol,
|
||||
dns_spoofing_provider_threshold,
|
||||
);
|
||||
let control_traceroute = async {
|
||||
join_all(control_targets.into_iter().map(|target| {
|
||||
traceroute::tcp_traceroute(target, args.traceroute_max_hops, args.traceroute_retries)
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min_by_key(|trace| match trace.result {
|
||||
TcpTracerouteOutcome::Rst { hop }
|
||||
| TcpTracerouteOutcome::Connected { hop }
|
||||
| TcpTracerouteOutcome::IcmpTimeExceeded { hop } => hop,
|
||||
TcpTracerouteOutcome::Timeout => u8::MAX,
|
||||
})
|
||||
};
|
||||
let (responses, target_traceroute, control_traceroute, dns) =
|
||||
tokio::join!(sni_check, target_traceroute, control_traceroute, dns_check);
|
||||
let responses = responses?;
|
||||
|
||||
client
|
||||
.publish(
|
||||
result_topic,
|
||||
QoS::AtLeastOnce,
|
||||
false,
|
||||
serde_json::to_vec(&result)?,
|
||||
serde_json::to_vec(&ProbeResult {
|
||||
responses,
|
||||
target_traceroute,
|
||||
control_traceroute,
|
||||
dns,
|
||||
})?,
|
||||
)
|
||||
.await
|
||||
.context("publish probe result")
|
||||
}
|
||||
|
||||
async fn probe_host(host: &Host, target: &str) -> ProbeEvidence {
|
||||
let timeout = Duration::from_secs(host.timeout_sec as u64);
|
||||
let tcp = match time::timeout(timeout, TcpStream::connect((host.host.as_str(), 443))).await {
|
||||
Ok(Ok(tcp)) => tcp,
|
||||
Ok(Err(_)) | Err(_) => return ProbeEvidence::ConnectionError,
|
||||
};
|
||||
|
||||
let tls_config = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||
.with_no_client_auth();
|
||||
let connector = TlsConnector::from(Arc::new(tls_config));
|
||||
|
||||
let server_name = match ServerName::try_from(target.to_string()) {
|
||||
Ok(server_name) => server_name,
|
||||
Err(_) => return ProbeEvidence::ClientHello,
|
||||
};
|
||||
|
||||
let mut tls = match time::timeout(timeout, connector.connect(server_name, tcp)).await {
|
||||
Ok(Ok(tls)) => tls,
|
||||
Ok(Err(_)) | Err(_) => return ProbeEvidence::ClientHello,
|
||||
};
|
||||
|
||||
let request = format!(
|
||||
"GET /{} HTTP/1.1\r\nHost: {}\r\nUser-Agent: cheburcheck-probe/{}\r\nRange: bytes=0-{}\r\nConnection: close\r\n\r\n",
|
||||
host.file_path.trim_start_matches('/'),
|
||||
target,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
host.min_data.saturating_sub(1)
|
||||
);
|
||||
|
||||
if !matches!(
|
||||
time::timeout(timeout, tls.write_all(request.as_bytes())).await,
|
||||
Ok(Ok(()))
|
||||
) {
|
||||
return ProbeEvidence::ClientHello;
|
||||
}
|
||||
|
||||
let mut received = 0u32;
|
||||
let mut headers_done = false;
|
||||
let mut pending = Vec::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
match time::timeout(timeout, tls.read(&mut buffer)).await {
|
||||
Ok(Ok(0)) | Err(_) => {
|
||||
return if received >= host.min_data {
|
||||
ProbeEvidence::Good
|
||||
} else {
|
||||
ProbeEvidence::DataTimeout { bytes: received }
|
||||
};
|
||||
}
|
||||
Ok(Ok(bytes)) => {
|
||||
add_response_body_bytes(
|
||||
&buffer[..bytes],
|
||||
&mut pending,
|
||||
&mut headers_done,
|
||||
&mut received,
|
||||
);
|
||||
if received >= host.min_data {
|
||||
return ProbeEvidence::Good;
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
return if received >= host.min_data {
|
||||
ProbeEvidence::Good
|
||||
} else {
|
||||
ProbeEvidence::DataTimeout { bytes: received }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_response_body_bytes(
|
||||
chunk: &[u8],
|
||||
pending: &mut Vec<u8>,
|
||||
headers_done: &mut bool,
|
||||
received: &mut u32,
|
||||
) {
|
||||
if *headers_done {
|
||||
*received = received.saturating_add(chunk.len() as u32);
|
||||
return;
|
||||
}
|
||||
|
||||
pending.extend_from_slice(chunk);
|
||||
if let Some(body_start) = pending.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
*headers_done = true;
|
||||
let body_bytes = pending.len().saturating_sub(body_start + 4);
|
||||
*received = received.saturating_add(body_bytes as u32);
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoCertificateVerification;
|
||||
|
||||
impl ServerCertVerifier for NoCertificateVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, TlsError> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
vec![
|
||||
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
SignatureScheme::ED25519,
|
||||
SignatureScheme::RSA_PSS_SHA256,
|
||||
SignatureScheme::RSA_PSS_SHA384,
|
||||
SignatureScheme::RSA_PSS_SHA512,
|
||||
SignatureScheme::RSA_PKCS1_SHA256,
|
||||
SignatureScheme::RSA_PKCS1_SHA384,
|
||||
SignatureScheme::RSA_PKCS1_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
use anyhow::{Result, bail};
|
||||
use futures::future::join_all;
|
||||
use log::warn;
|
||||
use reports::probe::{Host, HostProbeResult, ProbeConfig, ProbeEvidence};
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
pub async fn check_sni(
|
||||
config: Option<&ProbeConfig>,
|
||||
domain: Option<&str>,
|
||||
timeout: Duration,
|
||||
job_id: &str,
|
||||
task_timeout_ms: u64,
|
||||
) -> Result<Option<Vec<HostProbeResult>>> {
|
||||
let Some(domain) = domain else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(config) = config else {
|
||||
bail!("no config");
|
||||
};
|
||||
let probing = join_all(config.hosts.iter().map(|host| async move {
|
||||
let probe_evidence = probe_host(host, domain).await;
|
||||
HostProbeResult {
|
||||
probe_evidence,
|
||||
host_id: host.id.clone(),
|
||||
}
|
||||
}));
|
||||
|
||||
match time::timeout(timeout, probing).await {
|
||||
Ok(responses) => Ok(Some(responses)),
|
||||
Err(_) => {
|
||||
warn!("SNI checks expired for task {job_id}: timeout {task_timeout_ms}ms");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_host(host: &Host, target: &str) -> ProbeEvidence {
|
||||
let timeout = Duration::from_secs(host.timeout_sec as u64);
|
||||
let tcp = match time::timeout(timeout, TcpStream::connect((host.host.as_str(), 443))).await {
|
||||
Ok(Ok(tcp)) => tcp,
|
||||
Ok(Err(_)) | Err(_) => return ProbeEvidence::ConnectionError,
|
||||
};
|
||||
|
||||
let tls_config = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||
.with_no_client_auth();
|
||||
let connector = TlsConnector::from(Arc::new(tls_config));
|
||||
|
||||
let server_name = match ServerName::try_from(target.to_string()) {
|
||||
Ok(server_name) => server_name,
|
||||
Err(_) => return ProbeEvidence::ClientHello,
|
||||
};
|
||||
|
||||
let mut tls = match time::timeout(timeout, connector.connect(server_name, tcp)).await {
|
||||
Ok(Ok(tls)) => tls,
|
||||
Ok(Err(_)) | Err(_) => return ProbeEvidence::ClientHello,
|
||||
};
|
||||
|
||||
let request = format!(
|
||||
"GET /{} HTTP/1.1\r\nHost: {}\r\nUser-Agent: cheburcheck-probe/{}\r\nRange: bytes=0-{}\r\nConnection: close\r\n\r\n",
|
||||
host.file_path.trim_start_matches('/'),
|
||||
target,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
host.min_data.saturating_sub(1)
|
||||
);
|
||||
|
||||
if !matches!(
|
||||
time::timeout(timeout, tls.write_all(request.as_bytes())).await,
|
||||
Ok(Ok(()))
|
||||
) {
|
||||
return ProbeEvidence::ClientHello;
|
||||
}
|
||||
|
||||
let mut received = 0u32;
|
||||
let mut headers_done = false;
|
||||
let mut pending = Vec::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
match time::timeout(timeout, tls.read(&mut buffer)).await {
|
||||
Ok(Ok(0)) | Err(_) => {
|
||||
return if received >= host.min_data {
|
||||
ProbeEvidence::Good
|
||||
} else {
|
||||
ProbeEvidence::DataTimeout { bytes: received }
|
||||
};
|
||||
}
|
||||
Ok(Ok(bytes)) => {
|
||||
add_response_body_bytes(
|
||||
&buffer[..bytes],
|
||||
&mut pending,
|
||||
&mut headers_done,
|
||||
&mut received,
|
||||
);
|
||||
if received >= host.min_data {
|
||||
return ProbeEvidence::Good;
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
return if received >= host.min_data {
|
||||
ProbeEvidence::Good
|
||||
} else {
|
||||
ProbeEvidence::DataTimeout { bytes: received }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_response_body_bytes(
|
||||
chunk: &[u8],
|
||||
pending: &mut Vec<u8>,
|
||||
headers_done: &mut bool,
|
||||
received: &mut u32,
|
||||
) {
|
||||
if *headers_done {
|
||||
*received = received.saturating_add(chunk.len() as u32);
|
||||
return;
|
||||
}
|
||||
|
||||
pending.extend_from_slice(chunk);
|
||||
if let Some(body_start) = pending.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
*headers_done = true;
|
||||
let body_bytes = pending.len().saturating_sub(body_start + 4);
|
||||
*received = received.saturating_add(body_bytes as u32);
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoCertificateVerification;
|
||||
|
||||
impl ServerCertVerifier for NoCertificateVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, TlsError> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
vec![
|
||||
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
SignatureScheme::ED25519,
|
||||
SignatureScheme::RSA_PSS_SHA256,
|
||||
SignatureScheme::RSA_PSS_SHA384,
|
||||
SignatureScheme::RSA_PSS_SHA512,
|
||||
SignatureScheme::RSA_PKCS1_SHA256,
|
||||
SignatureScheme::RSA_PKCS1_SHA384,
|
||||
SignatureScheme::RSA_PKCS1_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use etherparse::{
|
||||
Icmpv4Type, Icmpv6Slice, Icmpv6Type, IpNumber, LaxNetSlice, LaxSlicedPacket, TransportSlice,
|
||||
icmpv4, icmpv6,
|
||||
};
|
||||
use polling::{Event, Events, Poller};
|
||||
use reports::probe::{TcpTracerouteOutcome, TcpTracerouteResult};
|
||||
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
|
||||
use std::io::{self, Read};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const HTTPS_PORT: u16 = 443;
|
||||
const HOP_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
pub async fn tcp_traceroute(
|
||||
target: IpAddr,
|
||||
max_hops: u8,
|
||||
retries: u8,
|
||||
) -> Option<TcpTracerouteResult> {
|
||||
tokio::task::spawn_blocking(move || trace_blocking(target, max_hops, retries))
|
||||
.await
|
||||
.map_err(|error| log::warn!("TCP traceroute task failed for {target}: {error}"))
|
||||
.ok()?
|
||||
.map_err(|error| log::warn!("TCP traceroute failed for {target}: {error}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn trace_blocking(target: IpAddr, max_hops: u8, retries: u8) -> io::Result<TcpTracerouteResult> {
|
||||
let (domain, icmp_protocol) = match target {
|
||||
IpAddr::V4(_) => (Domain::IPV4, Protocol::ICMPV4),
|
||||
IpAddr::V6(_) => (Domain::IPV6, Protocol::ICMPV6),
|
||||
};
|
||||
let receiver = Socket::new(domain, Type::RAW, Some(icmp_protocol))?;
|
||||
let mut last_icmp_hop = None;
|
||||
|
||||
for ttl in 1..=max_hops {
|
||||
let destination = SockAddr::from(SocketAddr::new(target, HTTPS_PORT));
|
||||
let mut tcp_attempts = Vec::with_capacity(retries as usize);
|
||||
let mut last_error = None;
|
||||
for _ in 0..retries {
|
||||
let tcp = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
|
||||
tcp.set_nonblocking(true)?;
|
||||
match target {
|
||||
IpAddr::V4(_) => tcp.set_ttl_v4(ttl as u32)?,
|
||||
IpAddr::V6(_) => tcp.set_unicast_hops_v6(ttl as u32)?,
|
||||
}
|
||||
let unspecified = match target {
|
||||
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
|
||||
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
|
||||
};
|
||||
tcp.bind(&SockAddr::from(unspecified))?;
|
||||
if let Err(error) = tcp.connect(&destination) {
|
||||
if error.kind() == io::ErrorKind::ConnectionRefused {
|
||||
return Ok(TcpTracerouteResult {
|
||||
target,
|
||||
result: TcpTracerouteOutcome::Rst { hop: ttl },
|
||||
});
|
||||
}
|
||||
if !is_connect_in_progress(&error) {
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let source_port = tcp
|
||||
.local_addr()?
|
||||
.as_socket()
|
||||
.map(|addr| addr.port())
|
||||
.unwrap_or(0);
|
||||
tcp_attempts.push((tcp, source_port));
|
||||
}
|
||||
if tcp_attempts.is_empty() {
|
||||
return Err(last_error.unwrap_or_else(|| io::Error::other("no traceroute attempts")));
|
||||
}
|
||||
match wait_for_hop_response(&receiver, &tcp_attempts, target)? {
|
||||
HopResponse::IcmpTimeExceeded => last_icmp_hop = Some(ttl),
|
||||
HopResponse::Rst => {
|
||||
return Ok(TcpTracerouteResult {
|
||||
target,
|
||||
result: TcpTracerouteOutcome::Rst { hop: ttl },
|
||||
});
|
||||
}
|
||||
HopResponse::Connected => {
|
||||
return Ok(TcpTracerouteResult {
|
||||
target,
|
||||
result: TcpTracerouteOutcome::Connected { hop: ttl },
|
||||
});
|
||||
}
|
||||
HopResponse::Timeout => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TcpTracerouteResult {
|
||||
target,
|
||||
result: last_icmp_hop
|
||||
.map(|hop| TcpTracerouteOutcome::IcmpTimeExceeded { hop })
|
||||
.unwrap_or(TcpTracerouteOutcome::Timeout),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_connect_in_progress(error: &io::Error) -> bool {
|
||||
error.kind() == io::ErrorKind::WouldBlock
|
||||
|| error.raw_os_error() == Some(rustix::io::Errno::INPROGRESS.raw_os_error())
|
||||
}
|
||||
|
||||
enum HopResponse {
|
||||
IcmpTimeExceeded,
|
||||
Rst,
|
||||
Connected,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
fn wait_for_hop_response(
|
||||
receiver: &Socket,
|
||||
tcp_attempts: &[(Socket, u16)],
|
||||
target: IpAddr,
|
||||
) -> io::Result<HopResponse> {
|
||||
const ICMP_KEY: usize = 1;
|
||||
|
||||
let poller = Poller::new()?;
|
||||
// SAFETY: all sockets remain alive until polling and cleanup finish.
|
||||
unsafe {
|
||||
poller.add(receiver, Event::readable(ICMP_KEY))?;
|
||||
for (index, (tcp, _)) in tcp_attempts.iter().enumerate() {
|
||||
if let Err(error) = poller.add(tcp, Event::writable(index + 2)) {
|
||||
let _ = poller.delete(receiver);
|
||||
for (added, _) in tcp_attempts.iter().take(index) {
|
||||
let _ = poller.delete(added);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = wait_on_poller(&poller, receiver, tcp_attempts, target);
|
||||
for (tcp, _) in tcp_attempts {
|
||||
let _ = poller.delete(tcp);
|
||||
}
|
||||
let _ = poller.delete(receiver);
|
||||
result
|
||||
}
|
||||
|
||||
fn wait_on_poller(
|
||||
poller: &Poller,
|
||||
receiver: &Socket,
|
||||
tcp_attempts: &[(Socket, u16)],
|
||||
target: IpAddr,
|
||||
) -> io::Result<HopResponse> {
|
||||
const ICMP_KEY: usize = 1;
|
||||
|
||||
let deadline = Instant::now() + HOP_TIMEOUT;
|
||||
let mut watching_tcp = vec![true; tcp_attempts.len()];
|
||||
let mut buffer = [0u8; 2048];
|
||||
let mut events = Events::new();
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Ok(HopResponse::Timeout);
|
||||
}
|
||||
events.clear();
|
||||
match poller.wait(&mut events, Some(remaining)) {
|
||||
Ok(0) => return Ok(HopResponse::Timeout),
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
for event in events.iter().filter(|event| event.key >= 2) {
|
||||
let index = event.key - 2;
|
||||
if !watching_tcp.get(index).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
match tcp_attempts[index].0.take_error()? {
|
||||
Some(error) if error.kind() == io::ErrorKind::ConnectionRefused => {
|
||||
return Ok(HopResponse::Rst);
|
||||
}
|
||||
None => return Ok(HopResponse::Connected),
|
||||
Some(_) => watching_tcp[index] = false,
|
||||
}
|
||||
}
|
||||
|
||||
if events.iter().any(|event| event.key == ICMP_KEY) {
|
||||
let mut raw = receiver;
|
||||
match raw.read(&mut buffer) {
|
||||
Ok(bytes)
|
||||
if tcp_attempts.iter().any(|(_, source_port)| {
|
||||
is_matching_time_exceeded(&buffer[..bytes], target, *source_port)
|
||||
}) =>
|
||||
{
|
||||
return Ok(HopResponse::IcmpTimeExceeded);
|
||||
}
|
||||
Ok(_) => poller.modify(receiver, Event::readable(ICMP_KEY))?,
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
poller.modify(receiver, Event::readable(ICMP_KEY))?;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn is_matching_time_exceeded(packet: &[u8], target: IpAddr, source_port: u16) -> bool {
|
||||
match target {
|
||||
IpAddr::V4(target) => matching_ipv4_time_exceeded(packet, target, source_port),
|
||||
IpAddr::V6(target) => matching_ipv6_time_exceeded(packet, target, source_port),
|
||||
}
|
||||
}
|
||||
|
||||
fn matching_ipv4_time_exceeded(packet: &[u8], target: Ipv4Addr, source_port: u16) -> bool {
|
||||
let Ok(outer) = LaxSlicedPacket::from_ip(packet) else {
|
||||
return false;
|
||||
};
|
||||
let Some(TransportSlice::Icmpv4(icmp)) = outer.transport else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
icmp.icmp_type(),
|
||||
Icmpv4Type::TimeExceeded(icmpv4::TimeExceededCode::TtlExceededInTransit)
|
||||
) && matching_quoted_tcp(icmp.payload(), IpAddr::V4(target), source_port)
|
||||
}
|
||||
|
||||
fn matching_ipv6_time_exceeded(packet: &[u8], target: Ipv6Addr, source_port: u16) -> bool {
|
||||
let icmp = if packet.first().map(|byte| byte >> 4) == Some(6) {
|
||||
let Ok(outer) = LaxSlicedPacket::from_ip(packet) else {
|
||||
return false;
|
||||
};
|
||||
let Some(TransportSlice::Icmpv6(icmp)) = outer.transport else {
|
||||
return false;
|
||||
};
|
||||
icmp
|
||||
} else {
|
||||
let Ok(icmp) = Icmpv6Slice::from_slice(packet) else {
|
||||
return false;
|
||||
};
|
||||
icmp
|
||||
};
|
||||
matches!(
|
||||
icmp.icmp_type(),
|
||||
Icmpv6Type::TimeExceeded(icmpv6::TimeExceededCode::HopLimitExceeded)
|
||||
) && matching_quoted_tcp(icmp.payload(), IpAddr::V6(target), source_port)
|
||||
}
|
||||
|
||||
fn matching_quoted_tcp(packet: &[u8], target: IpAddr, source_port: u16) -> bool {
|
||||
let Ok(quoted) = LaxSlicedPacket::from_ip(packet) else {
|
||||
return false;
|
||||
};
|
||||
let (ip_number, payload) = match (quoted.net, target) {
|
||||
(Some(LaxNetSlice::Ipv4(ip)), IpAddr::V4(target))
|
||||
if ip.header().destination_addr() == target =>
|
||||
{
|
||||
(ip.payload().ip_number, ip.payload().payload)
|
||||
}
|
||||
(Some(LaxNetSlice::Ipv6(ip)), IpAddr::V6(target))
|
||||
if ip.header().destination_addr() == target =>
|
||||
{
|
||||
(ip.payload().ip_number, ip.payload().payload)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
if ip_number != IpNumber::TCP {
|
||||
return false;
|
||||
}
|
||||
let Some(ports) = payload.get(..4) else {
|
||||
return false;
|
||||
};
|
||||
u16::from_be_bytes([ports[0], ports[1]]) == source_port
|
||||
&& u16::from_be_bytes([ports[2], ports[3]]) == HTTPS_PORT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_ipv4_time_exceeded_for_tcp_flow() {
|
||||
let target = Ipv4Addr::new(203, 0, 113, 10);
|
||||
let mut packet = vec![0; 20 + 8 + 20 + 8];
|
||||
packet[0] = 0x45;
|
||||
packet[2..4].copy_from_slice(&56u16.to_be_bytes());
|
||||
packet[9] = IpNumber::ICMP.0;
|
||||
packet[20] = 11;
|
||||
packet[28] = 0x45;
|
||||
packet[30..32].copy_from_slice(&28u16.to_be_bytes());
|
||||
packet[37] = 6;
|
||||
packet[44..48].copy_from_slice(&target.octets());
|
||||
packet[48..50].copy_from_slice(&42_000u16.to_be_bytes());
|
||||
packet[50..52].copy_from_slice(&HTTPS_PORT.to_be_bytes());
|
||||
|
||||
assert!(matching_ipv4_time_exceeded(&packet, target, 42_000));
|
||||
assert!(!matching_ipv4_time_exceeded(&packet, target, 42_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_ipv6_time_exceeded_for_tcp_flow() {
|
||||
let target = "2001:db8::10".parse::<Ipv6Addr>().unwrap();
|
||||
let mut packet = vec![0; 8 + 40 + 8];
|
||||
packet[0] = 3;
|
||||
packet[8] = 0x60;
|
||||
packet[14] = 6;
|
||||
packet[32..48].copy_from_slice(&target.octets());
|
||||
packet[48..50].copy_from_slice(&42_000u16.to_be_bytes());
|
||||
packet[50..52].copy_from_slice(&HTTPS_PORT.to_be_bytes());
|
||||
|
||||
assert!(matching_ipv6_time_exceeded(&packet, target, 42_000));
|
||||
assert!(!matching_ipv6_time_exceeded(&packet, target, 42_001));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,36 @@ impl From<&str> for Target {
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub fn is_bogon(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
let [a, b, c, d] = ip.octets();
|
||||
a == 0
|
||||
|| a == 10
|
||||
|| a == 127
|
||||
|| (a == 100 && (64..=127).contains(&b))
|
||||
|| (a == 169 && b == 254)
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 0 && c == 0 && d != 9 && d != 10)
|
||||
|| (a == 192 && b == 0 && c == 2)
|
||||
|| (a == 192 && b == 88 && c == 99)
|
||||
|| (a == 192 && b == 168)
|
||||
|| (a == 198 && (b == 18 || b == 19))
|
||||
|| (a == 198 && b == 51 && c == 100)
|
||||
|| (a == 203 && b == 0 && c == 113)
|
||||
|| a >= 224
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
let segments = ip.segments();
|
||||
let first = segments[0];
|
||||
first & 0xe000 != 0x2000
|
||||
|| (first == 0x2001 && segments[1] < 0x0200)
|
||||
|| (first == 0x2001 && segments[1] == 0x0db8)
|
||||
|| (first == 0x3fff && segments[1] < 0x1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn readable_type(&self) -> &'static str {
|
||||
match self {
|
||||
Target::Domain(_) => "Домен",
|
||||
@@ -155,3 +185,34 @@ fn format_large_number(n: u128) -> String {
|
||||
format!("{:.1}Z", n as f64 / 1_000_000_000_000_000_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identifies_bogon_addresses() {
|
||||
for address in [
|
||||
"10.0.0.1",
|
||||
"100.64.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.1.1",
|
||||
"192.0.2.1",
|
||||
"198.18.0.1",
|
||||
"224.0.0.1",
|
||||
"::1",
|
||||
"2001:db8::1",
|
||||
"fc00::1",
|
||||
"fe80::1",
|
||||
] {
|
||||
assert!(Target::is_bogon(address.parse().unwrap()), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_public_addresses() {
|
||||
for address in ["1.1.1.1", "8.8.8.8", "2001:4860:4860::8888"] {
|
||||
assert!(!Target::is_bogon(address.parse().unwrap()), "{address}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+92
-1
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeStatus<'a> {
|
||||
@@ -13,6 +14,22 @@ pub struct ProbeConfig {
|
||||
pub task_timeout_ms: u64,
|
||||
pub published_at: String,
|
||||
pub hosts: Vec<Host>,
|
||||
#[serde(default)]
|
||||
pub traceroute_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub control_hosts: Vec<String>,
|
||||
#[serde(default = "default_dns_samples_per_protocol")]
|
||||
pub dns_samples_per_protocol: u8,
|
||||
#[serde(default = "default_dns_spoofing_provider_threshold")]
|
||||
pub dns_spoofing_provider_threshold: u8,
|
||||
}
|
||||
|
||||
pub const fn default_dns_samples_per_protocol() -> u8 {
|
||||
3
|
||||
}
|
||||
|
||||
pub const fn default_dns_spoofing_provider_threshold() -> u8 {
|
||||
2
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -35,7 +52,8 @@ pub enum HostType {
|
||||
pub struct ProbeTask<'a> {
|
||||
pub id: String,
|
||||
pub query_id: String,
|
||||
pub target: &'a str,
|
||||
pub domain: Option<&'a str>,
|
||||
pub ip: IpAddr,
|
||||
pub created_at: String,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
@@ -45,6 +63,79 @@ pub struct ProbeResultEvent {
|
||||
pub job_id: String,
|
||||
pub probe_id: String,
|
||||
pub host_results: Vec<HostProbeResult>,
|
||||
pub target_traceroute: Option<TcpTracerouteResult>,
|
||||
pub control_traceroute: Option<TcpTracerouteResult>,
|
||||
pub dns: Option<DnsProbeResult>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeResult {
|
||||
pub responses: Option<Vec<HostProbeResult>>,
|
||||
pub target_traceroute: Option<TcpTracerouteResult>,
|
||||
pub control_traceroute: Option<TcpTracerouteResult>,
|
||||
#[serde(default)]
|
||||
pub dns: Option<DnsProbeResult>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct DnsProbeResult {
|
||||
pub spoofing_detected: bool,
|
||||
#[serde(default)]
|
||||
pub suspicious_provider_count: u8,
|
||||
#[serde(default)]
|
||||
pub verdict_threshold: u8,
|
||||
#[serde(default)]
|
||||
pub samples_per_protocol: u8,
|
||||
pub observations: Vec<DnsObservation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct DnsObservation {
|
||||
pub provider: String,
|
||||
pub protocol: DnsProtocol,
|
||||
pub outcome: DnsOutcome,
|
||||
#[serde(default)]
|
||||
pub suspected_spoofing: bool,
|
||||
#[serde(default)]
|
||||
pub metadata: DnsResponseMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DnsResponseMetadata {
|
||||
pub response_codes: Vec<String>,
|
||||
pub ipv4_count: u16,
|
||||
pub ipv6_count: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum DnsProtocol {
|
||||
Udp,
|
||||
Tcp,
|
||||
Doh,
|
||||
Dot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum DnsOutcome {
|
||||
Answer { addresses: Vec<IpAddr> },
|
||||
NoRecords,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct TcpTracerouteResult {
|
||||
pub target: IpAddr,
|
||||
pub result: TcpTracerouteOutcome,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum TcpTracerouteOutcome {
|
||||
Rst { hop: u8 },
|
||||
Connected { hop: u8 },
|
||||
IcmpTimeExceeded { hop: u8 },
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "website"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE probe_reports
|
||||
ADD COLUMN target_hop_count SMALLINT,
|
||||
ADD COLUMN target_trace_result VARCHAR(32),
|
||||
ADD COLUMN control_hop_count SMALLINT,
|
||||
ADD COLUMN control_trace_result VARCHAR(32);
|
||||
@@ -1,5 +1,8 @@
|
||||
timeout_sec = 3
|
||||
min_data = 65536
|
||||
dns_samples_per_protocol = 3
|
||||
dns_spoofing_provider_threshold = 2
|
||||
control_hosts = ["kinopoisk.ru", "wildberries.ru", "yandex.ru", "mail.ru"]
|
||||
|
||||
[[hosts]]
|
||||
id = "hil-hetzner" # hil-speed.hetzner.com
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@ mod status;
|
||||
pub use check_endpoint::check;
|
||||
pub use feedback_endpoint::feedback;
|
||||
pub use probe::probe_query;
|
||||
pub use rate_limit::build_rate_limiter;
|
||||
pub use rate_limit::{build_probe_rate_limiter, build_rate_limiter};
|
||||
pub use status::{get_system_status, healthcheck};
|
||||
|
||||
@@ -45,7 +45,7 @@ pub async fn check(
|
||||
pool: &State<PgPool>,
|
||||
limiter: &State<Arc<ApiRateLimiter>>,
|
||||
) -> Result<Json<ApiCheckResponse>, Status> {
|
||||
if limiter.check_key(&addr.ip).is_err() {
|
||||
if !limiter.check(&addr.ip) {
|
||||
return Err(Status::TooManyRequests);
|
||||
}
|
||||
|
||||
|
||||
+218
-21
@@ -1,9 +1,10 @@
|
||||
use super::rate_limit::ApiRateLimiter;
|
||||
use super::rate_limit::ProbeRateLimiter;
|
||||
use crate::mqtt::{MqttPublisher, PublishError};
|
||||
use log::warn;
|
||||
use querying::target::Target;
|
||||
use reports::probe::{
|
||||
Host, HostProbeResult, HostType, ProbeConfig, ProbeEvidence, ProbeResultEvent,
|
||||
TcpTracerouteOutcome, TcpTracerouteResult,
|
||||
};
|
||||
use rocket::State;
|
||||
use rocket::http::Status;
|
||||
@@ -15,6 +16,7 @@ use rocket_client_addr::ClientRealAddr;
|
||||
use sqlx::postgres::PgPool;
|
||||
use sqlx::types::Uuid;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -30,30 +32,47 @@ pub async fn probe_query(
|
||||
addr: &ClientRealAddr,
|
||||
pool: &State<PgPool>,
|
||||
mqtt: &State<MqttPublisher>,
|
||||
limiter: &State<Arc<ApiRateLimiter>>,
|
||||
limiter: &State<Arc<ProbeRateLimiter>>,
|
||||
) -> Result<EventStream![Event], Status> {
|
||||
if limiter.check_key(&addr.ip).is_err() {
|
||||
if !limiter.check(&addr.ip) {
|
||||
return Err(Status::TooManyRequests);
|
||||
}
|
||||
|
||||
let id = Uuid::try_parse(id).map_err(|_| Status::BadRequest)?;
|
||||
let query: Option<String> = sqlx::query_scalar("SELECT query FROM queries WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&**pool)
|
||||
.await
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
let query: Option<(String, Vec<String>)> =
|
||||
sqlx::query_as("SELECT query, resolved_ips FROM queries WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&**pool)
|
||||
.await
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
|
||||
let query = query.ok_or(Status::NotFound)?;
|
||||
let Target::Domain(domain) = Target::from(query.trim()) else {
|
||||
return Err(Status::BadRequest);
|
||||
let (query, resolved_ips) = query.ok_or(Status::NotFound)?;
|
||||
let target = Target::from(query.trim());
|
||||
let domain = match &target {
|
||||
Target::Domain(domain) => Some(domain.as_str()),
|
||||
Target::Ipv4(_) | Target::Ipv6(_) => None,
|
||||
Target::Ipv4Subnet(_) | Target::Ipv6Subnet(_) | Target::Asn(_) => {
|
||||
return Err(Status::BadRequest);
|
||||
}
|
||||
};
|
||||
let probe_config = mqtt.probe_config();
|
||||
if domain.is_none() && !probe_config.traceroute_enabled {
|
||||
return Err(Status::BadRequest);
|
||||
}
|
||||
let ip = resolved_ips
|
||||
.first()
|
||||
.and_then(|ip| ip.parse::<IpAddr>().ok())
|
||||
.ok_or(Status::BadRequest)?;
|
||||
if Target::is_bogon(ip) {
|
||||
return Err(Status::Forbidden);
|
||||
}
|
||||
|
||||
let mut results = mqtt.subscribe_probe_results(id).await.map_err(|error| {
|
||||
warn!("api: failed to subscribe to probe results for {id}: {error}");
|
||||
publish_error_status(error)
|
||||
})?;
|
||||
|
||||
mqtt.publish_probe_task(id, &domain)
|
||||
mqtt.publish_probe_task(id, domain, ip)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!("api: failed to publish probe task for {id}: {error}");
|
||||
@@ -62,7 +81,6 @@ pub async fn probe_query(
|
||||
|
||||
let timeout = mqtt.task_timeout();
|
||||
let online_probes = mqtt.online_probe_count().await;
|
||||
let probe_config = mqtt.probe_config();
|
||||
let pool = pool.inner().clone();
|
||||
let query_id = id;
|
||||
let id = id.to_string();
|
||||
@@ -73,7 +91,7 @@ pub async fn probe_query(
|
||||
|
||||
yield Event::data(json!({
|
||||
"id": id,
|
||||
"target": domain,
|
||||
"target": query,
|
||||
"online_probes": online_probes,
|
||||
}).to_string()).event("started");
|
||||
|
||||
@@ -88,6 +106,8 @@ pub async fn probe_query(
|
||||
match result {
|
||||
Ok(result) => {
|
||||
responded_probes.insert(result.probe_id.clone());
|
||||
let target_traceroute = result.target_traceroute.clone();
|
||||
let control_traceroute = result.control_traceroute.clone();
|
||||
let reporter_info = match fetch_probe_reporter_info(&result.probe_id, &pool).await {
|
||||
Ok(info) => info,
|
||||
Err(error) => {
|
||||
@@ -99,7 +119,13 @@ pub async fn probe_query(
|
||||
}
|
||||
};
|
||||
let response = build_probe_response(result, &probe_config, reporter_info);
|
||||
if let Err(error) = insert_probe_report(query_id, &response, &pool).await {
|
||||
if let Err(error) = insert_probe_report(
|
||||
query_id,
|
||||
&response,
|
||||
target_traceroute.as_ref(),
|
||||
control_traceroute.as_ref(),
|
||||
&pool,
|
||||
).await {
|
||||
warn!("api: failed to save probe report for query {id}: {error}");
|
||||
}
|
||||
yield Event::data(response.to_string()).event("result");
|
||||
@@ -127,7 +153,22 @@ pub fn build_probe_response(
|
||||
reporter_info: Option<ProbeReporterInfo>,
|
||||
) -> Value {
|
||||
let hosts: HashMap<&String, &Host> = config.hosts.iter().map(|h| (&h.id, h)).collect();
|
||||
let verdict = build_probe_verdict(&raw.host_results, config);
|
||||
let verdict = build_probe_verdict(
|
||||
&raw.host_results,
|
||||
config,
|
||||
raw.target_traceroute.as_ref(),
|
||||
raw.control_traceroute.as_ref(),
|
||||
raw.dns.as_ref(),
|
||||
);
|
||||
let target_hop =
|
||||
raw.target_traceroute
|
||||
.as_ref()
|
||||
.and_then(|traceroute| match &traceroute.result {
|
||||
TcpTracerouteOutcome::Rst { hop }
|
||||
| TcpTracerouteOutcome::Connected { hop }
|
||||
| TcpTracerouteOutcome::IcmpTimeExceeded { hop } => Some(*hop),
|
||||
TcpTracerouteOutcome::Timeout => None,
|
||||
});
|
||||
let region = reporter_info.as_ref().and_then(|info| info.region.as_ref());
|
||||
let provider = reporter_info
|
||||
.as_ref()
|
||||
@@ -155,12 +196,16 @@ pub fn build_probe_response(
|
||||
"asn": asn,
|
||||
"verdict": verdict,
|
||||
"host_results": host_results,
|
||||
"target_hop": target_hop,
|
||||
"dns": raw.dns,
|
||||
})
|
||||
}
|
||||
|
||||
async fn insert_probe_report(
|
||||
query_id: Uuid,
|
||||
response: &Value,
|
||||
target_traceroute: Option<&TcpTracerouteResult>,
|
||||
control_traceroute: Option<&TcpTracerouteResult>,
|
||||
pool: &PgPool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let probe_id = response
|
||||
@@ -171,6 +216,8 @@ async fn insert_probe_report(
|
||||
.get("verdict")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("uncertain");
|
||||
let (target_hop_count, target_trace_result) = traceroute_columns(target_traceroute);
|
||||
let (control_hop_count, control_trace_result) = traceroute_columns(control_traceroute);
|
||||
|
||||
let Some(probe_id) = probe_id else {
|
||||
warn!("api: ignoring probe report with non-numeric probe_id");
|
||||
@@ -179,25 +226,57 @@ async fn insert_probe_report(
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO probe_reports (query_id, probe_id, verdict, result)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO probe_reports (
|
||||
query_id,
|
||||
probe_id,
|
||||
verdict,
|
||||
result,
|
||||
target_hop_count,
|
||||
target_trace_result,
|
||||
control_hop_count,
|
||||
control_trace_result
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (query_id, probe_id)
|
||||
DO UPDATE SET
|
||||
date = NOW(),
|
||||
verdict = EXCLUDED.verdict,
|
||||
result = EXCLUDED.result
|
||||
result = EXCLUDED.result,
|
||||
target_hop_count = EXCLUDED.target_hop_count,
|
||||
target_trace_result = EXCLUDED.target_trace_result,
|
||||
control_hop_count = EXCLUDED.control_hop_count,
|
||||
control_trace_result = EXCLUDED.control_trace_result
|
||||
"#,
|
||||
)
|
||||
.bind(query_id)
|
||||
.bind(probe_id)
|
||||
.bind(verdict)
|
||||
.bind(response)
|
||||
.bind(target_hop_count)
|
||||
.bind(target_trace_result)
|
||||
.bind(control_hop_count)
|
||||
.bind(control_trace_result)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn traceroute_columns(
|
||||
traceroute: Option<&TcpTracerouteResult>,
|
||||
) -> (Option<i16>, Option<&'static str>) {
|
||||
let Some(traceroute) = traceroute else {
|
||||
return (None, None);
|
||||
};
|
||||
let (hop, result) = match &traceroute.result {
|
||||
TcpTracerouteOutcome::Rst { hop } => (Some(*hop), "Rst"),
|
||||
TcpTracerouteOutcome::Connected { hop } => (Some(*hop), "Connected"),
|
||||
TcpTracerouteOutcome::IcmpTimeExceeded { hop } => (Some(*hop), "IcmpTimeExceeded"),
|
||||
TcpTracerouteOutcome::Timeout => (None, "Timeout"),
|
||||
};
|
||||
(hop.map(i16::from), Some(result))
|
||||
}
|
||||
|
||||
async fn fetch_probe_reporter_info(
|
||||
probe_id: &str,
|
||||
pool: &PgPool,
|
||||
@@ -210,7 +289,33 @@ async fn fetch_probe_reporter_info(
|
||||
.await
|
||||
}
|
||||
|
||||
fn build_probe_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'static str {
|
||||
fn build_probe_verdict(
|
||||
results: &[HostProbeResult],
|
||||
config: &ProbeConfig,
|
||||
target_traceroute: Option<&TcpTracerouteResult>,
|
||||
control_traceroute: Option<&TcpTracerouteResult>,
|
||||
dns: Option<&reports::probe::DnsProbeResult>,
|
||||
) -> &'static str {
|
||||
let dns_spoofing = dns.is_some_and(|result| result.spoofing_detected);
|
||||
if let (
|
||||
Some(TcpTracerouteResult {
|
||||
result: TcpTracerouteOutcome::IcmpTimeExceeded { hop: target_hop },
|
||||
..
|
||||
}),
|
||||
Some(TcpTracerouteResult {
|
||||
result: TcpTracerouteOutcome::IcmpTimeExceeded { hop: control_hop },
|
||||
..
|
||||
}),
|
||||
) = (target_traceroute, control_traceroute)
|
||||
&& target_hop < control_hop
|
||||
{
|
||||
return "tspu_block";
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return if dns_spoofing { "dns_spoofing" } else { "ok" };
|
||||
}
|
||||
|
||||
let matched = results
|
||||
.iter()
|
||||
.filter_map(|result| {
|
||||
@@ -223,7 +328,11 @@ fn build_probe_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'s
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if matched.is_empty() {
|
||||
return "uncertain";
|
||||
return if dns_spoofing {
|
||||
"dns_spoofing"
|
||||
} else {
|
||||
"uncertain"
|
||||
};
|
||||
}
|
||||
|
||||
if is_strict_majority(
|
||||
@@ -236,6 +345,10 @@ fn build_probe_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'s
|
||||
return "sni_block";
|
||||
}
|
||||
|
||||
if dns_spoofing {
|
||||
return "dns_spoofing";
|
||||
}
|
||||
|
||||
if is_strict_majority(
|
||||
matched.len(),
|
||||
matched
|
||||
@@ -306,3 +419,87 @@ fn done_event(id: &str, response_count: usize, online_probes: usize) -> Event {
|
||||
fn is_strict_majority(total: usize, count: usize) -> bool {
|
||||
count > total / 2
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn icmp_trace(hop: u8) -> TcpTracerouteResult {
|
||||
TcpTracerouteResult {
|
||||
target: "192.0.2.1".parse().unwrap(),
|
||||
result: TcpTracerouteOutcome::IcmpTimeExceeded { hop },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tspu_block_requires_an_earlier_target_icmp_hop() {
|
||||
let target = icmp_trace(3);
|
||||
let control = icmp_trace(5);
|
||||
assert_eq!(
|
||||
build_probe_verdict(&[], &empty_config(), Some(&target), Some(&control), None),
|
||||
"tspu_block"
|
||||
);
|
||||
|
||||
let target = icmp_trace(5);
|
||||
assert_eq!(
|
||||
build_probe_verdict(&[], &empty_config(), Some(&target), Some(&control), None),
|
||||
"ok"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tspu_block_requires_two_icmp_outcomes() {
|
||||
let target = TcpTracerouteResult {
|
||||
target: "192.0.2.1".parse().unwrap(),
|
||||
result: TcpTracerouteOutcome::Connected { hop: 2 },
|
||||
};
|
||||
let control = icmp_trace(5);
|
||||
assert_eq!(
|
||||
build_probe_verdict(&[], &empty_config(), Some(&target), Some(&control), None),
|
||||
"ok"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sni_block_takes_priority_over_dns_spoofing() {
|
||||
let mut config = empty_config();
|
||||
config.hosts.push(Host {
|
||||
id: "test".to_string(),
|
||||
host: "192.0.2.1".to_string(),
|
||||
host_type: HostType::Blacklist,
|
||||
file_path: String::new(),
|
||||
timeout_sec: 1,
|
||||
min_data: 1,
|
||||
});
|
||||
let results = vec![HostProbeResult {
|
||||
host_id: "test".to_string(),
|
||||
probe_evidence: ProbeEvidence::ClientHello,
|
||||
}];
|
||||
let dns = reports::probe::DnsProbeResult {
|
||||
spoofing_detected: true,
|
||||
suspicious_provider_count: 2,
|
||||
verdict_threshold: 2,
|
||||
samples_per_protocol: 3,
|
||||
observations: vec![],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
build_probe_verdict(&results, &config, None, None, Some(&dns)),
|
||||
"sni_block"
|
||||
);
|
||||
}
|
||||
|
||||
fn empty_config() -> ProbeConfig {
|
||||
ProbeConfig {
|
||||
version: String::new(),
|
||||
task_timeout_ms: 0,
|
||||
published_at: String::new(),
|
||||
hosts: vec![],
|
||||
traceroute_enabled: true,
|
||||
control_hosts: vec![],
|
||||
dns_samples_per_protocol: reports::probe::default_dns_samples_per_protocol(),
|
||||
dns_spoofing_provider_threshold:
|
||||
reports::probe::default_dns_spoofing_provider_threshold(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,33 @@ use governor::{Quota, RateLimiter};
|
||||
use std::net::IpAddr;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
pub type ApiRateLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
|
||||
type KeyedRateLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
|
||||
|
||||
pub struct ApiRateLimiter(KeyedRateLimiter);
|
||||
|
||||
pub struct ProbeRateLimiter(KeyedRateLimiter);
|
||||
|
||||
impl ApiRateLimiter {
|
||||
pub fn check(&self, ip: &IpAddr) -> bool {
|
||||
self.0.check_key(ip).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProbeRateLimiter {
|
||||
pub fn check(&self, ip: &IpAddr) -> bool {
|
||||
self.0.check_key(ip).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_rate_limiter(per_minute: u32) -> ApiRateLimiter {
|
||||
ApiRateLimiter(build_limiter(per_minute))
|
||||
}
|
||||
|
||||
pub fn build_probe_rate_limiter(per_minute: u32) -> ProbeRateLimiter {
|
||||
ProbeRateLimiter(build_limiter(per_minute))
|
||||
}
|
||||
|
||||
fn build_limiter(per_minute: u32) -> KeyedRateLimiter {
|
||||
RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(per_minute).expect("rate limit must be > 0"),
|
||||
))
|
||||
|
||||
@@ -58,7 +58,12 @@ async fn rocket() -> _ {
|
||||
.unwrap_or("30".to_string())
|
||||
.parse()
|
||||
.unwrap_or(30);
|
||||
let probe_rate_limit_rpm: u32 = std::env::var("PROBE_RATE_LIMIT_RPM")
|
||||
.unwrap_or("5".to_string())
|
||||
.parse()
|
||||
.unwrap_or(5);
|
||||
let api_limiter = std::sync::Arc::new(api::build_rate_limiter(rate_limit_rpm));
|
||||
let probe_limiter = std::sync::Arc::new(api::build_probe_rate_limiter(probe_rate_limit_rpm));
|
||||
let mqtt_publisher = mqtt::MqttPublisher::start_from_env();
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
@@ -84,6 +89,7 @@ async fn rocket() -> _ {
|
||||
.manage(checker)
|
||||
.manage(pool)
|
||||
.manage(api_limiter)
|
||||
.manage(probe_limiter)
|
||||
.manage(mqtt_publisher)
|
||||
.attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations))
|
||||
.mount(
|
||||
|
||||
+47
-10
@@ -1,6 +1,6 @@
|
||||
use log::{info, warn};
|
||||
use reports::probe::HostType;
|
||||
use reports::probe::{Host, ProbeConfig, ProbeResultEvent, ProbeStatus, ProbeTask};
|
||||
use reports::probe::{Host, ProbeConfig, ProbeResult, ProbeResultEvent, ProbeStatus, ProbeTask};
|
||||
use rocket::serde::json::serde_json;
|
||||
use rumqttc::{AsyncClient, Event as MqttEvent, Incoming, MqttOptions, QoS};
|
||||
use serde::Deserialize;
|
||||
@@ -8,6 +8,7 @@ use sqlx::types::Uuid;
|
||||
use sqlx::types::chrono::Utc;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -60,6 +61,12 @@ pub type ProbeResultReceiver = rocket::tokio::sync::broadcast::Receiver<ProbeRes
|
||||
struct ProbeHostsFile {
|
||||
timeout_sec: u32,
|
||||
min_data: u32,
|
||||
#[serde(default = "reports::probe::default_dns_samples_per_protocol")]
|
||||
dns_samples_per_protocol: u8,
|
||||
#[serde(default = "reports::probe::default_dns_spoofing_provider_threshold")]
|
||||
dns_spoofing_provider_threshold: u8,
|
||||
#[serde(default)]
|
||||
control_hosts: Vec<String>,
|
||||
hosts: Vec<ProbeHostEntry>,
|
||||
}
|
||||
|
||||
@@ -85,6 +92,11 @@ impl MqttPublisher {
|
||||
task_timeout_ms,
|
||||
published_at: Utc::now().to_rfc3339(),
|
||||
hosts: Vec::new(),
|
||||
traceroute_enabled: false,
|
||||
control_hosts: Vec::new(),
|
||||
dns_samples_per_protocol: reports::probe::default_dns_samples_per_protocol(),
|
||||
dns_spoofing_provider_threshold:
|
||||
reports::probe::default_dns_spoofing_provider_threshold(),
|
||||
}
|
||||
}));
|
||||
let admin_token = match std::env::var("MQTT_ADMIN_TOKEN") {
|
||||
@@ -216,14 +228,16 @@ impl MqttPublisher {
|
||||
pub async fn publish_probe_task(
|
||||
&self,
|
||||
query_id: Uuid,
|
||||
domain: &str,
|
||||
domain: Option<&str>,
|
||||
ip: IpAddr,
|
||||
) -> Result<(), PublishError> {
|
||||
let client = self.client.as_ref().ok_or(PublishError::NotConfigured)?;
|
||||
let query_id = query_id.to_string();
|
||||
let task = ProbeTask {
|
||||
id: query_id.clone(),
|
||||
query_id: query_id.clone(),
|
||||
target: domain,
|
||||
domain,
|
||||
ip,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
timeout_ms: self.task_timeout_ms,
|
||||
};
|
||||
@@ -250,7 +264,7 @@ async fn publish_probe_config(
|
||||
}
|
||||
|
||||
fn load_probe_config(task_timeout_ms: u64) -> Result<ProbeConfig, PublishError> {
|
||||
let hosts = if let Some(path) = std::env::var_os("PROBE_CONFIG_PATH") {
|
||||
let config = if let Some(path) = std::env::var_os("PROBE_CONFIG_PATH") {
|
||||
let contents = std::fs::read_to_string(path).map_err(PublishError::Config)?;
|
||||
parse_probe_hosts(&contents)?
|
||||
} else {
|
||||
@@ -260,14 +274,27 @@ fn load_probe_config(task_timeout_ms: u64) -> Result<ProbeConfig, PublishError>
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
task_timeout_ms,
|
||||
published_at: Utc::now().to_rfc3339(),
|
||||
hosts,
|
||||
hosts: config.hosts,
|
||||
traceroute_enabled: std::env::var("PROBE_TRACEROUTE_ENABLED")
|
||||
.ok()
|
||||
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")),
|
||||
control_hosts: config.control_hosts,
|
||||
dns_samples_per_protocol: config.dns_samples_per_protocol,
|
||||
dns_spoofing_provider_threshold: config.dns_spoofing_provider_threshold,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_probe_hosts(contents: &str) -> Result<Vec<Host>, PublishError> {
|
||||
struct ParsedProbeConfig {
|
||||
hosts: Vec<Host>,
|
||||
control_hosts: Vec<String>,
|
||||
dns_samples_per_protocol: u8,
|
||||
dns_spoofing_provider_threshold: u8,
|
||||
}
|
||||
|
||||
fn parse_probe_hosts(contents: &str) -> Result<ParsedProbeConfig, PublishError> {
|
||||
let config: ProbeHostsFile = toml::from_str(&contents).map_err(PublishError::ConfigParse)?;
|
||||
|
||||
Ok(config
|
||||
let hosts = config
|
||||
.hosts
|
||||
.into_iter()
|
||||
.map(|host| Host {
|
||||
@@ -278,7 +305,14 @@ fn parse_probe_hosts(contents: &str) -> Result<Vec<Host>, PublishError> {
|
||||
timeout_sec: host.timeout_sec.unwrap_or(config.timeout_sec),
|
||||
min_data: host.min_data.unwrap_or(config.min_data),
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
|
||||
Ok(ParsedProbeConfig {
|
||||
hosts,
|
||||
control_hosts: config.control_hosts,
|
||||
dns_samples_per_protocol: config.dns_samples_per_protocol,
|
||||
dns_spoofing_provider_threshold: config.dns_spoofing_provider_threshold,
|
||||
})
|
||||
}
|
||||
|
||||
async fn dispatch_probe_status(
|
||||
@@ -320,7 +354,7 @@ async fn dispatch_probe_result(
|
||||
return;
|
||||
};
|
||||
|
||||
let result = match serde_json::from_slice(payload) {
|
||||
let result: ProbeResult = match serde_json::from_slice(payload) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
warn!("ignoring invalid probe result JSON on {topic}: {error}");
|
||||
@@ -333,7 +367,10 @@ async fn dispatch_probe_result(
|
||||
let _ = sender.send(ProbeResultEvent {
|
||||
job_id: job_id.to_string(),
|
||||
probe_id: probe_id.to_string(),
|
||||
host_results: result,
|
||||
host_results: result.responses.unwrap_or_default(),
|
||||
target_traceroute: result.target_traceroute,
|
||||
control_traceroute: result.control_traceroute,
|
||||
dns: result.dns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ pub async fn auth(
|
||||
pool: &rocket::State<PgPool>,
|
||||
) -> Json<MqttAuthResponse> {
|
||||
let request = request.into_inner();
|
||||
let _ = (request.clientid, request.protocol);
|
||||
let _ = request.protocol;
|
||||
|
||||
if request.username == "admin"
|
||||
&& std::env::var("MQTT_ADMIN_TOKEN")
|
||||
@@ -66,20 +66,19 @@ pub async fn auth(
|
||||
return Json(MqttAuthResponse::allow_superuser());
|
||||
}
|
||||
|
||||
if request.username != "probe" || request.password.is_empty() {
|
||||
if request.username != "probe" || request.clientid.is_empty() || request.password.is_empty() {
|
||||
return Json(MqttAuthResponse::deny());
|
||||
}
|
||||
|
||||
let token_exists =
|
||||
let reporter_id =
|
||||
sqlx::query_scalar::<_, i32>("SELECT id FROM reporters WHERE token = $1 LIMIT 1")
|
||||
.bind(request.password)
|
||||
.fetch_optional(&**pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
.flatten();
|
||||
|
||||
if token_exists {
|
||||
if reporter_id.is_some_and(|id| id.to_string() == request.clientid) {
|
||||
Json(MqttAuthResponse::allow())
|
||||
} else {
|
||||
Json(MqttAuthResponse::deny())
|
||||
|
||||
@@ -8,7 +8,7 @@ use rocket_cache_response::CacheResponse;
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::io;
|
||||
|
||||
enum ExportType {
|
||||
pub(crate) enum ExportType {
|
||||
Full,
|
||||
Domains,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user