feat: add public JSON API for IP/domain checking (#60)

Adds GET /api/v1/check?target= endpoint that returns JSON instead of HTML.
Rate limited to 30 req/min per IP (configurable via API_RATE_LIMIT_RPM env var).
Queries are logged to the database same as web UI checks.
This commit is contained in:
Mikhail Gorbachev 2026-04-20 02:34:03 +07:00 • committed by GitHub
parent 7042f3a42b
commit 741ae887f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 260 additions and 3 deletions

119
Cargo.lock generated
View file

@ -544,6 +544,19 @@ dependencies = [
"memchr",
]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
@ -918,6 +931,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.32"
@ -1029,6 +1048,26 @@ dependencies = [
"walkdir",
]
[[package]]
name = "governor"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
dependencies = [
"cfg-if",
"dashmap",
"futures",
"futures-timer",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.8.5",
"smallvec",
"spinning_top",
]
[[package]]
name = "h2"
version = "0.3.27"
@ -1067,6 +1106,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -1390,7 +1435,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"system-configuration 0.6.1",
"tokio",
"tower-service",
@ -2012,6 +2057,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "normpath"
version = "1.5.0"
@ -2465,6 +2522,21 @@ dependencies = [
"yansi",
]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "querying"
version = "0.1.0"
@ -2502,7 +2574,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror",
"tokio",
"tracing",
@ -2539,7 +2611,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@ -2641,6 +2713,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "rayon"
version = "1.11.0"
@ -3306,6 +3387,15 @@ dependencies = [
"lock_api",
]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@ -4325,6 +4415,7 @@ dependencies = [
"dotenvy",
"env_logger",
"flate2",
"governor",
"log",
"querying",
"reports",
@ -4354,6 +4445,22 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@ -4363,6 +4470,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.48.0"

View file

@ -15,6 +15,7 @@ env_logger = "0.11.10"
rocket-cache-response = "0.6.4"
log = { workspace = true }
dotenvy = { version = "0.15.7" }
governor = { version = "0.6", features = ["dashmap"] }
[build-dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }

133
website/src/api.rs Normal file
View file

@ -0,0 +1,133 @@
use crate::db::{check_whitelist, save_query, WhitelistedEntry};
use governor::clock::DefaultClock;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::{Quota, RateLimiter};
use log::warn;
use querying::asn::AsnInfo;
use querying::geoip::IpInfo;
use querying::lists::NetworkRecord;
use querying::target::Target;
use querying::{Check, CheckError, CheckVerdict, Checker};
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 std::collections::HashMap;
use std::net::IpAddr;
use std::num::NonZeroU32;
use std::sync::Arc;
pub type ApiRateLimiter =
RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
pub fn build_rate_limiter(per_minute: u32) -> ApiRateLimiter {
RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).expect("rate limit must be > 0"),
))
}
#[derive(Serialize)]
pub struct ApiCheckResponse {
pub id: Option<String>,
pub target: String,
pub target_type: String,
pub blocked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rkn_domain: Option<String>,
pub ips: Vec<String>,
pub blocked_subnets: Vec<String>,
pub cdn_providers: HashMap<String, Vec<NetworkRecord>>,
pub geo: IpInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub asn_info: Option<AsnInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub whitelist: Option<WhitelistedEntry>,
}
fn build_response(
id: Option<String>,
target: &Target,
check: Check,
whitelist: Option<WhitelistedEntry>,
) -> ApiCheckResponse {
let (blocked, rkn_domain, cdn_providers) = match check.verdict {
CheckVerdict::Blocked {
rkn_domain,
cdn_provider_subnets,
} => {
let providers: HashMap<String, Vec<NetworkRecord>> = cdn_provider_subnets
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect();
(true, rkn_domain, providers)
}
CheckVerdict::Clear => (false, None, HashMap::new()),
};
ApiCheckResponse {
id,
target: target.to_query(),
target_type: target.readable_type().to_string(),
blocked,
rkn_domain,
ips: check.ips.iter().map(|ip| ip.to_string()).collect(),
blocked_subnets: check.rkn_subnets.iter().map(|n| n.to_string()).collect(),
cdn_providers,
geo: check.geo,
asn_info: check.asn_info,
whitelist,
}
}
#[get("/check?<target>")]
pub async fn check(
target: &str,
checker: &State<Arc<RwLock<Checker>>>,
addr: &ClientRealAddr,
pool: &State<PgPool>,
limiter: &State<Arc<ApiRateLimiter>>,
) -> Result<Json<ApiCheckResponse>, Status> {
if limiter.check_key(&addr.ip).is_err() {
return Err(Status::TooManyRequests);
}
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!("api: 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) => Err(Status::NotFound),
Ok(check) => Ok(Json(build_response(id, &target, check, whitelist))),
Err(e) => {
log::error!("api check failed {:?}", e);
Err(Status::InternalServerError)
}
}
}

View file

@ -1,6 +1,7 @@
#[macro_use]
extern crate rocket;
mod agency;
mod api;
mod db;
mod whitelist;
@ -298,6 +299,12 @@ async fn rocket() -> _ {
}
});
let rate_limit_rpm: u32 = std::env::var("API_RATE_LIMIT_RPM")
.unwrap_or("30".to_string())
.parse()
.unwrap_or(30);
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())
@ -316,9 +323,12 @@ async fn rocket() -> _ {
rocket::build()
.manage(checker)
.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("/agency", routes![agency::upload_report])
.mount("/whitelist", routes![whitelist::histogram, whitelist::export_csv])
.register("/agency", catchers![api_error])