feat!: svelte rewrite (#68)

* feat!: svelte rewrite

* feat: remove mock

* fix: formatting and optimisation

* feat: db optimizations

* feat: dockerfile and version

* ci: change dockerfile syntax

* fix: remove domain trie
This commit is contained in:
LowderPlay
2026-05-28 22:34:50 +05:00
committed by GitHub
parent 741ae887f5
commit bac3ab1507
94 changed files with 4254 additions and 1578 deletions
+7
View File
@@ -1,2 +1,9 @@
.git/
target/
frontend/node_modules
.idea/
**/target/
**/.env
frontend/.svelte-kit/
frontend/dist/
frontend/build/
Generated
+90 -55
View File
@@ -742,15 +742,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fid-rs"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6956a1e60e2d1412b44b4169d44a03dae518f8583d3e10090c912c105e48447"
dependencies = [
"rayon",
]
[[package]]
name = "figment"
version = "0.10.19"
@@ -760,7 +751,7 @@ dependencies = [
"atomic 0.6.1",
"pear",
"serde",
"toml",
"toml 0.8.23",
"uncased",
"version_check",
]
@@ -840,6 +831,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "frontend"
version = "0.0.1"
dependencies = [
"include_dir",
"serde",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -1591,6 +1591,25 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "2.12.1"
@@ -1905,15 +1924,6 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "louds-rs"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "936de6c22f08e7135a921f8ada907acd0d88880c4f42b5591f634b9f1dd8e07f"
dependencies = [
"fid-rs",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -2557,7 +2567,6 @@ dependencies = [
"serde_json",
"thiserror",
"tokio",
"trie-rs",
"url",
]
@@ -2722,26 +2731,6 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3237,6 +3226,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3928,11 +3926,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.13",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -3942,6 +3955,15 @@ dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
@@ -3950,10 +3972,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
"winnow 0.7.13",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
]
[[package]]
@@ -3962,6 +3993,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
version = "0.5.2"
@@ -4069,15 +4106,6 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "trie-rs"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f88f4b0a1ebd6c3d16be3e45eb0e8089372ccadd88849b7ca162ba64b5e6f6"
dependencies = [
"louds-rs",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -4410,11 +4438,12 @@ dependencies = [
[[package]]
name = "website"
version = "0.4.3"
version = "1.0.0"
dependencies = [
"dotenvy",
"env_logger",
"flate2",
"frontend",
"governor",
"log",
"querying",
@@ -4795,6 +4824,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
[[package]]
name = "winreg"
version = "0.50.0"
+1
View File
@@ -1,5 +1,6 @@
[workspace]
members = [
"frontend",
"querying",
"reporter",
"reports",
+62
View File
@@ -0,0 +1,62 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"html": {
"experimentalFullSupportEnabled": true,
"linter": {
"enabled": true
},
"formatter": {
"whitespaceSensitivity": "ignore",
"enabled": true
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"useEditorconfig": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noAssignInExpressions": "off",
"noGlobalAssign": "off"
},
"complexity": {
"noCommaOperator": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
},
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
dist/
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "frontend"
version = "0.0.1"
edition = "2024"
[dependencies]
include_dir = "0.7"
[build-dependencies]
serde = { workspace = true }
toml = "0.9"
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
pnpm dlx sv@0.15.3 create --template minimal --types ts --add tailwindcss="plugins:none" --install pnpm frontend
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "//"
}
+43
View File
@@ -0,0 +1,43 @@
use std::error::Error;
use std::path::PathBuf;
use std::process::Command;
use serde::Deserialize;
fn main() -> Result<(), Box<dyn Error>> {
println!("cargo:rerun-if-changed=package.json");
println!("cargo:rerun-if-changed=pnpm-lock.yaml");
println!("cargo:rerun-if-changed=svelte.config.js");
println!("cargo:rerun-if-changed=vite.config.ts");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=static");
println!("cargo:rerun-if-changed=../website/Cargo.toml");
if std::env::var_os("SKIP_FRONTEND_BUILD").is_some() {
return Ok(());
}
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
run_pnpm(&manifest_dir, &["install", "--frozen-lockfile"])?;
run_pnpm(&manifest_dir, &["build"])?;
Ok(())
}
fn run_pnpm(manifest_dir: &PathBuf, args: &[&str]) -> Result<(), Box<dyn Error>> {
let mut command = Command::new(pnpm());
command.args(args).current_dir(manifest_dir);
let status = command.status()?;
if status.success() {
Ok(())
} else {
Err(format!("pnpm {} failed", args.join(" ")).into())
}
}
fn pnpm() -> &'static str {
if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"type-check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "biome format --write .",
"check": "pnpm type-check && biome check --write .",
"lint": "biome lint ."
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^25.9.1",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"@lucide/svelte": "^1.3.0",
"@tanstack/svelte-query": "^6.1.33",
"layerchart": "2.0.0-next.64"
}
}
+1645
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- esbuild
+21
View File
@@ -0,0 +1,21 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
interface ImportMetaEnv {
readonly VITE_APP_VERSION: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="text-scale" content="scale">
%sveltekit.head%
</head>
<body
class="flex min-h-screen flex-col bg-[#0a0a0a] p-4 font-mono leading-normal text-neutral-300 selection:bg-neutral-700 selection:text-white sm:p-8"
data-sveltekit-preload-data="hover"
>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
pub use include_dir::{Dir, File, include_dir};
pub static DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist");
+114
View File
@@ -0,0 +1,114 @@
type ApiNetworkRecord = {
provider: string;
cidr: string;
region?: string | null;
};
type ApiAsnInfo = {
asn: number;
prefixes: string[];
blocked_prefixes: string[];
};
type ApiWhitelist = {
domain?: string | null;
rank?: number | null;
last_ok?: string | null;
};
type ApiCheckResponse = {
id?: string | null;
target: string;
target_type: string;
blocked: boolean;
rkn_domain?: string | null;
ips: string[];
blocked_subnets: string[];
cdn_providers: Record<string, ApiNetworkRecord[]>;
geo: {
asn?: string | null;
country_code?: string | null;
organisation?: string | null;
city_geo_name_id?: number | null;
location: string;
};
asn_info?: ApiAsnInfo | null;
whitelist?: ApiWhitelist | null;
subnet_size?: string | null;
};
export type CheckResult = {
id?: string | null;
targetType: string;
target: string;
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: { name: string; networks: ApiNetworkRecord[] }[];
blockedSubnets: string[];
asnInfo: { prefixes: string[]; blockedPrefixes: string[] } | null;
};
export class CheckRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "CheckRequestError";
}
}
const sortPrefixes = (prefixes: string[]) => [
...prefixes.filter((prefix) => !prefix.includes(":")),
...prefixes.filter((prefix) => prefix.includes(":")),
];
export async function fetchCheck(target: string): Promise<CheckResult> {
const response = await fetch(
`/api/v1/check?target=${encodeURIComponent(target)}`,
{
headers: {
Accept: "application/json",
},
},
);
if (!response.ok) {
throw new CheckRequestError(response.statusText, response.status);
}
const data = (await response.json()) as ApiCheckResponse;
return {
id: data.id,
targetType: data.target_type,
target: data.target,
found: data.blocked,
blocked: data.blocked,
whitelist: data.whitelist
? {
lastOk: data.whitelist.last_ok,
}
: null,
domain: data.rkn_domain,
ips: data.ips,
subnetSize: data.subnet_size,
geo: data.geo,
providers: Object.entries(data.cdn_providers).map(([name, networks]) => ({
name,
networks,
})),
blockedSubnets: data.blocked_subnets,
asnInfo: data.asn_info
? {
prefixes: sortPrefixes(data.asn_info.prefixes),
blockedPrefixes: sortPrefixes(data.asn_info.blocked_prefixes),
}
: null,
};
}
+30
View File
@@ -0,0 +1,30 @@
export type FeedbackPayload = {
id: string;
works: boolean;
};
export class FeedbackRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "FeedbackRequestError";
}
}
export async function submitFeedback({
id,
works,
}: FeedbackPayload): Promise<void> {
const response = await fetch(
`/api/v1/feedback/${encodeURIComponent(id)}/${works}`,
{
method: "POST",
},
);
if (!response.ok) {
throw new FeedbackRequestError(response.statusText, response.status);
}
}
+30
View File
@@ -0,0 +1,30 @@
export type StatusMetrics = {
domain_count: number;
v4_count: number;
last_update: string | null;
version: string;
};
export class StatusRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "StatusRequestError";
}
}
export async function fetchStatus(): Promise<StatusMetrics> {
const response = await fetch(`/api/v1/status`, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new StatusRequestError(response.statusText, response.status);
}
return await response.json();
}
+51
View File
@@ -0,0 +1,51 @@
type ApiWhitelistHistogramBin = {
bin_min_rank?: number | null;
bin_max_rank?: number | null;
count?: number | null;
};
export type WhitelistHistogramBin = {
label: string;
count: number;
};
export class WhitelistHistogramRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "WhitelistHistogramRequestError";
}
}
export async function fetchWhitelistHistogram(
limit: number,
filter = false,
): Promise<WhitelistHistogramBin[]> {
const params = new URLSearchParams({ limit: String(limit) });
if (filter) {
params.set("filter", "true");
}
const response = await fetch(`/api/v1/histogram?${params}`, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new WhitelistHistogramRequestError(
response.statusText,
response.status,
);
}
const data = (await response.json()) as ApiWhitelistHistogramBin[];
return data.map((bin) => ({
label: `${bin.bin_min_rank ?? 0}-${bin.bin_max_rank ?? 0}`,
count: bin.count ?? 0,
}));
}
@@ -0,0 +1,96 @@
<script lang="ts">
import { CloudAlert, Copyright } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import type { Snippet } from "svelte";
import type { StatusMetrics } from "$lib/api/status";
import { fetchStatus } from "$lib/api/status";
import { setStatusContext } from "$lib/context/status";
import GitHubIcon from "./GitHubIcon.svelte";
let { children }: { children: Snippet } = $props();
const year = new Date().getFullYear();
const statusCacheKey = "cheburcheck:status";
type CachedStatus = {
data: StatusMetrics;
updatedAt: number;
};
function getCachedStatus() {
const cachedStatus = localStorage.getItem(statusCacheKey);
if (!cachedStatus) {
return null;
}
try {
return JSON.parse(cachedStatus) as CachedStatus;
} catch {
localStorage.removeItem(statusCacheKey);
return null;
}
}
const cachedStatus = getCachedStatus();
const status = createQuery(() => ({
queryKey: ["status"],
queryFn: () => fetchStatus(),
initialData: cachedStatus?.data,
initialDataUpdatedAt: cachedStatus?.updatedAt,
}));
setStatusContext(status);
$effect(() => {
if (!status.data) {
return;
}
localStorage.setItem(
statusCacheKey,
JSON.stringify({
data: status.data,
updatedAt: Date.now(),
}),
);
});
</script>
<header class="mx-auto mb-12 w-full max-w-250 border-b border-neutral-800 pb-4">
<div class="flex items-center justify-between">
<a
class="flex items-center gap-2 text-neutral-100 no-underline tracking-tighter"
href="/"
>
<CloudAlert size={32} aria-hidden="true" />
<span class="text-2xl font-bold uppercase">Cheburcheck</span>
</a>
<div class="flex gap-4 text-xs text-neutral-500 items-center">
<a href="/kb/faq" class="text-neutral-500 underline">FAQ</a>
<a
class="flex items-center gap-2 text-neutral-500 no-underline"
href="https://github.com/LowderPlay/cheburcheck"
>
<span>v{status.data?.version}</span>
<GitHubIcon />
</a>
</div>
</div>
</header>
<main
class="relative mx-auto flex w-full max-w-250 grow flex-col justify-center"
>
{@render children()}
</main>
<footer
class="mx-auto mt-12 w-full max-w-250 border-t border-neutral-800 pt-4 text-center text-[#525252]"
>
<p class="inline-flex items-center gap-[0.35rem] text-xs uppercase">
<a href="mailto:support@cheburcheck.ru" class="underline">
support@cheburcheck.ru
</a>
- Сделано в России
<Copyright size={10} aria-hidden="true" />
{year}
</p>
</footer>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
label,
href,
}: { children: Snippet; label: string; href?: string } = $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 justify-end">
{@render children()}
</div>
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import { ShieldQuestionMark } from "@lucide/svelte";
let { targetType, target }: { targetType: string; target: string } = $props();
</script>
<div class="mt-4 border border-neutral-800 p-6">
<div class="mb-8 flex gap-4">
<div
class="flex items-center justify-center border border-current bg-white/5 p-3"
>
<ShieldQuestionMark size={32} aria-hidden="true" />
</div>
<div>
<h2
class="mb-1 text-3xl leading-[1.2] font-bold tracking-widest uppercase"
>
Не найдено
</h2>
<p class="text-sm">Возможно вы неправильно ввели запрос?</p>
</div>
</div>
<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>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import { TriangleAlert } from "@lucide/svelte";
const { status, reason }: { status: number; reason: string } = $props();
const error = $derived.by(() => {
switch (status) {
case 500:
return "Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.";
case 502:
return "Сервер временно недоступен. Попробуйте обновить страницу.";
case 503:
return "Сервис временно недоступен. Ведутся технические работы.";
case 504:
return "Превышено время ожидания ответа от сервера. Попробуйте позже.";
case 400:
return "Неверный запрос. Проверьте правильность введенных данных.";
case 404:
return "Страница не найдена. Проверьте правильность адреса.";
case 422:
return "Невозможно обработать запрос. Проверьте формат данных.";
case 429:
return "Слишком много запросов. Пожалуйста, подождите немного.";
default:
return "Что-то пошло не так. Возможно в запросе есть ошибка?";
}
});
</script>
<div class="flex items-center gap-4">
<TriangleAlert size={48} class="text-red-500" aria-hidden="true" />
<h2 class="text-4xl trackin text-red-500 uppercase">
{status}
{reason}
</h2>
</div>
<p class="text-2xl text-neutral-500">
{error}
</p>
@@ -0,0 +1,74 @@
<script lang="ts">
import { ThumbsDown, ThumbsUp } from "@lucide/svelte";
import { createMutation } from "@tanstack/svelte-query";
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">
{#if feedbackMutation.isSuccess}
<div class={`flex items-center gap-2 py-2 text-sm ${statusClass}`}>
<ThumbsUp size={16} 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}`}
type="button"
disabled={feedbackMutation.isPending}
onclick={() => submit(true)}
>
<ThumbsUp size={16} aria-hidden="true" />
Работает
</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}`}
type="button"
disabled={feedbackMutation.isPending}
onclick={() => submit(false)}
>
<ThumbsDown size={16} aria-hidden="true" />
Не работает
</button>
</div>
{#if feedbackMutation.isError}
<p class="mt-3 text-sm text-red-400">
Не удалось отправить отзыв. Попробуйте еще раз.
</p>
{/if}
{/if}
</div>
@@ -0,0 +1,13 @@
<svg
width="20"
height="20"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<title>GitHub</title>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>

After

Width:  |  Height:  |  Size: 882 B

@@ -0,0 +1,205 @@
<script lang="ts">
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 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;
};
} = $props();
const valueClass = "text-right text-sm font-medium text-neutral-200";
const alertValueClass = `${valueClass} text-red-500`;
const successValueClass = `${valueClass} text-green-500`;
const theme = $derived<ResultTheme>(
result.whitelist && !result.domain
? "whitelist"
: result.found
? "blocked"
: "clean",
);
const panelClass = $derived(
theme === "whitelist"
? "border-yellow-900/30 bg-[#2e2d05]/10"
: theme === "blocked"
? "border-red-900/30 bg-[#450a0a]/10"
: "border-green-900/30 bg-[#052e16]/10",
);
const reasonHeaderClass = $derived(
theme === "whitelist"
? "border-yellow-900/30 text-[#f0b100]"
: theme === "blocked"
? "border-red-900/30 text-red-400"
: "border-green-900/30 text-green-500",
);
const allPrefixes = $derived(result.asnInfo?.prefixes ?? []);
const blockedPrefixes = $derived(result.asnInfo?.blockedPrefixes ?? []);
const whitelistDate = $derived(
result.whitelist?.lastOk
? new Date(result.whitelist.lastOk).toLocaleDateString("ru-RU")
: "-",
);
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}
/>
{/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>
@@ -0,0 +1,33 @@
<script lang="ts">
import { ChevronRight, Search } from "@lucide/svelte";
</script>
<form
class="relative flex w-full flex-col gap-3 sm:flex-row sm:gap-0"
action="/check"
>
<!-- svelte-ignore a11y_autofocus -->
<!-- biome-ignore-start lint/a11y/noAutofocus: using autofocus is acceptable here -->
<input
type="text"
name="target"
placeholder="example.com, 1.1.1.1, 1.0.0.0/24, AS13335"
class="h-16 w-full border border-neutral-700 bg-neutral-900 py-4 pr-4 pl-12 font-[inherit] text-lg text-white outline-none transition-all focus:border-neutral-400 focus:bg-neutral-800 focus:[+div]:text-white sm:grow sm:pr-6"
minlength="3"
required
autofocus
>
<!-- biome-ignore-end lint/a11y/noAutofocus: using autofocus is acceptable here -->
<div
class="pointer-events-none absolute top-8 left-4 flex -translate-y-1/2 justify-center text-neutral-500 transition-colors"
>
<Search size={20} aria-hidden="true" />
</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"
>
<span>Проверить</span>
<ChevronRight size={16} aria-hidden="true" />
</button>
</form>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Activity, Globe, Server } from "@lucide/svelte";
let {
icon,
label,
value,
}: {
icon: "activity" | "globe" | "server";
label: string;
value: string;
} = $props();
const icons = {
activity: Activity,
globe: Globe,
server: Server,
} as const;
const StatIcon = $derived(icons[icon]);
</script>
<div class="flex flex-col gap-2 border border-neutral-800 p-3">
<div class="flex gap-2">
<StatIcon size={16} aria-hidden="true" />
<span>{label}</span>
</div>
<span>{value}</span>
</div>
@@ -0,0 +1,11 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
</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"
>
{@render children()}
</article>
@@ -0,0 +1,9 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
</script>
<i class="mt-6 mb-3 block text-center text-neutral-400">
{@render children()}
</i>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
height = "h-120",
}: {
children: Snippet;
height?: string;
} = $props();
</script>
<div class="{height} border border-neutral-800 bg-neutral-950/30 p-4">
{@render children()}
</div>
@@ -0,0 +1,9 @@
<script lang="ts">
import type { Snippet } from "svelte";
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"
>{@render children()}</pre>
@@ -0,0 +1 @@
<hr class="my-8 border-neutral-800">
@@ -0,0 +1,11 @@
<script lang="ts">
import { FileSymlink } from "@lucide/svelte";
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>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { children, id }: { children: Snippet; id: string } = $props();
</script>
<h2
{id}
class="mt-10 mb-4 text-[32px] leading-[1.2] font-bold tracking-[0.1em] text-neutral-100 uppercase"
>
<a href={`#${id}`} class="no-underline hover:underline">
{@render children()}
</a>
</h2>
@@ -0,0 +1,22 @@
<script lang="ts">
import { TriangleAlert } from "@lucide/svelte";
import type { Snippet } from "svelte";
let {
children,
variant = "warning",
}: {
children: Snippet;
variant?: "warning";
} = $props();
</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'
: ''}"
>
<TriangleAlert size={32} aria-hidden="true" />
<span>{@render children()} </span>
</p>
@@ -0,0 +1,28 @@
<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>
@@ -0,0 +1,63 @@
<script lang="ts">
let {
ips,
subnetSize,
}: {
ips: string[];
subnetSize?: string | null;
} = $props();
</script>
<div class="flex w-full flex-col items-end gap-2">
{#if ips.length === 1}
<span class="break-all font-mono text-sm font-medium text-neutral-200">
{ips[0]}
</span>
{#if subnetSize}
<span class="text-xs text-neutral-500">({subnetSize} всего)</span>
{/if}
{:else if ips.length <= 5}
{#each ips as ip}
<span class="break-all font-mono text-sm font-medium text-neutral-200">
{ip}
</span>
{/each}
{#if subnetSize}
<span class="text-xs text-neutral-500">({subnetSize} всего)</span>
{/if}
{:else}
<div class="flex flex-col gap-1 text-right">
<span class="font-mono text-sm font-medium text-neutral-200">
{ips[0]}
- {ips[ips.length - 1]}
</span>
{#if subnetSize}
<span class="text-xs text-neutral-500">
({subnetSize}
всего, проверено {ips.length}
)
</span>
{:else}
<span class="text-xs text-neutral-500">({ips.length} адресов)</span>
{/if}
</div>
<details class="w-fit">
<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"
>
Показать все
</summary>
<div
class="mt-3 grid max-h-100 grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2 overflow-y-auto border border-neutral-800 bg-neutral-900/30 p-3"
>
{#each ips as ip}
<span
class="break-all font-mono text-sm font-medium text-neutral-200"
>
{ip}
</span>
{/each}
</div>
</details>
{/if}
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { ShieldAlert, ShieldCheck, ShieldX } from "@lucide/svelte";
type ResultTheme = "blocked" | "clean" | "whitelist";
let {
theme,
blocked,
}: {
theme: ResultTheme;
blocked: boolean;
} = $props();
const title = $derived(
theme === "whitelist"
? "Белый список"
: theme === "blocked"
? "Заблокирован"
: "Доступен",
);
const subtitle = $derived(
theme === "whitelist"
? "Ресурс находится в белом списке"
: theme === "blocked"
? "Ресурс был найден в списках блокировок"
: "Ограничений не обнаружено",
);
const accentClass = $derived(
theme === "whitelist"
? "text-[#f0b100]"
: theme === "blocked"
? "text-red-500"
: "text-green-500",
);
const StatusIcon = $derived(
theme === "whitelist" ? ShieldAlert : blocked ? ShieldX : ShieldCheck,
);
</script>
<div class="mb-8 flex gap-4">
<div
class={`flex items-center 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}`}
>
{title}
</h2>
<p class={`text-sm ${accentClass}`}>{subtitle}</p>
</div>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
let {
items,
limit = 10,
alert = false,
}: {
items: string[];
limit?: number;
alert?: boolean;
} = $props();
const valueClass = $derived(
`text-right text-sm font-medium text-neutral-200${alert ? " text-red-500" : ""}`,
);
</script>
<div class="flex w-full flex-col items-end gap-2">
<div>
{#each items.slice(0, limit) as item}
<p class={valueClass}>{item}</p>
{/each}
</div>
{#if items.length > limit}
<details class="w-fit">
<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}
)
</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"
>
{#each items as item}
<p class={valueClass}>{item}</p>
{/each}
</div>
</details>
{/if}
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
let {
targetType,
target,
}: {
targetType: string;
target: string;
} = $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>
+8
View File
@@ -0,0 +1,8 @@
import type { CreateQueryResult } from "@tanstack/svelte-query";
import { createContext } from "svelte";
import type { StatusMetrics } from "$lib/api/status";
type StatusContext = CreateQueryResult<StatusMetrics, Error>;
export const [getStatusContext, setStatusContext] =
createContext<StatusContext>();
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import "./layout.css";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import AppShell from "$lib/components/AppShell.svelte";
let { children } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 30_000,
},
},
});
</script>
<svelte:head>
<title>Cheburcheck</title>
<meta
name="description"
content="Проверка доменов и IP-адресов на наличие в реестре блокировок Роскомнадзора и заблокированных CDN."
>
<meta
name="keywords"
content="проверка домена, заблокирован ли сайт, Роскомнадзор, чебурнет, cdn, блокировки"
>
<meta property="og:title" content="Cheburcheck">
<meta
property="og:description"
content="Проверка доменов и IP-адресов на наличие в реестре блокировок Роскомнадзора и заблокированных CDN."
>
<meta property="og:url" content="https://cheburcheck.ru">
<meta property="og:image" content="https://cheburcheck.ru/og-cover.png">
<meta property="og:site_name" content="Cheburcheck">
<meta property="og:type" content="website">
<meta name="author" content="Cheburcheck">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</svelte:head>
<QueryClientProvider client={queryClient}>
<AppShell>{@render children()} </AppShell>
</QueryClientProvider>
+1
View File
@@ -0,0 +1 @@
export const ssr = false;
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import SearchForm from "$lib/components/SearchForm.svelte";
import StatCard from "$lib/components/StatCard.svelte";
import { getStatusContext } from "$lib/context/status";
const statusQuery = getStatusContext();
const status = $derived(statusQuery.data);
</script>
<svelte:head>
<title>Cheburcheck</title>
</svelte:head>
<div class="mb-8">
<h1
class="mb-2 text-2xl tracking-widest text-neutral-100 uppercase font-bold"
>
Статус Ресурса
</h1>
<p class="text-neutral-500">
Введите домен или IP-адрес для поиска по спискам заблокированных адресов и
хостинг-провайдеров.
</p>
</div>
<SearchForm />
<div
class="mt-8 grid grid-cols-1 gap-4 text-xs text-neutral-500 sm:grid-cols-3"
>
<StatCard
icon="globe"
label="Количество доменов"
value={new Intl.NumberFormat('ru-RU').format(status?.domain_count ?? 0)}
/>
<StatCard
icon="server"
label="Количество IPv4-адресов"
value={new Intl.NumberFormat('ru-RU').format(status?.v4_count ?? 0)}
/>
<StatCard
icon="activity"
label="Последнее обновление"
value={new Intl.DateTimeFormat('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(status?.last_update ?? 0))}
/>
</div>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import { LoaderCircle } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { page } from "$app/state";
import { CheckRequestError, fetchCheck } from "$lib/api/check";
import EmptyResult from "$lib/components/EmptyResult.svelte";
import ErrorMessage from "$lib/components/ErrorMessage.svelte";
import ResultPanel from "$lib/components/ResultPanel.svelte";
import SearchForm from "$lib/components/SearchForm.svelte";
const target = $derived(page.url.searchParams.get("target")?.trim() ?? "");
const checkQuery = createQuery(() => ({
queryKey: ["check", target],
queryFn: () => fetchCheck(target),
enabled: target.length > 0,
staleTime: Infinity,
}));
const error = $derived(
(checkQuery.error instanceof CheckRequestError && checkQuery.error) || null,
);
</script>
<SearchForm />
{#if target.length === 0}
<div class="mt-4 border border-neutral-800 p-6 text-sm text-neutral-500">
Введите домен, IP-адрес, подсеть или ASN для проверки.
</div>
{:else if checkQuery.isPending}
<LoaderCircle class="mx-auto mt-4 animate-spin text-neutral-400" />
{:else if error?.status === 404}
<EmptyResult targetType="Запрос" {target} />
{:else if error}
<div class="mt-4 border border-red-900/30 bg-red-950/10 p-6">
<ErrorMessage status={error.status} reason={error.message} />
</div>
{:else if checkQuery.data}
<ResultPanel result={checkQuery.data} />
{/if}
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import KbArticle from "$lib/components/kb/KbArticle.svelte";
import KbHeading from "$lib/components/kb/KbHeading.svelte";
</script>
<svelte:head>
<title>FAQ - Cheburcheck</title>
<meta name="description" content="Ответы на частые вопросы">
<meta property="og:title" content="FAQ">
<meta property="og:description" content="Ответы на частые вопросы">
<meta property="og:url" content="https://cheburcheck.ru/kb/faq">
</svelte:head>
<KbArticle>
<h1>Ответы на частые вопросы</h1>
<p>
На этой странице собраны ответы на часто задаваемые вопросы. Если вас
интересует что-то ещё - пишите нам на почту
<a href="mailto:support@cheburcheck.ru">support@cheburcheck.ru</a>
.
</p>
<KbHeading id="что-это-за-сайт">Что это за сайт?</KbHeading>
<p>
Этот сайт - инструмент, который позволяет проверить, находится ли сайт,
домен, IP-адрес в
<a href="#откуда-вы-берете-эти-списки">списках</a>
, блокируемых Роскомнадзором на территории России.
</p>
<KbHeading id="откуда-вы-берете-эти-списки">
Откуда вы берете эти списки?
</KbHeading>
<p>
Для поиска сайтов мы используем списки IP-подсетей популярных
CDN-провайдеров, которые могут блокироваться Роскомнадзором, а также домены
из самого реестра Роскомнадзора.
</p>
<p>
Подсети, которые мы используем, публикуют сами провайдеры, либо собираются
из принадлежащих им автономных сетей. Мы используем
<a href="https://github.com/123jjck/cdn-ip-ranges">этот репозиторий</a>
, в котором автоматически агрегируются эти подсети.
</p>
<p>
Данные из Реестра Роскомнадзора подгружаются из сервисов
<a href="https://antifilter.download/">antifilter.download</a>
и
<a href="https://antifilter.network/">antifilter.network</a>
.
</p>
<KbHeading id="что-такое-белый-список">Что такое «белый список»?</KbHeading>
<p>
РКН формирует свой белый список доменов, которые доступны, даже если их
адреса находятся в списке заблокированных подсетей.
</p>
<a href="/kb/whitelist">Подробнее...</a>
<KbHeading id="мне-написало-что-сайт-заблокирован-но-у-меня-все-работает">
Мне написало, что сайт «Заблокирован», но у меня все работает!
</KbHeading>
<p>
Мы не можем с точностью утверждать, заблокирован сайт или нет. Блокировки
Роскомнадзора, зачастую, непредсказуемые, непубличные и незаконные.
Например, блокировка CDN-сервисов, таких, как
<a
href="https://blog.cloudflare.com/russian-internet-users-are-unable-to-access-the-open-internet/"
>
Cloudflare,
</a>
никак не комментировалась официально и не публиковались в Реестре.
Аналогичная ситуация с
<a href="https://ria.ru/20241219/putin-1990168675.html">YouTube,</a>
информация о блокировке которого, не добавлена в Реестр &mdash; для таких
случаев мы создали
<a
href="https://github.com/LowderPlay/cheburcheck/blob/master/querying/dist-domains.txt"
>
свой список
</a>
подобных сайтов. Однако, список не исчерпывающий, поэтому
<a href="https://github.com/LowderPlay/cheburcheck/pulls">
принимаются правки.
</a>
</p>
<p>
Сайты помечаются как "заблокированные", в случае, если они были найдены хотя
бы в одном списке.
</p>
<KbHeading id="как-часто-обновляются-списки">
Как часто обновляются списки?
</KbHeading>
<p>Мы обновляем списки из источников раз в 6 часов.</p>
<p>Сами источники обновляют их с разной периодичностью:</p>
<ul class="list-disc">
<li>
<a href="https://github.com/123jjck/cdn-ip-ranges">CDN</a>
- раз в 12 часов.
</li>
<li>
<a href="/kb/whitelist">Белые списки</a>
- топ-100,000 раз в 6 часов; топ-1,000,000 раз в сутки.
</li>
<li>
<a href="https://antifilter.download/">Домены из Реестра РКН</a>
- раз в полчаса.
</li>
<li>
<a href="https://antifilter.network/">Подсети из Реестра РКН</a>
- раз в час.
</li>
</ul>
<KbHeading id="как-я-могу-помочь-в-развитии-сайта">
Как я могу помочь в развитии сайта?
</KbHeading>
<p>Самый простой способ помочь - рассказать о сайте знакомым.</p>
<p>
Если у вас есть навыки разработки, то мы будем рады принять ваши
pull-requests на
<a href="https://github.com/LowderPlay/cheburcheck">GitHub.</a>
</p>
<p>Если вы хотите помочь финансово:</p>
<ul class="list-disc">
<li>TON: <code>UQAACsiwpGryjP-kqp4TJPAWpXytuB6M_puuO0Cg5zNvaSJW</code></li>
</ul>
<p>Большое спасибо!</p>
</KbArticle>
@@ -0,0 +1,196 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { BarChart } from "layerchart";
import { fetchWhitelistHistogram } from "$lib/api/whitelist";
import KbArticle from "$lib/components/kb/KbArticle.svelte";
import KbCaption from "$lib/components/kb/KbCaption.svelte";
import KbChartFrame from "$lib/components/kb/KbChartFrame.svelte";
import KbCode from "$lib/components/kb/KbCode.svelte";
import KbFileLink from "$lib/components/kb/KbFileLink.svelte";
import KbHeading from "$lib/components/kb/KbHeading.svelte";
import KbNote from "$lib/components/kb/KbNote.svelte";
const histogram30kQuery = createQuery(() => ({
queryKey: ["whitelist", "histogram", 30_000, "filtered"],
queryFn: () => fetchWhitelistHistogram(30_000, true),
}));
const histogram1mQuery = createQuery(() => ({
queryKey: ["whitelist", "histogram", 1_000_000],
queryFn: () => fetchWhitelistHistogram(1_000_000),
}));
const histogramError = $derived(
histogram30kQuery.isError || histogram1mQuery.isError,
);
const chartSeries = [
{
key: "count",
label: "Домены",
value: "count",
color: "#ef4444",
},
];
const chartPadding = {
top: 8,
right: 8,
bottom: 76,
left: 32,
};
const chartProps = {
xAxis: {
tickSpacing: 25,
tickLabelProps: {
rotate: -35,
textAnchor: "end",
verticalAnchor: "middle",
dx: -6,
dy: 10,
},
},
} as const;
</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/whitelist">
</svelte:head>
<KbArticle>
<h1>Белые списки доменов</h1>
<KbNote>
Не путать с
<a href="https://habr.com/ru/news/1000784/">
белыми списками <i>мобильного</i> интернета
</a>
!
</KbNote>
<p>
Российские операторы связи начали применять новый тип блокировок CDN, при
котором загрузка контента обрывается после передачи примерно 16–20 килобайт
данных, из‑за чего большинство сайтов на Cloudflare и других зарубежных
платформах становятся практически неработоспособными для пользователей в
России. Одновременно сохраняются и развиваются белые списки с популярными и
«социально значимыми» доменами, которые старательно выводятся из‑под
подобных ограничений.
</p>
<p>
Суть схемы в том, что соединение технически устанавливается, первые
килобайты HTML, CSS или скриптов передаются, но затем трафик режется или
соединение принудительно сбрасывается на уровне российских операторов,
обычно после примерно 16 килобайт (10–14 пакетов в зависимости от
протокола). Этого объёма достаточно, чтобы страница начала загружаться и
создавалась иллюзия доступности, но ключевые части сайта (основной HTML,
JS‑бандлы, стили, API‑запросы) не проходят, и сервис фактически перестаёт
работать.
</p>
<!-- biome-ignore format: code block -->
<KbCode>
$ curl -k https://cheburcheck.ru/100MB.bin -o/dev/null -r 0-65536 --resolve cheburcheck.ru:443:5.78.7.195 --max-time 5
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
24 65537 24 16101 0 0 3220 0 0:00:20 0:00:04 0:00:16 0
curl: (28) Operation timed out after 5000 milliseconds with 16101 out of 65537 bytes received
</KbCode>
<p>
Определение домена и выбор, «разрешить или задушить» запрос, в текущей схеме
делается на связке DPI‑фильтров. По умолчанию, все HTTP(s) запросы на адреса
из подсетей хостеров блокируются. Однако, запросы к некоторым доменам из тех
же подсетей проходят нормально &mdash; они обнаруживаются по открытому
расширению TLS SNI, либо по заголовку Host (в случае с plain http).
</p>
<p>
Проверить нахождение домена в белом списке можно с помощью простого
HTTPS-запроса с подменой SNI на адрес из заблокированных диапазонов:
</p>
<!-- biome-ignore format: code block -->
<KbCode>
$ curl -k https://ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve ok.ru:443:5.78.7.195
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 65537 100 65537 0 0 55226 0 0:00:01 0:00:01 --:--:-- 55258
</KbCode>
<p>
Также, стоит отметить, что домены белого списка добавляются с маской по
поддоменам:
</p>
<!-- biome-ignore format: code block -->
<KbCode>
$ curl -k https://ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve ok.ru:443:5.78.7.195
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 65537 100 65537 0 0 55226 0 0:00:01 0:00:01 --:--:-- 55258
</KbCode>
<KbHeading id="автоматическое-сканирование">
Автоматическое сканирование
</KbHeading>
<p>
Используя методы, указанные выше, мы разработали инструмент для
автоматического сканирования и анализа блокировок
<b>
<a href="https://github.com/LowderPlay/cheburcheck/tree/master/reporter">
Cheburcheck Reporter
</a>
</b>
.
</p>
<p>
Исходя из анализа 1,000,000 доменов из рейтинга
<a href="https://tranco-list.eu/list/2NPQ9">
Tranco list от 26 ноября 2025
</a>
, в белом списке содержится около 1000 доменов.
</p>
<i>
* - Мы не включаем в это число домены из зоны .co.uk, так как по какой-то
причине, они все находятся в белом списке.
</i>
<KbCaption>
Гистограмма количества доменов относительно их положения в рейтинге
(топ-30k, без .co.uk)
</KbCaption>
{#if histogramError}
<KbNote>Не удалось загрузить данные гистограммы.</KbNote>
{/if}
<KbChartFrame>
<BarChart
data={histogram30kQuery.data ?? []}
x="label"
y="count"
series={chartSeries}
padding={chartPadding}
props={chartProps}
/>
</KbChartFrame>
<KbCaption>
Гистограмма количества доменов относительно их положения в рейтинге
(топ-1kk, включая .co.uk)
</KbCaption>
<KbChartFrame>
<BarChart
data={histogram1mQuery.data ?? []}
x="label"
y="count"
series={chartSeries}
padding={chartPadding}
props={chartProps}
/>
</KbChartFrame>
<KbHeading id="скачать-списки">Скачать списки</KbHeading>
<p>Мы публикуем результаты наших сканирований в виде CSV-файлов:</p>
<KbFileLink href="/whitelist/full.csv">Полный список (CSV)</KbFileLink>
<KbFileLink href="/whitelist/domains.csv">Только домены (CSV)</KbFileLink>
</KbArticle>
+29
View File
@@ -0,0 +1,29 @@
@import "tailwindcss";
@source "../../node_modules/layerchart/dist";
@theme {
--color-surface-100: #171717;
--color-surface-200: #262626;
--color-surface-300: #404040;
--color-surface-content: #e5e5e5;
--color-danger: #ef4444;
--color-primary: 188 86% 53%;
}
@layer base {
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #09090b;
}
::-webkit-scrollbar-thumb {
background: #262626;
}
::-webkit-scrollbar-thumb:hover {
background: #404040;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-alert-icon lucide-cloud-alert">
<style>
path {
stroke: black;
}
@media (prefers-color-scheme: dark) {
path { stroke: white; }
}
</style>
<path d="M12 12v4"/><path d="M12 20h.01"/><path d="M17 18h.5a1 1 0 0 0 0-9h-1.79A7 7 0 1 0 7 17.708"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+20
View File
@@ -0,0 +1,20 @@
import adapter from "@sveltejs/adapter-static";
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
},
kit: {
adapter: adapter({
pages: "dist",
assets: "dist",
fallback: "index.html",
strict: true,
}),
},
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+14
View File
@@ -0,0 +1,14 @@
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
},
},
},
plugins: [tailwindcss(), sveltekit()],
});
-1
View File
@@ -8,7 +8,6 @@ ipnet-trie = "0.3.0"
ipnet = "2.12.0"
serde = { version = "1", features = ["derive"] }
async-trait = "0.1.89"
trie-rs = "0.4.2"
tokio = { workspace = true }
reqwest = { workspace = true }
csv = "1.4.0"
+14 -11
View File
@@ -57,20 +57,22 @@ pub async fn fetch_asn_prefixes(asn: u32) -> Result<Vec<String>, AsnError> {
"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{}",
asn
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let response = client.get(&url).send().await?;
if response.status() == 404 {
return Err(AsnError::NotFound);
}
let data: RipeStatResponse = response.json().await
let data: RipeStatResponse = response
.json()
.await
.map_err(|e| AsnError::ParseError(e.to_string()))?;
Ok(data.data.prefixes.into_iter().map(|p| p.prefix).collect())
}
@@ -86,7 +88,7 @@ pub async fn fetch_asn_prefixes_cached(
let prefixes = fetch_asn_prefixes(asn).await?;
cache_result(asn, prefixes.clone());
Ok(prefixes)
}
@@ -107,21 +109,22 @@ impl AsnInfo {
}
pub fn ipv4_prefixes(&self) -> Vec<&str> {
self.prefixes.iter()
self.prefixes
.iter()
.filter(|p| !p.contains(':'))
.map(|s| s.as_str())
.collect()
}
pub fn ipv6_prefixes(&self) -> Vec<&str> {
self.prefixes.iter()
self.prefixes
.iter()
.filter(|p| p.contains(':'))
.map(|s| s.as_str())
.collect()
}
}
pub struct AsnCache {
cache: Arc<RwLock<HashMap<u32, CachedAsnData>>>,
}
+45 -18
View File
@@ -1,11 +1,11 @@
use crate::updater::{fetch_db, Updatable};
use crate::updater::{Updatable, fetch_db};
use async_trait::async_trait;
use maxminddb::geoip2::{City, Country};
use maxminddb::{geoip2, MaxMindDbError};
use maxminddb::{MaxMindDbError, geoip2};
use serde::Serialize;
use std::io;
use std::io::Error;
use std::net::IpAddr;
use std::io;
pub struct GeoIp {
asn: Option<maxminddb::Reader<Vec<u8>>>,
@@ -39,7 +39,7 @@ impl GeoIp {
GeoIp {
asn: None,
country: None,
city: None
city: None,
}
}
@@ -49,7 +49,12 @@ impl GeoIp {
Ok(geoip)
}
pub fn update(&mut self, asn: Vec<u8>, country: Vec<u8>, city: Vec<u8>) -> Result<(), MaxMindDbError> {
pub fn update(
&mut self,
asn: Vec<u8>,
country: Vec<u8>,
city: Vec<u8>,
) -> Result<(), MaxMindDbError> {
self.asn = Some(maxminddb::Reader::from_source(asn)?);
self.country = Some(maxminddb::Reader::from_source(country)?);
self.city = Some(maxminddb::Reader::from_source(city)?);
@@ -59,29 +64,38 @@ impl GeoIp {
pub fn lookup(&self, ip: IpAddr) -> Result<IpInfo, MaxMindDbError> {
let asn = if let Some(db) = &self.asn {
db.lookup(ip)?.decode::<geoip2::Asn>()?
} else { None };
} else {
None
};
let city = if let Some(db) = &self.city {
db.lookup(ip)?.decode::<City>()?
} else { None };
} else {
None
};
let country = if let Some(db) = &self.country {
db.lookup(ip)?.decode::<Country>()?
} else { None };
} else {
None
};
let country_code = country.as_ref()
let country_code = country
.as_ref()
.map(|c| c.country.iso_code)
.flatten()
.map(|c| c.to_string());
let city_geo_name_id = city.as_ref()
.map(|c| c.city.geoname_id)
.flatten();
let city_geo_name_id = city.as_ref().map(|c| c.city.geoname_id).flatten();
let mut location = (None, None);
if let Some(city) = city {
location.0 = city.city.names.russian.or(city.city.names.english);
}
if let Some(country) = country {
location.1 = country.country.names.russian.or(country.country.names.english);
location.1 = country
.country
.names
.russian
.or(country.country.names.english);
}
let location = match location {
@@ -95,9 +109,12 @@ impl GeoIp {
location,
country_code,
city_geo_name_id,
asn: asn.clone().and_then(|asn| asn.autonomous_system_number)
asn: asn
.clone()
.and_then(|asn| asn.autonomous_system_number)
.map(|asn| format!("AS{}", asn)),
organisation: asn.and_then(|asn| asn.autonomous_system_organization)
organisation: asn
.and_then(|asn| asn.autonomous_system_organization)
.map(|org| org.to_string()),
})
}
@@ -108,9 +125,19 @@ impl Updatable for GeoIp {
type Base = (Vec<u8>, Vec<u8>, Vec<u8>);
async fn download() -> Result<Self::Base, Error> {
Ok((fetch_db(Self::get_url("GEO_ASN", "https://git.io/GeoLite2-ASN.mmdb")).await?,
fetch_db(Self::get_url("GEO_COUNTRY", "https://git.io/GeoLite2-Country.mmdb")).await?,
fetch_db(Self::get_url("GEO_CITY", "https://git.io/GeoLite2-City.mmdb")).await?))
Ok((
fetch_db(Self::get_url("GEO_ASN", "https://git.io/GeoLite2-ASN.mmdb")).await?,
fetch_db(Self::get_url(
"GEO_COUNTRY",
"https://git.io/GeoLite2-Country.mmdb",
))
.await?,
fetch_db(Self::get_url(
"GEO_CITY",
"https://git.io/GeoLite2-City.mmdb",
))
.await?,
))
}
async fn install(&mut self, (asn, country, city): Self::Base) -> Result<(), Error> {
+41 -18
View File
@@ -3,25 +3,25 @@ use crate::lists::{CdnList, NetworkRecord, RuBlacklist};
use crate::resolver::{ResolveError, Resolver};
use crate::target::Target;
use crate::updater::Updatable;
use arc_swap::ArcSwap;
use chrono::{DateTime, Utc};
use ipnet::IpNet;
use log::error;
use maxminddb::MaxMindDbError;
use std::collections::{HashMap, HashSet};
use std::io;
use std::net::IpAddr;
use std::sync::Arc;
use maxminddb::MaxMindDbError;
use thiserror::Error;
use tokio::sync::watch;
use arc_swap::ArcSwap;
pub mod asn;
pub mod geoip;
pub mod lists;
pub mod resolver;
pub mod updater;
pub mod target;
pub mod subnet_sampler;
pub mod target;
pub mod updater;
pub use subnet_sampler::{sample_ipv4_subnet, sample_ipv6_subnet};
@@ -62,7 +62,11 @@ pub enum CheckError {
NotFound,
}
pub type Bases = (<GeoIp as Updatable>::Base, <RuBlacklist as Updatable>::Base, <CdnList as Updatable>::Base);
pub type Bases = (
<GeoIp as Updatable>::Base,
<RuBlacklist as Updatable>::Base,
<CdnList as Updatable>::Base,
);
impl Checker {
pub async fn new() -> Checker {
@@ -91,7 +95,7 @@ impl Checker {
Err(e) => {
error!("{}", e);
return Err(CheckError::ResolveError(e));
},
}
};
let geo_ip = self.geo_ip.load();
let geo = match ips.get(0).map(|ip| geo_ip.lookup(ip.clone())) {
@@ -100,17 +104,22 @@ impl Checker {
Some(Err(e)) => {
error!("{}", e);
return Err(CheckError::GeoIpError);
},
}
};
let mut cdn_provider_subnets: HashMap<String, HashSet<NetworkRecord>> = HashMap::new();
let cdn_list = self.cdn_list.load();
ips.iter()
.filter_map(|ip| cdn_list.contains(ip))
.map(|ip| (match &ip.region {
None => ip.provider.clone(),
Some(region) => format!("{} ({})", ip.provider, region),
}, ip.clone()))
.map(|ip| {
(
match &ip.region {
None => ip.provider.clone(),
Some(region) => format!("{} ({})", ip.provider, region),
},
ip.clone(),
)
})
.for_each(|(k, v)| {
cdn_provider_subnets.entry(k).or_default().insert(v);
});
@@ -118,10 +127,11 @@ impl Checker {
let ru_blacklist = self.ru_blacklist.load();
let domain = match &target {
Target::Domain(domain) => ru_blacklist.contains_domain(domain),
_ => None
_ => None,
};
let rkn_subnets: HashSet<IpNet> = ips.iter()
let rkn_subnets: HashSet<IpNet> = ips
.iter()
.filter_map(|ip| ru_blacklist.contains_ip(ip))
.collect();
@@ -161,14 +171,20 @@ impl Checker {
None
};
let asn_has_blocked = asn_info.as_ref()
let asn_has_blocked = asn_info
.as_ref()
.map(|info| !info.blocked_prefixes.is_empty())
.unwrap_or(false);
let has_blocked_subnets = !rkn_subnets.is_empty();
Ok(Check {
verdict: match (domain, cdn_provider_subnets.is_empty(), asn_has_blocked, has_blocked_subnets) {
verdict: match (
domain,
cdn_provider_subnets.is_empty(),
asn_has_blocked,
has_blocked_subnets,
) {
(None, true, false, false) => CheckVerdict::Clear,
(domain, _, _, _) => CheckVerdict::Blocked {
rkn_domain: domain,
@@ -187,7 +203,11 @@ impl Checker {
}
pub async fn download_all() -> Result<Bases, io::Error> {
Ok((GeoIp::download().await?, RuBlacklist::download().await?, CdnList::download().await?))
Ok((
GeoIp::download().await?,
RuBlacklist::download().await?,
CdnList::download().await?,
))
}
pub async fn update_all(&self, (geo_ip_base, ru_blacklist_base, cdn_list_base): Bases) {
@@ -199,7 +219,11 @@ impl Checker {
}
};
let ru_blacklist = match RuBlacklist::load(ru_blacklist_base.0, ru_blacklist_base.1, ru_blacklist_base.2) {
let ru_blacklist = match RuBlacklist::load(
ru_blacklist_base.0,
ru_blacklist_base.1,
ru_blacklist_base.2,
) {
Ok(ru_blacklist) => Some(ru_blacklist),
Err(e) => {
error!("Failed to load RKN: {}", e);
@@ -235,5 +259,4 @@ impl Checker {
pub async fn total_v4s(&self) -> usize {
(self.cdn_list.load().v4_count() + self.ru_blacklist.load().v4_count()) as usize
}
}
+93 -30
View File
@@ -1,15 +1,14 @@
use crate::updater::{fetch_db, Updatable};
use crate::updater::{Updatable, fetch_db};
use async_trait::async_trait;
use ipnet::IpNet;
use ipnet_trie::IpnetTrie;
use log::info;
use serde::{de, Deserialize, Deserializer, Serializer};
use serde::{Deserialize, Deserializer, Serializer, de};
use std::collections::VecDeque;
use std::io;
use std::io::{BufRead, Error, Read};
use std::net::IpAddr;
use std::str::FromStr;
use trie_rs::map::{Trie, TrieBuilder};
pub struct CdnList {
trie: IpnetTrie<NetworkRecord>,
@@ -40,8 +39,10 @@ where
}
impl CdnList {
pub fn new() -> CdnList{
CdnList { trie: IpnetTrie::new() }
pub fn new() -> CdnList {
CdnList {
trie: IpnetTrie::new(),
}
}
pub fn load<R: Read>(list_reader: R) -> Result<Self, Error> {
@@ -50,7 +51,7 @@ impl CdnList {
Ok(list)
}
pub fn update<R: Read>(&mut self, list_reader: R) -> Result<(), Error> {
pub fn update<R: Read>(&mut self, list_reader: R) -> Result<(), Error> {
let mut trie = IpnetTrie::new();
let mut rdr = csv::Reader::from_reader(list_reader);
for result in rdr.deserialize() {
@@ -68,7 +69,9 @@ impl CdnList {
}
pub fn contains(&self, ip: &IpAddr) -> Option<NetworkRecord> {
self.trie.longest_match(&IpNet::from(*ip)).map(|(_, net)| net.clone())
self.trie
.longest_match(&IpNet::from(*ip))
.map(|(_, net)| net.clone())
}
}
@@ -90,7 +93,7 @@ impl Updatable for CdnList {
pub struct RuBlacklist {
ip_trie: IpnetTrie<()>,
domain_trie: Trie<String, String>,
blocked_domains: Vec<Box<str>>,
pub domain_count: usize,
}
@@ -98,39 +101,50 @@ impl RuBlacklist {
pub fn new() -> RuBlacklist {
RuBlacklist {
ip_trie: Default::default(),
domain_trie: TrieBuilder::new().build(),
domain_count: 0
blocked_domains: Vec::new(),
domain_count: 0,
}
}
pub fn load<R: BufRead>(ip_reader: R, domain_reader: R, custom_domains_reader: R) -> Result<Self, Error> {
pub fn load<R: BufRead>(
ip_reader: R,
domain_reader: R,
custom_domains_reader: R,
) -> Result<Self, Error> {
let mut list = Self::new();
list.update(ip_reader, domain_reader, custom_domains_reader)?;
Ok(list)
}
pub fn update<R: BufRead>(&mut self, ip_reader: R, domain_reader: R, custom_domains_reader: R) -> Result<(), Error> {
pub fn update<R: BufRead>(
&mut self,
ip_reader: R,
domain_reader: R,
custom_domains_reader: R,
) -> Result<(), Error> {
let mut ip_trie = IpnetTrie::new();
for net in ip_reader.lines() {
let net = net?;
let net = IpNet::from_str(&net)
.map_err(|e| Error::new(io::ErrorKind::InvalidData, e))?;
let net =
IpNet::from_str(&net).map_err(|e| Error::new(io::ErrorKind::InvalidData, e))?;
ip_trie.insert(net, ());
}
let (v4, v6) = ip_trie.ip_count();
info!("ip count: v4={}, v6={}", v4, v6);
self.ip_trie = ip_trie;
let mut domain_trie = TrieBuilder::new();
let mut blocked_domains = Vec::new();
let mut count = 0;
for domain in domain_reader.lines().chain(custom_domains_reader.lines()) {
let domain = domain?;
domain_trie.insert(Self::domain_chunks(&domain), domain);
blocked_domains.push(domain.into_boxed_str());
count += 1;
}
blocked_domains.sort_unstable();
blocked_domains.dedup();
info!("domain count: {}", count);
self.domain_count = count;
self.domain_trie = domain_trie.build();
self.blocked_domains = blocked_domains;
Ok(())
}
@@ -138,19 +152,57 @@ impl RuBlacklist {
self.ip_trie.ip_count().0
}
fn domain_chunks(domain: &str) -> Vec<String> {
domain.split(".").collect::<Vec<_>>()
.into_iter().map(|s| s.to_string())
.rev().collect()
}
pub fn contains_ip(&self, ip: &IpAddr) -> Option<IpNet> {
self.ip_trie.longest_match(&IpNet::from(*ip)).map(|(ip, _)| ip)
self.ip_trie
.longest_match(&IpNet::from(*ip))
.map(|(ip, _)| ip)
}
pub fn contains_domain(&self, domain: &str) -> Option<String> {
self.domain_trie.common_prefix_search(Self::domain_chunks(domain)).next()
.map(|(_, b): (Vec<_>, &String)| b).cloned()
let mut suffix = domain;
loop {
if let Ok(index) = self
.blocked_domains
.binary_search_by(|blocked_domain| blocked_domain.as_ref().cmp(suffix))
{
return Some(self.blocked_domains[index].to_string());
}
match suffix.find('.') {
Some(dot) => suffix = &suffix[dot + 1..],
None => return None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::RuBlacklist;
use std::io::Cursor;
#[test]
fn domain_lookup_matches_subdomains_on_label_boundaries() {
let list = RuBlacklist::load(
Cursor::new(""),
Cursor::new("blocked.example\n"),
Cursor::new("custom.test\n"),
)
.unwrap();
assert_eq!(
list.contains_domain("blocked.example"),
Some("blocked.example".to_string())
);
assert_eq!(
list.contains_domain("www.blocked.example"),
Some("blocked.example".to_string())
);
assert_eq!(
list.contains_domain("custom.test"),
Some("custom.test".to_string())
);
assert_eq!(list.contains_domain("notblocked.example"), None);
}
}
@@ -159,11 +211,22 @@ impl Updatable for RuBlacklist {
type Base = (VecDeque<u8>, VecDeque<u8>, VecDeque<u8>);
async fn download() -> Result<Self::Base, Error> {
Ok((VecDeque::from(
fetch_db(Self::get_url("RKN_NETS", "https://antifilter.network/download/ipsum.lst")).await?),
Ok((
VecDeque::from(
fetch_db(Self::get_url("RKN_DOMAINS", "https://antifilter.download/list/domains.lst")).await?),
VecDeque::from(include_bytes!("../dist-domains.txt").to_vec())
fetch_db(Self::get_url(
"RKN_NETS",
"https://antifilter.network/download/ipsum.lst",
))
.await?,
),
VecDeque::from(
fetch_db(Self::get_url(
"RKN_DOMAINS",
"https://antifilter.download/list/domains.lst",
))
.await?,
),
VecDeque::from(include_bytes!("../dist-domains.txt").to_vec()),
))
}
+22 -11
View File
@@ -1,6 +1,6 @@
use hickory_resolver::config::{LookupIpStrategy, ResolverConfig, ResolverOpts};
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::ProtoError;
use std::io::{Error, ErrorKind};
use std::net::IpAddr;
@@ -45,23 +45,34 @@ impl Resolver {
let config = ResolverConfig::https(&hickory_resolver::config::QUAD9);
let mut opts = ResolverOpts::default();
opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
let resolver = hickory_resolver::Resolver::builder_with_config(config, TokioRuntimeProvider::default())
.with_options(opts)
.build().expect("build resolver");
Resolver {
let resolver = hickory_resolver::Resolver::builder_with_config(
config,
TokioRuntimeProvider::default(),
)
.with_options(opts)
.build()
.expect("build resolver");
Resolver {
resolver,
asn_cache: Arc::new(AsnCache::new()),
}
}
pub async fn lookup_ips(&self, domain: &str) -> Result<Vec<IpAddr>, ResolveError> {
Ok(self.resolver.lookup_ip(domain).await
Ok(self
.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))
})?.iter().collect())
NetError::Proto(ProtoError::Msg(msg))
if msg.contains("Malformed label") || msg.contains("invalid characters") =>
{
ResolveError::NxDomain
}
_ => ResolveError::Other(Error::new(ErrorKind::Other, e)),
})?
.iter()
.collect())
}
}
+6 -6
View File
@@ -7,15 +7,15 @@ pub fn sample_ipv4_subnet(net: Ipv4Net) -> Vec<IpAddr> {
} else {
let mut ips = Vec::with_capacity(12);
ips.push(IpAddr::V4(net.network()));
let base = u32::from(net.network());
let max = (1u64 << (32 - net.prefix_len())) - 1;
for i in 1..=10 {
let offset = (max * i) / 10;
ips.push(IpAddr::V4(Ipv4Addr::from(base + offset as u32)));
}
ips.push(IpAddr::V4(net.broadcast()));
ips
}
@@ -27,7 +27,7 @@ pub fn sample_ipv6_subnet(net: Ipv6Net) -> Vec<IpAddr> {
} else {
let mut ips = Vec::with_capacity(12);
ips.push(IpAddr::V6(net.network()));
let base = u128::from(net.network());
let bits = 128 - net.prefix_len();
let max = if bits >= 64 {
@@ -35,12 +35,12 @@ pub fn sample_ipv6_subnet(net: Ipv6Net) -> Vec<IpAddr> {
} else {
(1u128 << bits) - 1
};
for i in 1..=10 {
let offset = (max * i) / 10;
ips.push(IpAddr::V6(Ipv6Addr::from(base + offset)));
}
ips.push(IpAddr::V6(net.broadcast()));
ips
}
+8 -10
View File
@@ -15,7 +15,6 @@ pub enum Target {
Asn(u32),
}
impl From<&str> for Target {
fn from(input: &str) -> Self {
if input.to_lowercase().starts_with("as") {
@@ -32,13 +31,12 @@ impl From<&str> for Target {
return Target::Ipv4Subnet(ipv4_net);
}
}
if let Ok(ipv6_net) = input.parse::<Ipv6Net>() {
if ipv6_net.prefix_len() >= 32 {
return Target::Ipv6Subnet(ipv6_net);
}
}
}
if let Ok(ipv4) = input.parse::<Ipv4Addr>() {
@@ -84,27 +82,27 @@ impl Target {
|asn, prefixes| resolver.asn_cache.cache_asn(asn, prefixes),
)
.await?;
if prefixes.is_empty() {
return Ok(vec![]);
}
if prefixes.len() > 100 {
prefixes.truncate(100);
}
let mut all_ips = Vec::new();
for prefix in &prefixes {
if let Ok(ipv4_net) = prefix.parse::<Ipv4Net>() {
all_ips.extend(sample_ipv4_subnet(ipv4_net));
} else if let Ok(ipv6_net) = prefix.parse::<Ipv6Net>() {
all_ips.extend(sample_ipv6_subnet(ipv6_net));
}
}
}
all_ips
},
}
})
}
+2 -1
View File
@@ -9,7 +9,8 @@ use std::io::Error;
pub async fn fetch_db<T: IntoUrl + Display>(url: T) -> Result<Vec<u8>, Error> {
info!("Fetching {}", url);
let response = reqwest::get(url).await
let response = reqwest::get(url)
.await
.map_err(|e| Error::new(io::ErrorKind::Other, e))?
.error_for_status()
.map_err(|e| Error::new(io::ErrorKind::Other, e))?;
+12 -7
View File
@@ -1,5 +1,5 @@
use std::{env, fs, path::Path};
use reqwest::blocking::Client;
use std::{env, fs, path::Path};
fn main() {
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
@@ -11,12 +11,17 @@ fn main() {
let client = Client::new();
println!("cargo:rerun-if-env-changed=DIST_DOMAIN_COUNT");
let data = client.get(
format!("https://tranco-list.eu/download/2NPQ9/{}", option_env!("DIST_DOMAIN_COUNT")
.map(|x| x.parse::<u32>().expect("DIST_DOMAIN_COUNT is not a number"))
.unwrap_or(1_000_000)))
.send().expect("Failed to send request")
.bytes().expect("Failed to read bytes");
let data = client
.get(format!(
"https://tranco-list.eu/download/2NPQ9/{}",
option_env!("DIST_DOMAIN_COUNT")
.map(|x| x.parse::<u32>().expect("DIST_DOMAIN_COUNT is not a number"))
.unwrap_or(1_000_000)
))
.send()
.expect("Failed to send request")
.bytes()
.expect("Failed to read bytes");
let list_csv = Path::new(&out_dir).join("list.csv");
fs::write(&list_csv, &data).expect("Failed to write list");
+20 -12
View File
@@ -1,9 +1,9 @@
use crate::Verbosity;
use log::info;
use reports::Evidence;
use std::collections::HashMap;
use std::fmt::Display;
use std::path::PathBuf;
use log::info;
use reports::Evidence;
use crate::Verbosity;
#[derive(Default)]
pub struct Counter {
@@ -31,8 +31,12 @@ impl Counter {
for (target, evidence) in &self.results {
match evidence {
Evidence::Ok if verbosity >= &Verbosity::All => println!(" [Ok] {}", target),
Evidence::Blocked if verbosity >= &Verbosity::Block => println!(" [Blocked] {}", target),
Evidence::ConnectError if verbosity >= &Verbosity::Error => println!(" [ConnectError] {}", target),
Evidence::Blocked if verbosity >= &Verbosity::Block => {
println!(" [Blocked] {}", target)
}
Evidence::ConnectError if verbosity >= &Verbosity::Error => {
println!(" [ConnectError] {}", target)
}
_ => {}
}
}
@@ -55,12 +59,16 @@ impl Counter {
impl Display for Counter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let total = self.total();
write!(f, "OK {} ({:.2}%) | Blocked {} (early: {}) ({:.2}%) | Error {} ({:.2}%)",
self.ok,
self.ok as f32 / total as f32 * 100.0,
self.block, self.early,
self.block as f32 / total as f32 * 100.0,
self.err,
self.err as f32 / total as f32 * 100.0)
write!(
f,
"OK {} ({:.2}%) | Blocked {} (early: {}) ({:.2}%) | Error {} ({:.2}%)",
self.ok,
self.ok as f32 / total as f32 * 100.0,
self.block,
self.early,
self.block as f32 / total as f32 * 100.0,
self.err,
self.err as f32 / total as f32 * 100.0
)
}
}
+55 -27
View File
@@ -1,25 +1,25 @@
mod resolver;
mod counter;
mod resolver;
use crate::resolver::Resolver;
use anyhow::Result;
use clap::{Parser, ValueEnum};
use futures::stream::FuturesUnordered;
use counter::Counter;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use indicatif::{ProgressIterator, ProgressStyle};
use log::{error, info, warn, LevelFilter};
use log::{LevelFilter, error, info, warn};
use reports::{AgencyReport, Evidence, ReporterConfig};
use reqwest::redirect::Policy;
use reqwest::Client;
use reqwest::redirect::Policy;
use serde::Serialize;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::time::Instant;
use counter::Counter;
const JUNK: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/junk.bin"));
@@ -33,7 +33,11 @@ enum Verbosity {
}
#[derive(Parser, Debug, Clone)]
#[command(author, version, about = "DPI probe: checks blockage of domains by SNI")]
#[command(
author,
version,
about = "DPI probe: checks blockage of domains by SNI"
)]
struct Args {
/// Output results file
#[arg(required = false)]
@@ -91,7 +95,6 @@ struct Args {
/// Agency endpoint API key
#[arg(short, long, env = "AGENCY_KEY")]
key: Option<String>,
}
impl Args {
@@ -129,17 +132,27 @@ async fn main() -> Result<()> {
{
let file_limit: Option<usize> = unsafe { libc::getdtablesize() }.try_into().ok();
if matches!(file_limit, Some(file_limit) if file_limit <= args.probe_count + 128) {
warn!("Open file limit is too low ({})! Consider increasing it using `ulimit -n`.", file_limit.unwrap());
warn!(
"Open file limit is too low ({})! Consider increasing it using `ulimit -n`.",
file_limit.unwrap()
);
}
}
let api_client = Client::new();
info!("Loading targets list...");
let targets = include_str!(concat!(env!("OUT_DIR"), "/list.csv"));
let targets: Vec<String> = targets.lines().take(args.count)
.map(|s| s.split(",").last().unwrap().to_string()).collect();
let targets: Vec<String> = targets
.lines()
.take(args.count)
.map(|s| s.split(",").last().unwrap().to_string())
.collect();
info!("Probing {} domains with {} concurrent probes...", targets.len(), args.probe_count);
info!(
"Probing {} domains with {} concurrent probes...",
targets.len(),
args.probe_count
);
let sem = Arc::new(tokio::sync::Semaphore::new(args.probe_count));
let cancelled = wait_for_ctrlc();
let start = Instant::now();
@@ -194,7 +207,11 @@ async fn main() -> Result<()> {
counter.save_results(output)?;
}
info!("Probed {} domains in {}s! \nSummary: {counter}", counter.total(), start.elapsed().as_secs());
info!(
"Probed {} domains in {}s! \nSummary: {counter}",
counter.total(),
start.elapsed().as_secs()
);
if let Err(e) = upload_results(&args, &api_client, counter.results).await {
warn!("Upload failed: {}", e);
}
@@ -202,10 +219,15 @@ async fn main() -> Result<()> {
Ok(())
}
async fn upload_results(args: &Args, api_client: &Client, results: HashMap<String, Evidence>) -> Result<()> {
async fn upload_results(
args: &Args,
api_client: &Client,
results: HashMap<String, Evidence>,
) -> Result<()> {
info!("Uploading to {}", args.agency_endpoint);
let uploaded = api_client.post(&args.agency_endpoint)
let uploaded = api_client
.post(&args.agency_endpoint)
.header("Content-Type", "application/msgpack")
.body(rmp_serde::to_vec(&AgencyReport {
version: env!("CARGO_PKG_VERSION").to_string(),
@@ -215,7 +237,9 @@ async fn upload_results(args: &Args, api_client: &Client, results: HashMap<Strin
let uploaded = if let Some(key) = &args.key {
uploaded.header("Authorization", format!("Bearer {key}"))
} else { uploaded };
} else {
uploaded
};
let uploaded = uploaded.send().await?;
@@ -246,9 +270,7 @@ fn wait_for_ctrlc() -> impl Fn() -> bool {
}
});
move || {
cancelled.load(Ordering::SeqCst) != 0
}
move || cancelled.load(Ordering::SeqCst) != 0
}
enum Verdict {
@@ -257,19 +279,21 @@ enum Verdict {
}
async fn check_target(args: &Args, target: &str) -> Result<Verdict, reqwest::Error> {
let url = format!("http{}://{target}/{}", if args.http {""} else {"s"}, args.path);
let url = format!(
"http{}://{target}/{}",
if args.http { "" } else { "s" },
args.path
);
let mut attempts = 0;
loop {
attempts += 1;
let client = build_client(&args, 1)?;
let mut resp = client.get(&url)
.header("Range", "bytes=0-65536");
let mut resp = client.get(&url).header("Range", "bytes=0-65536");
if args.tx {
resp = resp.body(JUNK)
}
let resp = resp.send()
.await;
let resp = resp.send().await;
let resp = match resp {
Ok(resp) => match (resp.status(), resp.bytes().await) {
@@ -283,7 +307,11 @@ async fn check_target(args: &Args, target: &str) -> Result<Verdict, reqwest::Err
let warn = if !status.is_success() {
Some(format!("Domain {target} returned non-OK code: {status}"))
} else if bytes.len() < 65535 {
Some(format!("Domain {target} completed with {} bytes: \n{}", bytes.len(), String::from_utf8_lossy(bytes.as_ref())))
Some(format!(
"Domain {target} completed with {} bytes: \n{}",
bytes.len(),
String::from_utf8_lossy(bytes.as_ref())
))
} else {
None
};
@@ -309,7 +337,7 @@ async fn check_target(args: &Args, target: &str) -> Result<Verdict, reqwest::Err
error!("{} -> Error: {:?}", target, e);
Err(e)
}
},
}
}
};
}
}
+1 -3
View File
@@ -16,8 +16,6 @@ impl Resolver {
impl Resolve for Resolver {
fn resolve(&self, _: Name) -> Resolving {
let ip = self.ip.clone();
Box::pin(async move {
Ok(Addrs::from(Box::new(vec![ip].into_iter())))
})
Box::pin(async move { Ok(Addrs::from(Box::new(vec![ip].into_iter()))) })
}
}
+39 -13
View File
@@ -1,18 +1,39 @@
# syntax=docker/dockerfile:1.10
FROM docker.io/rust:1-slim-bookworm AS build
WORKDIR /build
ENV PNPM_HOME=/pnpm
ENV PATH="${PNPM_HOME}:${PATH}"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl libssl-dev pkg-config && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
corepack enable && \
corepack prepare pnpm@10.33.0 --activate && \
pnpm config set store-dir /pnpm/store && \
rm -rf /var/lib/apt/lists/*
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./frontend/
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm --dir frontend fetch --frozen-lockfile
COPY . .
RUN apt update && apt install -y libssl-dev pkg-config
RUN --mount=type=cache,target=/build/target \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
RUN --mount=type=cache,id=website-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 \
--mount=type=cache,id=pnpm-store,target=/pnpm/store \
set -eux; \
export SQLX_OFFLINE=true; \
cargo build --release --package website; \
objcopy --compress-debug-sections target/release/website ./main
pnpm --dir frontend install --frozen-lockfile --offline; \
pnpm --dir frontend build; \
RUSTFLAGS="-C strip=symbols" SKIP_FRONTEND_BUILD=true SQLX_OFFLINE=true cargo build --locked --release --package website; \
cp target/release/website ./main
################################################################################
@@ -20,19 +41,24 @@ FROM docker.io/debian:bookworm-slim
WORKDIR /app
RUN apt update && apt install -y libssl3 ca-certificates curl
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends libssl3 ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
groupadd --system app && \
useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
COPY --from=build /build/website/Rocket.toml ./
## copy the main binary
COPY --from=build /build/main ./
COPY --from=build /build/website/static ./static
COPY --from=build /build/website/templates ./templates
## ensure the container listens globally on port 8080
ENV ROCKET_ADDRESS=::
ENV ROCKET_PORT=8080
EXPOSE 8080
CMD ./main
USER app
CMD ["./main"]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "website"
version = "0.4.3"
version = "1.0.0"
edition = "2024"
[dependencies]
@@ -11,6 +11,7 @@ rocket-client-addr = "0.5.4"
serde = { workspace = true }
reports = { path = "../reports" }
querying = { path = "../querying" }
frontend = { path = "../frontend" }
env_logger = "0.11.10"
rocket-cache-response = "0.6.4"
log = { workspace = true }
-73
View File
@@ -1,79 +1,6 @@
use std::{
env,
fs::File,
io::{self, Cursor},
path::Path,
};
use std::collections::HashMap;
use std::path::PathBuf;
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use serde::Deserialize;
use tar::Archive;
#[derive(Deserialize)]
struct NpmMetadata {
dist: Dist,
}
#[derive(Deserialize)]
struct Dist {
tarball: String,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=migrations");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
println!("cargo:rerun-if-env-changed=LUCIDE_VERSION");
install_npm("lucide", option_env!("LUCIDE_VERSION"), HashMap::from([
(PathBuf::from("package/dist/umd/lucide.min.js"), out_dir.join("lucide.js")),
]))?;
println!("cargo:rerun-if-env-changed=CHARTJS_VERSION");
install_npm("chart.js", option_env!("CHARTJS_VERSION"), HashMap::from([
(PathBuf::from("package/dist/chart.umd.min.js"), out_dir.join("chart.js")),
]))?;
println!("cargo:rerun-if-env-changed=CHARTDATALABELS_VERSION");
install_npm("chartjs-plugin-datalabels", option_env!("CHARTDATALABELS_VERSION"), HashMap::from([
(PathBuf::from("package/dist/chartjs-plugin-datalabels.min.js"), out_dir.join("chartjs-plugin-datalabels.js")),
]))?;
println!("cargo:rerun-if-changed=build.rs");
Ok(())
}
fn install_npm(package: &str, version: Option<&str>, mut entries: HashMap<PathBuf, PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let metadata_url = format!(
"https://registry.npmjs.org/{package}/{}",
version.unwrap_or("latest")
);
println!("Fetching npm metadata from {}", metadata_url);
let metadata: NpmMetadata = client.get(metadata_url).send()?.json()?;
println!("Downloading tarball from {}", metadata.dist.tarball);
let tarball_bytes = client.get(&metadata.dist.tarball).send()?.bytes()?;
let tar = GzDecoder::new(Cursor::new(tarball_bytes));
let mut archive = Archive::new(tar);
for entry in archive.entries()? {
let mut entry = entry?;
let path = PathBuf::from(entry.path()?);
if let Some(dest) = entries.remove(&path) {
println!("Found {:?}, extracting to {:?}", path.file_name(), dest);
let mut out_file = File::create(&dest)?;
io::copy(&mut entry, &mut out_file)?;
}
}
if !entries.is_empty() {
panic!("Not all entries found! {:?}", entries);
}
Ok(())
}
@@ -0,0 +1,61 @@
ALTER TABLE
queries
ALTER COLUMN
id
SET
DEFAULT uuidv7();
CREATE INDEX IF NOT EXISTS reports_reporter_date_id_desc_idx ON reports (reporter, date DESC, id DESC);
CREATE INDEX IF NOT EXISTS report_row_domain_idx ON report_row (domain);
DROP MATERIALIZED VIEW IF EXISTS whitelist;
CREATE MATERIALIZED VIEW whitelist AS WITH report_domains AS (
SELECT
DISTINCT domain
FROM
report_row
)
SELECT
latest.domain,
d.rank,
MAX(
CASE
WHEN latest.evidence = 'ok' THEN latest.date
END
) AS last_ok
FROM
report_domains rd
CROSS JOIN LATERAL (
SELECT
rd.domain,
rr.evidence,
r.date
FROM
reports r
JOIN report_row rr ON rr.report_id = r.id
AND rr.domain = rd.domain
WHERE
r.reporter = 1
ORDER BY
r.date DESC,
r.id DESC
LIMIT
5
) latest
LEFT JOIN domains d ON d.domain = latest.domain
GROUP BY
latest.domain,
d.rank
HAVING
COUNT(*) FILTER (
WHERE
latest.evidence = 'ok'
) >= COUNT(*) / 2.0
ORDER BY
d.rank;
CREATE UNIQUE INDEX IF NOT EXISTS whitelist_domain_idx ON whitelist (domain);
CREATE INDEX IF NOT EXISTS whitelist_rank_idx ON whitelist (rank);
+5 -2
View File
@@ -1,10 +1,10 @@
use reports::AgencyReport;
use rocket::State;
use rocket::http::Status;
use rocket::serde::json::serde_json::json;
use rocket::serde::json::{Json, Value};
use rocket::serde::msgpack::MsgPack;
use rocket_client_addr::ClientRealAddr;
use rocket::State;
use sqlx::Acquire;
use sqlx::postgres::PgPool;
@@ -20,7 +20,10 @@ pub async fn upload_report(
agency: Agency,
pool: &State<PgPool>,
) -> Result<Json<Value>, (Status, String)> {
let mut db = pool.acquire().await.map_err(|e| (Status::InternalServerError, e.to_string()))?;
let mut db = pool
.acquire()
.await
.map_err(|e| (Status::InternalServerError, e.to_string()))?;
let mut tx = db
.begin()
.await
+56 -4
View File
@@ -1,4 +1,4 @@
use crate::db::{check_whitelist, save_query, WhitelistedEntry};
use crate::db::{WhitelistedEntry, check_whitelist, save_query};
use governor::clock::DefaultClock;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::{Quota, RateLimiter};
@@ -8,20 +8,21 @@ 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::State;
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;
pub type ApiRateLimiter =
RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
pub type ApiRateLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
pub fn build_rate_limiter(per_minute: u32) -> ApiRateLimiter {
RateLimiter::keyed(Quota::per_minute(
@@ -45,6 +46,8 @@ pub struct ApiCheckResponse {
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(
@@ -79,6 +82,7 @@ fn build_response(
geo: check.geo,
asn_info: check.asn_info,
whitelist,
subnet_size: target.subnet_size(),
}
}
@@ -131,3 +135,51 @@ pub async fn check(
}
}
}
#[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"),
})
}
+20 -15
View File
@@ -2,14 +2,14 @@ use crate::agency::Agency;
use querying::target::Target;
use querying::{Check, CheckVerdict, Checker};
use rocket::http::Status;
use rocket::outcome::{try_outcome, IntoOutcome};
use rocket::outcome::{IntoOutcome, try_outcome};
use rocket::request::{FromRequest, Outcome};
use rocket::tokio::sync::RwLockReadGuard;
use rocket::{Request, State};
use rocket_client_addr::ClientRealAddr;
use serde::Serialize;
use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::Uuid;
use sqlx::types::chrono::NaiveDateTime;
pub async fn save_query(
db: &mut sqlx::PgConnection,
@@ -40,12 +40,14 @@ pub async fn save_query(
let (resolved_ips, cdn_networks) = match target {
Target::Asn(_) => (vec![], vec![]),
_ => (check
.ips
.iter()
.map(|i| i.to_string())
.collect::<Vec<String>>(),
cdn_networks)
_ => (
check
.ips
.iter()
.map(|i| i.to_string())
.collect::<Vec<String>>(),
cdn_networks,
),
};
let id = sqlx::query_scalar(
@@ -93,11 +95,12 @@ impl<'r> FromRequest<'r> for Agency {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let pool = request.guard::<&State<sqlx::PgPool>>().await.unwrap();
let mut db = try_outcome!(pool
.acquire()
.await
.map_err(|e| Some(e))
.or_forward(Status::InternalServerError));
let mut db = try_outcome!(
pool.acquire()
.await
.map_err(|e| Some(e))
.or_forward(Status::InternalServerError)
);
let token = request.headers().get_one("Authorization");
let token = try_outcome!(
@@ -166,7 +169,6 @@ pub async fn collect_histogram(
limit: i32,
filter: bool,
) -> Result<Vec<WhitelistHistogramBin>, sqlx::Error> {
sqlx::query_as!(
WhitelistHistogramBin,
"WITH bins AS (
@@ -181,7 +183,10 @@ FROM bins b
LEFT JOIN whitelist w
ON FLOOR(w.rank / $2) = b.bin
GROUP BY b.bin
ORDER BY b.bin;", bins, limit / bins, filter
ORDER BY b.bin;",
bins,
limit / bins,
filter
)
.fetch_all(db)
.await
+83 -245
View File
@@ -5,205 +5,21 @@ mod api;
mod db;
mod whitelist;
use crate::db::{check_whitelist, save_query, WhitelistedEntry};
use log::{error, info, warn, LevelFilter};
use querying::target::Target;
use querying::{Check, CheckError, CheckVerdict, Checker};
use env_logger::Env;
use log::{LevelFilter, error, info};
use querying::Checker;
use rocket::fairing::AdHoc;
use rocket::fs::FileServer;
use rocket::http::Status;
use rocket::response::content::RawJavaScript;
use rocket::http::{ContentType, Status};
use rocket::response::content::RawHtml;
use rocket::serde::json::Json;
use rocket::tokio::sync::RwLock;
use rocket::tokio::time;
use rocket::{fairing, tokio, Build, Request, Rocket, State};
use rocket_cache_response::CacheResponse;
use rocket_client_addr::ClientRealAddr;
use rocket_dyn_templates::{context, Metadata, Template};
use rocket::{Build, Request, Rocket, fairing, tokio};
use serde::Serialize;
use sqlx::postgres::PgPool;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use env_logger::Env;
use rocket::serde::json::Json;
use sqlx::types::Uuid;
use sqlx::postgres::PgPool;
#[derive(Serialize)]
struct GlobalContext {
version: &'static str,
}
impl GlobalContext {
fn new() -> Self {
GlobalContext {
version: env!("CARGO_PKG_VERSION"),
}
}
}
#[get("/")]
async fn index(checker: &State<Arc<RwLock<Checker>>>) -> Template {
let checker_ref = checker.read().await;
Template::render(
"index",
context! {
global: GlobalContext::new(),
domain_count: format_number(checker_ref.total_domains().await),
v4_count: format_number(checker_ref.total_v4s().await),
last_update: checker_ref.last_update(),
},
)
}
#[get("/kb/<page>")]
fn page(metadata: Metadata, page: &str) -> Option<Template> {
let page = format!("pages/{}", page);
if !metadata.contains_template(&page) {
return None;
}
Some(Template::render(
page,
context! {
global: GlobalContext::new(),
},
))
}
#[get("/healthcheck")]
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>")]
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(())
}
#[get("/check?<target>")]
async fn check(
target: &str,
checker: &State<Arc<RwLock<Checker>>>,
addr: &ClientRealAddr,
pool: &State<PgPool>,
) -> Result<Template, Status> {
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!("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) => Ok(Template::render(
"empty",
context! {
global: GlobalContext::new(),
target: target.to_query(),
target_type: target.readable_type(),
},
)),
Ok(Check {
verdict: CheckVerdict::Clear,
geo,
ips,
rkn_subnets,
asn_info,
}) => Ok(Template::render(
"result",
context! {
id,
global: GlobalContext::new(),
found: false,
target: target.to_query(),
target_type: target.readable_type(),
blocked_subnets: rkn_subnets.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>(),
whitelist,
ips,
geo,
subnet_size: target.subnet_size(),
asn_info,
},
)),
Ok(Check {
verdict:
CheckVerdict::Blocked {
rkn_domain,
cdn_provider_subnets,
},
geo,
rkn_subnets,
ips,
asn_info,
}) => Ok(Template::render(
"result",
context! {
id,
global: GlobalContext::new(),
found: true,
domain: rkn_domain,
providers: cdn_provider_subnets,
blocked_subnets: rkn_subnets.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>(),
target: target.to_query(),
target_type: target.readable_type(),
whitelist,
ips,
geo,
subnet_size: target.subnet_size(),
asn_info,
},
)),
Err(e) => {
error!("check failed {:?}", e);
Err(Status::InternalServerError)
}
}
}
#[catch(default)]
fn default(status: Status, _req: &Request) -> Template {
Template::render(
"error",
context! {
global: GlobalContext::new(),
status: status.code,
reason: status.reason_lossy(),
},
)
}
#[derive(Debug, Serialize)]
struct JsonError {
@@ -213,44 +29,57 @@ struct JsonError {
#[catch(default)]
fn api_error(status: Status, _: &Request) -> Json<JsonError> {
Json(JsonError { code: status.code, info: status.reason_lossy().to_string() })
Json(JsonError {
code: status.code,
info: status.reason_lossy().to_string(),
})
}
#[rocket::get("/lucide.js")]
fn lucide() -> CacheResponse<RawJavaScript<&'static [u8]>> {
CacheResponse::Public {
responder: RawJavaScript(include_bytes!(concat!(env!("OUT_DIR"), "/lucide.js"))),
max_age: 604800,
must_revalidate: false,
}
}
#[rocket::get("/chart.js")]
fn chartjs() -> CacheResponse<RawJavaScript<&'static [u8]>> {
CacheResponse::Public {
responder: RawJavaScript(include_bytes!(concat!(env!("OUT_DIR"), "/chart.js"))),
max_age: 604800,
must_revalidate: false,
}
}
#[rocket::get("/chartjs-plugin-datalabels.js")]
fn chartjs_datalabels() -> CacheResponse<RawJavaScript<&'static [u8]>> {
CacheResponse::Public {
responder: RawJavaScript(include_bytes!(concat!(env!("OUT_DIR"), "/chartjs-plugin-datalabels.js"))),
max_age: 604800,
must_revalidate: false,
}
#[get("/")]
fn frontend_index() -> Option<RawHtml<&'static str>> {
frontend::DIST
.get_file("index.html")
.and_then(frontend::File::contents_utf8)
.map(RawHtml)
}
fn format_number(number: usize) -> String {
number
.to_string()
.as_bytes()
.rchunks(3)
.rev()
.map(std::str::from_utf8)
.collect::<Result<Vec<&str>, _>>()
.unwrap()
.join(" ")
#[get("/<path..>", rank = 20)]
fn frontend_asset(path: PathBuf) -> Option<(ContentType, &'static [u8])> {
if is_backend_path(&path) {
return None;
}
if let Some(file) = embedded_file(&path) {
return Some((content_type(&path), file.contents()));
}
if path.extension().is_none() {
let index = frontend::DIST.get_file("index.html")?;
return Some((ContentType::HTML, index.contents()));
}
None
}
fn embedded_file(path: &PathBuf) -> Option<&'static frontend::File<'static>> {
let path = path.to_string_lossy().replace('\\', "/");
frontend::DIST.get_file(path)
}
fn is_backend_path(path: &PathBuf) -> bool {
matches!(
path.components()
.next()
.and_then(|component| component.as_os_str().to_str()),
Some("api" | "agency" | "whitelist")
)
}
fn content_type(path: &PathBuf) -> ContentType {
path.extension()
.and_then(|extension| extension.to_str())
.and_then(ContentType::from_extension)
.unwrap_or(ContentType::Binary)
}
async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
@@ -293,8 +122,8 @@ async fn rocket() -> _ {
info!("Downloaded, updating...");
checker_clone.read().await.update_all(bases).await;
info!("Updated databases");
},
Err(e) => log::error!("Failed to download all DBs"),
}
Err(_) => log::error!("Failed to download all DBs"),
}
}
});
@@ -306,14 +135,18 @@ async fn rocket() -> _ {
let api_limiter = std::sync::Arc::new(api::build_rate_limiter(rate_limit_rpm));
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(std::env::var("DATABASE_MAX_CONNECTIONS")
.unwrap_or("100".to_string())
.parse()
.unwrap())
.min_connections(std::env::var("DATABASE_MIN_CONNECTIONS")
.unwrap_or("10".to_string())
.parse()
.unwrap())
.max_connections(
std::env::var("DATABASE_MAX_CONNECTIONS")
.unwrap_or("100".to_string())
.parse()
.unwrap(),
)
.min_connections(
std::env::var("DATABASE_MIN_CONNECTIONS")
.unwrap_or("10".to_string())
.parse()
.unwrap(),
)
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(60))
.connect(&dotenvy::var("DATABASE_URL").expect("DATABASE_URL must be set"))
@@ -325,14 +158,19 @@ async fn rocket() -> _ {
.manage(pool)
.manage(api_limiter)
.attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations))
.mount("/", routes![index, check, healthcheck, page, feedback])
.mount("/vendor", routes![lucide, chartjs, chartjs_datalabels])
.mount("/api/v1", routes![api::check])
.register("/api", catchers![api_error])
.mount("/", routes![api::feedback]) // DEPRECATED: backwards compatibility
.mount(
"/api/v1",
routes![
api::check,
api::healthcheck,
api::feedback,
api::get_system_status,
whitelist::histogram
],
)
.mount("/agency", routes![agency::upload_report])
.mount("/whitelist", routes![whitelist::histogram, whitelist::export_csv])
.register("/agency", catchers![api_error])
.register("/", catchers![default])
.mount("/", FileServer::from(PathBuf::from("static")))
.attach(Template::fairing())
.mount("/whitelist", routes![whitelist::export_csv])
.mount("/", routes![frontend_index, frontend_asset])
.register("/", catchers![api_error])
}
+22 -9
View File
@@ -1,12 +1,12 @@
use crate::db::{WhitelistHistogramBin, collect_histogram};
use rocket::State;
use rocket::futures::StreamExt;
use rocket::http::{ContentType, Status};
use rocket::request::FromParam;
use rocket_cache_response::CacheResponse;
use rocket::State;
use std::io;
use rocket::serde::json::Json;
use crate::db::{collect_histogram, WhitelistHistogramBin};
use rocket_cache_response::CacheResponse;
use sqlx::postgres::PgPool;
use std::io;
enum ExportType {
Full,
@@ -39,7 +39,10 @@ pub async fn export_csv(
}
};
let mut db = pool.acquire().await.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let mut db = pool
.acquire()
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let mut stream = db
.copy_out_raw(query)
@@ -61,9 +64,19 @@ pub async fn export_csv(
}
#[get("/histogram?<filter>&<limit>")]
pub async fn histogram(pool: &State<PgPool>, filter: Option<bool>, limit: Option<i32>) -> Result<Json<Vec<WhitelistHistogramBin>>, Status> {
pub async fn histogram(
pool: &State<PgPool>,
filter: Option<bool>,
limit: Option<i32>,
) -> Result<Json<Vec<WhitelistHistogramBin>>, Status> {
let limit = limit.unwrap_or(100_000).clamp(0, 1_000_000);
let mut db = pool.acquire().await.map_err(|_| Status::InternalServerError)?;
Ok(Json(collect_histogram(&mut *db, 50, limit, filter.is_some()).await
.map_err(|_| Status::InternalServerError)?))
let mut db = pool
.acquire()
.await
.map_err(|_| Status::InternalServerError)?;
Ok(Json(
collect_histogram(&mut *db, 50, limit, filter.is_some())
.await
.map_err(|_| Status::InternalServerError)?,
))
}
-68
View File
@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
{% block head %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/main.css"/>
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{% block metadata %}
<title>Cheburcheck</title>
<meta name="description" content="Проверка доменов и IP-адресов на наличие в реестре блокировок Роскомнадзора и заблокированных CDN.">
<meta name="keywords" content="проверка домена, заблокирован ли сайт, Роскомнадзор, чебурнет, cdn, блокировки">
<meta property="og:title" content="Cheburcheck">
<meta property="og:description" content="Проверка доменов и IP-адресов на наличие в реестре блокировок Роскомнадзора и заблокированных CDN.">
<meta property="og:url" content="https://cheburcheck.ru">
<meta property="og:image" content="https://cheburcheck.ru/og-cover.png">
{% endblock metadata %}
<meta property="og:site_name" content="Cheburcheck">
<meta property="og:type" content="website">
<meta name="author" content="Cheburcheck">
{% endblock head %}
</head>
<body>
<header class="header">
{% block header %}
<div class="flex justify-between items-center">
<a class="logo" href="/">
<i data-lucide="cloud-alert" width="32" height="32"></i>
<span class="text-lg font-bold uppercase">Cheburcheck</span>
</a>
<div class="flex gap-4 text-xs text-muted">
<a href="/kb/faq" class="text-muted">FAQ</a>
<a class="version-link text-muted" href="https://github.com/LowderPlay/cheburcheck">
<span>v{{ global.version }}</span>
<svg width="20" height="20" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
</a>
</div>
</div>
{% endblock header %}
</header>
<main class="container">
{% block content %}{% endblock content %}
</main>
<footer class="footer">
{% block footer %}
<p class="text-xs uppercase">
<a href="mailto:support@cheburcheck.ru">support@cheburcheck.ru</a>
-
Сделано в России
<i data-lucide="copyright" width="10" height="10"></i> {{ now() | date(format="%Y") }}
</p>
{% endblock footer %}
</footer>
<script src="/vendor/lucide.js"></script>
<script>
lucide.createIcons();
</script>
</body>
</html>
-82
View File
@@ -1,82 +0,0 @@
{% extends 'base' %}
{% block content %}
{% include 'search-form' %}
<div class="result-panel">
<div class="result-header">
<div class="icon-box status-icon">
<i data-lucide="shield-question-mark" width="32" height="32"></i>
</div>
<div>
<h2>Не найдено</h2>
<p class="subheading text-sm">Возможно вы неправильно ввели запрос?</p>
</div>
</div>
<div class="target-info">
<div class="target-info-label">{{ target_type }}:</div>
<div class="target-value target-display">{{ target }}</div>
</div>
{# <div class="details-grid">#}
{# <div class="detail-section">#}
{# <h3 class="section-title">Сетевые данные</h3>#}
{# <div class="detail-row">#}
{# <span class="row-label">IP-адреса</span>#}
{# <div>#}
{# {% for ip in ips %}#}
{# <p class="row-value">{{ ip }}</p>#}
{# {% endfor %}#}
{# </div>#}
{# </div>#}
{# <div class="detail-row">#}
{# <span class="row-label">Хостинг / ISP</span>#}
{# <span class="row-value">{{ geo.organisation }}</span>#}
{# </div>#}
{# <div class="detail-row">#}
{# <span class="row-label">Location</span>#}
{# <span class="row-value">{{ geo.location }}</span>#}
{# </div>#}
{# <div class="detail-row">#}
{# <span class="row-label">ASN</span>#}
{# <span class="row-value">{{ geo.asn }}</span>#}
{# </div>#}
{# </div>#}
{# <div class="detail-section">#}
{# <h3 class="reason-header">Нахождение в списках</h3>#}
{# <div class="detail-row">#}
{# <span class="row-label">CDN</span>#}
{# {% if found %}#}
{# <div>#}
{# {% for net in subnets %}#}
{# <p class="row-value">{{ net }}</p>#}
{# {% endfor %}#}
{# </div>#}
{# {% else %}#}
{# <span class="row-value">Не найден</span>#}
{# {% endif %}#}
{# </div>#}
{# <div class="detail-row">#}
{# <span class="row-label">Статус РКН</span>#}
{# <span class="row-value alert">ОГРАНИЧЕН</span>#}
{# </div>#}
{# <div class="detail-row">#}
{# <span class="row-label">Орган</span>#}
{# <span class="row-value">Генпрокуратура</span>#}
{# </div>#}
{# <div class="detail-row">#}
{# <span class="row-label">Номер решения</span>#}
{# <span class="row-value">27-31-2024/Ид4124</span>#}
{# </div>#}
{# <div class="detail-row">#}
{# <span class="row-label">Дата внесения</span>#}
{# <span class="row-value">12.11.2024</span>#}
{# </div>#}
{# </div>#}
{# </div>#}
</div>
{% endblock content %}
-27
View File
@@ -1,27 +0,0 @@
{% extends 'base' %}
{% block content %}
<i class="error-code" data-lucide="triangle-alert" width="196" height="196"></i>
<h1 class="error-code">{{ status }} {{ reason }}</h1>
<p class="text-muted text-lg">
{% if status == 500 %}
Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.
{% elif status == 502 %}
Сервер временно недоступен. Попробуйте обновить страницу.
{% elif status == 503 %}
Сервис временно недоступен. Ведутся технические работы.
{% elif status == 504 %}
Превышено время ожидания ответа от сервера. Попробуйте позже.
{% elif status == 400 %}
Неверный запрос. Проверьте правильность введенных данных.
{% elif status == 404 %}
Страница не найдена. Проверьте правильность адреса.
{% elif status == 422 %}
Невозможно обработать запрос. Проверьте формат данных.
{% elif status == 429 %}
Слишком много запросов. Пожалуйста, подождите немного.
{% else %}
Что-то пошло не так. Возможно в запросе есть ошибка?
{% endif %}
</p>
{% endblock content %}
-137
View File
@@ -1,137 +0,0 @@
{% macro head() %}
<script src="/vendor/chart.js"></script>
<script src="/vendor/chartjs-plugin-datalabels.js"></script>
{% endmacro %}
{% macro histogram(id="histogram",
endpoint="/whitelist/histogram",
data_label="Количество ресурсов",
x_label="Ранг домена",
y_label="Количество ресурсов") %}
<canvas id="{{ id }}"></canvas>
<style>
#{{ id }} {
max-height: 480px;
}
</style>
<script>
(async function () {
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
const TEXT_LIGHT = '#d4d4d4';
const RED_ACCENT = '#ef4444';
const AXIS_COLOR = '#404040';
const GRID_COLOR = 'rgba(64, 64, 64, 0.2)';
const ctx = document.getElementById('{{ id }}').getContext('2d');
const response = await fetch("{{ endpoint | safe }}");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const labels = data.map(item =>
`${formatNumber(Math.round(item.bin_min_rank))}-${formatNumber(Math.round(item.bin_max_rank))}`
);
const counts = data.map(item => item.count);
Chart.register(ChartDataLabels);
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '{{ data_label }}',
data: counts,
backgroundColor: RED_ACCENT,
borderColor: RED_ACCENT,
borderWidth: 1,
borderRadius: 6,
hoverBackgroundColor: `${RED_ACCENT}cc`,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
datalabels: {
anchor: 'end',
align: 'top',
offset: 8,
color: TEXT_LIGHT,
font: {
weight: 'bold',
size: 10
},
formatter: (value) => value > 0 ? formatNumber(value) : '',
},
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += formatNumber(context.parsed.y);
}
return label;
}
},
backgroundColor: 'rgba(30, 30, 30, 0.9)',
titleColor: TEXT_LIGHT,
bodyColor: TEXT_LIGHT,
}
},
scales: {
x: {
title: {
display: true,
text: '{{ x_label }}',
color: TEXT_LIGHT
},
ticks: {
color: TEXT_LIGHT,
maxRotation: 45,
minRotation: 45
},
grid: {
color: GRID_COLOR,
drawBorder: false
},
border: {
color: AXIS_COLOR
}
},
y: {
type: 'linear',
title: {
display: true,
text: '{{ y_label }}',
color: TEXT_LIGHT
},
ticks: {
color: TEXT_LIGHT,
callback: () => ''
},
grid: {
color: GRID_COLOR,
drawBorder: false
},
border: {
color: AXIS_COLOR
}
}
}
}
});
})();
</script>
{% endmacro metadata %}
-34
View File
@@ -1,34 +0,0 @@
{% extends 'base' %}
{% block content %}
<div class="search-container">
<h1>Статус Ресурса</h1>
<p class="text-muted">Введите домен или IP-адрес для поиска по спискам заблокированных адресов и хостинг-провайдеров.</p>
</div>
{% include 'search-form' %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-header">
<i data-lucide="globe" width="16" height="16"></i>
<span>Количество доменов</span>
</div>
<span>{{ domain_count }}</span>
</div>
<div class="stat-card">
<div class="stat-card-header">
<i data-lucide="server" width="16" height="16"></i>
<span>Количество IPv4-адресов</span>
</div>
<span>{{ v4_count }}</span>
</div>
<div class="stat-card">
<div class="stat-card-header">
<i data-lucide="activity" width="16" height="16"></i>
<span>Последнее обновление</span>
</div>
<span><script>document.write(new Date("{{ last_update }}").toLocaleString())</script></span>
</div>
</div>
{% endblock content %}
-18
View File
@@ -1,18 +0,0 @@
{% extends 'base' %}
{% import 'typography' as typography %}
{% macro metadata(title, description, image="https://cheburcheck.ru/og-cover.png", keywords, url="/") %}
<title>{{ title }} - Cheburcheck</title>
<meta name="description" content="{{ description }}">
<meta name="keywords" content="проверка домена, заблокирован ли сайт, Роскомнадзор, чебурнет, cdn, блокировки, {{ keywords }}">
<meta property="og:title" content="{{ title }}">
<meta property="og:description" content="{{ description }}">
<meta property="og:url" content="https://cheburcheck.ru{{ url }}">
<meta property="og:image" content="{{ image }}">
{% endmacro metadata %}
{% block content %}
<div class="page-container">
{% block page_text %}{% endblock %}
</div>
{% endblock content %}
-92
View File
@@ -1,92 +0,0 @@
{% extends 'page' %}
{% import 'page' as page %}
{% block metadata %}
{{ page::metadata(title="FAQ", description="Ответы на частые вопросы", keywords="faq", url="/kb/faq") }}
{% endblock metadata %}
{% block page_text %}
<h1>Ответы на частые вопросы</h1>
<p>
На этой странице собраны ответы на часто задаваемые вопросы.
Если вас интересует что-то ещё &mdash; пишите нам на почту <a href="mailto:support@cheburcheck.ru">support@cheburcheck.ru</a>.
</p>
{{ typography::heading(title="Что это за сайт?") }}
<p>
Этот сайт &mdash; инструмент, который позволяет проверить, находится ли сайт, домен,
IP-адрес в <a href="#{{ "Откуда вы берете эти списки?" | slugify }}">списках</a>, блокируемых Роскомнадзором на территории России.
</p>
{{ typography::heading(title="Откуда вы берете эти списки?") }}
<p>
Для поиска сайтов мы используем списки IP-подсетей популярных CDN-провайдеров,
которые могут блокироваться Роскомнадзором, а также домены из самого реестра Роскомнадзора.
</p>
<p>
Подсети, которые мы используем, публикуют сами провайдеры, либо собираются из принадлежащих им автономных сетей.
Мы используем <a href="https://github.com/123jjck/cdn-ip-ranges">этот репозиторий</a>, в котором автоматически
агрегируются эти подсети.
</p>
<p>
Данные из Реестра Роскомнадзора подгружаются из сервисов <a href="https://antifilter.download/">antifilter.download</a>
и <a href="https://antifilter.network/">antifilter.network</a>.
</p>
{{ typography::heading(title="Что такое «белый список»?") }}
<p>
РКН формирует свой белый список доменов, которые доступны,
даже если их адреса находятся в списке заблокированных подсетей.
</p>
<a href="/kb/whitelist">Подробнее...</a>
{{ typography::heading(title='Мне написало, что сайт «Заблокирован», но у меня все работает!') }}
<p>
Мы не можем с точностью утверждать, заблокирован сайт или нет.
Блокировки Роскомнадзора, зачастую, непредсказуемые, непубличные и незаконные.
Например, блокировка CDN-сервисов, таких, как
<a href="https://blog.cloudflare.com/russian-internet-users-are-unable-to-access-the-open-internet/">Cloudflare</a>,
никак не комментировалась официально и не публиковались в Реестре. Аналогичная ситуация с
<a href="https://ria.ru/20241219/putin-1990168675.html">YouTube</a>, информация о
блокировке которого, не добавлена в Реестр &mdash; для таких случаев мы создали
<a href="https://github.com/LowderPlay/cheburcheck/blob/master/querying/dist-domains.txt">свой список</a> подобных сайтов.
Однако, список не исчерпывающий, поэтому
<a href="https://github.com/LowderPlay/cheburcheck/pulls">принимаются правки</a>.
</p>
<p>
Сайты помечаются как "заблокированные", в случае, если они были найдены хотя бы в одном списке.
</p>
{{ typography::heading(title='Как часто обновляются списки?') }}
<p>
Мы обновляем списки из источников раз в 6 часов.
</p>
<p>
Сами источники обновляют их с разной периодичностью:
</p>
<ul>
<li><a href="https://github.com/123jjck/cdn-ip-ranges">CDN</a> &mdash; раз в 12 часов.</li>
<li><a href="/kb/whitelist">Белые списки</a> &mdash; топ-100,000 раз в 6 часов; топ-1,000,000 раз в сутки.</li>
<li><a href="https://antifilter.download/">Домены из Реестра РКН</a> &mdash; раз в полчаса.</li>
<li><a href="https://antifilter.network/">Подсети из Реестра РКН</a> &mdash; раз в час.</li>
</ul>
{{ typography::heading(title='Как я могу помочь в развитии сайта?') }}
<p>
Самый простой способ помочь &mdash; рассказать о сайте знакомым.
</p>
<p>
Если у вас есть навыки разработки, то мы будем рады принять ваши pull-requests
на <a href="https://github.com/LowderPlay/cheburcheck">GitHub</a>.
</p>
<p>
Если вы хотите помочь финансово:
</p>
<ul>
<li>TON: <code>UQAACsiwpGryjP-kqp4TJPAWpXytuB6M_puuO0Cg5zNvaSJW</code></li>
</ul>
<p>
Большое спасибо! ❤️
</p>
{% endblock page_text %}
-111
View File
@@ -1,111 +0,0 @@
{% extends 'page' %}
{% import 'page' as page %}
{% block metadata %}
{{ page::metadata(title="Test", description="Проверка", keywords="test", url="/kb/test") }}
{% endblock metadata %}
{% block page_text %}
<img class="fullwidth" src="https://placehold.co/600x400">
<h1>Hello, world!</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aenean at scelerisque purus. Integer eu justo vel mi consectetur sagittis.
Proin aliquam, nulla sit amet ornare interdum, leo orci posuere nulla, convallis consequat nibh ex id felis.
Nam dapibus enim mauris, bibendum rutrum orci blandit at.
Curabitur sed dui sit amet felis consequat cursus quis at magna.
Suspendisse at neque lobortis, pulvinar quam at, posuere lorem.
Etiam sed elit vehicula leo tincidunt rhoncus et ut metus.
Quisque egestas diam non sem pellentesque bibendum.
Fusce posuere pretium nisl sit amet pharetra.
Maecenas urna odio, dignissim non efficitur vel, varius eu nulla.
Sed convallis id lorem sed suscipit. In hac habitasse platea dictumst.
Nulla fermentum arcu urna, vitae gravida leo accumsan ut. Phasellus accumsan hendrerit erat,
nec imperdiet odio ullamcorper sed.
</p>
{{ typography::heading(title="Test1") }}
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aenean at scelerisque purus. Integer eu justo vel mi consectetur sagittis.
Proin aliquam, nulla sit amet ornare interdum, leo orci posuere nulla, convallis consequat nibh ex id felis.
Nam dapibus enim mauris, bibendum rutrum orci blandit at.
Curabitur sed dui sit amet felis consequat cursus quis at magna.
Suspendisse at neque lobortis, pulvinar quam at, posuere lorem.
Etiam sed elit vehicula leo tincidunt rhoncus et ut metus.
Quisque egestas diam non sem pellentesque bibendum.
Fusce posuere pretium nisl sit amet pharetra.
Maecenas urna odio, dignissim non efficitur vel, varius eu nulla.
Sed convallis id lorem sed suscipit. In hac habitasse platea dictumst.
Nulla fermentum arcu urna, vitae gravida leo accumsan ut. Phasellus accumsan hendrerit erat,
nec imperdiet odio ullamcorper sed.
</p>
<img src="https://placehold.co/600x400">
<i class="caption">Centered image</i>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aenean at scelerisque purus. Integer eu justo vel mi consectetur sagittis.
Proin aliquam, nulla sit amet ornare interdum, leo orci posuere nulla, convallis consequat nibh ex id felis.
Nam dapibus enim mauris, bibendum rutrum orci blandit at.
Curabitur sed dui sit amet felis consequat cursus quis at magna.
Suspendisse at neque lobortis, pulvinar quam at, posuere lorem.
Etiam sed elit vehicula leo tincidunt rhoncus et ut metus.
Quisque egestas diam non sem pellentesque bibendum.
Fusce posuere pretium nisl sit amet pharetra.
Maecenas urna odio, dignissim non efficitur vel, varius eu nulla.
Sed convallis id lorem sed suscipit. In hac habitasse platea dictumst.
Nulla fermentum arcu urna, vitae gravida leo accumsan ut. Phasellus accumsan hendrerit erat,
nec imperdiet odio ullamcorper sed.
</p>
{{ typography::heading(title="Test2") }}
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aenean at scelerisque purus. Integer eu justo vel mi consectetur sagittis.
Proin aliquam, nulla sit amet ornare interdum, leo orci posuere nulla, convallis consequat nibh ex id felis.
Nam dapibus enim mauris, bibendum rutrum orci blandit at.
Curabitur sed dui sit amet felis consequat cursus quis at magna.
Suspendisse at neque lobortis, pulvinar quam at, posuere lorem.
Etiam sed elit vehicula leo tincidunt rhoncus et ut metus.
Quisque egestas diam non sem pellentesque bibendum.
Fusce posuere pretium nisl sit amet pharetra.
Maecenas urna odio, dignissim non efficitur vel, varius eu nulla.
Sed convallis id lorem sed suscipit. In hac habitasse platea dictumst.
Nulla fermentum arcu urna, vitae gravida leo accumsan ut. Phasellus accumsan hendrerit erat,
nec imperdiet odio ullamcorper sed.
</p>
{{ typography::heading(title="Test3") }}
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aenean at scelerisque purus. Integer eu justo vel mi consectetur sagittis.
Proin aliquam, nulla sit amet ornare interdum, leo orci posuere nulla, convallis consequat nibh ex id felis.
Nam dapibus enim mauris, bibendum rutrum orci blandit at.
Curabitur sed dui sit amet felis consequat cursus quis at magna.
Suspendisse at neque lobortis, pulvinar quam at, posuere lorem.
Etiam sed elit vehicula leo tincidunt rhoncus et ut metus.
Quisque egestas diam non sem pellentesque bibendum.
Fusce posuere pretium nisl sit amet pharetra.
Maecenas urna odio, dignissim non efficitur vel, varius eu nulla.
Sed convallis id lorem sed suscipit. In hac habitasse platea dictumst.
Nulla fermentum arcu urna, vitae gravida leo accumsan ut. Phasellus accumsan hendrerit erat,
nec imperdiet odio ullamcorper sed.
</p>
{{ typography::heading(title="Test4") }}
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aenean at scelerisque purus. Integer eu justo vel mi consectetur sagittis.
Proin aliquam, nulla sit amet ornare interdum, leo orci posuere nulla, convallis consequat nibh ex id felis.
Nam dapibus enim mauris, bibendum rutrum orci blandit at.
Curabitur sed dui sit amet felis consequat cursus quis at magna.
Suspendisse at neque lobortis, pulvinar quam at, posuere lorem.
Etiam sed elit vehicula leo tincidunt rhoncus et ut metus.
Quisque egestas diam non sem pellentesque bibendum.
Fusce posuere pretium nisl sit amet pharetra.
Maecenas urna odio, dignissim non efficitur vel, varius eu nulla.
Sed convallis id lorem sed suscipit. In hac habitasse platea dictumst.
Nulla fermentum arcu urna, vitae gravida leo accumsan ut. Phasellus accumsan hendrerit erat,
nec imperdiet odio ullamcorper sed.
</p>
{% endblock page_text %}
@@ -1,99 +0,0 @@
{% extends 'page' %}
{% import 'page' as page %}
{% import 'histogram' as histogram %}
{% block head %}
{{ super() }}
{{ histogram::head() }}
{% endblock head %}
{% block metadata %}
{{ page::metadata(title="Белые списки", description="Анализ данных из белых списков блокировок", keywords="белые списки, whitelist", url="/kb/whitelist") }}
{% endblock metadata %}
{% block page_text %}
<h1>Белые списки доменов</h1>
<p class="warning-note">
<i data-lucide="triangle-alert" width="32" height="32"></i>
<span>
Не путать с <a href="https://habr.com/ru/news/1000784/">белыми списками <i>мобильного</i> интернета</a>!
</span>
</p>
<p>
Российские операторы связи начали применять новый тип блокировок CDN,
при котором загрузка контента обрывается после передачи примерно 16–20 килобайт данных,
из‑за чего большинство сайтов на Cloudflare и других зарубежных платформах становятся
практически неработоспособными для пользователей в России.
Одновременно сохраняются и развиваются белые списки с популярными и «социально значимыми» доменами,
которые старательно выводятся из‑под подобных ограничений.
</p>
<p>
Суть схемы в том, что соединение технически устанавливается, первые килобайты HTML,
CSS или скриптов передаются, но затем трафик режется или соединение принудительно сбрасывается на уровне
российских операторов, обычно после примерно 16 килобайт (10–14 пакетов в зависимости от протокола).
Этого объёма достаточно, чтобы страница начала загружаться и создавалась иллюзия доступности,
но ключевые части сайта (основной HTML, JS‑бандлы, стили, API‑запросы) не проходят, и сервис фактически
перестаёт работать.
</p>
<pre class="code">$ curl -k https://cheburcheck.ru/100MB.bin -o/dev/null -r 0-65536 --resolve cheburcheck.ru:443:5.78.7.195 --max-time 5
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
24 65537 24 16101 0 0 3220 0 0:00:20 0:00:04 0:00:16 0
curl: (28) Operation timed out after 5000 milliseconds with 16101 out of 65537 bytes received</pre>
<p>
Определение домена и выбор, «разрешить или задушить» запрос, в текущей схеме делается на связке DPI‑фильтров.
По умолчанию, все HTTP(s) запросы на адреса из подсетей хостеров блокируются. Однако, запросы
к некоторым доменам из тех же подсетей проходят нормально &mdash; они обнаруживаются по открытому расширению
TLS SNI, либо по заголовку Host (в случае с plain http).
</p>
<p>
Проверить нахождение домена в белом списке можно с помощью простого HTTPS-запроса с подменой SNI на адрес из
заблокированных диапазонов:
</p>
<pre class="code">$ curl -k https://ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve ok.ru:443:5.78.7.195
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 65537 100 65537 0 0 55226 0 0:00:01 0:00:01 --:--:-- 55258</pre>
<p>
Также, стоит отметить, что домены белого списка добавляются с маской по поддоменам:
</p>
<pre class="code">$ curl -k https://cheburcheck.ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve cheburcheck.ok.ru:443:
5.78.7.195
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 65537 100 65537 0 0 53192 0 0:00:01 0:00:01 --:--:-- 53195</pre>
{{ typography::heading(title="Автоматическое сканирование") }}
<p>
Используя методы, указанные выше, мы разработали инструмент для автоматического сканирования и анализа
блокировок
<b><a href="https://github.com/LowderPlay/cheburcheck/tree/master/reporter">Cheburcheck Reporter</a></b>.
Он позволяет сканировать большое количество доменов
за короткий срок за счет параллелизации запросов.
</p>
<p>
Исходя из анализа 1,000,000 доменов из рейтинга
<a href="https://tranco-list.eu/list/2NPQ9">Tranco list от 26 ноября 2025</a>,
в белом списке содержится около 1000 доменов.
</p>
<i>
* &mdash; Мы не включаем в это число домены из зоны .co.uk, так как по какой-то причине, они все находятся в белом списке.
</i>
<i class="caption">Гистограмма количества доменов относительно их положения в рейтинге (топ-30k, без .co.uk)</i>
{{ histogram::histogram(endpoint="/whitelist/histogram?filter&limit=30000") }}
<br>
<i class="caption">Гистограмма количества доменов относительно их положения в рейтинге (топ-1kk, включая .co.uk)</i>
{{ histogram::histogram(id="filtered", endpoint="/whitelist/histogram?limit=1000000") }}
{{ typography::heading(title="Скачать списки") }}
<p>
Мы публикуем результаты наших сканирований в виде CSV-файлов:
</p>
<div>
<i data-lucide="file-symlink" width="16" height="16"></i> <a href="/whitelist/full.csv">Полный список (CSV)</a>
</div>
<div>
<i data-lucide="file-symlink" width="16" height="16"></i> <a href="/whitelist/domains.csv">Только домены (CSV)</a>
</div>
{% endblock page_text %}
-315
View File
@@ -1,315 +0,0 @@
{% extends 'base' %}
{% block content %}
{% include 'search-form' %}
{% set warning = whitelist and not domain %}
<div class="result-panel {% if warning %}whitelist-theme{% elif found %}blocked-theme{% else %}clean-theme{% endif %}">
<div class="result-header">
<div class="icon-box status-icon">
{% if whitelist %}
<i data-lucide="shield-alert" width="32" height="32"></i>
{% elif blocked %}
<i data-lucide="shield-x" width="32" height="32"></i>
{% else %}
<i data-lucide="shield-check" width="32" height="32"></i>
{% endif %}
</div>
{% if warning %}
<div>
<h2>Белый список</h2>
<p class="subheading text-sm">
Ресурс находится в белом списке
</p>
</div>
{% elif found %}
<div>
<h2>Заблокирован</h2>
<p class="subheading text-sm">Ресурс был найден в списках блокировок</p>
</div>
{% else %}
<div>
<h2>Доступен</h2>
<p class="subheading text-sm">Ограничений не обнаружено</p>
</div>
{% endif %}
</div>
<div class="target-info">
<div class="target-info-label">{{ target_type }}:</div>
<div class="target-value target-display">{{ target }}</div>
</div>
{% if asn_info %}
<div class="asn-summary">
<div class="asn-stat">
<span class="asn-stat-label">Всего подсетей:</span>
<span class="asn-stat-value">{{ asn_info.prefixes | length }}</span>
</div>
{% if asn_info.blocked_prefixes | length > 0 %}
<div class="asn-stat">
<span class="asn-stat-label">Заблокировано:</span>
<span class="asn-stat-value alert">{{ asn_info.blocked_prefixes | length }}</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="details-grid">
<div class="detail-section">
<h3 class="section-title">Сетевые данные</h3>
{% if not asn_info %}
<div class="detail-row">
<span class="row-label">IP-адреса</span>
<div class="ip-list-container">
{% if ips | length == 1 %}
<span class="ip-item">{{ ips | first }}</span>
{% if subnet_size %}
<span class="ip-count">({{ subnet_size }} всего)</span>
{% endif %}
{% elif ips | length <= 5 %}
{% for ip in ips %}
<span class="ip-item">{{ ip }}</span>
{% endfor %}
{% if subnet_size %}
<span class="ip-count">({{ subnet_size }} всего)</span>
{% endif %}
{% else %}
<div class="ip-summary">
<span class="ip-range">{{ ips | first }} — {{ ips | last }}</span>
{% if subnet_size %}
<span class="ip-count">({{ subnet_size }} всего, проверено {{ ips | length }})</span>
{% else %}
<span class="ip-count">({{ ips | length }} адресов)</span>
{% endif %}
</div>
<details class="ip-list-details">
<summary class="ip-list-toggle">
Показать все
</summary>
<div class="ip-list-expanded">
{% for ip in ips %}
<span class="ip-item">{{ ip }}</span>
{% endfor %}
</div>
</details>
{% endif %}
</div>
</div>
{% endif %}
<div class="detail-row">
<span class="row-label">Хостинг / ISP</span>
<span class="row-value">{% if geo.organisation %}{{ geo.organisation }}{% else %}-{% endif %}</span>
</div>
<div class="detail-row">
<span class="row-label">Локация</span>
<span class="row-value">{{ geo.location }}</span>
</div>
<div class="detail-row">
<span class="row-label">ASN</span>
<span class="row-value">
{% if geo.asn %}
<a href="/check?target={{ geo.asn }}" class="asn-link">{{ geo.asn }}</a>
{% else %}
-
{% endif %}
</span>
</div>
{% if asn_info %}
<div class="detail-row">
<span class="row-label">Подсети ASN</span>
<div class="prefix-list-container">
{% set ipv4_list = [] %}
{% set ipv6_list = [] %}
{% for prefix in asn_info.prefixes %}
{% if ":" in prefix %}
{% set_global ipv6_list = ipv6_list | concat(with=prefix) %}
{% else %}
{% set_global ipv4_list = ipv4_list | concat(with=prefix) %}
{% endif %}
{% endfor %}
{% set all_prefixes = ipv4_list | concat(with=ipv6_list) %}
{% if all_prefixes | length <= 10 %}
<div>
{% for prefix in all_prefixes %}
<p class="row-value">{{ prefix }}</p>
{% endfor %}
</div>
{% else %}
<div>
{% for prefix in all_prefixes | slice(end=10) %}
<p class="row-value">{{ prefix }}</p>
{% endfor %}
</div>
<details class="prefix-list-details">
<summary class="prefix-list-toggle">
Показать все ({{ all_prefixes | length }})
</summary>
<div class="prefix-list-expanded">
{% for prefix in all_prefixes %}
<p class="row-value">{{ prefix }}</p>
{% endfor %}
</div>
</details>
{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="detail-section">
<h3 class="reason-header">Нахождение в списках</h3>
<div class="detail-row">
<span class="row-label">CDN</span>
{% if providers %}
<p class="row-value alert">НАЙДЕН</p>
{% else %}
<span class="row-value">Не найден</span>
{% endif %}
</div>
{% if providers %}
{% for provider, networks in providers %}
<div class="detail-row">
<span class="row-label">{{ provider }}</span>
<div class="cdn-networks-container">
{% if networks | length <= 5 %}
{% for network in networks %}
<p class="row-value">{{ network.cidr }}</p>
{% endfor %}
{% else %}
<div>
{% for network in networks | slice(end=5) %}
<p class="row-value">{{ network.cidr }}</p>
{% endfor %}
</div>
<details class="cdn-networks-details">
<summary class="cdn-networks-toggle">
Показать все ({{ networks | length }})
</summary>
<div class="cdn-networks-expanded">
{% for network in networks %}
<p class="row-value">{{ network.cidr }}</p>
{% endfor %}
</div>
</details>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
{% if whitelist %}
<div class="detail-row">
<a href="/kb/whitelist" class="row-label">Белый список (?)</a>
<span class="row-value success">
НАЙДЕН - <span class="hint"
title="Дата последнего сканирования, когда данный домен был найден в белом списке">
<script>
document.write(new Date("{{ whitelist.last_ok }}").toLocaleDateString());
</script>
</span>
</span>
</div>
{% endif %}
<div class="detail-row">
<span class="row-label">Реестр РКН</span>
{% if domain %}
<span class="row-value alert">ОГРАНИЧЕН</span>
{% elif blocked_subnets %}
<span class="row-value alert hint"
title="Адреса пересекаются с подсетями заблокированных доменов (не гарантирует блокировку)">
IP-АДРЕСА
</span>
{% else %}
<span class="row-value">Не найден</span>
{% endif %}
</div>
{% if domain %}
<div class="detail-row">
<span class="row-label">Заблокированный домен</span>
<span class="row-value">{{ domain }}</span>
</div>
{% endif %}
{% if blocked_subnets and not asn_info %}
<div class="detail-row">
<span class="row-label">Заблокированные подсети</span>
<div>
{% for network in blocked_subnets %}
<p class="row-value">{{ network }}</p>
{% endfor %}
</div>
</div>
{% endif %}
{% if asn_info and asn_info.blocked_prefixes | length > 0 %}
<div class="detail-row">
<span class="row-label">Заблокированные подсети ASN</span>
<div class="blocked-prefix-list-container">
{% if asn_info.blocked_prefixes | length <= 10 %}
<div>
{% for prefix in asn_info.blocked_prefixes %}
<p class="row-value alert">{{ prefix }}</p>
{% endfor %}
</div>
{% else %}
<div>
{% for prefix in asn_info.blocked_prefixes | slice(end=10) %}
<p class="row-value alert">{{ prefix }}</p>
{% endfor %}
</div>
<details class="blocked-prefix-list-details">
<summary class="blocked-prefix-list-toggle">
Показать все ({{ asn_info.blocked_prefixes | length }})
</summary>
<div class="blocked-prefix-list-expanded">
{% for prefix in asn_info.blocked_prefixes %}
<p class="row-value alert">{{ prefix }}</p>
{% endfor %}
</div>
</details>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<div class="user-feedback-section">
<p class="feedback-prompt">У вас работает этот ресурс?</p>
<div class="feedback-buttons">
<button class="feedback-btn feedback-works" onclick="sendFeedback(true)">
<i data-lucide="thumbs-up" width="16" height="16"></i>
Работает
</button>
<button class="feedback-btn feedback-not-works" onclick="sendFeedback(false)">
<i data-lucide="thumbs-down" width="16" height="16"></i>
Не работает
</button>
</div>
<div class="feedback-status hidden">
<i data-lucide="thumbs-up" width="16" height="16"></i>
<span>Спасибо за ваш отзыв!</span>
</div>
</div>
</div>
<script>
function sendFeedback(works) {
document.querySelector('.feedback-buttons').classList.add('hidden');
document.querySelector('.feedback-prompt').classList.add('hidden');
document.querySelector('.feedback-status').classList.remove('hidden');
fetch(`/feedback/{{ id }}/${works}`, {
method: 'POST',
});
}
</script>
{% endblock content %}
-18
View File
@@ -1,18 +0,0 @@
<form class="search-form" action="/check">
<input
type="text"
name="target"
placeholder="example.com, 1.1.1.1, 1.0.0.0/24, AS13335"
class="search-input"
minlength="3"
required
autofocus
>
<div class="search-icon-wrapper">
<i data-lucide="search" width="20" height="20"></i>
</div>
<button type="submit" class="search-btn">
<span>Проверить</span>
<i data-lucide="chevron-right" width="16" height="16"></i>
</button>
</form>
-5
View File
@@ -1,5 +0,0 @@
{% macro heading(title) %}
<h2 id="{{ title | slugify }}">
<a href="#{{ title | slugify }}">{{ title }}</a>
</h2>
{% endmacro title %}