initial commit

This commit is contained in:
Lowder
2025-11-19 21:52:35 +05:00
commit 19ea4b30e2
21 changed files with 5356 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
+2
View File
@@ -0,0 +1,2 @@
/target
/databases
Generated
+3808
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "blocklist-check"
version = "0.1.0"
edition = "2024"
[dependencies]
hickory-resolver = { version = "0.26.0-alpha.1", features = ["tokio", "webpki-roots", "https-ring"] }
rocket = "0.5.1"
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
serde = { version = "1", features = ["derive"] }
log = "0.4.28"
maxminddb = "0.26"
ipnet-trie = "0.3.0"
ipnet = "2.11.0"
csv = "1.4.0"
trie-rs = "0.4.2"
env_logger = "0.11.8"
reqwest = { version = "0.12.24", features = ["stream", "rustls-tls-webpki-roots"] }
chrono = { version = "0.4.42", features = ["serde"] }
rocket-cache-response = "0.6.4"
[build-dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
tar = "0.4"
flate2 = "1.0"
+39
View File
@@ -0,0 +1,39 @@
FROM docker.io/rust:1-slim-bookworm AS build
## cargo package name: customize here or provide via --build-arg
ARG pkg=blocklist-check
WORKDIR /build
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 \
set -eux; \
cargo build --release; \
objcopy --compress-debug-sections target/release/$pkg ./main
################################################################################
FROM docker.io/debian:bookworm-slim
WORKDIR /app
RUN apt update && apt install -y libssl3 ca-certificates
## copy the main binary
COPY --from=build /build/main ./
COPY --from=build /build/static ./static
COPY --from=build /build/templates ./templates
## ensure the container listens globally on port 8080
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=8080
EXPOSE 8080
CMD ./main
+28
View File
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2023, Ivan Evstratov
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+57
View File
@@ -0,0 +1,57 @@
# ![Cheburcheck Logo](static/favicon.svg) Cheburcheck.ru
[Cheburcheck.ru](https://cheburcheck.ru) — open-source сервис для проверки доменов и IP-адресов на наличие в списках блокировок Роскомнадзора и популярных заблокированных CDN-провайдеров.
---
## О проекте
Этот сервис реализован на языке Rust с использованием веб-фреймворка [Rocket.rs](https://rocket.rs/). Основная задача — проверять, заблокирован ли домен или IP-адрес, основываясь на актуальных данных из реестров Роскомнадзора и других источниках.
---
## Технологии
- **Rust** — язык программирования, на котором написан весь бекенд.
- **Rocket.rs** — веб-фреймворк для построения HTTP API и серверной логики.
- **DoH резолвер Cloudflare** — для разрешения DNS-запросов используется DNS-over-HTTPS через Cloudflare, что позволяет обойти локальные ограничения и получать актуальные данные.
- **Tera** — шаблонизатор для генерации HTML страниц, используется для фронтенда.
- **lucide** — библиотека иконок, применяемая для визуального оформления интерфейса.
---
## Как это работает
1. Пользователь вводит домен или IP-адрес в форму на сайте.
2. Сервер выполняет DNS-запрос через Cloudflare DoH для получения актуальной информации.
3. Полученный домен/IP проверяется на наличие в блокировочных списках через префиксные деревья.
4. Результат формируется с помощью Tera и отображается пользователю.
---
## Списки
Для проверки используются списки [123jjck/cdn-ip-ranges](https://github.com/123jjck/cdn-ip-ranges) и [antifilter.download](https://antifilter.download/).
---
## Структура проекта
* `src/` — исходный код приложения
* `templates/` — Tera-шаблоны для фронтенда
* `static/` — статические файлы
* `Cargo.toml` — зависимости и конфигурация Rust-проекта
---
## Вклад
Если хотите помочь с разработкой — открывайте issue или присылайте pull requests.
Если хотите помочь финансово:
- TON: `UQAACsiwpGryjP-kqp4TJPAWpXytuB6M_puuO0Cg5zNvaSJW`
---
## Контакты
Для вопросов и обсуждений можно написать на [support@cheburcheck.ru](mailto:support@cheburcheck.ru).
+61
View File
@@ -0,0 +1,61 @@
use std::{
env,
fs::{File},
io::{self, Cursor},
path::Path,
};
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>> {
let out_dir = env::var("OUT_DIR")?;
let lucide_js_path = Path::new(&out_dir).join("lucide.js");
let client = Client::new();
println!("cargo:rerun-if-env-changed=LUCIDE_VERSION");
let metadata_url = format!("https://registry.npmjs.org/lucide/{}", option_env!("LUCIDE_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);
let mut lucide_js_found = false;
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if path == Path::new("package/dist/umd/lucide.min.js") {
println!("Found lucide.min.js, extracting to {:?}", lucide_js_path);
let mut out_file = File::create(&lucide_js_path)?;
io::copy(&mut entry, &mut out_file)?;
lucide_js_found = true;
break;
}
}
if !lucide_js_found {
panic!("Could not find dist/lucide.min.js inside the tarball");
}
println!("cargo:rerun-if-changed=build.rs");
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
use std::{io};
use std::io::Error;
use std::net::IpAddr;
use maxminddb::{geoip2, MaxMindDbError};
use maxminddb::geoip2::{city, country, City, Country};
use rocket::serde::Serialize;
use crate::updater::{fetch_db, Updatable};
pub struct GeoIp {
asn: Option<maxminddb::Reader<Vec<u8>>>,
city: Option<maxminddb::Reader<Vec<u8>>>,
country: Option<maxminddb::Reader<Vec<u8>>>,
}
#[derive(Serialize, Debug)]
pub struct IpInfo {
asn: String,
organisation: String,
location: String,
}
impl Default for IpInfo {
fn default() -> IpInfo {
IpInfo {
asn: "-".to_string(),
organisation: "-".to_string(),
location: "-".to_string(),
}
}
}
impl GeoIp {
pub fn new() -> Self {
GeoIp {
asn: None,
country: None,
city: None
}
}
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)?);
Ok(())
}
pub fn lookup(&self, ip: IpAddr) -> Result<IpInfo, MaxMindDbError> {
let asn = if let Some(db) = &self.asn {
db.lookup::<geoip2::Asn>(ip)?
} else { None };
let city = if let Some(db) = &self.city {
db.lookup::<City>(ip)?
} else { None };
let country = if let Some(db) = &self.country {
db.lookup::<Country>(ip)?
} else { None };
let location = match (city, country) {
(Some(City { city: Some(city::City { names: Some(city), .. }),
country: Some(country::Country { names: Some(country), .. }), .. }), _) => {
let city = city.get("ru").unwrap_or(&"-");
let country = country.get("ru").unwrap_or(&"-");
format!("{}, {}", city, country)
}
(_, Some(Country { country: Some(country::Country { names: Some(country), .. }), .. })) => {
country.get("ru").unwrap_or(&"-").to_string()
}
(_, _) => "-".to_string(),
};
Ok(IpInfo {
location,
asn: asn.clone().and_then(|asn| asn.autonomous_system_number)
.map(|asn| format!("AS{}", asn))
.unwrap_or("-".to_string()),
organisation: asn.and_then(|asn| asn.autonomous_system_organization)
.unwrap_or("-").to_string(),
})
}
}
#[async_trait]
impl Updatable for GeoIp {
type Base = (Vec<u8>, Vec<u8>, Vec<u8>);
async fn download() -> Result<Self::Base, io::Error> {
Ok((fetch_db(Self::get_url("GEO_ASN", "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb")).await?,
fetch_db(Self::get_url("GEO_COUNTRY", "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb")).await?,
fetch_db(Self::get_url("GEO_CITY", "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb")).await?))
}
async fn install(&mut self, (asn, country, city): Self::Base) -> Result<(), Error> {
self.update(asn, country, city)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))
}
}
+157
View File
@@ -0,0 +1,157 @@
use std::collections::VecDeque;
use std::io;
use std::io::{BufRead, Error, Read};
use std::net::IpAddr;
use std::str::FromStr;
use ipnet::IpNet;
use ipnet_trie::IpnetTrie;
use serde::{de, Deserialize, Deserializer, Serializer};
use trie_rs::map::{Trie, TrieBuilder};
use crate::updater::{fetch_db, Updatable};
pub struct CdnList {
trie: IpnetTrie<NetworkRecord>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Eq, PartialEq, Hash)]
pub struct NetworkRecord {
pub provider: String,
#[serde(deserialize_with = "deserialize_ip_net")]
#[serde(serialize_with = "serialize_ip_net")]
pub cidr: IpNet,
pub region: Option<String>,
}
fn deserialize_ip_net<'de, D>(deserializer: D) -> Result<IpNet, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
FromStr::from_str(&s).map_err(de::Error::custom)
}
fn serialize_ip_net<S>(ip_net: &IpNet, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&ip_net.to_string())
}
impl CdnList {
pub fn new() -> CdnList{
CdnList { trie: IpnetTrie::new() }
}
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() {
let record: NetworkRecord = result?;
trie.insert(record.cidr, record);
}
let (v4, v6) = trie.ip_count();
info!("ip count: v4={}, v6={}", v4, v6);
self.trie = trie;
Ok(())
}
pub fn v4_count(&self) -> u32 {
self.trie.ip_count().0
}
pub fn contains(&self, ip: &IpAddr) -> Option<NetworkRecord> {
self.trie.longest_match(&IpNet::from(*ip)).map(|(_, net)| net.clone())
}
}
#[async_trait]
impl Updatable for CdnList {
type Base = VecDeque<u8>;
async fn download() -> Result<Self::Base, Error> {
Ok(VecDeque::from(fetch_db(Self::get_url(
"CDN_SOURCE",
"https://raw.githubusercontent.com/123jjck/cdn-ip-ranges/refs/heads/main/all/all.csv"
)).await?))
}
async fn install(&mut self, base: Self::Base) -> Result<(), Error> {
self.update(base)
}
}
pub struct RuBlacklist {
ip_trie: IpnetTrie<()>,
domain_trie: Trie<String, String>,
pub domain_count: usize,
}
impl RuBlacklist {
pub fn new() -> RuBlacklist {
RuBlacklist {
ip_trie: Default::default(),
domain_trie: TrieBuilder::new().build(),
domain_count: 0
}
}
pub fn update<R: BufRead>(&mut self, ip_reader: R, domain_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))?;
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 count = 0;
for domain in domain_reader.lines() {
let domain = domain?;
domain_trie.insert(Self::domain_chunks(&domain), domain);
count += 1;
}
info!("domain count: {}", count);
self.domain_count = count;
self.domain_trie = domain_trie.build();
Ok(())
}
pub fn v4_count(&self) -> u32 {
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)
}
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()
}
}
#[async_trait]
impl Updatable for RuBlacklist {
type Base = (VecDeque<u8>, VecDeque<u8>);
async fn download() -> Result<Self::Base, Error> {
Ok((VecDeque::from(
fetch_db(Self::get_url("RKN_NETS", "https://antifilter.download/list/allyouneed.lst")).await?),
VecDeque::from(
fetch_db(Self::get_url("RKN_DOMAINS", "https://antifilter.download/list/domains.lst")).await?)))
}
async fn install(&mut self, (nets, domains): Self::Base) -> Result<(), Error> {
self.update(nets, domains)
}
}
+203
View File
@@ -0,0 +1,203 @@
mod target;
mod resolver;
mod geoip;
mod lists;
mod updater;
#[macro_use] extern crate rocket;
extern crate maxminddb;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use rocket::fs::FileServer;
use rocket::http::Status;
use rocket::{tokio, Request, State};
use rocket::response::content::RawJavaScript;
use rocket_dyn_templates::{context, Template};
use serde::Serialize;
use log::error;
use rocket::tokio::sync::{watch, RwLock};
use rocket::tokio::time;
use rocket_cache_response::CacheResponse;
use crate::geoip::{GeoIp, IpInfo};
use crate::lists::{CdnList, NetworkRecord, RuBlacklist};
use crate::resolver::Resolver;
use crate::target::Target;
use crate::updater::update_all;
#[derive(Serialize)]
struct GlobalContext {
version: &'static str,
}
impl GlobalContext {
fn new() -> Self {
GlobalContext { version: env!("CARGO_PKG_VERSION") }
}
}
#[get("/")]
fn index(info: &State<watch::Receiver<UpdateInfo>>) -> Template {
Template::render("index", context! {
global: GlobalContext::new(),
info: &*info.borrow(),
})
}
#[get("/healthcheck")]
fn healthcheck(info: &State<watch::Receiver<UpdateInfo>>) -> (Status, String) {
if info.borrow().last_update.is_some() {
(Status::Ok, "OK".to_string())
} else {
(Status::InternalServerError, "LOADING DATABASES".to_string())
}
}
#[get("/check?<target>")]
async fn check(target: &str, resolver: &State<Resolver>,
geo_ip: &State<Arc<RwLock<GeoIp>>>,
cdn: &State<Arc<RwLock<CdnList>>>,
ru_blacklist: &State<Arc<RwLock<RuBlacklist>>>) -> Result<Template, Status> {
let parsed = Target::from(target);
let ips = match parsed.resolve(resolver).await {
Ok(ips) => ips,
Err(e) if e.kind().is_no_records_found() => {
return Ok(Template::render("empty", context! {
global: GlobalContext::new(),
target,
target_type: parsed.readable_type(),
}));
}
Err(e) => {
error!("{}", e);
return Err(Status::BadRequest);
},
};
let geo_ip = geo_ip.read().await;
let geo = match ips.get(0).map(|ip| geo_ip.lookup(ip.clone())) {
None => IpInfo::default(),
Some(Ok(ip)) => ip,
Some(Err(e)) => {
error!("{}", e);
return Err(Status::BadRequest);
},
};
let mut providers: HashMap<String, HashSet<NetworkRecord>> = HashMap::new();
let cdn = cdn.read().await;
ips.iter()
.filter_map(|ip| cdn.contains(ip))
.map(|ip| (match &ip.region {
None => ip.provider.clone(),
Some(region) => format!("{} ({})", ip.provider, region),
}, ip.clone()))
.for_each(|(k, v)| {
providers.entry(k).or_default().insert(v);
});
let ru_blacklist = ru_blacklist.read().await;
let domain = match &parsed {
Target::Domain(domain) => ru_blacklist.contains_domain(domain),
_ => None
};
let blocked_subnets: HashSet<String> = ips.iter()
.filter_map(|ip| ru_blacklist.contains_ip(ip))
.map(|ip| ip.to_string()).collect();
Ok(Template::render("result", context! {
global: GlobalContext::new(),
found: providers.len() > 0 || domain.is_some() || blocked_subnets.len() > 0,
cdn_found: providers.len() > 0,
domain_found: domain.is_some(),
subnet_found: blocked_subnets.len() > 0,
providers,
domain,
blocked_subnets,
target,
target_type: parsed.readable_type(),
ips,
geo,
}))
}
#[catch(default)]
fn default(status: Status, _req: &Request) -> Template {
Template::render("error", context! {
global: GlobalContext::new(),
status: status.code,
reason: status.reason_lossy(),
})
}
#[rocket::get("/vendor/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,
}
}
#[derive(Serialize, Default)]
pub struct UpdateInfo {
last_update: Option<DateTime<Utc>>,
domain_count: String,
v4_count: String,
}
fn format_number(number: u64) -> String {
number.to_string()
.as_bytes()
.rchunks(3)
.rev()
.map(std::str::from_utf8)
.collect::<Result<Vec<&str>, _>>()
.unwrap()
.join(" ")
}
#[launch]
async fn rocket() -> _ {
env_logger::builder().filter_level(log::LevelFilter::Info).init();
let mut interval = time::interval(Duration::from_secs(std::env::var("DATABASE_INTERVAL_SECONDS")
.unwrap_or("21600".to_string()).parse().unwrap()));
let (tx, rx) = watch::channel(UpdateInfo::default());
let cdn_list = Arc::new(RwLock::new(CdnList::new()));
let rkn_list = Arc::new(RwLock::new(RuBlacklist::new()));
let geo_ip = Arc::new(RwLock::new(GeoIp::new()));
let geo_ip_clone = geo_ip.clone();
let rkn_list_clone = rkn_list.clone();
let cdn_list_clone = cdn_list.clone();
tokio::spawn(async move {
info!("Refreshing DB every {:?}", interval.period());
loop {
interval.tick().await;
update_all(geo_ip_clone.clone(), rkn_list_clone.clone(), cdn_list_clone.clone()).await;
let domain_count = rkn_list_clone.read().await.domain_count;
let v4_count = rkn_list_clone.read().await.v4_count() + cdn_list_clone.read().await.v4_count();
tx.send(UpdateInfo {
last_update: Some(Utc::now()),
domain_count: format_number(domain_count as u64),
v4_count: format_number(v4_count as u64),
}).unwrap();
}
});
rocket::build()
.manage(Resolver::new().await)
.manage(cdn_list)
.manage(rkn_list)
.manage(geo_ip)
.manage(rx)
.mount("/", routes![index, lucide, check, healthcheck])
.register("/", catchers![default])
.mount("/", FileServer::from(PathBuf::from("static")))
.attach(Template::fairing())
}
+25
View File
@@ -0,0 +1,25 @@
use std::net::IpAddr;
use hickory_resolver::config::{LookupIpStrategy, ResolverConfig, ResolverOpts};
use hickory_resolver::name_server::{TokioConnectionProvider};
use hickory_resolver::proto::ProtoError;
pub struct Resolver {
resolver: hickory_resolver::Resolver<TokioConnectionProvider>,
}
impl Resolver {
pub async fn new() -> Resolver {
let config = ResolverConfig::quad9_https();
let mut opts = ResolverOpts::default();
opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
let resolver = hickory_resolver::Resolver::builder_with_config(config, TokioConnectionProvider::default())
.with_options(opts)
.build();
Resolver { resolver }
}
pub async fn lookup_ips(&self, domain: &str) -> Result<Vec<IpAddr>, ProtoError> {
Ok(self.resolver.lookup_ip(domain).await?
.into_iter().collect())
}
}
+40
View File
@@ -0,0 +1,40 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use hickory_resolver::proto::ProtoError;
use crate::resolver::Resolver;
pub enum Target {
Domain(String),
Ipv4(Ipv4Addr),
Ipv6(Ipv6Addr),
}
impl From<&str> for Target {
fn from(input: &str) -> Self {
if let Ok(ipv4) = input.parse::<Ipv4Addr>() {
return Target::Ipv4(ipv4);
}
if let Ok(ipv6) = input.parse::<Ipv6Addr>() {
return Target::Ipv6(ipv6);
}
Target::Domain(input.to_string())
}
}
impl Target {
pub fn readable_type(&self) -> &'static str {
match self {
Target::Domain(_) => "Домен",
Target::Ipv4(_) => "IPv4-адрес",
Target::Ipv6(_) => "IPv6-адрес"
}
}
pub async fn resolve(&self, resolver: &Resolver) -> Result<Vec<IpAddr>, ProtoError> {
Ok(match self {
Target::Domain(domain) => resolver.lookup_ips(domain).await?,
Target::Ipv4(ipv4) => vec![IpAddr::V4(ipv4.clone())],
Target::Ipv6(ipv6) => vec![IpAddr::V6(ipv6.clone())],
})
}
}
+63
View File
@@ -0,0 +1,63 @@
use std::io;
use std::io::Error;
use std::sync::Arc;
use reqwest::IntoUrl;
use rocket::tokio::sync::RwLock;
use crate::geoip::GeoIp;
use crate::lists::{CdnList, RuBlacklist};
pub async fn fetch_db<T: IntoUrl>(url: T) -> Result<Vec<u8>, Error> {
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))?;
let bytes = response.bytes().await
.map_err(|e| Error::new(io::ErrorKind::Other, e))?;
Ok(bytes.to_vec())
}
#[async_trait]
pub trait Updatable {
type Base;
async fn download() -> Result<Self::Base, Error>;
async fn install(&mut self, base: Self::Base) -> Result<(), Error>;
fn get_url(key: &'static str, default: &'static str) -> String {
std::env::var(key).ok().unwrap_or(default.to_string())
}
}
pub async fn update_all(geo_ip: Arc<RwLock<GeoIp>>, rkn: Arc<RwLock<RuBlacklist>>, cdn: Arc<RwLock<CdnList>>) {
info!("Updating all DBs");
match GeoIp::download().await {
Ok(base) => {
if let Err(e) = geo_ip.write().await.install(base).await {
error!("Failed to update GeoIP: {}", e);
}
}
Err(e) => {
error!("Failed to download GeoIP: {}", e);
}
}
match RuBlacklist::download().await {
Ok(base) => {
if let Err(e) = rkn.write().await.install(base).await {
error!("Failed to update RKN: {}", e);
}
}
Err(e) => {
error!("Failed to download RKN: {}", e);
}
}
match CdnList::download().await {
Ok(base) => {
if let Err(e) = cdn.write().await.install(base).await {
error!("Failed to update CDN: {}", e);
}
}
Err(e) => {
error!("Failed to download CDN: {}", e);
}
}
info!("Updated databases");
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-alert-icon lucide-cloud-alert">
<style>
path {
stroke: black;
}
@media (prefers-color-scheme: dark) {
path { stroke: white; }
}
</style>
<path d="M12 12v4"/><path d="M12 20h.01"/><path d="M17 18h.5a1 1 0 0 0 0-9h-1.79A7 7 0 1 0 7 17.708"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

+433
View File
@@ -0,0 +1,433 @@
:root {
--bg-color: #0a0a0a; /* neutral-950 */
--text-main: #d4d4d4; /* neutral-300 */
--text-muted: #737373; /* neutral-500 */
--text-bright: #f5f5f5; /* neutral-100 */
--border-color: #262626; /* neutral-800 */
--input-bg: #171717; /* neutral-900 */
--input-border: #404040; /* neutral-700 */
--btn-bg: #f5f5f5; /* neutral-100 */
--btn-text: #0a0a0a; /* neutral-950 */
--red-color: #ef4444; /* red-500 */
--red-bg-subtle: rgba(69, 10, 10, 0.2);
--red-border: rgba(127, 29, 29, 0.3);
--green-color: #22c55e; /* green-500 */
--green-bg-subtle: rgba(5, 46, 22, 0.2);
--green-border: rgba(20, 83, 45, 0.3);
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-main);
font-family: var(--font-mono), sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem;
line-height: 1.5;
}
::selection {
background-color: #404040;
color: white;
}
.container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}
.header, .footer {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.column { flex-direction: column; }
.hidden { display: none !important; }
h1 {
font-size: 1.5rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-bright);
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.25rem;
line-height: 1.2;
}
h3 {
font-size: 0.875rem;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
}
h1.error-code {
font-size: 4rem;
line-height: 4rem;
}
.error-code {
color: var(--red-color);
}
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-lg { font-size: 1.5rem; }
.uppercase { text-transform: uppercase; }
.font-bold { font-weight: 700; }
.text-muted { color: var(--text-muted); }
.text-bright { color: var(--text-bright); }
.break-all { word-break: break-all; }
.header {
margin-bottom: 3rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-bright);
letter-spacing: -0.05em;
text-decoration: none;
}
.version-link {
display: flex;
gap: 0.5rem;
align-items: center;
text-decoration: none;
}
.footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
text-align: center;
color: #525252;
}
.search-container {
margin-bottom: 2rem;
}
.search-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
position: relative;
width: 100%;
}
.search-icon-wrapper {
display: flex;
justify-content: center;
position: absolute;
left: 1rem;
top: 2rem;
transform: translateY(-50%);
color: var(--text-muted);
transition: color 0.2s;
pointer-events: none;
}
.search-input {
width: 100%;
background-color: var(--input-bg);
border: 1px solid var(--input-border);
color: white;
padding: 1rem 1rem 1rem 3rem;
height: 4rem;
font-size: 1.125rem;
font-family: inherit;
outline: none;
transition: all 0.2s;
}
.search-input:focus {
border-color: #a3a3a3;
background-color: #262626;
}
.search-input:focus + .search-icon-wrapper {
color: white;
}
.search-btn {
/* Убрано абсолютное позиционирование */
width: 100%; /* Полная ширина на мобильных */
height: 4rem; /* Высота совпадает с полем ввода */
padding: 0 1.5rem;
background-color: var(--btn-bg);
color: var(--btn-text);
border: none;
font-weight: bold;
text-transform: uppercase;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center; /* Центрируем текст и иконку */
gap: 0.5rem;
font-family: inherit;
transition: background 0.2s;
}
.search-btn:hover {
background-color: white;
}
/* Статистика под поиском */
.stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 2rem;
color: var(--text-muted);
font-size: 0.75rem;
}
.stat-card {
border: 1px solid var(--border-color);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-card-header {
display: flex;
gap: 0.5rem;
}
/* --- 6. Результаты (Blocked/Clean) --- */
.result-panel {
border: 1px solid var(--border-color);
padding: 1.5rem;
margin-top: 1rem;
/*background-color: rgba(69, 10, 10, 0.1);*/
}
/* Цветовые схемы для результатов */
.blocked-theme {
border-color: var(--red-border);
background-color: rgba(69, 10, 10, 0.1); /* Очень прозрачный красный */
}
.blocked-theme h2, .blocked-theme .status-icon, .blocked-theme .subheading { color: var(--red-color); }
.blocked-theme .reason-header { border-bottom: 1px solid var(--red-border); color: #f87171; }
.blocked-theme .alert-box {
border: 1px solid var(--red-border);
background: var(--red-bg-subtle);
color: #f87171;
}
.clean-theme {
border-color: var(--green-border);
background-color: rgba(5, 46, 22, 0.1); /* Очень прозрачный зеленый */
}
.clean-theme h2, .clean-theme .status-icon, .clean-theme .subheading { color: var(--green-color); }
.clean-theme .reason-header { border-bottom: 1px solid var(--green-border); color: var(--green-color); }
.clean-theme .alert-box {
border: 1px solid var(--green-border);
background: var(--green-bg-subtle);
color: #4ade80;
}
/* Общие элементы результатов */
.result-header {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.icon-box {
padding: 0.75rem;
border: 1px solid currentColor;
background-color: rgba(255,255,255,0.05);
display: flex;
align-items: center;
justify-content: center;
}
.target-info {
background-color: rgba(23, 23, 23, 0.5);
padding: 1rem;
border: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.target-info-label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.target-value {
font-size: 1.25rem;
color: white;
}
/* Сетка деталей */
.details-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.detail-section h3 {
border-bottom: 1px solid var(--border-color);
color: white;
margin-bottom: 0;
}
.detail-row {
display: flex;
flex-direction: column;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
gap: 0.25rem;
}
.detail-row:last-child { border-bottom: none; }
.row-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.row-value {
font-size: 0.875rem;
font-weight: 500;
color: #e5e5e5;
text-align: right;
}
.row-value.alert { color: var(--red-color); }
.alert-box {
padding: 1rem;
font-size: 0.75rem;
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.reset-btn {
text-decoration: none;
margin-top: 2rem;
width: 100%;
padding: 0.75rem 1.5rem;
border: 1px solid #404040;
background: transparent;
color: #a3a3a3;
text-transform: uppercase;
font-weight: bold;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: inherit;
transition: all 0.2s;
}
.reset-btn:hover {
border-color: white;
color: white;
}
/* --- 7. Demo Controls --- */
.demo-controls {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
opacity: 0.3;
transition: opacity 0.2s;
}
.demo-controls:hover { opacity: 1; }
.demo-btn {
padding: 0.25rem 0.5rem;
font-size: 0.625rem;
text-transform: uppercase;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.1);
font-family: inherit;
}
.demo-btn.block { background-color: #450a0a; color: #fee2e2; border-color: #7f1d1d; }
.demo-btn.clean { background-color: #052e16; color: #dcfce7; border-color: #14532d; }
@media (min-width: 640px) {
body { padding: 2rem; }
h1.error-code {
font-size: 6rem;
line-height: 6rem;
}
.search-form {
flex-direction: row;
gap: 0;
}
.search-input {
flex-grow: 1;
padding-right: 1.5rem;
}
.search-btn {
width: auto;
margin-left: -1px;
height: 4rem;
justify-content: space-between;
padding: 0 1.5rem;
}
.stats-grid { grid-template-columns: repeat(3, 1fr); }
.details-grid { grid-template-columns: 1fr 1fr; }
.detail-row {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.reset-btn { width: auto; }
}
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="ru">
<head>
{% block head %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cheburcheck</title>
<link rel="stylesheet" href="/main.css"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{% 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 class="version-link text-muted" href="#">
<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">
Сделано в России
<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>
+85
View File
@@ -0,0 +1,85 @@
{% extends 'base' %}
{% block content %}
<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>
<a href="/" class="reset-btn">
<i data-lucide="rotate-ccw" width="14" height="14"></i>
<span>Проверить другой</span>
</a>
{% endblock content %}
+7
View File
@@ -0,0 +1,7 @@
{% 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">Что-то пошло не так. Возможно в запросе есть ошибка?</p>
{% endblock content %}
+51
View File
@@ -0,0 +1,51 @@
{% extends 'base' %}
{% block content %}
<div class="search-container">
<h1>Статус Ресурса</h1>
<p class="text-muted">Введите домен или IP-адрес для поиска по спискам заблокированных адресов и хостинг-провайдеров.</p>
</div>
<form class="search-form" action="/check">
<input
type="text"
name="target"
placeholder="example.com или 1.1.1.1 или 2606:4700:4700::1001"
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>
<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>{{ info.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>{{ info.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("{{ info.last_update }}").toLocaleString())</script></span>
</div>
</div>
{% endblock content %}
+112
View File
@@ -0,0 +1,112 @@
{% extends 'base' %}
{% block content %}
<div class="result-panel {% if found %}blocked-theme{% else %}clean-theme{% endif %}">
<div class="result-header">
<div class="icon-box status-icon">
{% if found %}
<i data-lucide="shield-alert" width="32" height="32"></i>
{% else %}
<i data-lucide="shield-check" width="32" height="32"></i>
{% endif %}
</div>
{% if 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>
<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">Локация</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 cdn_found %}
<p class="row-value alert">НАЙДЕН</p>
{% else %}
<span class="row-value">Не найден</span>
{% endif %}
</div>
{% for provider, networks in providers %}
<div class="detail-row">
<span class="row-label">{{ provider }}</span>
<div>
{% for network in networks %}
<p class="row-value">{{ network.cidr }}</p>
{% endfor %}
</div>
</div>
{% endfor %}
<div class="detail-row">
<span class="row-label">Статус РКН</span>
{% if domain_found or subnet_found %}
<span class="row-value alert">ОГРАНИЧЕН</span>
{% else %}
<span class="row-value">Не найден</span>
{% endif %}
</div>
{% if domain_found %}
<div class="detail-row">
<span class="row-label">Домен</span>
<span class="row-value">{{ domain }}</span>
</div>
{% endif %}
{% if subnet_found %}
<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 %}
</div>
</div>
</div>
<a href="/" class="reset-btn">
<i data-lucide="rotate-ccw" width="14" height="14"></i>
<span>Проверить другой</span>
</a>
{% endblock content %}