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 00:34:03 +05:00
committed by GitHub
parent 7042f3a42b
commit 741ae887f5
4 changed files with 260 additions and 3 deletions
+1
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
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)
}
}
}
+10
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])