feat: dynamic probing (#71)

This commit is contained in:
LowderPlay
2026-06-06 03:03:48 +05:00
committed by GitHub
parent 7d11e14804
commit f828302ea6
60 changed files with 3761 additions and 641 deletions
+76 -75
View File
@@ -1,80 +1,81 @@
name: Build Rust Application
name: Build Rust Application
on:
push:
branches: [ master ]
tags: [ 'v*.*.*' ]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
linux-amd64:
name: Linux amd64 (.deb)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build Debian package
run: cargo deb -- --bin cheburchecker
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: linux-amd64-deb
path: target/debian/*.deb
compression-level: 0
linux-arm64:
name: Linux arm64 (.deb via cross)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build binary
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: "aarch64-unknown-linux-gnu"
args: "--release --bin cheburchecker"
strip: true
- name: Create Debian archive
run: cargo deb --target aarch64-unknown-linux-gnu --no-strip --no-build -- --bin cheburchecker
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: linux-arm64-deb
path: target/aarch64-unknown-linux-gnu/debian/*.deb
windows:
name: Windows amd64
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Build release binary
run: cargo build --release --bin cheburchecker
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: windows-amd64-exe
path: target/release/*.exe
env:
CARGO_TERM_COLOR: always
jobs:
linux-amd64:
name: Linux amd64 (.deb)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build Debian package
run: cargo deb -p reporter -- --bin cheburchecker
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: linux-amd64-deb
path: target/debian/*.deb
compression-level: 0
linux-arm64:
name: Linux arm64 (.deb via cross)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build binary
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: "aarch64-unknown-linux-gnu"
args: "--release --bin cheburchecker"
strip: true
- name: Create Debian archive
run: cargo deb --target aarch64-unknown-linux-gnu --no-strip --no-build -p reporter -- --bin cheburchecker
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: linux-arm64-deb
path: target/aarch64-unknown-linux-gnu/debian/*.deb
windows:
name: Windows amd64
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Build release binary
run: cargo build --release --bin cheburchecker
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: windows-amd64-exe
path: target/release/*.exe
+183
View File
@@ -0,0 +1,183 @@
name: Build Probe
on:
push:
branches: [ master ]
tags: [ 'v*.*.*' ]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-probe
jobs:
linux-amd64:
name: Linux amd64 binary and .deb
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build release binary
run: cargo build --release --package probe --bin cheburprobe
- name: Build Debian package
run: cargo deb --package probe --no-build -- --bin cheburprobe
- name: Upload Linux amd64 binary
uses: actions/upload-artifact@v7
with:
name: cheburprobe-linux-amd64
path: target/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
compression-level: 0
linux-arm64:
name: Linux arm64 binary and .deb
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build release binary
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: "aarch64-unknown-linux-gnu"
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
- name: Upload Linux arm64 binary
uses: actions/upload-artifact@v7
with:
name: cheburprobe-linux-arm64
path: target/aarch64-unknown-linux-gnu/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
compression-level: 0
windows-amd64:
name: Windows amd64 binary
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Build release binary
run: cargo build --release --package probe --bin cheburprobe
- name: Upload Windows amd64 binary
uses: actions/upload-artifact@v7
with:
name: cheburprobe-windows-amd64
path: target/release/cheburprobe.exe
compression-level: 0
docker:
name: Docker image amd64/arm64
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
id: setup-buildx
uses: docker/setup-buildx-action@v4
- name: Cache Rust Docker build mounts
id: rust-build-cache
uses: actions/cache@v4
with:
path: rust-build-cache
key: ${{ runner.os }}-probe-docker-rust-${{ hashFiles('**/Cargo.lock', 'probe/Dockerfile') }}
restore-keys: |
${{ runner.os }}-probe-docker-rust-
- name: Restore Rust Docker build cache
uses: reproducible-containers/buildkit-cache-dance@v3
with:
builder: ${{ steps.setup-buildx.outputs.name }}
cache-map: |
{
"rust-build-cache/probe-target": {
"target": "/build/target",
"id": "probe-target"
},
"rust-build-cache/cargo-registry": {
"target": "/usr/local/cargo/registry",
"id": "cargo-registry"
},
"rust-build-cache/cargo-git": {
"target": "/usr/local/cargo/git",
"id": "cargo-git"
}
}
skip-extraction: ${{ steps.rust-build-cache.outputs.cache-hit }}
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
file: ./probe/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+154
View File
@@ -0,0 +1,154 @@
name: Draft Release
on:
push:
tags: [ 'v*.*.*' ]
permissions:
actions: read
contents: write
concurrency:
group: draft-release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
draft-release:
name: Draft GitHub release
runs-on: ubuntu-latest
steps:
- name: Collect build artifacts
uses: actions/github-script@v8
env:
TAG_NAME: ${{ github.ref_name }}
WORKFLOWS: |
Build Rust Application
Build Probe
with:
script: |
const fs = require('fs');
const path = require('path');
const owner = context.repo.owner;
const repo = context.repo.repo;
const tagName = process.env.TAG_NAME;
const workflows = process.env.WORKFLOWS
.split(/\r?\n/)
.map((name) => name.trim())
.filter(Boolean);
const targetSha = context.sha;
const outDir = path.join(process.cwd(), 'artifact-archives');
const pollDelayMs = 30_000;
const timeoutMs = 30 * 60 * 1000;
fs.mkdirSync(outDir, { recursive: true });
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function findWorkflowRun(workflowName) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner,
repo,
head_sha: targetSha,
event: 'push',
per_page: 100,
},
);
const run = runs.find((candidate) =>
candidate.name === workflowName &&
candidate.head_sha === targetSha &&
candidate.head_branch === tagName
);
if (run?.status === 'completed') {
if (run.conclusion === 'success') {
return run;
}
throw new Error(
`${workflowName} completed with conclusion ${run.conclusion}: ${run.html_url}`,
);
}
core.info(
run
? `${workflowName} is ${run.status}; waiting for completion.`
: `Waiting for ${workflowName} to start for ${tagName}.`,
);
await sleep(pollDelayMs);
}
throw new Error(`Timed out waiting for ${workflowName} on ${tagName}.`);
}
for (const workflowName of workflows) {
const run = await findWorkflowRun(workflowName);
core.info(`Downloading artifacts from ${workflowName}: ${run.html_url}`);
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{
owner,
repo,
run_id: run.id,
per_page: 100,
},
);
for (const artifact of artifacts.filter((item) => !item.expired)) {
const archive = await github.rest.actions.downloadArtifact({
owner,
repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
const artifactPath = path.join(outDir, `${artifact.name}.zip`);
fs.writeFileSync(artifactPath, Buffer.from(archive.data));
core.info(`Saved ${artifactPath}`);
}
}
- name: Extract release assets
shell: bash
run: |
set -euo pipefail
mkdir -p release-artifacts
for archive in artifact-archives/*.zip; do
artifact_name="$(basename "$archive" .zip)"
extract_dir="$(mktemp -d)"
unzip -q "$archive" -d "$extract_dir"
while IFS= read -r -d '' file; do
filename="$(basename "$file")"
target="release-artifacts/$filename"
if [ -e "$target" ]; then
target="release-artifacts/${artifact_name}-${filename}"
fi
mv "$file" "$target"
done < <(find "$extract_dir" -type f -print0)
rm -rf "$extract_dir"
done
- name: Publish draft release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
draft: true
fail_on_unmatched_files: true
files: release-artifacts/*
Generated
+382 -51
View File
@@ -130,6 +130,38 @@ dependencies = [
"syn",
]
[[package]]
name = "async-tungstenite"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef0f7efedeac57d9b26170f72965ecfd31473ca52ca7a64e925b0b6f5f079886"
dependencies = [
"atomic-waker",
"futures-core",
"futures-io",
"futures-task",
"futures-util",
"log",
"pin-project-lite",
"rustls-native-certs 0.8.4",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tungstenite",
]
[[package]]
name = "async_io_stream"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
dependencies = [
"futures",
"pharos",
"rustc_version",
"tokio",
]
[[package]]
name = "atoi"
version = "2.0.0"
@@ -166,6 +198,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -192,9 +246,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
dependencies = [
"serde_core",
]
@@ -249,6 +303,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -351,6 +407,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -416,6 +481,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -615,7 +690,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
@@ -651,6 +726,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.15.0"
@@ -774,6 +855,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -831,6 +918,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -1034,7 +1127,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"ignore",
"walkdir",
]
@@ -1167,11 +1260,11 @@ dependencies = [
"ipnet",
"jni",
"rand 0.10.0",
"rustls",
"thiserror",
"rustls 0.23.35",
"thiserror 2.0.18",
"tinyvec",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tracing",
"url",
"webpki-roots",
@@ -1191,7 +1284,7 @@ dependencies = [
"prefix-trie 0.8.2",
"rand 0.10.0",
"ring",
"thiserror",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"url",
@@ -1216,12 +1309,12 @@ dependencies = [
"parking_lot",
"rand 0.10.0",
"resolv-conf",
"rustls",
"rustls 0.23.35",
"smallvec",
"system-configuration 0.7.0",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tracing",
"webpki-roots",
]
@@ -1384,10 +1477,10 @@ dependencies = [
"http 1.4.0",
"hyper 1.8.1",
"hyper-util",
"rustls",
"rustls 0.23.35",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots",
]
@@ -1739,7 +1832,7 @@ dependencies = [
"jni-sys",
"log",
"simd_cesu8",
"thiserror",
"thiserror 2.0.18",
"walkdir",
"windows-link",
]
@@ -1776,6 +1869,16 @@ dependencies = [
"syn",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.82"
@@ -1839,7 +1942,7 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"libc",
"redox_syscall",
]
@@ -1921,7 +2024,7 @@ dependencies = [
"log",
"memchr",
"serde",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -2025,10 +2128,10 @@ dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-probe 0.1.6",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@@ -2066,7 +2169,7 @@ version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
@@ -2172,7 +2275,7 @@ version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"cfg-if",
"foreign-types",
"libc",
@@ -2198,6 +2301,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.111"
@@ -2335,6 +2444,16 @@ dependencies = [
"sha2",
]
[[package]]
name = "pharos"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414"
dependencies = [
"futures",
"rustc_version",
]
[[package]]
name = "phf"
version = "0.11.3"
@@ -2482,6 +2601,24 @@ dependencies = [
"syn",
]
[[package]]
name = "probe"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"env_logger",
"futures",
"log",
"reports",
"rumqttc 0.25.1",
"rustls 0.23.35",
"serde",
"serde_json",
"tokio",
"tokio-rustls 0.26.4",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -2537,7 +2674,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tokio",
"url",
]
@@ -2554,9 +2691,9 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"rustls 0.23.35",
"socket2 0.6.3",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@@ -2574,10 +2711,10 @@ dependencies = [
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls 0.23.35",
"rustls-pki-types",
"slab",
"thiserror",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@@ -2700,7 +2837,7 @@ version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
]
[[package]]
@@ -2709,7 +2846,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
]
[[package]]
@@ -2815,7 +2952,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls 0.23.35",
"rustls-pki-types",
"serde",
"serde_json",
@@ -2823,7 +2960,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tokio-util",
"tower",
"tower-http",
@@ -3011,6 +3148,48 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rumqttc"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1568e15fab2d546f940ed3a21f48bbbd1c494c90c99c4481339364a497f94a9"
dependencies = [
"bytes",
"flume",
"futures-util",
"log",
"rustls-native-certs 0.7.3",
"rustls-pemfile",
"rustls-webpki 0.102.8",
"thiserror 1.0.69",
"tokio",
"tokio-rustls 0.25.0",
]
[[package]]
name = "rumqttc"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0feff8d882bff0b2fddaf99355a10336d43dd3ed44204f85ece28cf9626ab519"
dependencies = [
"async-tungstenite",
"bytes",
"fixedbitset",
"flume",
"futures-util",
"http 1.4.0",
"log",
"rustls-native-certs 0.8.4",
"rustls-pemfile",
"rustls-webpki 0.102.8",
"thiserror 2.0.18",
"tokio",
"tokio-rustls 0.26.4",
"tokio-stream",
"tokio-util",
"ws_stream_tungstenite",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3032,28 +3211,77 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
[[package]]
name = "rustls"
version = "0.23.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.103.8",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [
"openssl-probe 0.1.6",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework 2.11.1",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
"security-framework 3.7.0",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.13.0"
@@ -3064,12 +3292,24 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -3123,8 +3363,21 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"bitflags 2.12.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.12.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3132,9 +3385,9 @@ dependencies = [
[[package]]
name = "security-framework-sys"
version = "2.15.0"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
@@ -3408,7 +3661,7 @@ dependencies = [
"serde_json",
"sha2",
"smallvec",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tracing",
@@ -3462,7 +3715,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags 2.10.0",
"bitflags 2.12.1",
"byteorder",
"bytes",
"chrono",
@@ -3492,7 +3745,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 2.0.18",
"tracing",
"uuid",
"whoami",
@@ -3506,7 +3759,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags 2.10.0",
"bitflags 2.12.1",
"byteorder",
"chrono",
"crc",
@@ -3531,7 +3784,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 2.0.18",
"tracing",
"uuid",
"whoami",
@@ -3557,7 +3810,7 @@ dependencies = [
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror",
"thiserror 2.0.18",
"tracing",
"url",
"uuid",
@@ -3647,8 +3900,8 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"bitflags 2.12.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -3658,8 +3911,8 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"bitflags 2.12.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -3725,13 +3978,33 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -3848,13 +4121,24 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"rustls 0.23.35",
"tokio",
]
@@ -3944,7 +4228,7 @@ version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"bytes",
"futures-util",
"http 1.4.0",
@@ -4036,6 +4320,25 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http 1.4.0",
"httparse",
"log",
"rand 0.9.2",
"rustls 0.23.35",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.19.0"
@@ -4136,6 +4439,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4325,7 +4634,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.12.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -4362,7 +4671,7 @@ dependencies = [
[[package]]
name = "website"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"dotenvy",
"env_logger",
@@ -4376,9 +4685,11 @@ dependencies = [
"rocket-cache-response",
"rocket-client-addr",
"rocket_dyn_templates",
"rumqttc 0.24.0",
"serde",
"sqlx",
"tar",
"toml",
]
[[package]]
@@ -4821,7 +5132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.10.0",
"bitflags 2.12.1",
"indexmap",
"log",
"serde",
@@ -4857,6 +5168,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "ws_stream_tungstenite"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3c9c55940d22313a53398bfeb9438c5f519de475fa37ed7ff068f8c1ca8eb45"
dependencies = [
"async-tungstenite",
"async_io_stream",
"bitflags 2.12.1",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"pharos",
"rustc_version",
"tokio",
"tracing",
"tungstenite",
]
[[package]]
name = "xattr"
version = "1.6.1"
+1
View File
@@ -1,5 +1,6 @@
[workspace]
members = [
"probe",
"querying",
"reporter",
"reports",
+27 -26
View File
@@ -15,8 +15,8 @@
- **Rust** — язык программирования, на котором написан весь бекенд.
- **Rocket.rs** — веб-фреймворк для построения HTTP API и серверной логики.
- **DoH резолвер Quad9** — для разрешения DNS-запросов используется DNS-over-HTTPS, что позволяет обойти локальные ограничения и получать актуальные данные.
- **SvelteKit** — SSR-фронтенд, который запускается отдельным Node.js сервисом.
- **nginx** — публичная точка входа, проксирует API в Rocket, а страницы в SvelteKit.
- **SvelteKit** — SSR-фронтенд, который запускается отдельным Node.js сервисом.
- **nginx** — публичная точка входа, проксирует API в Rocket, а страницы в SvelteKit.
- **lucide** — библиотека иконок, применяемая для визуального оформления интерфейса.
---
@@ -26,7 +26,7 @@
1. Пользователь вводит домен или IP-адрес в форму на сайте.
2. Сервер выполняет DNS-запрос через Quad9 DoH для получения актуальной информации.
3. Полученный домен/IP проверяется на наличие в блокировочных списках через префиксные деревья.
4. Результат возвращается через API и отображается SSR-фронтендом SvelteKit.
4. Результат возвращается через API и отображается SSR-фронтендом SvelteKit.
---
@@ -42,29 +42,30 @@
* `querying` — модуль проверки сайтов по базам данных
* `reporter` — [Cheburcheck Reporter](reporter/README.md)
* `reports` — общий протокол для отправки отчетов
* `website` — Rocket API и серверная логика
* `frontend` — SvelteKit SSR-интерфейс
---
## Запуск через Docker Compose
```sh
docker compose up --build
```
По умолчанию nginx слушает `http://localhost:8080`. Порт можно изменить через
`HTTP_PORT`, например:
```sh
HTTP_PORT=80 docker compose up --build
```
Маршрутизация:
* `/api/v1/*`, `/agency/*`, `/whitelist/*` и устаревший `/feedback/*` идут в Rocket
* остальные запросы идут в SvelteKit SSR
* `reports` — общий протокол для отправки отчетов
* `website` — Rocket API и серверная логика
* `probe` — [Cheburcheck Probe](probe/README.md)
* `frontend` — SvelteKit SSR-интерфейс
---
## Запуск через Docker Compose
```sh
docker compose up --build
```
По умолчанию nginx слушает `http://localhost:8080`. Порт можно изменить через
`HTTP_PORT`, например:
```sh
HTTP_PORT=80 docker compose up --build
```
Маршрутизация:
* `/api/v1/*`, `/agency/*`, `/whitelist/*` идут в Rocket
* остальные запросы идут в SvelteKit SSR
---
+17
View File
@@ -4,6 +4,7 @@ services:
depends_on:
- frontend
- website
- rmqtt
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
@@ -28,5 +29,21 @@ services:
ROCKET_ADDRESS: "::"
ROCKET_PORT: 8000
DATABASE_URL: "${DATABASE_URL}"
MQTT_ADMIN_TOKEN: "${MQTT_ADMIN_TOKEN}"
MQTT_HOST: rmqtt
MQTT_PORT: 11883
expose:
- "8000"
rmqtt:
image: docker.io/rmqtt/rmqtt:latest
depends_on:
- website
volumes:
- ./rmqtt/rmqtt.toml:/app/rmqtt/rmqtt.toml:ro
- ./rmqtt/rmqtt-plugins:/app/rmqtt/rmqtt-plugins:ro
expose:
- "8080"
- "1883"
- "11883"
- "6060"
+3
View File
@@ -23,6 +23,7 @@ type ApiCheckResponse = {
blocked: boolean;
rkn_domain?: string | null;
ips: string[];
reverse_lookup: string[];
blocked_subnets: string[];
cdn_providers: Record<string, ApiNetworkRecord[]>;
geo: {
@@ -46,6 +47,7 @@ export type CheckResult = {
whitelist?: { lastOk?: string | null } | null;
domain?: string | null;
ips: string[];
reverseLookup: string[];
subnetSize?: string | null;
geo: { organisation?: string | null; location: string; asn?: string | null };
providers: { name: string; networks: ApiNetworkRecord[] }[];
@@ -97,6 +99,7 @@ export async function fetchCheck(target: string): Promise<CheckResult> {
: null,
domain: data.rkn_domain,
ips: data.ips,
reverseLookup: data.reverse_lookup,
subnetSize: data.subnet_size,
geo: data.geo,
providers: Object.entries(data.cdn_providers).map(([name, networks]) => ({
+73
View File
@@ -0,0 +1,73 @@
export type ProbeHostResult = {
host_id: string;
host: string;
probe_evidence:
| {
type: "ConnectionError" | "ClientHello" | "Good";
}
| {
type: "DataTimeout";
bytes: number;
};
};
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[];
};
export type ProbeStatus = {
id: string;
target: string;
online_probes: number;
response_count: number;
status: "started" | "progress" | "done" | "error";
};
export function startProbeSSE(
id: string,
onResult: (result: ProbeResult) => void,
onStatus: (status: Partial<ProbeStatus>) => void,
) {
const eventSource = new EventSource(`/api/v1/probe/${id}`);
eventSource.addEventListener("started", (event) => {
const data = JSON.parse(event.data);
onStatus({
id: data.id,
target: data.target,
online_probes: data.online_probes,
status: "started",
response_count: 0,
});
});
eventSource.addEventListener("result", (event) => {
const data = JSON.parse(event.data) as ProbeResult;
onResult(data);
onStatus({ status: "progress" });
});
eventSource.addEventListener("done", (event) => {
const data = JSON.parse(event.data);
onStatus({
status: "done",
response_count: data.response_count,
online_probes: data.online_probes,
});
eventSource.close();
});
eventSource.onerror = (error) => {
console.error("Probe SSE error:", error);
onStatus({ status: "error" });
eventSource.close();
};
return () => eventSource.close();
}
+25 -14
View File
@@ -1,28 +1,39 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Component, Snippet } from "svelte";
let {
children,
label,
href,
}: { children: Snippet; label: string; href?: string } = $props();
icon: Icon,
}: {
children: Snippet;
label: string;
href?: string;
icon?: Component;
} = $props();
</script>
<div
class="flex flex-col gap-1 border-b border-neutral-800 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between"
>
{#if href}
<a
{href}
class="text-xs tracking-wider text-neutral-500 uppercase underline decoration-neutral-500"
>
{label}
</a>
{:else}
<span class="text-xs tracking-wider text-neutral-500 uppercase">
{label}
</span>
{/if}
<div class="flex items-center gap-2">
{#if Icon}
<Icon size={14} class="text-neutral-500" aria-hidden="true" />
{/if}
{#if href}
<a
{href}
class="text-xs font-medium text-neutral-400 uppercase underline decoration-neutral-400/50 hover:text-neutral-300"
>
{label}
</a>
{:else}
<span class="text-xs font-medium text-neutral-400 uppercase">
{label}
</span>
{/if}
</div>
<div class="flex justify-end">
{@render children()}
</div>
+6 -25
View File
@@ -5,49 +5,30 @@ import { submitFeedback } from "$lib/api/feedback";
let {
id,
theme = "clean",
}: {
id: string;
theme?: "blocked" | "clean" | "whitelist";
} = $props();
const feedbackMutation = createMutation(() => ({
mutationFn: submitFeedback,
}));
const worksClass = $derived(
theme === "blocked"
? "border-transparent bg-green-500 text-neutral-950 hover:bg-emerald-400"
: "border border-green-500 bg-transparent text-green-500 hover:bg-green-500/10",
);
const notWorksClass = $derived(
theme === "clean"
? "border-transparent bg-red-500 text-neutral-100 hover:bg-red-400"
: "border border-red-500 bg-transparent text-red-500 hover:bg-red-500/10",
);
const statusClass = $derived(
theme === "blocked"
? "text-red-400"
: theme === "clean"
? "text-green-500"
: "text-neutral-300",
);
const submit = (works: boolean) => {
feedbackMutation.mutate({ id, works });
};
</script>
<div class="mt-6 border-t border-dashed border-neutral-800 pt-4">
<div class="mt-2 border-t border-dashed border-neutral-800 pt-4">
{#if feedbackMutation.isSuccess}
<div class={`flex items-center gap-2 py-2 text-sm ${statusClass}`}>
<ThumbsUp size={16} aria-hidden="true" />
<span>Спасибо за ваш отзыв!</span>
<div class={`flex items-center gap-2 py-2 text-lg text-green-500`}>
<ThumbsUp size={24} aria-hidden="true" />
<span>Спасибо за отзыв!</span>
</div>
{:else}
<p class="mb-3 text-sm text-neutral-500">У вас работает этот ресурс?</p>
<div class="flex gap-3">
<button
class={`flex grow cursor-pointer items-center justify-center gap-2 border px-4 py-2 font-[inherit] text-sm font-bold transition-all ${worksClass}`}
class="flex grow cursor-pointer items-center justify-center gap-2 px-4 py-2 font-[inherit] text-sm font-bold transition-all border border-green-500 bg-transparent text-green-500 hover:bg-green-500/10"
type="button"
disabled={feedbackMutation.isPending}
onclick={() => submit(true)}
@@ -56,7 +37,7 @@ const submit = (works: boolean) => {
Работает
</button>
<button
class={`flex grow cursor-pointer items-center justify-center gap-2 border px-4 py-2 font-[inherit] text-sm font-bold transition-all ${notWorksClass}`}
class="flex grow cursor-pointer items-center justify-center gap-2 px-4 py-2 font-[inherit] text-sm font-bold transition-all border border-red-500 bg-transparent text-red-500 hover:bg-red-500/10"
type="button"
disabled={feedbackMutation.isPending}
onclick={() => submit(false)}
+173 -151
View File
@@ -1,39 +1,31 @@
<script lang="ts">
import {
Activity,
EthernetPort,
Globe,
Hash,
Info,
Layers,
MapPin,
Network,
ScrollText,
Server,
ShieldCheck,
} from "@lucide/svelte";
import type { CheckResult } from "$lib/api/check";
import DetailRow from "./DetailRow.svelte";
import Feedback from "./Feedback.svelte";
import ResultAsnSummary from "./result/ResultAsnSummary.svelte";
import ResultIpList from "./result/ResultIpList.svelte";
import ResultStatusHeader from "./result/ResultStatusHeader.svelte";
import ResultStringList from "./result/ResultStringList.svelte";
import ResultTargetCard from "./result/ResultTargetCard.svelte";
type Network = { cidr: string };
type Provider = { name: string; networks: Network[] };
type AsnInfo = { prefixes: string[]; blockedPrefixes: string[] } | null;
type Provider = { name: string; networks: { cidr: string }[] };
type ResultTheme = "blocked" | "clean" | "whitelist";
let {
result,
}: {
result: {
targetType: string;
target: string;
id?: string | null;
found: boolean;
blocked: boolean;
whitelist?: { lastOk?: string | null } | null;
domain?: string | null;
ips: string[];
subnetSize?: string | null;
geo: {
organisation?: string | null;
location: string;
asn?: string | null;
};
providers: Provider[];
blockedSubnets: string[];
asnInfo: AsnInfo;
};
result: CheckResult;
} = $props();
const valueClass = "text-right text-sm font-medium text-neutral-200";
@@ -72,134 +64,164 @@ const providerCidrs = (provider: Provider) =>
provider.networks.map((network) => network.cidr);
</script>
<div class={`mt-4 border p-6 ${panelClass}`}>
<ResultStatusHeader {theme} blocked={result.blocked} />
<ResultTargetCard targetType={result.targetType} target={result.target} />
{#if result.asnInfo}
<ResultAsnSummary
total={allPrefixes.length}
blocked={blockedPrefixes.length}
<div class="mt-8 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class={`border p-4 rounded-lg flex items-center ${panelClass}`}>
<ResultStatusHeader {theme} blocked={result.blocked} />
</div>
<ResultTargetCard
targetType={result.targetType}
target={result.target}
asnStats={result.asnInfo ? { total: allPrefixes.length, blocked: blockedPrefixes.length } : undefined}
/>
{/if}
<div class="mb-8 grid grid-cols-1 gap-8 sm:grid-cols-2">
<div>
<h3
class="mb-0 border-b border-neutral-800 pb-2 text-sm font-bold text-white uppercase"
>
Сетевые данные
</h3>
{#if !result.asnInfo}
<DetailRow label="IP-адреса">
<ResultIpList ips={result.ips} subnetSize={result.subnetSize} />
</DetailRow>
{/if}
<DetailRow label="Хостинг / ISP">
<span class={valueClass}>{result.geo.organisation || "-"}</span>
</DetailRow>
<DetailRow label="Локация">
<span class={valueClass}>{result.geo.location}</span>
</DetailRow>
<DetailRow label="ASN">
<span class={valueClass}>
{#if result.geo.asn}
<a
href={`/check?target=${result.geo.asn}`}
class="text-neutral-100 underline decoration-neutral-500 transition-all hover:text-white hover:decoration-neutral-100"
>
{result.geo.asn}
</a>
{:else}
-
{/if}
</span>
</DetailRow>
{#if result.asnInfo}
<DetailRow label="Подсети ASN">
<ResultStringList items={allPrefixes} />
</DetailRow>
{/if}
</div>
<div>
<h3
class={`mb-0 border-b pb-2 text-sm font-bold uppercase ${reasonHeaderClass}`}
>
Нахождение в списках
</h3>
<DetailRow label="CDN">
{#if result.providers.length > 0}
<p class={alertValueClass}>НАЙДЕН</p>
{:else}
<span class={valueClass}>Не найден</span>
{/if}
</DetailRow>
{#each result.providers as provider}
<DetailRow label={provider.name}>
<ResultStringList items={providerCidrs(provider)} limit={5} />
</DetailRow>
{/each}
{#if result.whitelist}
<DetailRow label="Белый список (?)" href="/kb/whitelist">
<span class={successValueClass}>
НАЙДЕН -
<span
class="underline decoration-dotted"
title="Дата последнего сканирования, когда данный домен был найден в белом списке"
>
{whitelistDate}
</span>
</span>
</DetailRow>
{/if}
<DetailRow label="Реестр РКН">
{#if result.domain}
<span class={alertValueClass}>ОГРАНИЧЕН</span>
{:else if result.blockedSubnets.length > 0}
<span
class={`${alertValueClass} underline decoration-dotted`}
title="Адреса пересекаются с подсетями заблокированных доменов (не гарантирует блокировку)"
>
IP-АДРЕСА
</span>
{:else}
<span class={valueClass}>Не найден</span>
{/if}
</DetailRow>
{#if result.domain}
<DetailRow label="Заблокированный домен">
<span class={valueClass}>{result.domain}</span>
</DetailRow>
{/if}
{#if result.blockedSubnets.length > 0 && !result.asnInfo}
<DetailRow label="Заблокированные подсети">
<div>
{#each result.blockedSubnets as network}
<p class={valueClass}>{network}</p>
{/each}
</div>
</DetailRow>
{/if}
{#if result.asnInfo && blockedPrefixes.length > 0}
<DetailRow label="Заблокированные подсети ASN">
<ResultStringList items={blockedPrefixes} alert />
</DetailRow>
{/if}
</div>
</div>
{#if result.id}
<Feedback id={result.id} {theme} />
{/if}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="space-y-4">
<div class="flex items-center gap-2 border-b border-neutral-800 pb-2">
<h3
class="text-sm font-bold text-white uppercase flex items-center gap-2"
>
<Network size={16} class="text-primary" />
Сетевые данные
</h3>
</div>
<div
class="border border-neutral-800 rounded-lg bg-neutral-900/10 px-4 py-1"
>
{#if !result.asnInfo}
<DetailRow label="IP-адреса" icon={Globe}>
<ResultIpList ips={result.ips} subnetSize={result.subnetSize} />
</DetailRow>
{/if}
{#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}
</DetailRow>
{/if}
<DetailRow label="Хостинг / ISP" icon={Server}>
<span class={valueClass}>{result.geo.organisation || "-"}</span>
</DetailRow>
<DetailRow label="Локация" icon={MapPin}>
<span class={valueClass}>{result.geo.location}</span>
</DetailRow>
<DetailRow label="ASN" icon={Hash}>
<span class={valueClass}>
{#if result.geo.asn}
<a
href={`/check?target=${result.geo.asn}`}
class="text-neutral-100 underline decoration-neutral-500 transition-all hover:text-white hover:decoration-neutral-100"
>
{result.geo.asn}
</a>
{:else}
-
{/if}
</span>
</DetailRow>
{#if result.asnInfo}
<DetailRow label="Подсети ASN" icon={Layers}>
<ResultStringList items={allPrefixes} />
</DetailRow>
{/if}
</div>
</div>
<div class="space-y-4">
<div class="flex items-center gap-2 border-b border-neutral-800 pb-2">
<h3
class="text-sm font-bold text-white uppercase flex items-center gap-2"
>
<ScrollText size={16} class="text-primary" />
Нахождение в списках
</h3>
</div>
<div
class="border border-neutral-800 rounded-lg bg-neutral-900/10 px-4 py-1"
>
<DetailRow label="CDN" icon={Activity}>
{#if result.providers.length > 0}
<p class={alertValueClass}>НАЙДЕН</p>
{:else}
<span class={valueClass}>Не найден</span>
{/if}
</DetailRow>
{#each result.providers as provider}
<DetailRow label={provider.name} icon={Server}>
<ResultStringList items={providerCidrs(provider)} limit={5} />
</DetailRow>
{/each}
{#if result.whitelist}
<DetailRow
label="Белый список (?)"
href="/kb/whitelist"
icon={ShieldCheck}
>
<span class={successValueClass}>
НАЙДЕН -
<span
class="underline decoration-dotted"
title="Дата последнего сканирования, когда данный домен был найден в белом списке"
>
{whitelistDate}
</span>
</span>
</DetailRow>
{/if}
<DetailRow label="Реестр РКН" icon={ScrollText}>
{#if result.domain}
<span class={alertValueClass}>ОГРАНИЧЕН</span>
{:else if result.blockedSubnets.length > 0}
<span
class={`${alertValueClass} underline decoration-dotted`}
title="Адреса пересекаются с подсетями заблокированных доменов (не гарантирует блокировку)"
>
IP-АДРЕСА
</span>
{:else}
<span class={valueClass}>Не найден</span>
{/if}
</DetailRow>
{#if result.domain}
<DetailRow label="Заблокированный домен" icon={Info}>
<span class={valueClass}>{result.domain}</span>
</DetailRow>
{/if}
{#if result.blockedSubnets.length > 0 && !result.asnInfo}
<DetailRow label="Заблокированные подсети" icon={Layers}>
<div class="text-right">
{#each result.blockedSubnets as network}
<p class={valueClass}>{network}</p>
{/each}
</div>
</DetailRow>
{/if}
{#if result.asnInfo && blockedPrefixes.length > 0}
<DetailRow label="Заблокированные подсети ASN" icon={Layers}>
<ResultStringList items={blockedPrefixes} alert />
</DetailRow>
{/if}
</div>
</div>
</div>
</div>
@@ -3,7 +3,7 @@ import { ChevronRight, Search } from "@lucide/svelte";
</script>
<form
class="relative flex w-full flex-col gap-3 sm:flex-row sm:gap-0"
class="group relative flex w-full flex-col gap-3 sm:flex-row sm:gap-0"
action="/check"
>
<!-- svelte-ignore a11y_autofocus -->
@@ -25,9 +25,9 @@ import { ChevronRight, Search } from "@lucide/svelte";
</div>
<button
type="submit"
class="flex h-16 w-full cursor-pointer items-center justify-center gap-2 border-0 bg-neutral-100 px-6 font-[inherit] text-sm font-bold text-neutral-950 uppercase transition-colors hover:bg-white sm:ml-[-1px] sm:w-auto sm:justify-between"
class="flex h-16 w-full cursor-pointer items-center justify-center gap-2 border border-neutral-700 bg-neutral-800 px-6 font-[inherit] text-sm font-bold text-neutral-100 uppercase transition-colors hover:bg-neutral-700 group-focus-within:border-neutral-400 group-focus-within:bg-neutral-700 group-focus-within:hover:bg-neutral-600 sm:ml-[-1px] sm:w-auto sm:justify-between"
>
<span>Проверить</span>
<ChevronRight size={16} aria-hidden="true" />
<ChevronRight size={16} aria-hidden="true" class="text-primary" />
</button>
</form>
@@ -1,11 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getKbContext } from "$lib/context/kb.svelte";
let { children }: { children: Snippet } = $props();
const kb = getKbContext();
kb?.reset();
</script>
<article
class="[&_a]:text-neutral-100 [&_code]:bg-neutral-900/70 [&_code]:px-1 [&_code]:py-0.5 [&_h1]:my-4 [&_h1]:text-5xl [&_h1]:tracking-[0.1em] [&_h1]:text-neutral-100 [&_h1]:uppercase [&_li]:my-1 [&_p]:my-4 [&_p]:text-justify [&_ul]:list-inside"
class="
[&_a]:text-neutral-100 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-neutral-700 [&_a:hover]:decoration-neutral-400 [&_a]:transition-colors
[&_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
[&_p]:my-6 [&_p]:text-lg [&_p]:leading-relaxed [&_p]:text-neutral-300
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-6
"
>
{@render children()}
</article>
@@ -4,6 +4,8 @@ import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
</script>
<i class="mt-6 mb-3 block text-center text-neutral-400">
<p
class="mt-6 mb-4 text-center text-sm font-medium tracking-wide text-neutral-500 uppercase"
>
{@render children()}
</i>
</p>
@@ -10,6 +10,8 @@ let {
} = $props();
</script>
<div class="{height} border border-neutral-800 bg-neutral-950/30 p-4">
<div
class="{height} my-8 rounded-lg border border-neutral-800 bg-neutral-950/30 p-6 shadow-inner"
>
{@render children()}
</div>
+1 -1
View File
@@ -5,5 +5,5 @@ let { children }: { children: Snippet } = $props();
</script>
<pre
class="my-4 overflow-x-scroll border-l-2 border-neutral-800 pl-4 font-[inherit] text-neutral-500"
class="my-8 overflow-x-auto rounded-lg border border-neutral-800 bg-neutral-900/50 p-6 font-mono text-sm leading-relaxed text-neutral-300 shadow-inner"
>{@render children()}</pre>
@@ -1 +1 @@
<hr class="my-8 border-neutral-800">
<hr class="my-12 border-neutral-800">
@@ -5,7 +5,9 @@ import type { Snippet } from "svelte";
let { children, href }: { children: Snippet; href: string } = $props();
</script>
<div class="my-1 flex items-center gap-2">
<FileSymlink size={16} aria-hidden="true" />
<a {href}>{@render children()}</a>
<div class="my-3 flex items-center gap-3">
<div class="text-neutral-500">
<FileSymlink size={20} aria-hidden="true" />
</div>
<a {href} class="text-lg font-medium">{@render children()}</a>
</div>
@@ -1,14 +1,41 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { onMount, type Snippet } from "svelte";
import { getKbContext } from "$lib/context/kb.svelte";
let { children, id }: { children: Snippet; id: string } = $props();
let {
children,
id,
title,
}: {
children?: Snippet;
id: string;
title?: string;
} = $props();
const kb = getKbContext();
onMount(() => {
if (kb) {
kb.registerHeading({ id, title: title || id });
}
});
</script>
<h2
{id}
class="mt-10 mb-4 text-[32px] leading-[1.2] font-bold tracking-[0.1em] text-neutral-100 uppercase"
class="relative mt-12 mb-6 text-3xl font-bold tracking-tight text-neutral-100 group"
>
<a href={`#${id}`} class="no-underline hover:underline">
{@render children()}
<a href={`#${id}`} class="no-underline">
<span
class="absolute -left-8 top-0 opacity-0 transition-opacity group-hover:opacity-50"
aria-hidden="true"
>
#
</span>
{#if children}
{@render children()}
{:else}
{title}
{/if}
</a>
</h2>
+8 -6
View File
@@ -7,16 +7,18 @@ let {
variant = "warning",
}: {
children: Snippet;
variant?: "warning";
variant?: "warning" | "info";
} = $props();
const variantStyle = {
warning: "border-red-900/30 bg-[#450a0a]/20 text-red-400",
info: "border-blue-900/30 bg-[#0a1045]/20 text-blue-400",
};
</script>
<p
class="flex items-center gap-4 border p-4 text-xl [&_a]:text-inherit {variant ===
'warning'
? 'border-red-900/30 bg-[#450a0a]/20 text-red-400'
: ''}"
class="my-8 flex items-start gap-4 border-l-4 p-6 text-lg [&_a]:text-inherit [&_a]:font-bold {variantStyle[variant]}"
>
<TriangleAlert size={32} aria-hidden="true" />
<TriangleAlert size={24} aria-hidden="true" class="mt-1 shrink-0" />
<span>{@render children()} </span>
</p>
@@ -0,0 +1,218 @@
<script lang="ts">
import {
Activity,
ChevronDown,
ChevronUp,
CircleCheck,
CircleQuestionMark,
CircleX,
LoaderCircle,
ShieldCheck,
} from "@lucide/svelte";
import type { ProbeResult, ProbeStatus } from "$lib/api/probe";
let {
probes,
status,
isStaticBlocked,
}: {
probes: ProbeResult[];
status: ProbeStatus;
isStaticBlocked: boolean;
} = $props();
let expandedRows = $state<Record<string, boolean>>({});
function toggleRow(id: string) {
expandedRows[id] = !expandedRows[id];
}
const verdictStyles = {
ok: {
icon: CircleCheck,
text: "Доступен",
class: "text-green-500",
bg: "bg-green-500/10",
border: "border-green-500/20",
},
cdn_block: {
icon: CircleX,
text: "CDN Блок (16-20)",
class: "text-red-500",
bg: "bg-red-500/10",
border: "border-red-500/20",
},
sni_block: {
icon: CircleX,
text: "SNI Блок",
class: "text-red-500",
bg: "bg-red-500/10",
border: "border-red-500/20",
},
whitelist: {
icon: ShieldCheck,
text: "Белый список",
class: "text-amber-500",
bg: "bg-amber-500/10",
border: "border-amber-500/20",
},
uncertain: {
icon: CircleQuestionMark,
text: "Неясно",
class: "text-neutral-400",
bg: "bg-neutral-400/10",
border: "border-neutral-400/20",
},
};
</script>
<div class="mt-8 space-y-4">
<div
class="flex items-center justify-between border-b border-neutral-800 pb-2"
>
<h3 class="text-sm font-bold text-white uppercase flex items-center gap-2">
<Activity size={16} class="text-primary" />
<a
class="underline decoration-dotted underline-offset-2"
href="/kb/probing"
>
Результаты динамической проверки
</a>
</h3>
<div class="text-xs text-neutral-400 flex items-center gap-3">
<div class="flex items-center gap-1">
<span
class={`w-2 h-2 rounded-full ${status.online_probes > 0 ? 'bg-green-500 animate-pulse' : 'bg-neutral-600'}`}
></span>
Сканеров онлайн: {status.online_probes}
</div>
<div class="flex items-center gap-1">
Получено ответов: {probes.length} / {status.online_probes}
</div>
</div>
</div>
{#if status.online_probes > 0 && probes.length < status.online_probes && status.status !== 'done'}
<div class="h-1 w-full bg-neutral-800 rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all duration-500 ease-out"
style:width={`${(probes.length / status.online_probes) * 100}%`}
></div>
</div>
{/if}
{#if probes.length === 0 && status.status !== 'done'}
<div
class="flex flex-col items-center justify-center py-12 border border-neutral-800 bg-neutral-900/20 rounded-lg"
>
<LoaderCircle class="animate-spin text-primary mb-4" size={32} />
<p class="text-neutral-400 text-sm">Ожидание ответов от сканеров...</p>
</div>
{:else}
<div
class="overflow-x-auto border border-neutral-800 rounded-lg bg-neutral-900/10"
>
<table class="w-full text-left text-sm border-collapse">
<thead>
<tr class="border-b border-neutral-800 bg-neutral-900/40">
<th class="p-3 font-semibold text-neutral-300">Регион</th>
<th class="p-3 font-semibold text-neutral-300">Провайдер / AS</th>
<th class="p-3 font-semibold text-neutral-300">Вердикт</th>
<th class="p-3 w-10"></th>
</tr>
</thead>
<tbody>
{#each probes as probe (probe.probe_id)}
{@const style = verdictStyles[(isStaticBlocked && 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">
<div class="font-medium text-neutral-200">
{probe.region || "Неизвестно"}
</div>
</td>
<td class="p-3">
<div class="text-neutral-200">{probe.provider || "-"}</div>
<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>
</td>
<td class="p-3 text-right">
{#if isExpanded}
<ChevronUp size={16} class="text-neutral-500" />
{:else}
<ChevronDown size={16} class="text-neutral-500" />
{/if}
</td>
</tr>
{#if isExpanded}
<tr class="bg-neutral-900/30">
<td colspan="4" class="p-4 border-b border-neutral-800/50">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#each probe.host_results as host}
<div
class="flex items-center justify-between p-2 rounded bg-neutral-800/30 border border-neutral-700/30"
>
<div class="flex flex-col">
<span class="text-xs font-bold text-neutral-400">
Сервер {host.host_id}
({host.host === "Blacklist" ? "в заблокированных" : "в доступных"} диапазонах)
</span>
<span class="text-xs text-neutral-200">
{#if host.probe_evidence.type === 'Good'}
Успешно
{:else if host.probe_evidence.type === 'ClientHello'}
Блокировка после ClientHello
{:else if host.probe_evidence.type === 'DataTimeout'}
Таймаут получения данных, получено {host.probe_evidence.bytes} байт
{:else if host.probe_evidence.type === 'ConnectionError'}
Ошибка подключения
{/if}
</span>
</div>
{#if host.probe_evidence.type === 'Good'}
<CircleCheck size={14} class="text-green-500" />
{:else if host.probe_evidence.type === 'ClientHello'}
<CircleX size={14} class="text-red-500" />
{:else if host.probe_evidence.type === 'DataTimeout'}
<CircleX size={14} class="text-orange-500" />
{:else}
<CircleQuestionMark
size={14}
class="text-neutral-500"
/>
{/if}
</div>
{/each}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
{#if status.status === 'done' && probes.length === 0}
<div
class="p-6 border border-neutral-800 bg-neutral-900/20 text-center rounded-lg"
>
<p class="text-neutral-400 text-sm">
Сканеры не ответили на запрос или недоступны.
</p>
</div>
{/if}
</div>
@@ -1,28 +0,0 @@
<script lang="ts">
let {
total,
blocked,
}: {
total: number;
blocked: number;
} = $props();
</script>
<div
class="mb-8 flex flex-wrap gap-8 border border-neutral-800 bg-neutral-900/50 p-4"
>
<div class="flex flex-col gap-1">
<span class="text-xs tracking-wider text-neutral-500 uppercase">
Всего подсетей:
</span>
<span class="text-2xl font-bold text-neutral-100">{total}</span>
</div>
{#if blocked > 0}
<div class="flex flex-col gap-1">
<span class="text-xs tracking-wider text-neutral-500 uppercase">
Заблокировано:
</span>
<span class="text-2xl font-bold text-red-500">{blocked}</span>
</div>
{/if}
</div>
@@ -37,18 +37,16 @@ const StatusIcon = $derived(
);
</script>
<div class="mb-8 flex gap-4">
<div class="flex items-center gap-4">
<div
class={`flex items-center justify-center border border-current bg-white/5 p-3 ${accentClass}`}
class={`flex items-center rounded-lg justify-center border border-current bg-white/5 p-3 ${accentClass}`}
>
<StatusIcon size={32} aria-hidden="true" />
</div>
<div>
<h2
class={`mb-1 text-3xl leading-[1.2] font-bold tracking-widest uppercase ${accentClass}`}
>
<h2 class={`text-2xl leading-tight font-bold uppercase ${accentClass}`}>
{title}
</h2>
<p class={`text-sm ${accentClass}`}>{subtitle}</p>
<p class="text-sm opacity-80">{subtitle}</p>
</div>
</div>
@@ -25,8 +25,7 @@ const valueClass = $derived(
<summary
class="w-fit max-w-37.5 cursor-pointer list-none border border-neutral-800 bg-neutral-900/50 px-2.5 py-1 text-center text-[0.7rem] text-neutral-500 select-none transition-all marker:hidden hover:border-neutral-700 hover:bg-neutral-800/50 hover:text-neutral-100 [&::-webkit-details-marker]:hidden"
>
Показать все ({items.length}
)
Показать все ({items.length})
</summary>
<div
class="mt-3 flex max-h-100 flex-col gap-1 overflow-y-auto border border-neutral-800 bg-neutral-900/30 p-3"
@@ -2,13 +2,44 @@
let {
targetType,
target,
asnStats,
}: {
targetType: string;
target: string;
asnStats?: { total: number; blocked: number };
} = $props();
</script>
<div class="mb-8 border border-neutral-800 bg-neutral-900/50 p-4">
<div class="mb-1 text-xs text-neutral-500">{targetType}:</div>
<div class="text-xl text-white">{target}</div>
<div
class="border border-neutral-800 bg-neutral-900/50 px-5 py-4 rounded-lg flex flex-col justify-between h-full min-h-[100px]"
>
<div class={asnStats ? "mb-4" : "my-auto"}>
<div class="text-xs text-neutral-500 uppercase font-bold mb-1">
{targetType}
</div>
<div class="text-xl text-white font-medium break-all">{target}</div>
</div>
{#if asnStats}
<div class="pt-4 border-t border-neutral-800 grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-neutral-500 uppercase font-bold mb-0.5">
Всего подсетей
</div>
<div class="text-lg font-bold text-white">{asnStats.total}</div>
</div>
<div>
<div
class="text-xs uppercase font-bold mb-0.5 {asnStats.blocked > 0 ? 'text-red-500/70' : 'text-green-500/70'}"
>
Заблокировано
</div>
<div
class="text-lg font-bold {asnStats.blocked > 0 ? 'text-red-500' : 'text-green-500'}"
>
{asnStats.blocked}
</div>
</div>
</div>
{/if}
</div>
+29
View File
@@ -0,0 +1,29 @@
import { getContext, setContext } from "svelte";
export interface Heading {
id: string;
title: string;
}
class KbState {
headings = $state<Heading[]>([]);
registerHeading(heading: Heading) {
if (this.headings.some((h) => h.id === heading.id)) return;
this.headings.push(heading);
}
reset() {
this.headings = [];
}
}
const KB_CONTEXT_KEY = Symbol("kb_context");
export function setKbContext() {
return setContext(KB_CONTEXT_KEY, new KbState());
}
export function getKbContext() {
return getContext<KbState>(KB_CONTEXT_KEY);
}
+1 -3
View File
@@ -12,9 +12,7 @@ const status = $derived(statusQuery.data);
</svelte:head>
<div class="mb-8">
<h1
class="mb-2 text-2xl tracking-widest text-neutral-100 uppercase font-bold"
>
<h1 class="mb-2 text-2xl text-neutral-100 uppercase font-bold">
Статус Ресурса
</h1>
<p class="text-neutral-500">
+102 -1
View File
@@ -1,14 +1,28 @@
<script lang="ts">
import { LoaderCircle } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { page } from "$app/state";
import { CheckRequestError, fetchCheck } from "$lib/api/check";
import {
type ProbeResult,
type ProbeStatus,
startProbeSSE,
} from "$lib/api/probe";
import EmptyResult from "$lib/components/EmptyResult.svelte";
import ErrorMessage from "$lib/components/ErrorMessage.svelte";
import Feedback from "$lib/components/Feedback.svelte";
import ResultPanel from "$lib/components/ResultPanel.svelte";
import ProbeTable from "$lib/components/result/ProbeTable.svelte";
import SearchForm from "$lib/components/SearchForm.svelte";
type ProbeQueryData = {
probes: ProbeResult[];
status: ProbeStatus;
};
const queryClient = useQueryClient();
const target = $derived(page.url.searchParams.get("target")?.trim() ?? "");
const checkQuery = createQuery(() => ({
queryKey: ["check", target],
queryFn: () => fetchCheck(target),
@@ -16,6 +30,79 @@ const checkQuery = createQuery(() => ({
staleTime: Infinity,
}));
const queryId = $derived(checkQuery.data?.id);
const shouldProbe = $derived(
!!queryId && checkQuery.data?.targetType === "Домен",
);
function createInitialProbeData(id: string): ProbeQueryData {
return {
probes: [],
status: {
id,
target,
status: "started",
online_probes: 0,
response_count: 0,
},
};
}
const probeQuery = createQuery(() => ({
queryKey: ["probes", queryId],
queryFn: () => createInitialProbeData(queryId ?? ""),
enabled: shouldProbe,
staleTime: Infinity,
gcTime: Infinity,
}));
$effect(() => {
if (!queryId || !shouldProbe) return;
queryClient.setQueryData<ProbeQueryData>(
["probes", queryId],
createInitialProbeData(queryId),
);
const cleanup = startProbeSSE(
queryId,
(result) => {
queryClient.setQueryData<ProbeQueryData>(["probes", queryId], (old) => {
const current = old ?? createInitialProbeData(queryId);
const probes = current.probes.some(
(probe) => probe.probe_id === result.probe_id,
)
? current.probes.map((probe) =>
probe.probe_id === result.probe_id ? result : probe,
)
: [...current.probes, result];
return {
...current,
probes,
status: {
...current.status,
status: "progress",
response_count: probes.length,
},
};
});
},
(statusUpdate) => {
queryClient.setQueryData<ProbeQueryData>(["probes", queryId], (old) => {
const current = old ?? createInitialProbeData(queryId);
return {
...current,
status: { ...current.status, ...statusUpdate },
};
});
},
);
return cleanup;
});
const error = $derived(
(checkQuery.error instanceof CheckRequestError && checkQuery.error) || null,
);
@@ -37,4 +124,18 @@ const error = $derived(
</div>
{:else if checkQuery.data}
<ResultPanel result={checkQuery.data} />
{#if shouldProbe && probeQuery.data && probeQuery.data.status.online_probes > 0}
<ProbeTable
probes={probeQuery.data.probes}
status={probeQuery.data.status}
isStaticBlocked={checkQuery.data.blocked}
/>
{/if}
{#if checkQuery.data.id}
<div class="mt-4">
<Feedback id={checkQuery.data.id} />
</div>
{/if}
{/if}
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { page } from "$app/state";
import { setKbContext } from "$lib/context/kb.svelte";
let { children } = $props();
const kb = setKbContext();
</script>
<div class="flex flex-col gap-12 xl:flex-row xl:items-start">
<aside class="order-1 xl:w-56 xl:shrink-0 xl:sticky xl:top-8">
<nav
aria-label="Table of contents"
class="bg-neutral-900/20 p-6 rounded-xl border border-neutral-800 xl:bg-transparent xl:p-0 xl:rounded-none xl:border-0"
>
<h3
class="mb-4 text-xs font-bold tracking-widest text-neutral-500 uppercase"
>
Содержание
</h3>
<ul class="flex flex-col gap-3 border-l border-neutral-800 pl-4">
{#each kb.headings as heading}
<li>
<a
href={`#${heading.id}`}
class="block text-sm text-neutral-400 no-underline transition-colors hover:text-neutral-100"
>
{heading.title}
</a>
</li>
{/each}
</ul>
</nav>
</aside>
<div class="order-2 flex-1 min-w-0 xl:max-w-3xl">
{@render children()}
</div>
</div>
+52 -25
View File
@@ -13,22 +13,27 @@ import KbHeading from "$lib/components/kb/KbHeading.svelte";
<KbArticle>
<h1>Ответы на частые вопросы</h1>
<!-- biome-ignore format: link punctuation -->
<p>
На этой странице собраны ответы на часто задаваемые вопросы. Если вас
интересует что-то ещё - пишите нам на почту
<a href="mailto:support@cheburcheck.ru">support@cheburcheck.ru</a>
.
<a href="mailto:support@cheburcheck.ru">support@cheburcheck.ru</a>.
</p>
<KbHeading id="что-это-за-сайт">Что это за сайт?</KbHeading>
<KbHeading id="что-это-за-сайт" title="Что это за сайт?">
Что это за сайт?
</KbHeading>
<!-- biome-ignore format: link punctuation -->
<p>
Этот сайт - инструмент, который позволяет проверить, находится ли сайт,
домен, IP-адрес в
<a href="#откуда-вы-берете-эти-списки">списках</a>
, блокируемых Роскомнадзором на территории России.
<a href="#откуда-вы-берете-эти-списки">списках</a>, блокируемых Роскомнадзором на территории России.
</p>
<KbHeading id="откуда-вы-берете-эти-списки">
<KbHeading
id="откуда-вы-берете-эти-списки"
title="Откуда вы берете эти списки?"
>
Откуда вы берете эти списки?
</KbHeading>
<p>
@@ -36,60 +41,77 @@ import KbHeading from "$lib/components/kb/KbHeading.svelte";
CDN-провайдеров, которые могут блокироваться Роскомнадзором, а также домены
из самого реестра Роскомнадзора.
</p>
<!-- biome-ignore format: link punctuation -->
<p>
Подсети, которые мы используем, публикуют сами провайдеры, либо собираются
из принадлежащих им автономных сетей. Мы используем
<a href="https://github.com/123jjck/cdn-ip-ranges">этот репозиторий</a>
, в котором автоматически агрегируются эти подсети.
<a href="https://github.com/123jjck/cdn-ip-ranges">этот репозиторий</a>, в котором автоматически агрегируются эти подсети.
</p>
<!-- biome-ignore format: link punctuation -->
<p>
Данные из Реестра Роскомнадзора подгружаются из сервисов
<a href="https://antifilter.download/">antifilter.download</a>
и
<a href="https://antifilter.network/">antifilter.network</a>
.
<a href="https://antifilter.network/">antifilter.network</a>.
</p>
<KbHeading id="что-такое-белый-список">Что такое «белый список»?</KbHeading>
<KbHeading id="что-такое-белый-список" title="Что такое «белый список»?">
Что такое «белый список»?
</KbHeading>
<p>
РКН формирует свой белый список доменов, которые доступны, даже если их
адреса находятся в списке заблокированных подсетей.
</p>
<a href="/kb/whitelist">Подробнее...</a>
<KbHeading id="мне-написало-что-сайт-заблокирован-но-у-меня-все-работает">
<KbHeading
id="что-такое-динамическое-сканирование"
title="Что такое динамическое сканирование?"
>
Что такое динамическое сканирование?
</KbHeading>
<p>
Динамическое сканирование просит внешние сканеры проверить домен «вживую» и
помогает понять, как соединение ведет себя у разных операторов.
</p>
<a href="/kb/probing">Подробнее...</a>
<KbHeading
id="мне-написало-что-сайт-заблокирован-но-у-меня-все-работает"
title="Мне написало, что сайт «Заблокирован», но у меня все работает!"
>
Мне написало, что сайт «Заблокирован», но у меня все работает!
</KbHeading>
<!-- biome-ignore format: link punctuation -->
<p>
Мы не можем с точностью утверждать, заблокирован сайт или нет. Блокировки
Роскомнадзора, зачастую, непредсказуемые, непубличные и незаконные.
Например, блокировка CDN-сервисов, таких, как
<a
href="https://blog.cloudflare.com/russian-internet-users-are-unable-to-access-the-open-internet/"
>
Cloudflare,
</a>
>Cloudflare</a>,
никак не комментировалась официально и не публиковались в Реестре.
Аналогичная ситуация с
<a href="https://ria.ru/20241219/putin-1990168675.html">YouTube,</a>
<a href="https://ria.ru/20241219/putin-1990168675.html">YouTube</a>,
информация о блокировке которого, не добавлена в Реестр &mdash; для таких
случаев мы создали
<a
href="https://github.com/LowderPlay/cheburcheck/blob/master/querying/dist-domains.txt"
>
свой список
</a>
>свой список</a>
подобных сайтов. Однако, список не исчерпывающий, поэтому
<a href="https://github.com/LowderPlay/cheburcheck/pulls">
принимаются правки.
</a>
<a href="https://github.com/LowderPlay/cheburcheck/pulls">принимаются правки</a>.
</p>
<p>
Сайты помечаются как "заблокированные", в случае, если они были найдены хотя
бы в одном списке.
</p>
<KbHeading id="как-часто-обновляются-списки">
<KbHeading
id="как-часто-обновляются-списки"
title="Как часто обновляются списки?"
>
Как часто обновляются списки?
</KbHeading>
<p>Мы обновляем списки из источников раз в 6 часов.</p>
@@ -113,15 +135,20 @@ import KbHeading from "$lib/components/kb/KbHeading.svelte";
</li>
</ul>
<KbHeading id="как-я-могу-помочь-в-развитии-сайта">
<KbHeading
id="как-я-могу-помочь-в-развитии-сайта"
title="Как я могу помочь в развитии сайта?"
>
Как я могу помочь в развитии сайта?
</KbHeading>
<p>Самый простой способ помочь - рассказать о сайте знакомым.</p>
<!-- biome-ignore format: link punctuation -->
<p>
Если у вас есть навыки разработки, то мы будем рады принять ваши
pull-requests на
<a href="https://github.com/LowderPlay/cheburcheck">GitHub.</a>
<a href="https://github.com/LowderPlay/cheburcheck">GitHub</a>.
</p>
<p>Если вы хотите помочь финансово:</p>
<ul class="list-disc">
<li>TON: <code>UQAACsiwpGryjP-kqp4TJPAWpXytuB6M_puuO0Cg5zNvaSJW</code></li>
+142
View File
@@ -0,0 +1,142 @@
<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>
<svelte:head>
<title>Динамическое сканирование - Cheburcheck</title>
<meta name="description" content="Динамическое сканирование">
<meta property="og:title" content="Динамическое сканирование">
<meta
property="og:description"
content="Динамическое сканирование сайтов для обнаружения метода блокировки"
>
<meta property="og:url" content="https://cheburcheck.ru/kb/probing">
</svelte:head>
<KbArticle>
<h1>Динамическое сканирование</h1>
<p>
Обычная проверка Cheburcheck отвечает на вопрос «есть ли домен, IP-адрес или
подсеть в известных списках блокировок». Это полезно, но не всегда
достаточно: часть ограничений не публикуется в открытых реестрах и зависит
от того, как именно оператор связи обрабатывает конкретное соединение.
</p>
<p>
Для таких случаев мы добавили динамическое сканирование. Оно не только
сравнивает домен со списками, но и просит внешние динамические сканеры
попробовать загрузить данные так, как это сделал бы пользователь из своей
сети. Ответы сканеров приходят в реальном времени, поэтому результат может
уточняться прямо на странице проверки.
</p>
<KbNote variant="info">
Динамическое сканирование не заменяет основную проверку по спискам. Оно
помогает понять, как домен ведет себя «вживую» у доступных сканеров.
</KbNote>
<KbHeading id="как-это-работает" title="Как это работает">
Как это работает
</KbHeading>
<p>
Когда вы запускаете динамическую проверку домена, сайт отправляет задачу
доступным сканерам. Каждый сканер получает домен, выполняет несколько
коротких проверок и отправляет ответ обратно. Страница показывает ответы
сразу, не дожидаясь завершения всех проверок.
</p>
<p>
Сканер не пытается открыть сайт целиком или подключиться напрямую к серверу.
Вместо этого он делает небольшую контрольную загрузку с тестовых хостов.
Часть этих хостов используется как условно заблокированные направления,
часть — как контрольные доступные направления. Это помогает отличить разные
типы поведения: обрыв на этапе начала HTTPS-соединения, недогрузку данных
или нормальную передачу.
</p>
<KbHeading id="что-показывает-результат" title="Что показывает результат">
Что показывает результат
</KbHeading>
<p>
В динамическом результате есть общий вердикт и подробности по отдельным
тестовым хостам. Общий вердикт — это краткое обобщение того, что увидел
конкретный сканер.
</p>
<ul class="list-disc">
<li>
<b>Доступен</b> – сайт не найден в списках CDN, не блокируется на
контрольных хостах, но может блокироваться на зарубежных CDN.
</li>
<li>
<b>CDN Блок (16-20)</b> – сайт найден в списках CDN, не блокируется на
контрольных хостах, но блокируется на зарубежных CDN.
</li>
<li>
<b>SNI Блок</b> – похоже на блокировку по домену сайта в начале
HTTPS-соединения. На практике это похоже на ситуацию, когда оборудование
оператора (ТСПУ) видит имя домена и разрывает соединение до передачи
полезных данных.
</li>
<li>
<!-- biome-ignore format: link punctuation -->
<a
class="underline decoration-dotted underline-offset-2"
href="/kb/whitelist"
>Белый список</a> – похоже на блокировку по домену сайта в начале
HTTPS-соединения. Вероятнее всего, указывает на блокировку сайта на ТСПУ.
</li>
<li>
<b>Неясно</b> – данных недостаточно или ответы противоречат друг другу.
</li>
</ul>
<KbHeading id="подробности-по-хостам" title="Подробности по хостам">
Подробности по хостам
</KbHeading>
<p>
У каждого тестового хоста есть собственное наблюдение. Оно помогает понять,
из чего сложился общий вердикт:
</p>
<ul class="list-disc">
<li><b>Успешно</b> — сканер получил достаточно данных.</li>
<li>
<b>Таймаут получения данных</b>
— соединение началось, но данных пришло меньше ожидаемого объёма.
</li>
<li>
<b>Блокировка после ClientHello</b>
— соединение оборвалось на раннем этапе HTTPS.
</li>
<li>
<b>Ошибка подключения</b>
— сканер не смог подключиться к тестовому хосту.
</li>
</ul>
<p>
Один сбойный хост сам по себе не доказывает блокировку. Важно смотреть на
большинство ответов и на то, какие именно хосты дали сбой: контрольные,
проверочные или все сразу.
</p>
<KbHeading
id="почему-результаты-могут-отличаться"
title="Почему результаты могут отличаться"
>
Почему результаты могут отличаться
</KbHeading>
<p>
Блокировки могут отличаться у разных операторов, в разных регионах и даже в
разное время суток. Динамический сканер показывает состояние из той сети,
где он запущен. Поэтому несколько сканеров могут дать разные ответы — это не
ошибка, а часть реальности таких блокировок.
</p>
<p>
Также стоит учитывать временные проблемы: недоступность тестового хоста,
перегрузку сети, потери пакетов или слишком медленное соединение. Поэтому
динамический результат лучше воспринимать как сильный сигнал, а не как
абсолютное юридическое или техническое доказательство.
</p>
</KbArticle>
+16 -12
View File
@@ -67,13 +67,13 @@ const chartProps = {
<KbArticle>
<h1>Белые списки доменов</h1>
<!-- biome-ignore format: link punctuation -->
<KbNote>
Не путать с
<a href="https://habr.com/ru/news/1000784/">
белыми списками <i>мобильного</i> интернета
</a>
!
белыми списками <i>мобильного</i> интернета</a>!
</KbNote>
<p>
Российские операторы связи начали применять новый тип блокировок CDN, при
котором загрузка контента обрывается после передачи примерно 16–20 килобайт
@@ -131,26 +131,28 @@ $ curl -k https://ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve ok.ru:443:5.7
100 65537 100 65537 0 0 55226 0 0:00:01 0:00:01 --:--:-- 55258
</KbCode>
<KbHeading id="автоматическое-сканирование">
<KbHeading
id="автоматическое-сканирование"
title="Автоматическое сканирование"
>
Автоматическое сканирование
</KbHeading>
<!-- biome-ignore format: link punctuation -->
<p>
Используя методы, указанные выше, мы разработали инструмент для
автоматического сканирования и анализа блокировок
<b>
<a href="https://github.com/LowderPlay/cheburcheck/tree/master/reporter">
Cheburcheck Reporter
</a>
</b>
.
Cheburcheck Reporter</a></b>.
</p>
<!-- biome-ignore format: link punctuation -->
<p>
Исходя из анализа 1,000,000 доменов из рейтинга
<a href="https://tranco-list.eu/list/2NPQ9">
Tranco list от 26 ноября 2025
</a>
, в белом списке содержится около 1000 доменов.
Tranco list от 26 ноября 2025</a>, в белом списке содержится около 1000 доменов.
</p>
<i>
* - Мы не включаем в это число домены из зоны .co.uk, так как по какой-то
причине, они все находятся в белом списке.
@@ -189,7 +191,9 @@ $ curl -k https://ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve ok.ru:443:5.7
/>
</KbChartFrame>
<KbHeading id="скачать-списки">Скачать списки</KbHeading>
<KbHeading id="скачать-списки" title="Скачать списки">
Скачать списки
</KbHeading>
<p>Мы публикуем результаты наших сканирований в виде CSV-файлов:</p>
<KbFileLink href="/whitelist/full.csv">Полный список (CSV)</KbFileLink>
<KbFileLink href="/whitelist/domains.csv">Только домены (CSV)</KbFileLink>
+21
View File
@@ -6,6 +6,15 @@ upstream frontend {
server frontend:3000;
}
upstream mqtt_ws {
server rmqtt:8080;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
@@ -21,6 +30,9 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
location /api/v1/ {
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 60s;
proxy_pass http://website_backend;
}
@@ -32,6 +44,15 @@ server {
proxy_pass http://website_backend;
}
location /mqtt {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
proxy_pass http://mqtt_ws;
}
location /feedback/ {
proxy_pass http://website_backend/api/v1/feedback/;
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "probe"
version = "0.1.0"
edition = "2024"
license-file = "../LICENSE"
description = "Dynamic network probe daemon for Cheburcheck"
[[bin]]
name = "cheburprobe"
path = "src/main.rs"
[package.metadata.deb]
name = "cheburprobe"
maintainer = "Lowder <me@lowderplay.dev>"
maintainer-scripts = "debian/"
systemd-units = [
{ unit-name = "cheburprobe", enable = false, start = false },
]
assets = [
["target/release/cheburprobe", "usr/bin/", "755"],
["debian/cheburprobe.default", "etc/default/cheburprobe", "644"],
]
[dependencies]
anyhow = "1.0"
clap = { version = "4.6.0", features = ["derive", "env"] }
env_logger = "0.11.10"
futures = "0.3"
log = { workspace = true }
rumqttc = { version = "0.25", features = ["use-rustls", "websocket"] }
serde = { workspace = true }
serde_json = "1.0"
tokio = { workspace = true }
reports = { path = "../reports" }
rustls = "0.23"
tokio-rustls = "0.26"
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1.10
FROM docker.io/rust:1-slim-bookworm AS build
WORKDIR /build
COPY . .
RUN --mount=type=cache,id=probe-target,target=/build/target \
--mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
set -eux; \
RUSTFLAGS="-C strip=symbols" cargo build --locked --release --package probe --bin cheburprobe; \
cp target/release/cheburprobe ./probe-bin
FROM docker.io/debian:bookworm-slim
RUN groupadd --system app && \
useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=build /build/probe-bin ./probe
USER app
ENTRYPOINT ["./probe"]
+149
View File
@@ -0,0 +1,149 @@
# Cheburcheck Probe
[![Build Probe](https://github.com/LowderPlay/cheburcheck/actions/workflows/probe-build.yml/badge.svg)](https://github.com/LowderPlay/cheburcheck/actions/workflows/probe-build.yml)
Динамический сканер для Cheburcheck.
Подключается к MQTT-брокеру Cheburcheck по WebSocket, получает задания на проверку доменов, выполняет сетевые пробы со своей точки подключения и отправляет результаты обратно на сайт.
Сканер нужен для проверки «изнутри» разных сетей: например, от разных операторов, регионов или хостингов.
Он не принимает итоговое решение сам, а передает технические признаки, по которым Cheburcheck показывает результат пользователю.
## Сборка
Готовые бинарные файлы и Debian-пакеты можно скачать на [странице релизов](https://github.com/LowderPlay/cheburcheck/releases).
На Debian-based дистрибутивах можно собрать пакет через `cargo-deb`:
```shell
cargo deb --package probe -- --bin cheburprobe
```
На прочих дистрибутивах и ОС можно запустить напрямую:
```shell
cargo run --package probe --bin cheburprobe -- \
--probe-id <ID_СКАНЕРА> \
--probe-token <ТОКЕН_СКАНЕРА>
```
Также доступен Docker-образ, который собирается из `probe/Dockerfile`.
## Получение доступа
Для подключения сканера нужен `PROBE_ID` и `PROBE_TOKEN`.
Они должны соответствовать записи в таблице `reporters` на стороне Cheburcheck.
Чтобы получить доступ, напишите на [support@cheburcheck.ru](mailto:support@cheburcheck.ru).
В письме укажите:
- регион;
- интернет-провайдера или хостинг;
- ASN, если он известен;
- где будет запущен сканер: сервер, домашний роутер, микрокомпьютер и так далее.
## Установка как systemd-демона
Самый простой способ установки на Debian-based систему — скачать `.deb` пакет `cheburprobe` со [страницы релизов](https://github.com/LowderPlay/cheburcheck/releases).
Debian-пакет устанавливает systemd unit `cheburprobe.service` и файл конфигурации `/etc/default/cheburprobe`.
Сервис не включается автоматически: сначала нужно указать данные сканера.
1. Установите пакет:
```shell
sudo apt install ./cheburprobe_*.deb
```
2. Настройте `/etc/default/cheburprobe`:
```shell
sudo nano /etc/default/cheburprobe
```
Минимальная конфигурация:
```shell
PROBE_ID=1
PROBE_TOKEN=ваш-токен
MQTT_HOST=wss://cheburcheck.ru/mqtt
MQTT_PORT=443
```
3. Запустите и включите сервис:
```shell
sudo systemctl enable --now cheburprobe.service
```
4. Проверьте статус:
```shell
systemctl status cheburprobe.service
```
5. Посмотрите логи:
```shell
journalctl -u cheburprobe.service -f
```
Сервис запускается с `DynamicUser=yes`, поэтому сканеру не нужен root-доступ.
## Запуск без установки
Пример запуска из исходников:
```shell
PROBE_ID=1 \
PROBE_TOKEN=ваш-токен \
MQTT_HOST=wss://cheburcheck.ru/mqtt \
MQTT_PORT=443 \
cargo run --package probe --bin cheburprobe
```
Пример запуска через Docker:
```shell
docker run --rm \
-e PROBE_ID=1 \
-e PROBE_TOKEN=ваш-токен \
-e MQTT_HOST=wss://cheburcheck.ru/mqtt \
-e MQTT_PORT=443 \
ghcr.io/lowderplay/cheburcheck-probe:latest
```
## Конфигурация
| Параметр | Описание | Значение по умолчанию |
| --- | --- | --- |
| `--mqtt-host`, `MQTT_HOST` | Адрес MQTT-брокера по WebSocket. Поддерживаются `ws://` и `wss://`. | `wss://cheburcheck.ru/mqtt` |
| `--mqtt-port`, `MQTT_PORT` | Порт MQTT-брокера. | `443` |
| `--mqtt-connection-timeout-secs`, `MQTT_CONNECTION_TIMEOUT_SECS` | Таймаут подключения к MQTT-брокеру. | `30` |
| `--probe-id`, `PROBE_ID` | ID сканера. | обязательно |
| `--probe-token`, `PROBE_TOKEN` | Секретный токен сканера. | обязательно |
| `--max-concurrent-tasks`, `MAX_CONCURRENT_TASKS` | Максимальное количество одновременных заданий. | `8` |
| `RUST_LOG` | Уровень логирования. | `info` |
`MAX_CONCURRENT_TASKS` должен быть больше нуля.
## Как работает проверка
После подключения сканер:
1. публикует retained-статус `online` в MQTT;
2. подписывается на конфигурацию динамического сканирования;
3. получает задания на проверку доменов;
4. параллельно проверяет домен на настроенных тестовых хостах;
5. отправляет результат обратно в Cheburcheck.
Для каждого тестового хоста сканер открывает TCP-соединение, начинает TLS-handshake с проверяемым доменом в SNI, затем отправляет простой HTTP GET-запрос.
Проверка намеренно отключает валидацию TLS-сертификата, потому что измеряется доступность соединения, а не доверие к сертификату.
## Диагностика
Если сканер не подключается:
- проверьте `PROBE_ID` и `PROBE_TOKEN`;
- убедитесь, что `MQTT_HOST` начинается с `ws://` или `wss://`;
- проверьте доступность `MQTT_HOST:MQTT_PORT` с сервера;
- посмотрите логи через `journalctl -u cheburprobe.service -f`;
- временно установите `RUST_LOG=debug`.
+12
View File
@@ -0,0 +1,12 @@
# Required: reporter id and token. You can request them at support@cheburcheck.ru.
PROBE_ID=
PROBE_TOKEN=
# MQTT broker
MQTT_HOST=wss://cheburcheck.ru/mqtt
MQTT_PORT=443
# Optional tuning.
MQTT_CONNECTION_TIMEOUT_SECS=30
MAX_CONCURRENT_TASKS=8
RUST_LOG=info
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Cheburcheck dynamic probe
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
EnvironmentFile=/etc/default/cheburprobe
ExecStart=/usr/bin/cheburprobe
Restart=always
RestartSec=5s
DynamicUser=yes
LimitNOFILE=16384
[Install]
WantedBy=multi-user.target
+398
View File
@@ -0,0 +1,398 @@
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 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::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(Parser, Debug, Clone)]
#[command(author, version, about = "Dynamic probing daemon")]
struct Args {
#[arg(long, env = "MQTT_HOST", default_value = "wss://cheburcheck.ru/mqtt")]
mqtt_host: String,
#[arg(long, env = "MQTT_PORT", default_value_t = 443)]
mqtt_port: u16,
#[arg(long, env = "MQTT_CONNECTION_TIMEOUT_SECS", default_value_t = 30)]
mqtt_connection_timeout_secs: u64,
#[arg(long, env = "PROBE_ID")]
probe_id: String,
#[arg(long, env = "PROBE_TOKEN")]
probe_token: String,
#[arg(long, env = "MAX_CONCURRENT_TASKS", default_value_t = 8)]
max_concurrent_tasks: usize,
}
#[tokio::main]
async fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse();
if args.max_concurrent_tasks == 0 {
bail!("max_concurrent_tasks must be greater than zero");
}
let status_topic = format!("probe/status/v1/{}", args.probe_id);
let offline_status = serde_json::to_vec(&ProbeStatus {
online: false,
probe_id: &args.probe_id,
version: env!("CARGO_PKG_VERSION"),
})?;
let mut options = MqttOptions::new(&args.probe_id, &args.mqtt_host, args.mqtt_port);
options.set_transport(mqtt_transport(&args.mqtt_host)?);
options.set_credentials("probe", &args.probe_token);
options.set_keep_alive(Duration::from_secs(10));
options.set_last_will(LastWill::new(
status_topic.clone(),
offline_status,
QoS::AtLeastOnce,
true,
));
let (client, mut eventloop) = AsyncClient::new(options, 100);
let config = Arc::new(RwLock::new(None));
let task_semaphore = Arc::new(tokio::sync::Semaphore::new(args.max_concurrent_tasks));
let mut network_options = NetworkOptions::new();
network_options.set_connection_timeout(args.mqtt_connection_timeout_secs);
eventloop.set_network_options(network_options);
wait_for_connection(&mut eventloop).await;
publish_status(&client, &status_topic, &args, true).await?;
client.subscribe(CONFIG_TOPIC, QoS::AtLeastOnce).await?;
client
.subscribe("probe/tasks/v1/+", QoS::AtLeastOnce)
.await?;
info!(
"probe {} connected over WebSocket to {}",
args.probe_id, args.mqtt_host
);
loop {
match eventloop.poll().await {
Ok(Event::Incoming(Incoming::Publish(publish))) => {
if publish.topic == CONFIG_TOPIC {
if let Err(error) = update_config(&config, &publish.payload).await {
warn!("failed to update probe config: {error}");
}
} else {
let client = client.clone();
let args = args.clone();
let config = config.clone();
let semaphore = task_semaphore.clone();
let topic = publish.topic;
let payload = publish.payload.to_vec();
let received_at = Instant::now();
tokio::spawn(async move {
let task: ProbeTask =
match serde_json::from_slice(&payload).context("decode probe task") {
Ok(task) => task,
Err(error) => {
warn!("failed to decode task on {topic}: {error}");
return;
}
};
let permit = match semaphore.acquire_owned().await {
Ok(permit) => permit,
Err(error) => {
warn!("failed to acquire task permit: {error}");
return;
}
};
if let Err(error) =
handle_task(&client, &args, &config, &topic, task, received_at).await
{
warn!("failed to handle task on {topic}: {error}");
}
drop(permit);
});
}
}
Ok(_) => {}
Err(error) => {
error!("mqtt connection error: {error}");
wait_for_connection(&mut eventloop).await;
publish_status(&client, &status_topic, &args, true).await?;
client.subscribe(CONFIG_TOPIC, QoS::AtLeastOnce).await?;
client
.subscribe("probe/tasks/v1/+", QoS::AtLeastOnce)
.await?;
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
fn mqtt_transport(mqtt_host: &str) -> Result<Transport> {
if mqtt_host.starts_with("wss://") {
Ok(Transport::wss_with_default_config())
} else if mqtt_host.starts_with("ws://") {
Ok(Transport::Ws)
} else {
bail!("MQTT_HOST must start with ws:// or wss://");
}
}
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);
info!("updated retained probe config");
Ok(())
}
async fn wait_for_connection(eventloop: &mut rumqttc::EventLoop) {
loop {
match eventloop.poll().await {
Ok(Event::Incoming(Incoming::ConnAck(_))) => {
info!("mqtt connection established");
return;
}
Ok(event) => {
info!("mqtt event before connection: {event:?}");
}
Err(error) => {
error!("mqtt connection error while waiting for CONNACK: {error}");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
async fn publish_status(
client: &AsyncClient,
topic: &str,
args: &Args,
online: bool,
) -> Result<()> {
let payload = serde_json::to_vec(&ProbeStatus {
online,
probe_id: &args.probe_id,
version: env!("CARGO_PKG_VERSION"),
})?;
client
.publish(topic, QoS::AtLeastOnce, true, payload)
.await
.context("publish probe status")
}
async fn handle_task(
client: &AsyncClient,
args: &Args,
config: &Arc<RwLock<Option<ProbeConfig>>>,
topic: &str,
task: ProbeTask<'_>,
received_at: Instant,
) -> Result<()> {
let job_id = topic
.strip_prefix("probe/tasks/v1/")
.filter(|id| !id.is_empty())
.unwrap_or(&task.id);
let timeout = Duration::from_millis(task.timeout_ms);
let Some(remaining) = timeout.checked_sub(received_at.elapsed()) else {
warn!(
"dropping expired queued task {job_id}: timeout {}ms",
task.timeout_ms
);
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 result = match time::timeout(remaining, probing).await {
Ok(result) => result,
Err(_) => {
warn!(
"dropping expired task {job_id}: timeout {}ms",
task.timeout_ms
);
return Ok(());
}
};
client
.publish(
result_topic,
QoS::AtLeastOnce,
false,
serde_json::to_vec(&result)?,
)
.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,
]
}
}
+15
View File
@@ -39,6 +39,7 @@ pub struct Check {
pub verdict: CheckVerdict,
pub geo: IpInfo,
pub ips: Vec<IpAddr>,
pub reverse_lookup: Vec<String>,
pub rkn_subnets: HashSet<IpNet>,
pub asn_info: Option<asn::AsnInfo>,
}
@@ -97,6 +98,19 @@ impl Checker {
return Err(CheckError::ResolveError(e));
}
};
let reverse_lookup = if let Some(ip) = ips.get(0).cloned() {
match self.resolver.lookup_ptr(ip).await {
Ok(ptr) => ptr,
Err(e) => {
error!("ptr lookup error: {}", e);
vec![]
}
}
} else {
vec![]
};
let geo_ip = self.geo_ip.load();
let geo = match ips.get(0).map(|ip| geo_ip.lookup(ip.clone())) {
None => IpInfo::default(),
@@ -194,6 +208,7 @@ impl Checker {
rkn_subnets,
geo,
ips,
reverse_lookup,
asn_info,
})
}
+29 -9
View File
@@ -2,6 +2,7 @@ use hickory_resolver::config::{LookupIpStrategy, ResolverConfig, ResolverOpts};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::ProtoError;
use hickory_resolver::proto::rr::RData;
use std::io::{Error, ErrorKind};
use std::net::IpAddr;
use std::sync::Arc;
@@ -63,16 +64,35 @@ impl Resolver {
.resolver
.lookup_ip(domain)
.await
.map_err(|e| match e {
NetError::Dns(DnsError::NoRecordsFound(..)) => ResolveError::NxDomain,
NetError::Proto(ProtoError::Msg(msg))
if msg.contains("Malformed label") || msg.contains("invalid characters") =>
{
ResolveError::NxDomain
}
_ => ResolveError::Other(Error::new(ErrorKind::Other, e)),
})?
.map_err(map_resolve_error)?
.iter()
.collect())
}
pub async fn lookup_ptr(&self, ip: IpAddr) -> Result<Vec<String>, ResolveError> {
Ok(self
.resolver
.reverse_lookup(ip)
.await
.map_err(map_resolve_error)?
.answers()
.iter()
.filter_map(|record| match record.data() {
RData::PTR(ptr) => Some(ptr.to_string().trim_end_matches('.').to_string()),
_ => None,
})
.collect())
}
}
fn map_resolve_error(error: NetError) -> ResolveError {
match error {
NetError::Dns(DnsError::NoRecordsFound(..)) => ResolveError::NxDomain,
NetError::Proto(ProtoError::Msg(msg))
if msg.contains("Malformed label") || msg.contains("invalid characters") =>
{
ResolveError::NxDomain
}
_ => ResolveError::Other(Error::new(ErrorKind::Other, error)),
}
}
+2 -2
View File
@@ -49,10 +49,10 @@ impl From<&str> for Target {
if let Ok(url) = input.parse::<Url>() {
if let Some(host) = url.host_str() {
return Target::Domain(host.to_string());
return Target::Domain(host.trim_end_matches('.').to_string());
}
}
Target::Domain(input.to_string())
Target::Domain(input.trim_end_matches('.').to_string())
}
}
+2
View File
@@ -1,3 +1,5 @@
pub mod probe;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Display;
+63
View File
@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub struct ProbeStatus<'a> {
pub online: bool,
pub probe_id: &'a str,
pub version: &'a str,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ProbeConfig {
pub version: String,
pub task_timeout_ms: u64,
pub published_at: String,
pub hosts: Vec<Host>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Host {
pub id: String,
pub host: String,
pub host_type: HostType,
pub file_path: String,
pub timeout_sec: u32,
pub min_data: u32,
}
#[derive(Clone, Serialize, Deserialize)]
pub enum HostType {
Whitelist,
Blacklist,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ProbeTask<'a> {
pub id: String,
pub query_id: String,
pub target: &'a str,
pub created_at: String,
pub timeout_ms: u64,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ProbeResultEvent {
pub job_id: String,
pub probe_id: String,
pub host_results: Vec<HostProbeResult>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct HostProbeResult {
pub host_id: String,
pub probe_evidence: ProbeEvidence,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ProbeEvidence {
ConnectionError,
ClientHello,
DataTimeout { bytes: u32 },
Good,
}
+23
View File
@@ -0,0 +1,23 @@
http_timeout = "5s"
disconnect_if_pub_rejected = true
disconnect_if_expiry = false
deny_if_error = true
[http_headers]
accept = "application/json"
Cache-Control = "no-cache"
User-Agent = "RMQTT/blocklist-check"
Connection = "keep-alive"
[http_auth_req]
url = "http://website:8000/mqtt/auth"
method = "post"
headers = { content-type = "application/x-www-form-urlencoded" }
params = { clientid = "%c", username = "%u", password = "%P", protocol = "%r" }
[http_acl_req]
url = "http://website:8000/mqtt/acl"
method = "post"
headers = { content-type = "application/x-www-form-urlencoded" }
params = { access = "%A", username = "%u", clientid = "%c", ipaddr = "%a", topic = "%t", protocol = "%r" }
+5
View File
@@ -0,0 +1,5 @@
storage.type = "ram"
max_retained_messages = 0
max_payload_size = "1MB"
retained_message_ttl = "0m"
+31
View File
@@ -0,0 +1,31 @@
[log]
to = "console"
level = "info"
dir = "/var/log/rmqtt"
file = "rmqtt.log"
[plugins]
dir = "rmqtt-plugins/"
default_startups = [
"rmqtt-auth-http",
"rmqtt-retainer",
]
[listener.tcp.external]
addr = "0.0.0.0:1883"
allow_anonymous = false
max_packet_size = "1MB"
retain_available = true
[listener.ws.external]
addr = "0.0.0.0:8080"
allow_anonymous = false
max_packet_size = "1MB"
retain_available = true
[listener.tcp.internal]
enable = true
addr = "0.0.0.0:11883"
allow_anonymous = false
max_packet_size = "1MB"
retain_available = true
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "website"
version = "1.0.0"
version = "1.1.0"
edition = "2024"
[dependencies]
@@ -16,6 +16,8 @@ rocket-cache-response = "0.6.4"
log = { workspace = true }
dotenvy = { version = "0.15.7" }
governor = { version = "0.6", features = ["dashmap"] }
rumqttc = "0.24"
toml = "0.8"
[build-dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }
@@ -0,0 +1,21 @@
ALTER TABLE
reporters
ADD
COLUMN IF NOT EXISTS region VARCHAR(255),
ADD
COLUMN IF NOT EXISTS asn VARCHAR(32),
ADD
COLUMN IF NOT EXISTS provider VARCHAR(255);
CREATE TABLE IF NOT EXISTS probe_reports (
id BIGSERIAL PRIMARY KEY,
query_id UUID NOT NULL REFERENCES queries (id) ON DELETE CASCADE,
probe_id INT NOT NULL REFERENCES reporters (id) ON DELETE CASCADE,
date TIMESTAMP DEFAULT NOW(),
verdict VARCHAR(32) NOT NULL,
result JSONB NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS probe_reports_query_probe_idx ON probe_reports (query_id, probe_id);
CREATE INDEX IF NOT EXISTS probe_reports_probe_date_idx ON probe_reports (probe_id, date DESC);
+44
View File
@@ -0,0 +1,44 @@
timeout_sec = 3
min_data = 65536
[[hosts]]
id = "hil-hetzner" # hil-speed.hetzner.com
host = "5.78.7.195"
host_type = "Blacklist"
file_path = "100MB.bin"
[[hosts]]
id = "hil-ovh" # hil.proof.ovh.us
host = "51.81.154.196"
host_type = "Blacklist"
file_path = "files/1Mb.dat"
[[hosts]]
id = "sbg-ovh" # sbg.proof.ovh.net
host = "51.91.75.40"
host_type = "Blacklist"
file_path = "files/1Mb.dat"
[[hosts]]
id = "fra-akamai" # speedtest.frankfurt.linode.com
host = "139.162.130.8"
host_type = "Blacklist"
file_path = "100MB-frankfurt.bin"
[[hosts]]
id = "lon-akamai" # speedtest.london.linode.com
host = "176.58.107.39"
host_type = "Blacklist"
file_path = "100MB-london.bin"
[[hosts]]
id = "msk1-selectel" # speedtest.selectel.ru
host = "188.93.16.211"
host_type = "Whitelist"
file_path = "10MB"
[[hosts]]
id = "msk2-selectel" # speedtest-backend01.foxfordschool.com
host = "31.128.51.92"
host_type = "Whitelist"
file_path = "api/download?bytes=100000"
+12 -184
View File
@@ -1,185 +1,13 @@
use crate::db::{WhitelistedEntry, check_whitelist, save_query};
use governor::clock::DefaultClock;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::{Quota, RateLimiter};
use log::warn;
use querying::asn::AsnInfo;
use querying::geoip::IpInfo;
use querying::lists::NetworkRecord;
use querying::target::Target;
use querying::{Check, CheckError, CheckVerdict, Checker};
use rocket::State;
use rocket::http::Status;
use rocket::serde::json::Json;
use rocket::tokio::sync::RwLock;
use rocket_client_addr::ClientRealAddr;
use serde::Serialize;
use sqlx::postgres::PgPool;
use sqlx::types::Uuid;
use sqlx::types::chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::net::IpAddr;
use std::num::NonZeroU32;
use std::sync::Arc;
#[path = "api/check.rs"]
mod check_endpoint;
#[path = "api/feedback.rs"]
mod feedback_endpoint;
mod probe;
mod rate_limit;
mod status;
pub type ApiRateLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
pub fn build_rate_limiter(per_minute: u32) -> ApiRateLimiter {
RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).expect("rate limit must be > 0"),
))
}
#[derive(Serialize)]
pub struct ApiCheckResponse {
pub id: Option<String>,
pub target: String,
pub target_type: String,
pub blocked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rkn_domain: Option<String>,
pub ips: Vec<String>,
pub blocked_subnets: Vec<String>,
pub cdn_providers: HashMap<String, Vec<NetworkRecord>>,
pub geo: IpInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub asn_info: Option<AsnInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub whitelist: Option<WhitelistedEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subnet_size: Option<String>,
}
fn build_response(
id: Option<String>,
target: &Target,
check: Check,
whitelist: Option<WhitelistedEntry>,
) -> ApiCheckResponse {
let (blocked, rkn_domain, cdn_providers) = match check.verdict {
CheckVerdict::Blocked {
rkn_domain,
cdn_provider_subnets,
} => {
let providers: HashMap<String, Vec<NetworkRecord>> = cdn_provider_subnets
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect();
(true, rkn_domain, providers)
}
CheckVerdict::Clear => (false, None, HashMap::new()),
};
ApiCheckResponse {
id,
target: target.to_query(),
target_type: target.readable_type().to_string(),
blocked,
rkn_domain,
ips: check.ips.iter().map(|ip| ip.to_string()).collect(),
blocked_subnets: check.rkn_subnets.iter().map(|n| n.to_string()).collect(),
cdn_providers,
geo: check.geo,
asn_info: check.asn_info,
whitelist,
subnet_size: target.subnet_size(),
}
}
#[get("/check?<target>")]
pub async fn check(
target: &str,
checker: &State<Arc<RwLock<Checker>>>,
addr: &ClientRealAddr,
pool: &State<PgPool>,
limiter: &State<Arc<ApiRateLimiter>>,
) -> Result<Json<ApiCheckResponse>, Status> {
if limiter.check_key(&addr.ip).is_err() {
return Err(Status::TooManyRequests);
}
let target = Target::from(target.trim());
let check = checker.read().await.check(target.clone()).await;
let mut db = pool
.acquire()
.await
.map_err(|_| Status::InternalServerError)?;
let id: Option<String> = if let Ok(check) = &check {
match save_query(&mut *db, &target, check, addr, checker.read().await).await {
Ok(id) => Some(id.to_string()),
Err(e) => {
warn!("api: failed to save check: {:?}", e);
None
}
}
} else {
None
};
let whitelist: Option<WhitelistedEntry> = if let Target::Domain(domain) = &target {
check_whitelist(domain, &mut *db)
.await
.map_err(|_| Status::InternalServerError)?
} else {
None
};
match check {
Err(CheckError::NotFound) => Err(Status::NotFound),
Ok(check) => Ok(Json(build_response(id, &target, check, whitelist))),
Err(e) => {
log::error!("api check failed {:?}", e);
Err(Status::InternalServerError)
}
}
}
#[get("/healthcheck")]
pub async fn healthcheck(checker: &State<Arc<RwLock<Checker>>>) -> (Status, String) {
if checker.read().await.last_update().is_some() {
(Status::Ok, "OK".to_string())
} else {
(Status::InternalServerError, "LOADING DATABASES".to_string())
}
}
#[post("/feedback/<uuid>/<works>")]
pub async fn feedback(
uuid: &str,
works: bool,
pool: &State<PgPool>,
addr: &ClientRealAddr,
) -> Result<(), Status> {
sqlx::query!(
"INSERT INTO human_reports (id, source_ip, works) VALUES ($1, $2, $3)",
Uuid::try_parse(uuid).map_err(|_| Status::BadRequest)?,
addr.ip.to_string(),
works
)
.execute(&**pool)
.await
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
#[derive(Serialize)]
pub struct ApiStatusResponse {
domain_count: usize,
v4_count: usize,
last_update: Option<DateTime<Utc>>,
version: &'static str,
}
#[get("/status")]
pub async fn get_system_status(checker: &State<Arc<RwLock<Checker>>>) -> Json<ApiStatusResponse> {
let checker_ref = checker.read().await;
Json(ApiStatusResponse {
domain_count: checker_ref.total_domains().await,
v4_count: checker_ref.total_v4s().await,
last_update: checker_ref.last_update(),
version: env!("CARGO_PKG_VERSION"),
})
}
pub use check_endpoint::check;
pub use feedback_endpoint::feedback;
pub use probe::probe_query;
pub use rate_limit::build_rate_limiter;
pub use status::{get_system_status, healthcheck};
+125
View File
@@ -0,0 +1,125 @@
use super::rate_limit::ApiRateLimiter;
use crate::db::{WhitelistedEntry, check_whitelist, save_query};
use log::warn;
use querying::asn::AsnInfo;
use querying::geoip::IpInfo;
use querying::lists::NetworkRecord;
use querying::target::Target;
use querying::{Check, CheckError, CheckVerdict, Checker};
use rocket::State;
use rocket::http::Status;
use rocket::serde::json::Json;
use rocket::tokio::sync::RwLock;
use rocket_client_addr::ClientRealAddr;
use serde::Serialize;
use sqlx::postgres::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Serialize)]
pub struct ApiCheckResponse {
pub id: Option<String>,
pub target: String,
pub target_type: String,
pub blocked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rkn_domain: Option<String>,
pub ips: Vec<String>,
pub blocked_subnets: Vec<String>,
pub cdn_providers: HashMap<String, Vec<NetworkRecord>>,
pub geo: IpInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub asn_info: Option<AsnInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub whitelist: Option<WhitelistedEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subnet_size: Option<String>,
pub reverse_lookup: Vec<String>,
}
#[get("/check?<target>")]
pub async fn check(
target: &str,
checker: &State<Arc<RwLock<Checker>>>,
addr: &ClientRealAddr,
pool: &State<PgPool>,
limiter: &State<Arc<ApiRateLimiter>>,
) -> Result<Json<ApiCheckResponse>, Status> {
if limiter.check_key(&addr.ip).is_err() {
return Err(Status::TooManyRequests);
}
let target = Target::from(target.trim());
let check = checker.read().await.check(target.clone()).await;
let mut db = pool
.acquire()
.await
.map_err(|_| Status::InternalServerError)?;
let id: Option<String> = if let Ok(check) = &check {
match save_query(&mut *db, &target, check, addr, checker.read().await).await {
Ok(id) => Some(id.to_string()),
Err(e) => {
warn!("api: failed to save check: {:?}", e);
None
}
}
} else {
None
};
let whitelist: Option<WhitelistedEntry> = if let Target::Domain(domain) = &target {
check_whitelist(domain, &mut *db)
.await
.map_err(|_| Status::InternalServerError)?
} else {
None
};
match check {
Err(CheckError::NotFound) => Err(Status::NotFound),
Ok(check) => Ok(Json(build_response(id, &target, check, whitelist))),
Err(e) => {
log::error!("api check failed {:?}", e);
Err(Status::InternalServerError)
}
}
}
fn build_response(
id: Option<String>,
target: &Target,
check: Check,
whitelist: Option<WhitelistedEntry>,
) -> ApiCheckResponse {
let (blocked, rkn_domain, cdn_providers) = match check.verdict {
CheckVerdict::Blocked {
rkn_domain,
cdn_provider_subnets,
} => {
let providers: HashMap<String, Vec<NetworkRecord>> = cdn_provider_subnets
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect();
(true, rkn_domain, providers)
}
CheckVerdict::Clear => (false, None, HashMap::new()),
};
ApiCheckResponse {
id,
target: target.to_query(),
target_type: target.readable_type().to_string(),
blocked,
rkn_domain,
ips: check.ips.iter().map(|ip| ip.to_string()).collect(),
reverse_lookup: check.reverse_lookup,
blocked_subnets: check.rkn_subnets.iter().map(|n| n.to_string()).collect(),
cdn_providers,
geo: check.geo,
asn_info: check.asn_info,
whitelist,
subnet_size: target.subnet_size(),
}
}
+25
View File
@@ -0,0 +1,25 @@
use rocket::State;
use rocket::http::Status;
use rocket_client_addr::ClientRealAddr;
use sqlx::postgres::PgPool;
use sqlx::types::Uuid;
#[post("/feedback/<uuid>/<works>")]
pub async fn feedback(
uuid: &str,
works: bool,
pool: &State<PgPool>,
addr: &ClientRealAddr,
) -> Result<(), Status> {
sqlx::query!(
"INSERT INTO human_reports (id, source_ip, works) VALUES ($1, $2, $3)",
Uuid::try_parse(uuid).map_err(|_| Status::BadRequest)?,
addr.ip.to_string(),
works
)
.execute(&**pool)
.await
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
+308
View File
@@ -0,0 +1,308 @@
use super::rate_limit::ApiRateLimiter;
use crate::mqtt::{MqttPublisher, PublishError};
use log::warn;
use querying::target::Target;
use reports::probe::{
Host, HostProbeResult, HostType, ProbeConfig, ProbeEvidence, ProbeResultEvent,
};
use rocket::State;
use rocket::http::Status;
use rocket::response::stream::{Event, EventStream};
use rocket::serde::json::serde_json::Value;
use rocket::serde::json::serde_json::json;
use rocket::tokio::time;
use rocket_client_addr::ClientRealAddr;
use sqlx::postgres::PgPool;
use sqlx::types::Uuid;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[derive(sqlx::FromRow)]
pub struct ProbeReporterInfo {
pub region: Option<String>,
pub provider: Option<String>,
pub asn: Option<String>,
}
#[get("/probe/<id>")]
pub async fn probe_query(
id: &str,
addr: &ClientRealAddr,
pool: &State<PgPool>,
mqtt: &State<MqttPublisher>,
limiter: &State<Arc<ApiRateLimiter>>,
) -> Result<EventStream![Event], Status> {
if limiter.check_key(&addr.ip).is_err() {
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 = query.ok_or(Status::NotFound)?;
let Target::Domain(domain) = Target::from(query.trim()) else {
return Err(Status::BadRequest);
};
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)
.await
.map_err(|error| {
warn!("api: failed to publish probe task for {id}: {error}");
publish_error_status(error)
})?;
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();
Ok(EventStream! {
let mut responded_probes = HashSet::new();
let timeout = time::sleep(timeout);
rocket::tokio::pin!(timeout);
yield Event::data(json!({
"id": id,
"target": domain,
"online_probes": online_probes,
}).to_string()).event("started");
loop {
if responded_probes.len() >= online_probes {
yield done_event(&id, responded_probes.len(), online_probes);
break;
}
rocket::tokio::select! {
result = results.recv() => {
match result {
Ok(result) => {
responded_probes.insert(result.probe_id.clone());
let reporter_info = match fetch_probe_reporter_info(&result.probe_id, &pool).await {
Ok(info) => info,
Err(error) => {
warn!(
"api: failed to fetch reporter info for probe {}: {}",
result.probe_id, error
);
None
}
};
let response = build_probe_response(result, &probe_config, reporter_info);
if let Err(error) = insert_probe_report(query_id, &response, &pool).await {
warn!("api: failed to save probe report for query {id}: {error}");
}
yield Event::data(response.to_string()).event("result");
}
Err(rocket::tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
continue;
}
Err(rocket::tokio::sync::broadcast::error::RecvError::Closed) => {
break;
}
}
}
_ = &mut timeout => {
yield done_event(&id, responded_probes.len(), online_probes);
break;
}
}
}
})
}
pub fn build_probe_response(
raw: ProbeResultEvent,
config: &ProbeConfig,
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 region = reporter_info.as_ref().and_then(|info| info.region.as_ref());
let provider = reporter_info
.as_ref()
.and_then(|info| info.provider.as_ref());
let asn = reporter_info.as_ref().and_then(|info| info.asn.as_ref());
let host_results = raw
.host_results
.into_iter()
.filter_map(|result| {
let host = hosts.get(&result.host_id)?;
Some(json!({
"host_id": result.host_id,
"host": host.host_type,
"probe_evidence": result.probe_evidence,
}))
})
.collect::<Vec<_>>();
json!({
"job_id": raw.job_id,
"probe_id": raw.probe_id,
"region": region,
"provider": provider,
"asn": asn,
"verdict": verdict,
"host_results": host_results,
})
}
async fn insert_probe_report(
query_id: Uuid,
response: &Value,
pool: &PgPool,
) -> Result<(), sqlx::Error> {
let probe_id = response
.get("probe_id")
.and_then(Value::as_str)
.and_then(|probe_id| probe_id.parse::<i32>().ok());
let verdict = response
.get("verdict")
.and_then(Value::as_str)
.unwrap_or("uncertain");
let Some(probe_id) = probe_id else {
warn!("api: ignoring probe report with non-numeric probe_id");
return Ok(());
};
sqlx::query(
r#"
INSERT INTO probe_reports (query_id, probe_id, verdict, result)
VALUES ($1, $2, $3, $4)
ON CONFLICT (query_id, probe_id)
DO UPDATE SET
date = NOW(),
verdict = EXCLUDED.verdict,
result = EXCLUDED.result
"#,
)
.bind(query_id)
.bind(probe_id)
.bind(verdict)
.bind(response)
.execute(pool)
.await?;
Ok(())
}
async fn fetch_probe_reporter_info(
probe_id: &str,
pool: &PgPool,
) -> Result<Option<ProbeReporterInfo>, sqlx::Error> {
sqlx::query_as::<_, ProbeReporterInfo>(
"SELECT region, provider, asn FROM reporters WHERE id = $1 LIMIT 1",
)
.bind(probe_id.parse::<i32>().unwrap_or(-1))
.fetch_optional(pool)
.await
}
fn build_probe_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'static str {
let matched = results
.iter()
.filter_map(|result| {
config
.hosts
.iter()
.find(|host| host.id == result.host_id)
.map(|host| (host, &result.probe_evidence))
})
.collect::<Vec<_>>();
if matched.is_empty() {
return "uncertain";
}
if is_strict_majority(
matched.len(),
matched
.iter()
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::ClientHello))
.count(),
) {
return "sni_block";
}
if is_strict_majority(
matched.len(),
matched
.iter()
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::Good))
.count(),
) {
return "whitelist";
}
let blacklist = matched
.iter()
.filter(|(host, _)| matches!(host.host_type, HostType::Blacklist))
.collect::<Vec<_>>();
let whitelist = matched
.iter()
.filter(|(host, _)| matches!(host.host_type, HostType::Whitelist))
.collect::<Vec<_>>();
let most_blacklist_timed_out = !blacklist.is_empty()
&& is_strict_majority(
blacklist.len(),
blacklist
.iter()
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::DataTimeout { .. }))
.count(),
);
let most_whitelist_good = !whitelist.is_empty()
&& is_strict_majority(
whitelist.len(),
whitelist
.iter()
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::Good))
.count(),
);
if most_blacklist_timed_out && most_whitelist_good {
"ok"
} else {
"uncertain"
}
}
fn publish_error_status(error: PublishError) -> Status {
match error {
PublishError::NotConfigured => Status::ServiceUnavailable,
PublishError::Config(_)
| PublishError::ConfigParse(_)
| PublishError::Serialize(_)
| PublishError::Subscribe(_)
| PublishError::Publish(_) => Status::InternalServerError,
}
}
fn done_event(id: &str, response_count: usize, online_probes: usize) -> Event {
Event::data(
json!({
"id": id,
"status": "done",
"response_count": response_count,
"online_probes": online_probes,
})
.to_string(),
)
.event("done")
}
fn is_strict_majority(total: usize, count: usize) -> bool {
count > total / 2
}
+13
View File
@@ -0,0 +1,13 @@
use governor::clock::DefaultClock;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::{Quota, RateLimiter};
use std::net::IpAddr;
use std::num::NonZeroU32;
pub type ApiRateLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
pub fn build_rate_limiter(per_minute: u32) -> ApiRateLimiter {
RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).expect("rate limit must be > 0"),
))
}
+36
View File
@@ -0,0 +1,36 @@
use querying::Checker;
use rocket::State;
use rocket::http::Status;
use rocket::serde::json::Json;
use rocket::tokio::sync::RwLock;
use serde::Serialize;
use sqlx::types::chrono::{DateTime, Utc};
use std::sync::Arc;
#[get("/healthcheck")]
pub async fn healthcheck(checker: &State<Arc<RwLock<Checker>>>) -> (Status, String) {
if checker.read().await.last_update().is_some() {
(Status::Ok, "OK".to_string())
} else {
(Status::InternalServerError, "LOADING DATABASES".to_string())
}
}
#[derive(Serialize)]
pub struct ApiStatusResponse {
domain_count: usize,
v4_count: usize,
last_update: Option<DateTime<Utc>>,
version: &'static str,
}
#[get("/status")]
pub async fn get_system_status(checker: &State<Arc<RwLock<Checker>>>) -> Json<ApiStatusResponse> {
let checker_ref = checker.read().await;
Json(ApiStatusResponse {
domain_count: checker_ref.total_domains().await,
v4_count: checker_ref.total_v4s().await,
last_update: checker_ref.last_update(),
version: env!("CARGO_PKG_VERSION"),
})
}
+6
View File
@@ -3,6 +3,8 @@ extern crate rocket;
mod agency;
mod api;
mod db;
mod mqtt;
mod mqtt_auth;
mod whitelist;
use env_logger::Env;
@@ -84,6 +86,7 @@ async fn rocket() -> _ {
.parse()
.unwrap_or(30);
let api_limiter = std::sync::Arc::new(api::build_rate_limiter(rate_limit_rpm));
let mqtt_publisher = mqtt::MqttPublisher::start_from_env();
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(
@@ -108,11 +111,13 @@ async fn rocket() -> _ {
.manage(checker)
.manage(pool)
.manage(api_limiter)
.manage(mqtt_publisher)
.attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations))
.mount(
"/api/v1",
routes![
api::check,
api::probe_query,
api::healthcheck,
api::feedback,
api::get_system_status,
@@ -120,6 +125,7 @@ async fn rocket() -> _ {
],
)
.mount("/agency", routes![agency::upload_report])
.mount("/mqtt", routes![mqtt_auth::auth, mqtt_auth::acl])
.mount("/whitelist", routes![whitelist::export_csv])
.register("/", catchers![api_error])
}
+377
View File
@@ -0,0 +1,377 @@
use log::{info, warn};
use reports::probe::HostType;
use reports::probe::{Host, ProbeConfig, ProbeResultEvent, ProbeStatus, ProbeTask};
use rocket::serde::json::serde_json;
use rumqttc::{AsyncClient, Event as MqttEvent, Incoming, MqttOptions, QoS};
use serde::Deserialize;
use sqlx::types::Uuid;
use sqlx::types::chrono::Utc;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_PROBE_HOSTS: &str = include_str!("../probe-hosts.toml");
#[derive(Debug)]
pub enum PublishError {
NotConfigured,
Config(std::io::Error),
ConfigParse(toml::de::Error),
Serialize(serde_json::Error),
Subscribe(rumqttc::ClientError),
Publish(rumqttc::ClientError),
}
impl fmt::Display for PublishError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PublishError::NotConfigured => write!(formatter, "MQTT publisher is not configured"),
PublishError::Config(error) => {
write!(formatter, "failed to read probe config: {error}")
}
PublishError::ConfigParse(error) => {
write!(formatter, "failed to parse probe config: {error}")
}
PublishError::Serialize(error) => {
write!(formatter, "failed to serialize task: {error}")
}
PublishError::Subscribe(error) => {
write!(formatter, "failed to subscribe to results: {error}")
}
PublishError::Publish(error) => write!(formatter, "failed to publish task: {error}"),
}
}
}
#[derive(Clone)]
pub struct MqttPublisher {
client: Option<AsyncClient>,
sessions: Arc<rocket::tokio::sync::RwLock<HashMap<String, ProbeResultSender>>>,
online_probes: Arc<rocket::tokio::sync::RwLock<HashSet<String>>>,
probe_config: Arc<ProbeConfig>,
task_timeout_ms: u64,
}
type ProbeResultSender = rocket::tokio::sync::broadcast::Sender<ProbeResultEvent>;
pub type ProbeResultReceiver = rocket::tokio::sync::broadcast::Receiver<ProbeResultEvent>;
#[derive(Deserialize)]
struct ProbeHostsFile {
timeout_sec: u32,
min_data: u32,
hosts: Vec<ProbeHostEntry>,
}
#[derive(Deserialize)]
struct ProbeHostEntry {
id: String,
host: String,
host_type: HostType,
file_path: String,
timeout_sec: Option<u32>,
min_data: Option<u32>,
}
impl MqttPublisher {
pub fn start_from_env() -> Self {
let sessions = Arc::new(rocket::tokio::sync::RwLock::new(HashMap::new()));
let online_probes = Arc::new(rocket::tokio::sync::RwLock::new(HashSet::new()));
let task_timeout_ms = task_timeout_ms_from_env();
let probe_config = Arc::new(load_probe_config(task_timeout_ms).unwrap_or_else(|error| {
warn!("failed to load probe config: {error}");
ProbeConfig {
version: env!("CARGO_PKG_VERSION").to_string(),
task_timeout_ms,
published_at: Utc::now().to_rfc3339(),
hosts: Vec::new(),
}
}));
let admin_token = match std::env::var("MQTT_ADMIN_TOKEN") {
Ok(token) if !token.is_empty() => token,
_ => {
warn!("mqtt publisher disabled: MQTT_ADMIN_TOKEN is not set");
return Self {
client: None,
sessions,
online_probes,
probe_config,
task_timeout_ms: task_timeout_ms_from_env(),
};
}
};
let host = std::env::var("MQTT_HOST").unwrap_or_else(|_| "rmqtt".to_string());
let port = std::env::var("MQTT_PORT")
.ok()
.and_then(|port| port.parse().ok())
.unwrap_or(11883);
let client_id =
std::env::var("MQTT_CLIENT_ID").unwrap_or_else(|_| "website-api".to_string());
let mut options = MqttOptions::new(client_id, host.clone(), port);
options.set_credentials("admin", admin_token);
options.set_keep_alive(Duration::from_secs(10));
let (client, mut eventloop) = AsyncClient::new(options, 100);
let event_sessions = sessions.clone();
let event_online_probes = online_probes.clone();
let config_client = client.clone();
let event_probe_config = probe_config.clone();
rocket::tokio::spawn(async move {
loop {
match eventloop.poll().await {
Ok(MqttEvent::Incoming(Incoming::ConnAck(_))) => {
if let Err(error) =
publish_probe_config(&config_client, event_probe_config.as_ref()).await
{
warn!("failed to publish retained probe config: {error}");
}
}
Ok(MqttEvent::Incoming(Incoming::Publish(publish))) => {
dispatch_probe_result(&event_sessions, &publish.topic, &publish.payload)
.await;
dispatch_probe_status(
&event_online_probes,
&publish.topic,
&publish.payload,
)
.await;
}
Ok(_) => {}
Err(error) => {
warn!("mqtt publisher connection error: {error}");
rocket::tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
});
let status_client = client.clone();
rocket::tokio::spawn(async move {
if let Err(error) = status_client
.subscribe("probe/status/v1/+", QoS::AtLeastOnce)
.await
{
warn!("failed to subscribe to probe status updates: {error}");
}
});
info!("mqtt publisher configured for {host}:{port}");
Self {
client: Some(client),
sessions,
online_probes,
probe_config,
task_timeout_ms,
}
}
pub fn task_timeout(&self) -> Duration {
Duration::from_millis(self.task_timeout_ms)
}
pub async fn online_probe_count(&self) -> usize {
self.online_probes.read().await.len()
}
pub fn probe_config(&self) -> Arc<ProbeConfig> {
self.probe_config.clone()
}
pub async fn subscribe_probe_results(
&self,
query_id: Uuid,
) -> Result<ProbeResultReceiver, PublishError> {
let client = self.client.as_ref().ok_or(PublishError::NotConfigured)?;
let query_id = query_id.to_string();
let topic = format!("probe/results/v1/{query_id}/+");
let receiver = {
let mut sessions = self.sessions.write().await;
sessions
.entry(query_id.clone())
.or_insert_with(|| rocket::tokio::sync::broadcast::channel(100).0)
.subscribe()
};
client
.subscribe(topic.clone(), QoS::AtLeastOnce)
.await
.map_err(PublishError::Subscribe)?;
let client = client.clone();
let sessions = self.sessions.clone();
let cleanup_after = self.task_timeout() + Duration::from_secs(5);
rocket::tokio::spawn(async move {
rocket::tokio::time::sleep(cleanup_after).await;
sessions.write().await.remove(&query_id);
if let Err(error) = client.unsubscribe(topic).await {
warn!("failed to unsubscribe from probe results: {error}");
}
});
Ok(receiver)
}
pub async fn publish_probe_task(
&self,
query_id: Uuid,
domain: &str,
) -> 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,
created_at: Utc::now().to_rfc3339(),
timeout_ms: self.task_timeout_ms,
};
let payload = serde_json::to_vec(&task).map_err(PublishError::Serialize)?;
let topic = format!("probe/tasks/v1/{query_id}");
client
.publish(topic, QoS::AtLeastOnce, false, payload)
.await
.map_err(PublishError::Publish)
}
}
async fn publish_probe_config(
client: &AsyncClient,
config: &ProbeConfig,
) -> Result<(), PublishError> {
let payload = serde_json::to_vec(config).map_err(PublishError::Serialize)?;
client
.publish("probe/config/v1", QoS::AtLeastOnce, true, payload)
.await
.map_err(PublishError::Publish)
}
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 contents = std::fs::read_to_string(path).map_err(PublishError::Config)?;
parse_probe_hosts(&contents)?
} else {
parse_probe_hosts(DEFAULT_PROBE_HOSTS)?
};
Ok(ProbeConfig {
version: env!("CARGO_PKG_VERSION").to_string(),
task_timeout_ms,
published_at: Utc::now().to_rfc3339(),
hosts,
})
}
fn parse_probe_hosts(contents: &str) -> Result<Vec<Host>, PublishError> {
let config: ProbeHostsFile = toml::from_str(&contents).map_err(PublishError::ConfigParse)?;
Ok(config
.hosts
.into_iter()
.map(|host| Host {
id: host.id,
host: host.host,
host_type: host.host_type,
file_path: host.file_path,
timeout_sec: host.timeout_sec.unwrap_or(config.timeout_sec),
min_data: host.min_data.unwrap_or(config.min_data),
})
.collect())
}
async fn dispatch_probe_status(
online_probes: &Arc<rocket::tokio::sync::RwLock<HashSet<String>>>,
topic: &str,
payload: &[u8],
) {
let Some(probe_id) = parse_probe_status_topic(topic) else {
return;
};
if payload.is_empty() {
online_probes.write().await.remove(probe_id);
return;
}
let status: ProbeStatus = match serde_json::from_slice(payload) {
Ok(status) => status,
Err(error) => {
warn!("ignoring invalid probe status JSON on {topic}: {error}");
return;
}
};
let mut online_probes = online_probes.write().await;
if status.online {
online_probes.insert(probe_id.to_string());
} else {
online_probes.remove(probe_id);
}
}
async fn dispatch_probe_result(
sessions: &Arc<rocket::tokio::sync::RwLock<HashMap<String, ProbeResultSender>>>,
topic: &str,
payload: &[u8],
) {
let Some((job_id, probe_id)) = parse_probe_result_topic(topic) else {
return;
};
let result = match serde_json::from_slice(payload) {
Ok(result) => result,
Err(error) => {
warn!("ignoring invalid probe result JSON on {topic}: {error}");
return;
}
};
let sender = sessions.read().await.get(job_id).cloned();
if let Some(sender) = sender {
let _ = sender.send(ProbeResultEvent {
job_id: job_id.to_string(),
probe_id: probe_id.to_string(),
host_results: result,
});
}
}
fn parse_probe_status_topic(topic: &str) -> Option<&str> {
let mut parts = topic.split('/');
match (
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
) {
(Some("probe"), Some("status"), Some("v1"), Some(probe_id), None) => Some(probe_id),
_ => None,
}
}
fn parse_probe_result_topic(topic: &str) -> Option<(&str, &str)> {
let mut parts = topic.split('/');
match (
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
) {
(Some("probe"), Some("results"), Some("v1"), Some(job_id), Some(probe_id), None) => {
Some((job_id, probe_id))
}
_ => None,
}
}
fn task_timeout_ms_from_env() -> u64 {
std::env::var("MQTT_PROBE_TASK_TIMEOUT_MS")
.ok()
.and_then(|timeout| timeout.parse().ok())
.unwrap_or(15_000)
}
+135
View File
@@ -0,0 +1,135 @@
use rocket::form::Form;
use rocket::serde::json::Json;
use serde::Serialize;
use sqlx::PgPool;
#[derive(FromForm)]
pub struct MqttAuthRequest<'r> {
username: &'r str,
clientid: &'r str,
password: &'r str,
protocol: Option<&'r str>,
}
#[derive(FromForm)]
pub struct MqttAclRequest<'r> {
access: u8,
username: &'r str,
clientid: &'r str,
topic: &'r str,
protocol: Option<&'r str>,
}
#[derive(Serialize)]
pub struct MqttAuthResponse {
result: &'static str,
#[serde(skip_serializing_if = "std::ops::Not::not")]
superuser: bool,
}
impl MqttAuthResponse {
fn allow() -> Self {
Self {
result: "allow",
superuser: false,
}
}
fn allow_superuser() -> Self {
Self {
result: "allow",
superuser: true,
}
}
fn deny() -> Self {
Self {
result: "deny",
superuser: false,
}
}
}
#[post("/auth", data = "<request>")]
pub async fn auth(
request: Form<MqttAuthRequest<'_>>,
pool: &rocket::State<PgPool>,
) -> Json<MqttAuthResponse> {
let request = request.into_inner();
let _ = (request.clientid, request.protocol);
if request.username == "admin"
&& std::env::var("MQTT_ADMIN_TOKEN")
.map(|token| token == request.password)
.unwrap_or(false)
{
return Json(MqttAuthResponse::allow_superuser());
}
if request.username != "probe" || request.password.is_empty() {
return Json(MqttAuthResponse::deny());
}
let token_exists =
sqlx::query_scalar::<_, i32>("SELECT id FROM reporters WHERE token = $1 LIMIT 1")
.bind(request.password)
.fetch_optional(&**pool)
.await
.ok()
.flatten()
.is_some();
if token_exists {
Json(MqttAuthResponse::allow())
} else {
Json(MqttAuthResponse::deny())
}
}
#[post("/acl", data = "<request>")]
pub async fn acl(request: Form<MqttAclRequest<'_>>) -> Json<MqttAuthResponse> {
let request = request.into_inner();
let _ = request.protocol;
if request.username == "admin" {
return Json(MqttAuthResponse::allow());
}
if request.username != "probe" || request.clientid.is_empty() {
return Json(MqttAuthResponse::deny());
}
match request.access {
1 if can_probe_subscribe(request.topic) => Json(MqttAuthResponse::allow()),
2 if can_probe_publish(request.clientid, request.topic) => Json(MqttAuthResponse::allow()),
_ => Json(MqttAuthResponse::deny()),
}
}
fn can_probe_subscribe(topic: &str) -> bool {
matches!(
topic,
"probe/config/v1" | "probe/tasks/v1/+" | "probe/tasks/v1/#"
)
}
fn can_probe_publish(client_id: &str, topic: &str) -> bool {
let status_topic = format!("probe/status/v1/{client_id}");
if topic == status_topic {
return true;
}
let mut parts = topic.split('/');
matches!(
(
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next()
),
(Some("probe"), Some("results"), Some("v1"), Some(_job_id), Some(probe_id), None)
if probe_id == client_id
)
}