Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker/prod/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ WORKDIR /app
# The host's binaries are not reachable from here: network_mode host shares the
# network namespace, not the filesystem, so the CLI must live in this image.
RUN apt-get update && \
apt-get install --no-install-recommends -y ca-certificates nftables iptables && \
apt-get install --no-install-recommends -y ca-certificates sqlite3 nftables iptables && \
rm -rf /var/lib/apt/lists/*

# copy binary and configuration files
Expand Down
187 changes: 186 additions & 1 deletion src/api/security.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
//! Security API endpoints

use crate::database::repositories::offenses::{list_offenses, OffenseStatus};
use crate::database::{get_security_status_snapshot, DbPool, SecurityStatusSnapshot};
use crate::ip_ban::{IpBanConfig, IpBanEngine};
use crate::models::api::security::SecurityStatusResponse;
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;

/// Upper bound on `?limit=`, so one request cannot pull the whole table.
const MAX_BAN_LIMIT: usize = 500;
const DEFAULT_BAN_LIMIT: usize = 100;

#[derive(Debug, Deserialize)]
pub struct BanQuery {
/// active, blocked, or released. Case-insensitive; omit for all.
status: Option<String>,
limit: Option<usize>,
}

/// Get overall security status
///
Expand All @@ -19,9 +33,76 @@ pub async fn get_security_status(pool: web::Data<DbPool>) -> impl Responder {
}
}

/// List IP ban offenses
///
/// GET /api/security/bans?status=blocked&limit=100
pub async fn list_bans(pool: web::Data<DbPool>, query: web::Query<BanQuery>) -> impl Responder {
let status = match query.status.as_deref() {
None => None,
Some(raw) => match parse_offense_status(raw) {
Some(status) => Some(status),
None => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid status. Expected one of: active, blocked, released"
}))
}
},
};
let limit = query.limit.unwrap_or(DEFAULT_BAN_LIMIT).min(MAX_BAN_LIMIT);

match list_offenses(pool.get_ref(), status, limit) {
Ok(offenses) => HttpResponse::Ok().json(offenses),
Err(err) => {
log::error!("Failed to list IP bans: {}", err);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to list IP bans"
}))
}
}
}

/// Release an IP ban ahead of its expiry
///
/// DELETE /api/security/bans/{ip}
pub async fn delete_ban(pool: web::Data<DbPool>, path: web::Path<String>) -> impl Responder {
let ip_address = path.into_inner();
let engine = IpBanEngine::new(pool.get_ref().clone(), IpBanConfig::from_env());

match engine.unban_ip(&ip_address).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({
"ip_address": ip_address,
"status": "Released"
})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
"error": format!("No active block for {}", ip_address)
})),
Err(err) => {
log::error!("Failed to release ban for {}: {}", ip_address, err);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to release ban"
}))
}
}
}

/// Accept the lowercase spellings a URL query would realistically use.
fn parse_offense_status(raw: &str) -> Option<OffenseStatus> {
match raw.trim().to_ascii_lowercase().as_str() {
"active" => Some(OffenseStatus::Active),
"blocked" => Some(OffenseStatus::Blocked),
"released" => Some(OffenseStatus::Released),
_ => None,
}
}

/// Configure security routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/api/security").route("/status", web::get().to(get_security_status)));
cfg.service(
web::scope("/api/security")
.route("/status", web::get().to(get_security_status))
.route("/bans", web::get().to(list_bans))
.route("/bans/{ip}", web::delete().to(delete_ban)),
);
}

pub(crate) fn build_security_status(pool: &DbPool) -> anyhow::Result<SecurityStatusResponse> {
Expand Down Expand Up @@ -51,6 +132,110 @@ mod tests {
use actix_web::{test, App};
use chrono::Utc;

fn insert_blocked_offense(pool: &DbPool, ip: &str) {
use crate::database::repositories::offenses::{insert_offense, mark_blocked, NewIpOffense};

insert_offense(
pool,
&NewIpOffense {
id: format!("offense-{ip}"),
ip_address: ip.to_string(),
source_type: "sniff".into(),
container_id: None,
first_seen: Utc::now(),
reason: "repeated offenses".into(),
metadata: None,
},
)
.unwrap();
mark_blocked(
pool,
ip,
"sniff",
Utc::now() + chrono::Duration::minutes(30),
)
.unwrap();
}

#[actix_rt::test]
async fn test_list_bans_returns_offenses() {
let pool = create_pool(":memory:").unwrap();
init_database(&pool).unwrap();
insert_blocked_offense(&pool, "46.224.127.228");

let app = test::init_service(
App::new()
.app_data(web::Data::new(pool))
.configure(configure_routes),
)
.await;

let req = test::TestRequest::get()
.uri("/api/security/bans?status=blocked")
.to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, req).await;

assert_eq!(body.as_array().unwrap().len(), 1);
assert_eq!(body[0]["ip_address"], "46.224.127.228");
assert_eq!(body[0]["status"], "Blocked");
}

#[actix_rt::test]
async fn test_list_bans_rejects_unknown_status() {
let pool = create_pool(":memory:").unwrap();
init_database(&pool).unwrap();
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool))
.configure(configure_routes),
)
.await;

let req = test::TestRequest::get()
.uri("/api/security/bans?status=banned")
.to_request();
let resp = test::call_service(&app, req).await;

assert_eq!(resp.status(), actix_web::http::StatusCode::BAD_REQUEST);
}

#[actix_rt::test]
async fn test_delete_ban_returns_404_for_unknown_ip() {
let pool = create_pool(":memory:").unwrap();
init_database(&pool).unwrap();
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool))
.configure(configure_routes),
)
.await;

let req = test::TestRequest::delete()
.uri("/api/security/bans/203.0.113.99")
.to_request();
let resp = test::call_service(&app, req).await;

assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
}

// `#[test]` resolves to actix_web::test here, which requires async.
#[actix_rt::test]
async fn test_parse_offense_status_is_case_insensitive() {
assert_eq!(
parse_offense_status("Blocked"),
Some(OffenseStatus::Blocked)
);
assert_eq!(
parse_offense_status(" active "),
Some(OffenseStatus::Active)
);
assert_eq!(
parse_offense_status("released"),
Some(OffenseStatus::Released)
);
assert_eq!(parse_offense_status("nonsense"), None);
}

#[actix_rt::test]
async fn test_get_security_status() {
let pool = create_pool(":memory:").unwrap();
Expand Down
35 changes: 35 additions & 0 deletions src/database/repositories/offenses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,41 @@ pub fn expired_blocks(pool: &DbPool, now: DateTime<Utc>) -> Result<Vec<IpOffense
Ok(offenses)
}

/// List offenses, newest first, optionally narrowed to one status.
pub fn list_offenses(
pool: &DbPool,
status: Option<OffenseStatus>,
limit: usize,
) -> Result<Vec<IpOffenseRecord>> {
let conn = pool.get()?;
let base = "SELECT
id, ip_address, source_type, container_id, offense_count,
first_seen, last_seen, blocked_until, status, reason, metadata
FROM ip_offenses";

let mut offenses = Vec::new();
match status {
Some(status) => {
let mut stmt = conn.prepare(&format!(
"{base} WHERE status = ?1 ORDER BY last_seen DESC LIMIT ?2"
))?;
let rows = stmt.query_map(params![status.to_string(), limit as i64], map_row)?;
for row in rows {
offenses.push(row?);
}
}
None => {
let mut stmt = conn.prepare(&format!("{base} ORDER BY last_seen DESC LIMIT ?1"))?;
let rows = stmt.query_map(params![limit as i64], map_row)?;
for row in rows {
offenses.push(row?);
}
}
}

Ok(offenses)
}

pub fn mark_released(pool: &DbPool, offense_id: &str) -> Result<()> {
let conn = pool.get()?;
conn.execute(
Expand Down
59 changes: 56 additions & 3 deletions src/docker/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,29 @@ impl DockerClient {
}

/// Get container info by ID
///
/// The name comes from the container's own name, not its hostname: under
/// `network_mode: host` the hostname is the host's, and otherwise Docker
/// defaults it to the short ID — so hostname never yields what `docker ps`
/// shows.
pub async fn get_container_info(&self, container_id: &str) -> Result<ContainerInfo> {
let inspect = self
.client
.inspect_container(container_id, None::<InspectContainerOptions>)
.await
.context("Failed to inspect container")?;

let name = container_display_name(
inspect.name.as_deref(),
inspect.config.as_ref().and_then(|c| c.hostname.as_deref()),
container_id,
);
let config = inspect.config.unwrap_or_default();
let state = inspect.state.unwrap_or_default();

Ok(ContainerInfo {
id: container_id.to_string(),
name: config
.hostname
.unwrap_or_else(|| container_id[..12].to_string()),
name,
image: config.image.unwrap_or_else(|| "unknown".to_string()),
status: if state.running.unwrap_or(false) {
"Running"
Expand Down Expand Up @@ -272,6 +280,24 @@ pub struct ContainerInfo {
pub labels: HashMap<String, String>,
}

/// Pick the name to show for a container.
///
/// Docker returns the inspect name with a leading slash ("/redis"). Falls back
/// to the hostname, then to the short ID, so there is always something to print.
fn container_display_name(
inspect_name: Option<&str>,
hostname: Option<&str>,
container_id: &str,
) -> String {
inspect_name
.map(|name| name.trim_start_matches('/'))
.filter(|name| !name.is_empty())
.or(hostname)
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| container_id.chars().take(12).collect())
}

/// Container statistics
#[derive(Debug, Clone, Default)]
pub struct ContainerStats {
Expand All @@ -288,6 +314,33 @@ pub struct ContainerStats {
mod tests {
use super::*;

#[test]
fn test_container_display_name_prefers_real_name() {
assert_eq!(
container_display_name(Some("/redis"), Some("0f3b46ca0c16"), "0f3b46ca0c16aaaa"),
"redis"
);
}

#[test]
fn test_container_display_name_falls_back_to_hostname_then_id() {
// No name from inspect: hostname is the next best thing.
assert_eq!(
container_display_name(None, Some("web-01"), "0f3b46ca0c16aaaa"),
"web-01"
);

// Neither available: short ID keeps the output usable.
assert_eq!(
container_display_name(None, None, "0f3b46ca0c16aaaa"),
"0f3b46ca0c16"
);
assert_eq!(
container_display_name(Some("/"), Some(""), "0f3b46ca0c16aaaa"),
"0f3b46ca0c16"
);
}

#[actix_rt::test]
async fn test_docker_client_creation() {
// This test requires Docker daemon running
Expand Down
33 changes: 33 additions & 0 deletions src/ip_ban/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,39 @@ impl IpBanEngine {
Ok(false)
}

/// Release a ban ahead of its expiry.
///
/// Returns `false` when the address has no active block, so callers can
/// answer 404 rather than pretending something was undone.
pub async fn unban_ip(&self, ip_address: &str) -> Result<bool> {
let Some(offense) = active_block_for_ip(&self.pool, ip_address)? else {
return Ok(false);
};

#[cfg(target_os = "linux")]
self.with_firewall_backend(|backend| backend.unblock_ip(&offense.ip_address))?;

mark_released(&self.pool, &offense.id)?;
let alert = create_alert(
&self.pool,
Alert::new(
AlertType::SystemEvent,
AlertSeverity::Info,
format!("Released IP ban for {}", offense.ip_address),
)
.with_metadata(
AlertMetadata::default()
.with_source("ip_ban")
.with_reason(format!("Manually released ban for {}", offense.ip_address)),
),
)
.await?;
self.notify_action_alert(&alert, "STACKDOG_NOTIFY_IP_BAN_ACTIONS", "ip ban release")
.await;

Ok(true)
}

pub async fn unban_expired(&self) -> Result<usize> {
let now = Utc::now();
let expired = expired_blocks(&self.pool, now)?;
Expand Down
Loading
Loading