mirror of
https://github.com/LowderPlay/cheburcheck.git
synced 2026-10-12 09:08:18 +03:00
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:
@@ -1,2 +1,9 @@
|
||||
.git/
|
||||
target/
|
||||
frontend/node_modules
|
||||
.idea/
|
||||
**/target/
|
||||
**/.env
|
||||
frontend/.svelte-kit/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
|
||||
Generated
+90
-55
@@ -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,5 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"frontend",
|
||||
"querying",
|
||||
"reporter",
|
||||
"reports",
|
||||
|
||||
+62
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "//"
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1645
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
Vendored
+21
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use include_dir::{Dir, File, include_dir};
|
||||
|
||||
pub static DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist");
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
информация о блокировке которого, не добавлена в Реестр — для таких
|
||||
случаев мы создали
|
||||
<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) запросы на адреса
|
||||
из подсетей хостеров блокируются. Однако, запросы к некоторым доменам из тех
|
||||
же подсетей проходят нормально — они обнаруживаются по открытому
|
||||
расширению 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>
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
На этой странице собраны ответы на часто задаваемые вопросы.
|
||||
Если вас интересует что-то ещё — пишите нам на почту <a href="mailto:support@cheburcheck.ru">support@cheburcheck.ru</a>.
|
||||
</p>
|
||||
|
||||
{{ typography::heading(title="Что это за сайт?") }}
|
||||
<p>
|
||||
Этот сайт — инструмент, который позволяет проверить, находится ли сайт, домен,
|
||||
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>, информация о
|
||||
блокировке которого, не добавлена в Реестр — для таких случаев мы создали
|
||||
<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> — раз в 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>
|
||||
|
||||
{{ typography::heading(title='Как я могу помочь в развитии сайта?') }}
|
||||
<p>
|
||||
Самый простой способ помочь — рассказать о сайте знакомым.
|
||||
</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 %}
|
||||
@@ -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) запросы на адреса из подсетей хостеров блокируются. Однако, запросы
|
||||
к некоторым доменам из тех же подсетей проходят нормально — они обнаруживаются по открытому расширению
|
||||
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>
|
||||
* — Мы не включаем в это число домены из зоны .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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -1,5 +0,0 @@
|
||||
{% macro heading(title) %}
|
||||
<h2 id="{{ title | slugify }}">
|
||||
<a href="#{{ title | slugify }}">{{ title }}</a>
|
||||
</h2>
|
||||
{% endmacro title %}
|
||||
Reference in New Issue
Block a user