mirror of
https://github.com/LowderPlay/cheburcheck.git
synced 2026-09-22 22:37:59 +03:00
feat: dynamic probing (#71)
This commit is contained in:
+76
-75
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,5 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"probe",
|
||||
"querying",
|
||||
"reporter",
|
||||
"reports",
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]) => ({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>,
|
||||
информация о блокировке которого, не добавлена в Реестр — для таких
|
||||
случаев мы создали
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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/;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -0,0 +1,149 @@
|
||||
# Cheburcheck Probe
|
||||
[](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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod probe;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -0,0 +1,5 @@
|
||||
storage.type = "ram"
|
||||
|
||||
max_retained_messages = 0
|
||||
max_payload_size = "1MB"
|
||||
retained_message_ttl = "0m"
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
))
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user