feat: subnets support

* add cidr subnet support

allows checking entire subnets using cidr notation (e.g. 1.1.1.0/24, 2001:db8::/32)

- parse ipv4/ipv6 subnets from input with / delimiter
- sample 256 addresses from large subnets for checking
- show actual subnet size in ui (e.g. "8.2K всего, показано 256")
- update search placeholder with cidr examples
- limit min prefix to /8 for ipv4 and /32 for ipv6 to prevent abuse

* chore: bump version

---------

Co-authored-by: Lowder <me@lowderplay.dev>
This commit is contained in:
senya xdearboy :)
2026-03-03 00:54:50 +05:00
committed by GitHub
co-authored by Lowder
parent 020ed7be9a
commit fb3febd54e
7 changed files with 230 additions and 10 deletions
Generated
+1 -1
View File
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "website"
version = "0.2.4"
version = "0.3.0"
dependencies = [
"dotenvy",
"env_logger",
+106 -3
View File
@@ -1,4 +1,5 @@
use crate::resolver::{ResolveError, Resolver};
use ipnet::{Ipv4Net, Ipv6Net};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
@@ -7,10 +8,27 @@ pub enum Target {
Domain(String),
Ipv4(Ipv4Addr),
Ipv6(Ipv6Addr),
Ipv4Subnet(Ipv4Net),
Ipv6Subnet(Ipv6Net),
}
impl From<&str> for Target {
fn from(input: &str) -> Self {
if input.contains('/') {
if let Ok(ipv4_net) = input.parse::<Ipv4Net>() {
if ipv4_net.prefix_len() >= 8 {
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>() {
return Target::Ipv4(ipv4);
}
@@ -33,15 +51,60 @@ impl Target {
match self {
Target::Domain(_) => "Домен",
Target::Ipv4(_) => "IPv4-адрес",
Target::Ipv6(_) => "IPv6-адрес"
Target::Ipv6(_) => "IPv6-адрес",
Target::Ipv4Subnet(_) => "IPv4-подсеть",
Target::Ipv6Subnet(_) => "IPv6-подсеть",
}
}
pub async fn resolve(&self, resolver: &Resolver) -> Result<Vec<IpAddr>, ResolveError> {
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())],
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::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
}
},
})
}
@@ -50,6 +113,46 @@ impl Target {
Target::Domain(domain) => domain.clone(),
Target::Ipv4(v4) => v4.to_string(),
Target::Ipv6(v6) => v6.to_string(),
Target::Ipv4Subnet(net) => net.to_string(),
Target::Ipv6Subnet(net) => net.to_string(),
}
}
pub fn subnet_size(&self) -> Option<String> {
match self {
Target::Ipv4Subnet(net) => {
let size = 2u64.pow(32 - net.prefix_len() as u32);
Some(format_large_number(size as u128))
}
Target::Ipv6Subnet(net) => {
if net.prefix_len() == 128 {
Some("1".to_string())
} else {
let size = 2u128.pow(128 - net.prefix_len() as u32);
Some(format_large_number(size))
}
}
_ => None,
}
}
}
fn format_large_number(n: u128) -> String {
if n < 1_000 {
n.to_string()
} else if n < 1_000_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else if n < 1_000_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n < 1_000_000_000_000 {
format!("{:.1}B", n as f64 / 1_000_000_000.0)
} else if n < 1_000_000_000_000_000 {
format!("{:.1}T", n as f64 / 1_000_000_000_000.0)
} else if n < 1_000_000_000_000_000_000 {
format!("{:.1}P", n as f64 / 1_000_000_000_000_000.0)
} else if n < 1_000_000_000_000_000_000_000 {
format!("{:.1}E", n as f64 / 1_000_000_000_000_000_000.0)
} else {
format!("{:.1}Z", n as f64 / 1_000_000_000_000_000_000_000.0)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "website"
version = "0.2.4"
version = "0.3.0"
edition = "2024"
[dependencies]
+2
View File
@@ -151,6 +151,7 @@ async fn check(
whitelist,
ips,
geo,
subnet_size: target.subnet_size(),
},
)),
Ok(Check {
@@ -178,6 +179,7 @@ async fn check(
whitelist,
ips,
geo,
subnet_size: target.subnet_size(),
},
)),
Err(e) => {
+86
View File
@@ -554,3 +554,89 @@ h1.error-code {
}
.reset-btn { width: auto; }
}
.ip-list-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.ip-summary {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: right;
}
.ip-range {
font-size: 0.875rem;
font-weight: 500;
color: #e5e5e5;
font-family: var(--font-mono);
}
.ip-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.ip-item {
font-size: 0.875rem;
font-weight: 500;
color: #e5e5e5;
font-family: var(--font-mono);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.ip-list-details {
margin: 0;
}
.ip-list-toggle {
cursor: pointer;
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.5rem 0.75rem;
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);
}
.ip-list-toggle:hover {
color: var(--text-bright);
border-color: var(--input-border);
background-color: rgba(38, 38, 38, 0.5);
}
.ip-list-toggle::-webkit-details-marker {
display: none;
}
.ip-list-expanded {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.5rem;
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) {
.ip-list-container {
align-items: flex-end;
}
.ip-summary {
align-items: flex-end;
}
}
+33 -4
View File
@@ -47,10 +47,39 @@
<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 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>
<div class="detail-row">
+1 -1
View File
@@ -2,7 +2,7 @@
<input
type="text"
name="target"
placeholder="example.com или 1.1.1.1 или 2606:4700:4700::1001"
placeholder="example.com, 1.1.1.1, 1.0.0.0/24, 2606:4700::/32"
class="search-input"
minlength="3"
required