feat: asn support

* add asn and cidr subnet support

- parse AS numbers (AS13335 format)
- fetch prefixes from ripe stat api
- cache asn data for 24h, check results for 1h
- sample ips across subnets for checking
- show asn stats and prefix lists in ui
- link asn in results for quick lookup
- collapsible lists for long prefix/network lists
- trim whitespace from search input

* refactor: improve code structure and add enhanced error messages

* refactor: replace match with if let for single branch pattern

* fix: don't store ASN in database

* chore: bump version

---------

Co-authored-by: Lowder <me@lowderplay.dev>
This commit is contained in:
senya xdearboy :)
2026-03-03 23:39:45 +05:00
committed by GitHub
co-authored by Lowder
parent fb3febd54e
commit 58d9e7b68e
16 changed files with 640 additions and 69 deletions
Generated
+2 -1
View File
@@ -2344,6 +2344,7 @@ dependencies = [
"maxminddb",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"trie-rs",
@@ -4225,7 +4226,7 @@ dependencies = [
[[package]]
name = "website"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"dotenvy",
"env_logger",
+1 -1
View File
@@ -9,6 +9,6 @@ resolver = "3"
[workspace.dependencies]
tokio = { version = "1.48.0", features = ["full"] }
reqwest = { version = "0.12.24", default-features = false, features = ["stream", "rustls-tls-webpki-roots"] }
reqwest = { version = "0.12.24", default-features = false, features = ["stream", "rustls-tls-webpki-roots", "json"] }
log = "0.4.28"
serde = { version = "1", features = ["derive"] }
+1
View File
@@ -20,3 +20,4 @@ url = "2.5.7"
indicatif = "0.18.3"
chrono = { version = "0.4.42", features = ["serde"] }
futures-util = "0.3.31"
serde_json = "1.0"
+152
View File
@@ -0,0 +1,152 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
#[derive(Deserialize)]
pub struct RipeStatResponse {
pub data: RipeStatData,
}
#[derive(Deserialize)]
pub struct RipeStatData {
pub prefixes: Vec<Prefix>,
}
#[derive(Deserialize)]
pub struct Prefix {
pub prefix: String,
}
#[derive(Debug)]
pub enum AsnError {
NotFound,
NetworkError(String),
ParseError(String),
}
impl From<reqwest::Error> for AsnError {
fn from(err: reqwest::Error) -> Self {
AsnError::NetworkError(err.to_string())
}
}
pub struct CachedAsnData {
pub prefixes: Vec<String>,
pub cached_at: SystemTime,
}
impl CachedAsnData {
pub fn new(prefixes: Vec<String>) -> Self {
Self {
prefixes,
cached_at: SystemTime::now(),
}
}
pub fn is_expired(&self) -> bool {
SystemTime::now()
.duration_since(self.cached_at)
.map(|d| d.as_secs() > 86400)
.unwrap_or(true)
}
}
pub async fn fetch_asn_prefixes(asn: u32) -> Result<Vec<String>, AsnError> {
let url = format!(
"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
.map_err(|e| AsnError::ParseError(e.to_string()))?;
Ok(data.data.prefixes.into_iter().map(|p| p.prefix).collect())
}
pub async fn fetch_asn_prefixes_cached(
asn: u32,
get_cached: impl FnOnce(u32) -> Option<Vec<String>>,
cache_result: impl FnOnce(u32, Vec<String>),
) -> Result<Vec<String>, AsnError> {
if let Some(cached_prefixes) = get_cached(asn) {
return Ok(cached_prefixes);
}
let prefixes = fetch_asn_prefixes(asn).await?;
cache_result(asn, prefixes.clone());
Ok(prefixes)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AsnInfo {
pub asn: u32,
pub prefixes: Vec<String>,
pub blocked_prefixes: Vec<String>,
}
impl AsnInfo {
pub fn new(asn: u32, prefixes: Vec<String>, blocked_prefixes: Vec<String>) -> Self {
Self {
asn,
prefixes,
blocked_prefixes,
}
}
pub fn ipv4_prefixes(&self) -> Vec<&str> {
self.prefixes.iter()
.filter(|p| !p.contains(':'))
.map(|s| s.as_str())
.collect()
}
pub fn ipv6_prefixes(&self) -> Vec<&str> {
self.prefixes.iter()
.filter(|p| p.contains(':'))
.map(|s| s.as_str())
.collect()
}
}
pub struct AsnCache {
cache: Arc<RwLock<HashMap<u32, CachedAsnData>>>,
}
impl AsnCache {
pub fn new() -> Self {
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn get_cached_asn(&self, asn: u32) -> Option<Vec<String>> {
let cache = self.cache.read().ok()?;
let cached_data = cache.get(&asn)?;
if cached_data.is_expired() {
None
} else {
Some(cached_data.prefixes.clone())
}
}
pub fn cache_asn(&self, asn: u32, prefixes: Vec<String>) {
if let Ok(mut cache) = self.cache.write() {
cache.insert(asn, CachedAsnData::new(prefixes));
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub struct GeoIp {
country: Option<maxminddb::Reader<Vec<u8>>>,
}
#[derive(Serialize, Debug)]
#[derive(Serialize, Debug, Clone)]
pub struct IpInfo {
pub asn: Option<String>,
pub country_code: Option<String>,
+54 -4
View File
@@ -14,11 +14,15 @@ use maxminddb::MaxMindDbError;
use thiserror::Error;
use tokio::sync::{watch, RwLock};
pub mod asn;
pub mod geoip;
pub mod lists;
pub mod resolver;
pub mod updater;
pub mod target;
pub mod subnet_sampler;
pub use subnet_sampler::{sample_ipv4_subnet, sample_ipv6_subnet};
pub struct Checker {
rx: watch::Receiver<Option<DateTime<Utc>>>,
@@ -29,13 +33,16 @@ pub struct Checker {
resolver: Resolver,
}
#[derive(Clone)]
pub struct Check {
pub verdict: CheckVerdict,
pub geo: IpInfo,
pub ips: Vec<IpAddr>,
pub rkn_subnets: HashSet<IpNet>,
pub asn_info: Option<crate::asn::AsnInfo>,
}
#[derive(Clone)]
pub enum CheckVerdict {
Clear,
Blocked {
@@ -117,17 +124,60 @@ impl Checker {
.filter_map(|ip| ru_blacklist.contains_ip(ip))
.collect();
let asn_info = if let Target::Asn(asn) = &target {
let prefixes = crate::asn::fetch_asn_prefixes_cached(
*asn,
|asn| self.resolver.asn_cache.get_cached_asn(asn),
|asn, prefixes| self.resolver.asn_cache.cache_asn(asn, prefixes),
)
.await
.unwrap_or_default();
let mut blocked_prefixes: Vec<String> = prefixes
.iter()
.filter(|prefix| {
if let Ok(ipnet) = prefix.parse::<IpNet>() {
ru_blacklist.contains_ip(&ipnet.network()).is_some()
} else {
false
}
})
.cloned()
.collect();
for prefix in &prefixes {
if let Ok(ipnet) = prefix.parse::<IpNet>() {
if cdn_list.contains(&ipnet.network()).is_some() {
if !blocked_prefixes.contains(prefix) {
blocked_prefixes.push(prefix.clone());
}
}
}
}
Some(crate::asn::AsnInfo::new(*asn, prefixes, blocked_prefixes))
} else {
None
};
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()) {
(None, true) => CheckVerdict::Clear,
(domain, _) => CheckVerdict::Blocked {
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,
cdn_provider_subnets,
},
},
rkn_subnets,
geo,
ips
ips,
asn_info,
})
}
+26 -1
View File
@@ -2,10 +2,14 @@ use hickory_resolver::config::{LookupIpStrategy, ResolverConfig, ResolverOpts};
use hickory_resolver::name_server::TokioConnectionProvider;
use std::io::{Error, ErrorKind};
use std::net::IpAddr;
use std::sync::Arc;
use thiserror::Error;
use crate::asn::{AsnCache, AsnError};
pub struct Resolver {
resolver: hickory_resolver::Resolver<TokioConnectionProvider>,
pub asn_cache: Arc<AsnCache>,
}
#[derive(Error, Debug)]
@@ -14,6 +18,24 @@ pub enum ResolveError {
NxDomain,
#[error("resolver error")]
Other(#[from] Error),
#[error("not implemented")]
NotImplemented,
#[error("asn not found")]
AsnNotFound,
#[error("asn network error: {0}")]
AsnNetworkError(String),
#[error("asn parse error: {0}")]
AsnParseError(String),
}
impl From<AsnError> for ResolveError {
fn from(err: AsnError) -> Self {
match err {
AsnError::NotFound => ResolveError::AsnNotFound,
AsnError::NetworkError(msg) => ResolveError::AsnNetworkError(msg),
AsnError::ParseError(msg) => ResolveError::AsnParseError(msg),
}
}
}
impl Resolver {
@@ -24,7 +46,10 @@ impl Resolver {
let resolver = hickory_resolver::Resolver::builder_with_config(config, TokioConnectionProvider::default())
.with_options(opts)
.build();
Resolver { resolver }
Resolver {
resolver,
asn_cache: Arc::new(AsnCache::new()),
}
}
pub async fn lookup_ips(&self, domain: &str) -> Result<Vec<IpAddr>, ResolveError> {
+47
View File
@@ -0,0 +1,47 @@
use ipnet::{Ipv4Net, Ipv6Net};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
pub fn sample_ipv4_subnet(net: Ipv4Net) -> Vec<IpAddr> {
if net.prefix_len() >= 24 {
net.hosts().map(IpAddr::V4).collect()
} 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
}
}
pub fn sample_ipv6_subnet(net: Ipv6Net) -> Vec<IpAddr> {
if net.prefix_len() >= 124 {
net.hosts().map(IpAddr::V6).collect()
} 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 {
u64::MAX as u128
} 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
}
}
+41 -40
View File
@@ -1,4 +1,6 @@
use crate::asn::fetch_asn_prefixes_cached;
use crate::resolver::{ResolveError, Resolver};
use crate::{sample_ipv4_subnet, sample_ipv6_subnet};
use ipnet::{Ipv4Net, Ipv6Net};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
@@ -10,10 +12,20 @@ pub enum Target {
Ipv6(Ipv6Addr),
Ipv4Subnet(Ipv4Net),
Ipv6Subnet(Ipv6Net),
Asn(u32),
}
impl From<&str> for Target {
fn from(input: &str) -> Self {
if input.len() > 2 && input[..2].eq_ignore_ascii_case("as") {
if let Ok(asn) = input[2..].parse::<u32>() {
if asn >= 1 {
return Target::Asn(asn);
}
}
}
if input.contains('/') {
if let Ok(ipv4_net) = input.parse::<Ipv4Net>() {
if ipv4_net.prefix_len() >= 8 {
@@ -54,6 +66,7 @@ impl Target {
Target::Ipv6(_) => "IPv6-адрес",
Target::Ipv4Subnet(_) => "IPv4-подсеть",
Target::Ipv6Subnet(_) => "IPv6-подсеть",
Target::Asn(_) => "Автономная система",
}
}
@@ -62,48 +75,35 @@ impl Target {
Target::Domain(domain) => resolver.lookup_ips(domain).await?,
Target::Ipv4(ipv4) => vec![IpAddr::V4(*ipv4)],
Target::Ipv6(ipv6) => vec![IpAddr::V6(*ipv6)],
Target::Ipv4Subnet(net) => {
if net.prefix_len() >= 27 {
net.hosts().map(IpAddr::V4).collect()
} else {
let mut ips = Vec::with_capacity(256);
ips.push(IpAddr::V4(net.network()));
let base = u32::from(net.network());
let max = (1u64 << (32 - net.prefix_len())) - 1;
for i in 1..255 {
let offset = ((max as f64 * i as f64) / 255.0) as u32;
ips.push(IpAddr::V4(Ipv4Addr::from(base + offset)));
}
ips.push(IpAddr::V4(net.broadcast()));
ips
Target::Ipv4Subnet(net) => sample_ipv4_subnet(*net),
Target::Ipv6Subnet(net) => sample_ipv6_subnet(*net),
Target::Asn(asn) => {
let mut prefixes = fetch_asn_prefixes_cached(
*asn,
|asn| resolver.asn_cache.get_cached_asn(asn),
|asn, prefixes| resolver.asn_cache.cache_asn(asn, prefixes),
)
.await?;
if prefixes.is_empty() {
return Ok(vec![]);
}
},
Target::Ipv6Subnet(net) => {
if net.prefix_len() >= 124 {
net.hosts().map(IpAddr::V6).collect()
} else {
let mut ips = Vec::with_capacity(256);
ips.push(IpAddr::V6(net.network()));
let base = u128::from(net.network());
let bits = 128 - net.prefix_len();
let max = if bits >= 64 {
u64::MAX as u128
} else {
(1u128 << bits) - 1
};
for i in 1..255 {
let offset = ((max as f64 * i as f64) / 255.0) as u128;
ips.push(IpAddr::V6(Ipv6Addr::from(base + offset)));
}
ips.push(IpAddr::V6(net.broadcast()));
ips
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
},
})
}
@@ -115,6 +115,7 @@ impl Target {
Target::Ipv6(v6) => v6.to_string(),
Target::Ipv4Subnet(net) => net.to_string(),
Target::Ipv6Subnet(net) => net.to_string(),
Target::Asn(asn) => format!("AS{}", asn),
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "website"
version = "0.3.0"
version = "0.4.0"
edition = "2024"
[dependencies]
+11 -7
View File
@@ -40,6 +40,16 @@ pub async fn save_query(
(vec![], vec![], None)
};
let (resolved_ips, cdn_networks) = match target {
Target::Asn(_) => (vec![], vec![]),
_ => (check
.ips
.iter()
.map(|i| i.to_string())
.collect::<Vec<String>>(),
cdn_networks)
};
let id = sqlx::query_scalar(
"INSERT INTO queries (
query,
@@ -69,13 +79,7 @@ pub async fn save_query(
.bind(check.geo.country_code.clone())
.bind(check.geo.asn.clone())
.bind(check.geo.organisation.clone())
.bind(
check
.ips
.iter()
.map(|i| i.to_string())
.collect::<Vec<String>>(),
)
.bind(resolved_ips)
.bind(cdn_networks)
.bind(cdn_providers)
.bind(rkn_domain)
+8 -2
View File
@@ -101,7 +101,7 @@ async fn check(
addr: &ClientRealAddr,
mut db: Connection<Db>,
) -> Result<Template, Status> {
let target = Target::from(target);
let target = Target::from(target.trim());
let check = checker.read().await.check(target.clone()).await;
let id = if let Ok(check) = &check {
match save_query(&mut db, &target, check, addr, checker.read().await).await {
@@ -137,6 +137,7 @@ async fn check(
geo,
ips,
rkn_subnets,
asn_info,
}) => Ok(Template::render(
"result",
context! {
@@ -152,6 +153,7 @@ async fn check(
ips,
geo,
subnet_size: target.subnet_size(),
asn_info,
},
)),
Ok(Check {
@@ -163,6 +165,7 @@ async fn check(
geo,
rkn_subnets,
ips,
asn_info,
}) => Ok(Template::render(
"result",
context! {
@@ -180,6 +183,7 @@ async fn check(
ips,
geo,
subnet_size: target.subnet_size(),
asn_info,
},
)),
Err(e) => {
@@ -265,7 +269,9 @@ async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
#[launch]
async fn rocket() -> _ {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.filter_level(log::LevelFilter::Warn)
.filter_module("website", log::LevelFilter::Info)
.filter_module("querying", log::LevelFilter::Info)
.init();
let mut interval = time::interval(Duration::from_secs(
+152 -2
View File
@@ -349,6 +349,18 @@ h1.error-code {
color: var(--text-muted);
}
.row-value a, .asn-link {
color: var(--text-bright);
text-decoration: underline;
text-decoration-color: var(--text-muted);
transition: all 0.2s;
}
.row-value a:hover, .asn-link:hover {
color: white;
text-decoration-color: var(--text-bright);
}
.row-value {
font-size: 0.875rem;
font-weight: 500;
@@ -560,6 +572,7 @@ h1.error-code {
flex-direction: column;
gap: 0.5rem;
width: 100%;
align-items: flex-end;
}
.ip-summary {
@@ -593,19 +606,22 @@ h1.error-code {
.ip-list-details {
margin: 0;
width: fit-content;
}
.ip-list-toggle {
cursor: pointer;
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border-color);
text-align: center;
list-style: none;
user-select: none;
transition: all 0.2s;
background-color: rgba(23, 23, 23, 0.5);
width: fit-content;
max-width: 150px;
}
.ip-list-toggle:hover {
@@ -640,3 +656,137 @@ h1.error-code {
}
}
.asn-summary {
display: flex;
gap: 2rem;
padding: 1rem;
background-color: rgba(23, 23, 23, 0.5);
border: 1px solid var(--border-color);
margin-bottom: 2rem;
flex-wrap: wrap;
}
.asn-stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.asn-stat-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.asn-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-bright);
}
.asn-stat-value.alert {
color: var(--red-color);
}
.prefix-list-container,
.blocked-prefix-list-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
align-items: flex-end;
}
.prefix-list-details,
.blocked-prefix-list-details {
margin: 0;
width: fit-content;
}
.prefix-list-toggle,
.blocked-prefix-list-toggle {
cursor: pointer;
color: var(--text-muted);
font-size: 0.7rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border-color);
text-align: center;
list-style: none;
user-select: none;
transition: all 0.2s;
background-color: rgba(23, 23, 23, 0.5);
width: fit-content;
max-width: 150px;
}
.prefix-list-toggle:hover,
.blocked-prefix-list-toggle:hover {
color: var(--text-bright);
border-color: var(--input-border);
background-color: rgba(38, 38, 38, 0.5);
}
.prefix-list-toggle::-webkit-details-marker,
.blocked-prefix-list-toggle::-webkit-details-marker {
display: none;
}
.cdn-networks-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
align-items: flex-end;
}
.cdn-networks-details {
margin: 0;
width: fit-content;
}
.cdn-networks-toggle {
cursor: pointer;
color: var(--text-muted);
font-size: 0.7rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border-color);
text-align: center;
list-style: none;
user-select: none;
transition: all 0.2s;
background-color: rgba(23, 23, 23, 0.5);
width: fit-content;
max-width: 150px;
}
.cdn-networks-toggle:hover {
color: var(--text-bright);
border-color: var(--input-border);
background-color: rgba(38, 38, 38, 0.5);
}
.cdn-networks-toggle::-webkit-details-marker {
display: none;
}
.prefix-list-expanded,
.blocked-prefix-list-expanded,
.cdn-networks-expanded {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border-color);
background-color: rgba(23, 23, 23, 0.3);
max-height: 400px;
overflow-y: auto;
}
@media (min-width: 640px) {
.prefix-list-container,
.blocked-prefix-list-container {
align-items: flex-end;
}
}
+21 -1
View File
@@ -3,5 +3,25 @@
{% 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>
<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 %}
+121 -7
View File
@@ -42,9 +42,25 @@
<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">
@@ -64,7 +80,7 @@
<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>
<span class="ip-count">({{ subnet_size }} всего, проверено {{ ips | length }})</span>
{% else %}
<span class="ip-count">({{ ips | length }} адресов)</span>
{% endif %}
@@ -82,6 +98,7 @@
{% 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>
@@ -92,8 +109,56 @@
</div>
<div class="detail-row">
<span class="row-label">ASN</span>
<span class="row-value">{% if geo.asn %}{{ geo.asn }}{% else %}-{% endif %}</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">
@@ -110,10 +175,28 @@
{% 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 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 %}
@@ -154,7 +237,7 @@
<span class="row-value">{{ domain }}</span>
</div>
{% endif %}
{% if blocked_subnets %}
{% if blocked_subnets and not asn_info %}
<div class="detail-row">
<span class="row-label">Заблокированные подсети</span>
<div>
@@ -164,6 +247,37 @@
</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">
+1 -1
View File
@@ -2,7 +2,7 @@
<input
type="text"
name="target"
placeholder="example.com, 1.1.1.1, 1.0.0.0/24, 2606:4700::/32"
placeholder="example.com, 1.1.1.1, 1.0.0.0/24, AS13335"
class="search-input"
minlength="3"
required