mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
Compare commits
3 Commits
301d6ac1b8
...
c2c4ed5b23
| Author | SHA1 | Date | |
|---|---|---|---|
| c2c4ed5b23 | |||
| 965480fb9e | |||
| 6ca57dbe10 |
@ -1 +1 @@
|
||||
Subproject commit 00d347c5eae3ed32f595b0a3553601f16bedfa7e
|
||||
Subproject commit 9f1621ca8f2c9fe4e3a5e315abbc0b6c5f16498d
|
||||
@ -11,6 +11,7 @@ use tokio::sync::{broadcast, oneshot};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::domain::data_plane::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage};
|
||||
use crate::interface::port::drop_stats::DropStatsPort;
|
||||
|
||||
pub struct DropMonitor {
|
||||
broadcast_tx: broadcast::Sender<DropEventMessage>,
|
||||
@ -30,18 +31,13 @@ impl DropMonitor {
|
||||
self.broadcast_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn get_counters(&self) -> DropCounters {
|
||||
self.counters.snapshot()
|
||||
}
|
||||
|
||||
/// Record a userspace drop decision (XSK worker's DNS filter) by the
|
||||
/// per-reason counter. Callers at this layer haven't parsed src/dst yet,
|
||||
/// so no broadcast event is emitted — `/api/stats/drops` stays correct,
|
||||
/// `/ws/drops` simply does not surface the individual packet. Parse the
|
||||
/// packet upstream if you need a structured event.
|
||||
pub fn record_userspace_drop_count_only(&self, reason: u8) {
|
||||
self.counters.total.fetch_add(1, Ordering::Relaxed);
|
||||
let bucket = match reason {
|
||||
fn bucket_for(&self, reason: u8) -> Option<&std::sync::atomic::AtomicU64> {
|
||||
match reason {
|
||||
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
|
||||
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
|
||||
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
|
||||
@ -51,28 +47,22 @@ impl DropMonitor {
|
||||
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
|
||||
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(counter) = bucket {
|
||||
}
|
||||
}
|
||||
|
||||
fn record_drop(&self, reason: u8) {
|
||||
self.counters.total.fetch_add(1, Ordering::Relaxed);
|
||||
if let Some(counter) = self.bucket_for(reason) {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_userspace_drop_count_only(&self, reason: u8) {
|
||||
self.record_drop(reason);
|
||||
}
|
||||
|
||||
fn process_event(&self, raw: &RawDropEvent) {
|
||||
self.counters.total.fetch_add(1, Ordering::Relaxed);
|
||||
let bucket = match raw.reason {
|
||||
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
|
||||
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
|
||||
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
|
||||
DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp),
|
||||
DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns),
|
||||
DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter),
|
||||
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
|
||||
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(counter) = bucket {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.record_drop(raw.reason);
|
||||
|
||||
let reason_str = reason_to_str(raw.reason);
|
||||
|
||||
@ -94,6 +84,12 @@ impl DropMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
impl DropStatsPort for DropMonitor {
|
||||
fn get_counters(&self) -> DropCounters {
|
||||
self.counters.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_ips(raw: &RawDropEvent) -> (String, String) {
|
||||
match raw.ip_version {
|
||||
4 => {
|
||||
|
||||
@ -11,6 +11,7 @@ use parking_lot::RwLock;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_address::NativeConvert;
|
||||
use crate::interface::port::protocol_filter::ProtocolFilterPort;
|
||||
|
||||
pub struct ProtocolFilter {
|
||||
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
|
||||
@ -176,6 +177,116 @@ impl ProtocolFilter {
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolFilterPort for ProtocolFilter {
|
||||
fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
|
||||
self.get_ipv4_http_service()
|
||||
}
|
||||
|
||||
fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
|
||||
self.get_ipv6_http_service()
|
||||
}
|
||||
|
||||
fn add_ipv4_http_service(&self, addr: SocketAddrV4, m: Vec<HttpMethod>) -> Result<(), Error> {
|
||||
self.add_ipv4_http_service(addr, m)
|
||||
}
|
||||
|
||||
fn add_ipv6_http_service(&self, addr: SocketAddrV6, m: Vec<HttpMethod>) -> Result<(), Error> {
|
||||
self.add_ipv6_http_service(addr, m)
|
||||
}
|
||||
|
||||
fn remove_ipv4_http_service(&self, addr: SocketAddrV4, m: Vec<HttpMethod>) -> Result<(), Error> {
|
||||
self.remove_ipv4_http_service(addr, m)
|
||||
}
|
||||
|
||||
fn remove_ipv6_http_service(&self, addr: SocketAddrV6, m: Vec<HttpMethod>) -> Result<(), Error> {
|
||||
self.remove_ipv6_http_service(addr, m)
|
||||
}
|
||||
|
||||
fn is_ssh_white_list_enable(&self) -> bool {
|
||||
self.is_ssh_white_list_enable()
|
||||
}
|
||||
|
||||
fn enable_ssh_white_list(&self) -> Result<(), Error> {
|
||||
self.enable_ssh_white_list()
|
||||
}
|
||||
|
||||
fn disable_ssh_white_list(&self) -> Result<(), Error> {
|
||||
self.disable_ssh_white_list()
|
||||
}
|
||||
|
||||
fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
|
||||
self.get_ipv4_ssh_service()
|
||||
}
|
||||
|
||||
fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
|
||||
self.get_ipv6_ssh_service()
|
||||
}
|
||||
|
||||
fn add_ipv4_ssh_service(&self, addr: SocketAddrV4) -> Result<(), Error> {
|
||||
self.add_ipv4_ssh_service(addr)
|
||||
}
|
||||
|
||||
fn add_ipv6_ssh_service(&self, addr: SocketAddrV6) -> Result<(), Error> {
|
||||
self.add_ipv6_ssh_service(addr)
|
||||
}
|
||||
|
||||
fn remove_ipv4_ssh_service(&self, addr: SocketAddrV4) -> Result<(), Error> {
|
||||
self.remove_ipv4_ssh_service(addr)
|
||||
}
|
||||
|
||||
fn remove_ipv6_ssh_service(&self, addr: SocketAddrV6) -> Result<(), Error> {
|
||||
self.remove_ipv6_ssh_service(addr)
|
||||
}
|
||||
|
||||
fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
|
||||
self.get_ipv4_ssh_white_list()
|
||||
}
|
||||
|
||||
fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
|
||||
self.get_ipv6_ssh_white_list()
|
||||
}
|
||||
|
||||
fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.add_ipv4_ssh_white_list(ip)
|
||||
}
|
||||
|
||||
fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.add_ipv6_ssh_white_list(ip)
|
||||
}
|
||||
|
||||
fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.remove_ipv4_ssh_white_list(ip)
|
||||
}
|
||||
|
||||
fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.remove_ipv6_ssh_white_list(ip)
|
||||
}
|
||||
|
||||
fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr> {
|
||||
self.get_ipv4_ssh_black_list()
|
||||
}
|
||||
|
||||
fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr> {
|
||||
self.get_ipv6_ssh_black_list()
|
||||
}
|
||||
|
||||
fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.add_ipv4_ssh_black_list(ip)
|
||||
}
|
||||
|
||||
fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.add_ipv6_ssh_black_list(ip)
|
||||
}
|
||||
|
||||
fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.remove_ipv4_ssh_black_list(ip)
|
||||
}
|
||||
|
||||
fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.remove_ipv6_ssh_black_list(ip)
|
||||
}
|
||||
}
|
||||
|
||||
struct WhiteListControl {
|
||||
map: Option<AyaArray<MapData, PlaceHolder>>,
|
||||
}
|
||||
|
||||
@ -123,7 +123,6 @@ impl XskManager {
|
||||
network.clone(),
|
||||
queue_id,
|
||||
&network.ingress_ifname,
|
||||
&network.egress_ifname,
|
||||
Direction::Ingress,
|
||||
sink.clone(),
|
||||
dns_filter.clone(),
|
||||
@ -134,7 +133,6 @@ impl XskManager {
|
||||
network.clone(),
|
||||
queue_id,
|
||||
&network.egress_ifname,
|
||||
&network.ingress_ifname,
|
||||
Direction::Egress,
|
||||
sink,
|
||||
None,
|
||||
@ -194,7 +192,6 @@ impl XskPair {
|
||||
config: EbpfConfig,
|
||||
queue_id: u32,
|
||||
rx_ifname: &str,
|
||||
_tx_ifname: &str,
|
||||
direction: Direction,
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
|
||||
|
||||
@ -5,6 +5,7 @@ use serde::Deserialize;
|
||||
use crate::adapter::http::response::ok_or_error;
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::core::identity::jwt::JwtService;
|
||||
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::domain::identity::password;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
@ -118,10 +119,10 @@ async fn login(body: web::Json<LoginRequest>, db: web::Data<Repo>, jwt: web::Dat
|
||||
let permissions = db.list_user_permissions(id).unwrap_or_default();
|
||||
|
||||
let groups = db.list_groups_for_user(id).unwrap_or_default();
|
||||
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") {
|
||||
"admin".to_string()
|
||||
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN.to_string()
|
||||
} else {
|
||||
"viewer".to_string()
|
||||
ROLE_VIEWER.to_string()
|
||||
};
|
||||
|
||||
match jwt.create_token(id, &username, &role, permissions) {
|
||||
@ -146,12 +147,12 @@ async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::D
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if reg.role != "admin" && reg.role != "viewer" {
|
||||
if reg.role != ROLE_ADMIN && reg.role != ROLE_VIEWER {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
|
||||
}
|
||||
|
||||
// Only admins can create admin accounts
|
||||
if reg.role == "admin" && auth.role != "admin" {
|
||||
if reg.role == ROLE_ADMIN && auth.role != ROLE_ADMIN {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Only administrators can create admin accounts"}));
|
||||
}
|
||||
@ -166,7 +167,11 @@ async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::D
|
||||
match db.insert_user(®.username, &hash, ®.role, false) {
|
||||
Ok(new_user_id) => {
|
||||
// Auto-assign to default group based on role
|
||||
let default_group_name = if reg.role == "admin" { "Administrator" } else { "Viewer" };
|
||||
let default_group_name = if reg.role == ROLE_ADMIN {
|
||||
GROUP_ADMIN
|
||||
} else {
|
||||
GROUP_VIEWER
|
||||
};
|
||||
if let Ok(groups) = db.list_user_groups()
|
||||
&& let Some((group_id, _, _, _, _)) =
|
||||
groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name)
|
||||
@ -186,10 +191,10 @@ async fn me(auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
.iter()
|
||||
.map(|(_id, name, _desc, _perms)| name.clone())
|
||||
.collect();
|
||||
let role = if group_names.iter().any(|n| n == "Administrator") {
|
||||
"admin"
|
||||
let role = if group_names.iter().any(|n| n == GROUP_ADMIN) {
|
||||
ROLE_ADMIN
|
||||
} else {
|
||||
"viewer"
|
||||
ROLE_VIEWER
|
||||
};
|
||||
let permissions = db.list_user_permissions(auth.sub).unwrap_or_default();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
@ -257,10 +262,10 @@ async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
.iter()
|
||||
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
|
||||
.collect();
|
||||
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
|
||||
"admin"
|
||||
let role = if user_groups.iter().any(|(_id, name)| name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN
|
||||
} else {
|
||||
"viewer"
|
||||
ROLE_VIEWER
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
@ -288,7 +293,7 @@ async fn delete_user(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo
|
||||
|
||||
// Protect the built-in admin account
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Cannot delete the built-in admin account"}));
|
||||
}
|
||||
@ -316,7 +321,7 @@ async fn update_role(
|
||||
}
|
||||
|
||||
let role = match body.get("role").and_then(|v| v.as_str()) {
|
||||
Some(r) if r == "admin" || r == "viewer" => r,
|
||||
Some(r) if r == ROLE_ADMIN || r == ROLE_VIEWER => r,
|
||||
_ => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
|
||||
}
|
||||
@ -472,7 +477,7 @@ async fn update_group(
|
||||
let existing = match db.get_user_group(group_id) {
|
||||
Ok(Some(g)) => {
|
||||
// Protect built-in groups
|
||||
if g.1 == "Administrator" || g.1 == "Viewer" {
|
||||
if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
|
||||
}
|
||||
g
|
||||
@ -508,7 +513,7 @@ async fn delete_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Rep
|
||||
|
||||
// Protect built-in groups
|
||||
match db.get_user_group(group_id) {
|
||||
Ok(Some(g)) if g.1 == "Administrator" || g.1 == "Viewer" => {
|
||||
Ok(Some(g)) if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER => {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
|
||||
}
|
||||
_ => {}
|
||||
@ -531,7 +536,7 @@ async fn set_user_groups(
|
||||
|
||||
// Protect the default admin account
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"}));
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@ use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use common::model::http_method::HttpMethod;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
|
||||
use crate::adapter::http::response::ok_or_error;
|
||||
use crate::core::data_plane::dns_filter_service::DnsFilterService;
|
||||
use crate::interface::port::protocol_filter::ProtocolFilterPort;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/filter")
|
||||
@ -16,7 +16,7 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DnsDomainsPayload {
|
||||
struct DnsDomainsRequest {
|
||||
domains: Vec<String>,
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ async fn get_dns_blacklist(service: web::Data<DnsFilterService>) -> impl Respond
|
||||
}
|
||||
|
||||
async fn add_dns_blacklist(
|
||||
payload: web::Json<DnsDomainsPayload>,
|
||||
payload: web::Json<DnsDomainsRequest>,
|
||||
service: web::Data<DnsFilterService>,
|
||||
) -> impl Responder {
|
||||
let domains = payload.into_inner().domains;
|
||||
@ -45,7 +45,7 @@ async fn add_dns_blacklist(
|
||||
}
|
||||
|
||||
async fn remove_dns_blacklist(
|
||||
payload: web::Json<DnsDomainsPayload>,
|
||||
payload: web::Json<DnsDomainsRequest>,
|
||||
service: web::Data<DnsFilterService>,
|
||||
) -> impl Responder {
|
||||
let domains = payload.into_inner().domains;
|
||||
@ -102,17 +102,17 @@ fn ssh_blacklist_scope() -> Scope {
|
||||
|
||||
// --- HTTP service handlers ---
|
||||
|
||||
async fn get_ipv4_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv4_http_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_http_service())
|
||||
}
|
||||
|
||||
async fn get_ipv6_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv6_http_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_http_service())
|
||||
}
|
||||
|
||||
async fn add_ipv4_http_service(
|
||||
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.add_ipv4_http_service(addr, methods))
|
||||
@ -120,7 +120,7 @@ async fn add_ipv4_http_service(
|
||||
|
||||
async fn add_ipv6_http_service(
|
||||
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.add_ipv6_http_service(addr, methods))
|
||||
@ -128,7 +128,7 @@ async fn add_ipv6_http_service(
|
||||
|
||||
async fn remove_ipv4_http_service(
|
||||
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.remove_ipv4_http_service(addr, methods))
|
||||
@ -136,7 +136,7 @@ async fn remove_ipv4_http_service(
|
||||
|
||||
async fn remove_ipv6_http_service(
|
||||
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.remove_ipv6_http_service(addr, methods))
|
||||
@ -144,108 +144,126 @@ async fn remove_ipv6_http_service(
|
||||
|
||||
// --- SSH service handlers ---
|
||||
|
||||
async fn get_ipv4_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv4_ssh_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_ssh_service())
|
||||
}
|
||||
|
||||
async fn get_ipv6_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv6_ssh_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_ssh_service())
|
||||
}
|
||||
|
||||
async fn add_ipv4_ssh_service(ip_addr: web::Json<SocketAddrV4>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn add_ipv4_ssh_service(
|
||||
ip_addr: web::Json<SocketAddrV4>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn add_ipv6_ssh_service(ip_addr: web::Json<SocketAddrV6>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn add_ipv6_ssh_service(
|
||||
ip_addr: web::Json<SocketAddrV6>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv4_ssh_service(
|
||||
ip_addr: web::Json<SocketAddrV4>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv6_ssh_service(
|
||||
ip_addr: web::Json<SocketAddrV6>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
// --- SSH whitelist handlers ---
|
||||
|
||||
async fn is_ssh_white_list_enable(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn is_ssh_white_list_enable(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.is_ssh_white_list_enable())
|
||||
}
|
||||
|
||||
async fn enable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn enable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
ok_or_error(service.enable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn disable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
ok_or_error(service.disable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn get_ipv4_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv4_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn get_ipv6_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv6_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn add_ipv4_ssh_white_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn add_ipv4_ssh_white_list(
|
||||
ip_addr: web::Json<Ipv4Addr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn add_ipv6_ssh_white_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn add_ipv6_ssh_white_list(
|
||||
ip_addr: web::Json<Ipv6Addr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv4_ssh_white_list(
|
||||
ip_addr: web::Json<Ipv4Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv6_ssh_white_list(
|
||||
ip_addr: web::Json<Ipv6Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
// --- SSH blacklist handlers ---
|
||||
|
||||
async fn get_ipv4_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv4_ssh_black_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_ssh_black_list())
|
||||
}
|
||||
|
||||
async fn get_ipv6_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn get_ipv6_ssh_black_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_ssh_black_list())
|
||||
}
|
||||
|
||||
async fn add_ipv4_ssh_black_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn add_ipv4_ssh_black_list(
|
||||
ip_addr: web::Json<Ipv4Addr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn add_ipv6_ssh_black_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
async fn add_ipv6_ssh_black_list(
|
||||
ip_addr: web::Json<Ipv6Addr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv4_ssh_black_list(
|
||||
ip_addr: web::Json<Ipv4Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv6_ssh_black_list(
|
||||
ip_addr: web::Json<Ipv6Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ use tokio::sync::broadcast;
|
||||
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::core::inference::engine::Engine;
|
||||
use crate::core::inference::inference::Inference;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
|
||||
use crate::domain::common::event::AuditEvent;
|
||||
use crate::domain::detection::model_adapter::ModelSourceState;
|
||||
|
||||
@ -36,8 +36,8 @@ use tokio::task;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::core::inference::inference::Inference;
|
||||
use crate::core::inference::model_loader::build_adapter;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::{
|
||||
AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR,
|
||||
@ -164,16 +164,16 @@ async fn upload(
|
||||
return e.into_response();
|
||||
}
|
||||
};
|
||||
let outcome = validate_and_promote(
|
||||
&staging_dir,
|
||||
&summary,
|
||||
inference.get_ref(),
|
||||
audit_tx.get_ref(),
|
||||
promote_lock.get_ref(),
|
||||
&claims.username,
|
||||
let outcome = validate_and_promote(&PromoteContext {
|
||||
staging_dir: &staging_dir,
|
||||
summary: &summary,
|
||||
inference: inference.get_ref(),
|
||||
audit_tx: audit_tx.get_ref(),
|
||||
promote_lock: promote_lock.get_ref(),
|
||||
actor_username: &claims.username,
|
||||
batch_size,
|
||||
onnx_load_timeout,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
// Always sweep staging — successful promote renames the files out,
|
||||
@ -512,16 +512,26 @@ pub fn looks_like_onnx(first_chunk: &[u8]) -> bool {
|
||||
/// logged but does not roll back the rename; the chain prefers a
|
||||
/// missing audit entry to a rolled-back promote that a downstream
|
||||
/// subscriber may already have reacted to.
|
||||
async fn validate_and_promote(
|
||||
staging_dir: &Path,
|
||||
summary: &UploadSummary,
|
||||
inference: &Inference,
|
||||
audit_tx: &broadcast::Sender<AuditEvent>,
|
||||
promote_lock: &PromoteGate,
|
||||
actor_username: &str,
|
||||
struct PromoteContext<'a> {
|
||||
staging_dir: &'a Path,
|
||||
summary: &'a UploadSummary,
|
||||
inference: &'a Inference,
|
||||
audit_tx: &'a broadcast::Sender<AuditEvent>,
|
||||
promote_lock: &'a PromoteGate,
|
||||
actor_username: &'a str,
|
||||
batch_size: usize,
|
||||
onnx_load_timeout: Duration,
|
||||
) -> Result<PromoteReport, PromoteError> {
|
||||
}
|
||||
|
||||
async fn validate_and_promote(ctx: &PromoteContext<'_>) -> Result<PromoteReport, PromoteError> {
|
||||
let staging_dir = ctx.staging_dir;
|
||||
let summary = ctx.summary;
|
||||
let inference = ctx.inference;
|
||||
let audit_tx = ctx.audit_tx;
|
||||
let promote_lock = ctx.promote_lock;
|
||||
let actor_username = ctx.actor_username;
|
||||
let batch_size = ctx.batch_size;
|
||||
let onnx_load_timeout = ctx.onnx_load_timeout;
|
||||
let staging_manifest = staging_dir.join(MANIFEST_FILENAME);
|
||||
|
||||
// Structural manifest validation. The full `build_adapter` pipeline
|
||||
|
||||
@ -11,6 +11,7 @@ use crate::adapter::persistence::Database;
|
||||
use crate::core::identity::setup_guard::SetupCompleteFlag;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::identity::auth::DEFAULT_ADMIN_USERNAME;
|
||||
use crate::domain::identity::password;
|
||||
use crate::infrastructure::secret_store::SecretStore;
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
@ -143,7 +144,7 @@ async fn complete_setup(
|
||||
match password::hash_password(&body.admin_password) {
|
||||
Ok(hash) => {
|
||||
// Find admin user and update password
|
||||
if let Ok(Some(user)) = db.find_user("admin") {
|
||||
if let Ok(Some(user)) = db.find_user(DEFAULT_ADMIN_USERNAME) {
|
||||
if let Err(e) = db.update_user_password(user.0, &hash) {
|
||||
log!(SystemError::SetupPasswordUpdateFailed(e));
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::http::response::{ok_json_or_error, ok_or_error};
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::core::response::engine::SoarEngine;
|
||||
use crate::core::response::playbook_service::PlaybookService;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent};
|
||||
use crate::domain::response::playbook_data::{CreateConditionInput, CreatePlaybookInput};
|
||||
|
||||
@ -36,6 +38,47 @@ struct CreateConditionRequest {
|
||||
value2: Option<String>,
|
||||
}
|
||||
|
||||
fn map_request_to_input(body: &CreatePlaybookRequest, fallback_cooldown: i64) -> CreatePlaybookInput {
|
||||
let actions = body
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let params_str = a
|
||||
.params
|
||||
.as_ref()
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
|
||||
.unwrap_or_else(|| "{}".into());
|
||||
(a.action_type.clone(), params_str)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let conditions = body
|
||||
.conditions
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
CreateConditionInput::new(
|
||||
c.condition_type.clone(),
|
||||
c.operator.clone(),
|
||||
c.value.clone(),
|
||||
c.value2.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
CreatePlaybookInput {
|
||||
name: body.name.clone(),
|
||||
trigger_event: body.trigger_event.clone(),
|
||||
condition_threshold: body.condition_threshold,
|
||||
condition_count: body.condition_count,
|
||||
condition_window_secs: body.condition_window_secs,
|
||||
cooldown_secs: body.cooldown_secs.unwrap_or(fallback_cooldown),
|
||||
actions,
|
||||
conditions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/soar")
|
||||
.route("/playbooks", web::get().to(list_playbooks))
|
||||
@ -53,100 +96,16 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn list_playbooks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
match svc.list_playbooks() {
|
||||
Ok(playbooks) => {
|
||||
let responses: Vec<serde_json::Value> = playbooks
|
||||
.into_iter()
|
||||
.map(|pb| {
|
||||
let actions: Vec<serde_json::Value> = pb
|
||||
.actions
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"id": a.id,
|
||||
"action_order": a.action_order,
|
||||
"action_type": a.action_type,
|
||||
"params": a.params,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let conditions: Vec<serde_json::Value> = pb
|
||||
.conditions
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"condition_type": c.condition_type,
|
||||
"operator": c.operator,
|
||||
"value": c.value,
|
||||
"value2": c.value2,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"id": pb.id,
|
||||
"name": pb.name,
|
||||
"enabled": pb.enabled,
|
||||
"trigger_event": pb.trigger_event,
|
||||
"condition_threshold": pb.condition_threshold,
|
||||
"condition_count": pb.condition_count,
|
||||
"condition_window_secs": pb.condition_window_secs,
|
||||
"cooldown_secs": pb.cooldown_secs,
|
||||
"actions": actions,
|
||||
"conditions": conditions,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(responses)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(svc.list_playbooks())
|
||||
}
|
||||
|
||||
async fn create_playbook(
|
||||
_auth: AuthClaims,
|
||||
svc: web::Data<PlaybookService>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
body: web::Json<CreatePlaybookRequest>,
|
||||
) -> HttpResponse {
|
||||
let actions: Vec<(String, String)> = body
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let params_str = a
|
||||
.params
|
||||
.as_ref()
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
|
||||
.unwrap_or_else(|| "{}".into());
|
||||
(a.action_type.clone(), params_str)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let conditions: Vec<CreateConditionInput> = body
|
||||
.conditions
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
CreateConditionInput::new(
|
||||
c.condition_type.clone(),
|
||||
c.operator.clone(),
|
||||
c.value.clone(),
|
||||
c.value2.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let input = CreatePlaybookInput {
|
||||
name: body.name.clone(),
|
||||
trigger_event: body.trigger_event.clone(),
|
||||
condition_threshold: body.condition_threshold,
|
||||
condition_count: body.condition_count,
|
||||
condition_window_secs: body.condition_window_secs,
|
||||
cooldown_secs: body.cooldown_secs.unwrap_or(300),
|
||||
actions,
|
||||
conditions,
|
||||
};
|
||||
|
||||
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
|
||||
match svc.create_playbook(&input) {
|
||||
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -156,50 +115,12 @@ async fn create_playbook(
|
||||
async fn update_playbook(
|
||||
_auth: AuthClaims,
|
||||
svc: web::Data<PlaybookService>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<CreatePlaybookRequest>,
|
||||
) -> HttpResponse {
|
||||
let id = path.into_inner();
|
||||
|
||||
let actions: Vec<(String, String)> = body
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let params_str = a
|
||||
.params
|
||||
.as_ref()
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
|
||||
.unwrap_or_else(|| "{}".into());
|
||||
(a.action_type.clone(), params_str)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let conditions: Vec<CreateConditionInput> = body
|
||||
.conditions
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
CreateConditionInput::new(
|
||||
c.condition_type.clone(),
|
||||
c.operator.clone(),
|
||||
c.value.clone(),
|
||||
c.value2.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let input = CreatePlaybookInput {
|
||||
name: body.name.clone(),
|
||||
trigger_event: body.trigger_event.clone(),
|
||||
condition_threshold: body.condition_threshold,
|
||||
condition_count: body.condition_count,
|
||||
condition_window_secs: body.condition_window_secs,
|
||||
cooldown_secs: body.cooldown_secs.unwrap_or(300),
|
||||
actions,
|
||||
conditions,
|
||||
};
|
||||
|
||||
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
|
||||
match svc.update_playbook(id, &input) {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
|
||||
@ -234,23 +155,7 @@ async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, pat
|
||||
}
|
||||
|
||||
async fn list_active_blocks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
match svc.list_active_blocks() {
|
||||
Ok(blocks) => {
|
||||
let responses: Vec<serde_json::Value> = blocks
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
serde_json::json!({
|
||||
"id": b.id,
|
||||
"source_ip": b.source_ip,
|
||||
"playbook_id": b.playbook_id,
|
||||
"expires_at": b.expires_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(responses)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(svc.list_active_blocks())
|
||||
}
|
||||
|
||||
async fn manual_unblock(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
|
||||
@ -258,25 +163,7 @@ async fn manual_unblock(_auth: AuthClaims, svc: web::Data<PlaybookService>, path
|
||||
}
|
||||
|
||||
async fn list_executions(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
match svc.list_executions(100) {
|
||||
Ok(executions) => {
|
||||
let responses: Vec<serde_json::Value> = executions
|
||||
.into_iter()
|
||||
.map(|ex| {
|
||||
serde_json::json!({
|
||||
"id": ex.id,
|
||||
"playbook_id": ex.playbook_id,
|
||||
"source_ip": ex.source_ip,
|
||||
"trigger_event": ex.trigger_event,
|
||||
"actions_executed": ex.actions_executed,
|
||||
"created_at": ex.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(responses)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(svc.list_executions(100))
|
||||
}
|
||||
|
||||
async fn list_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
use crate::interface::port::drop_stats::DropStatsPort;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/stats")
|
||||
@ -24,6 +24,6 @@ async fn get_summary(stats: web::Data<FlowStatistics>) -> impl Responder {
|
||||
HttpResponse::Ok().json(stats.get_summary())
|
||||
}
|
||||
|
||||
async fn get_drop_stats(monitor: web::Data<DropMonitor>) -> impl Responder {
|
||||
async fn get_drop_stats(monitor: web::Data<dyn DropStatsPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(monitor.get_counters())
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::core::common::config_service::ConfigService;
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::domain::common::config::constants::PERMISSION_SYSTEM_ADMIN;
|
||||
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
|
||||
use crate::infrastructure::logger::Logger;
|
||||
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
|
||||
@ -135,7 +136,7 @@ async fn update_config(
|
||||
}
|
||||
|
||||
async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
|
||||
if !auth.permissions.iter().any(|p| p == "system:admin") {
|
||||
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
|
||||
}
|
||||
if handle.trigger(ShutdownMode::Shutdown) {
|
||||
@ -146,7 +147,7 @@ async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl R
|
||||
}
|
||||
|
||||
async fn restart(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
|
||||
if !auth.permissions.iter().any(|p| p == "system:admin") {
|
||||
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
|
||||
}
|
||||
if handle.trigger(ShutdownMode::Restart) {
|
||||
|
||||
@ -106,7 +106,7 @@ impl Database {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_api_keys(&self) -> Result<Vec<(i64, String, String, String, Option<String>)>, Error> {
|
||||
pub fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM api_keys")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
|
||||
@ -18,6 +18,7 @@ use rusqlite::{self, Connection, params};
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::domain::common::log::misc::MiscLog;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER};
|
||||
|
||||
/// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`.
|
||||
/// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode).
|
||||
@ -139,11 +140,8 @@ impl Database {
|
||||
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
|
||||
.map_err(|_| DatabaseError::DatabaseNotReadable)?;
|
||||
// Attach a plaintext destination (empty key = no encryption)
|
||||
conn.execute_batch(&format!(
|
||||
"ATTACH DATABASE '{}' AS plaintext KEY '';",
|
||||
dest_path.replace('\'', "''"),
|
||||
))
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.execute("ATTACH DATABASE ?1 AS plaintext KEY '';", params![dest_path])
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.query_row("SELECT sqlcipher_export('plaintext')", [], |_| Ok(()))
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.execute_batch("DETACH DATABASE plaintext;")
|
||||
@ -158,12 +156,8 @@ impl Database {
|
||||
// Verify it's readable as plaintext
|
||||
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
|
||||
.map_err(|_| DatabaseError::SourceDatabaseNotReadable)?;
|
||||
conn.execute_batch(&format!(
|
||||
"ATTACH DATABASE '{}' AS encrypted KEY '{}';",
|
||||
dest_path.replace('\'', "''"),
|
||||
key.replace('\'', "''"),
|
||||
))
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.execute("ATTACH DATABASE ?1 AS encrypted KEY ?2;", params![dest_path, key])
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.execute_batch("DETACH DATABASE encrypted;")
|
||||
@ -388,15 +382,11 @@ impl Database {
|
||||
|
||||
conn_ref.execute(
|
||||
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
"Administrator",
|
||||
"Full system access with all permissions",
|
||||
&all_permissions
|
||||
],
|
||||
params![GROUP_ADMIN, "Full system access with all permissions", &all_permissions],
|
||||
)?;
|
||||
conn_ref.execute(
|
||||
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
|
||||
params!["Viewer", "Read-only access to all modules", &viewer_permissions],
|
||||
params![GROUP_VIEWER, "Read-only access to all modules", &viewer_permissions],
|
||||
)?;
|
||||
}
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let result = conn.query_row("SELECT value FROM app_secrets WHERE key = ?1", params![key], |row| {
|
||||
row.get(0)
|
||||
@ -38,7 +38,7 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_secret(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
fn set_app_secret(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)",
|
||||
|
||||
@ -1,9 +1,32 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::params;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::response::playbook_data::UpdatePlaybookInput;
|
||||
use crate::interface::port::soar::{PlaybookRow, SoarExecutionRow, SoarRepo};
|
||||
use crate::domain::response::playbook_data::{
|
||||
ActionView, ActiveBlockView, ConditionView, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView,
|
||||
UpdatePlaybookInput,
|
||||
};
|
||||
use crate::interface::port::soar::SoarRepo;
|
||||
|
||||
/// Intermediate row from the playbooks LEFT JOIN playbook_actions query.
|
||||
/// Private to this module; consumed only by `SoarRepo::list_playbooks`.
|
||||
struct PlaybookActionRow {
|
||||
pb_id: i64,
|
||||
name: String,
|
||||
enabled: bool,
|
||||
trigger_event: String,
|
||||
condition_threshold: Option<f64>,
|
||||
condition_count: Option<i64>,
|
||||
condition_window_secs: Option<i64>,
|
||||
cooldown_secs: i64,
|
||||
action_id: Option<i64>,
|
||||
action_order: Option<i64>,
|
||||
action_type: Option<String>,
|
||||
action_params: Option<String>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn insert_playbook(
|
||||
@ -39,26 +62,7 @@ impl Database {
|
||||
}
|
||||
|
||||
/// Load all playbooks with their actions in a single JOIN query (avoids N+1).
|
||||
/// Returns Vec of (playbook fields..., action fields...).
|
||||
pub fn list_playbooks_with_actions(
|
||||
&self,
|
||||
) -> Result<
|
||||
Vec<(
|
||||
i64,
|
||||
String,
|
||||
bool,
|
||||
String,
|
||||
Option<f64>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>,
|
||||
Error,
|
||||
> {
|
||||
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookActionRow>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.id, p.name, p.enabled, p.trigger_event, p.condition_threshold, \
|
||||
@ -69,20 +73,20 @@ impl Database {
|
||||
ORDER BY p.id, a.action_order",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, bool>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, Option<f64>>(4)?,
|
||||
row.get::<_, Option<i64>>(5)?,
|
||||
row.get::<_, Option<i64>>(6)?,
|
||||
row.get::<_, i64>(7)?,
|
||||
row.get::<_, Option<i64>>(8)?,
|
||||
row.get::<_, Option<i64>>(9)?,
|
||||
row.get::<_, Option<String>>(10)?,
|
||||
row.get::<_, Option<String>>(11)?,
|
||||
))
|
||||
Ok(PlaybookActionRow {
|
||||
pb_id: row.get::<_, i64>(0)?,
|
||||
name: row.get::<_, String>(1)?,
|
||||
enabled: row.get::<_, bool>(2)?,
|
||||
trigger_event: row.get::<_, String>(3)?,
|
||||
condition_threshold: row.get::<_, Option<f64>>(4)?,
|
||||
condition_count: row.get::<_, Option<i64>>(5)?,
|
||||
condition_window_secs: row.get::<_, Option<i64>>(6)?,
|
||||
cooldown_secs: row.get::<_, i64>(7)?,
|
||||
action_id: row.get::<_, Option<i64>>(8)?,
|
||||
action_order: row.get::<_, Option<i64>>(9)?,
|
||||
action_type: row.get::<_, Option<String>>(10)?,
|
||||
action_params: row.get::<_, Option<String>>(11)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
@ -123,9 +127,7 @@ impl Database {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_all_playbook_conditions(
|
||||
&self,
|
||||
) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error> {
|
||||
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, ConditionView)>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, playbook_id, condition_type, operator, value, value2 \
|
||||
@ -133,12 +135,14 @@ impl Database {
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, Option<String>>(5)?,
|
||||
ConditionView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
condition_type: row.get::<_, String>(2)?,
|
||||
operator: row.get::<_, String>(3)?,
|
||||
value: row.get::<_, String>(4)?,
|
||||
value2: row.get::<_, Option<String>>(5)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
@ -163,23 +167,21 @@ impl Database {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_soar_executions(
|
||||
&self,
|
||||
limit: i64,
|
||||
) -> Result<Vec<(i64, i64, Option<String>, String, String, String)>, Error> {
|
||||
pub fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, playbook_id, source_ip, trigger_event, actions_executed, executed_at FROM soar_executions ORDER BY executed_at DESC LIMIT ?1"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
))
|
||||
let actions_str: String = row.get(4)?;
|
||||
Ok(ExecutionView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
playbook_id: row.get::<_, i64>(1)?,
|
||||
source_ip: row.get::<_, Option<String>>(2)?,
|
||||
trigger_event: row.get::<_, String>(3)?,
|
||||
actions_executed: serde_json::from_str(&actions_str).unwrap_or(Value::Null),
|
||||
created_at: row.get::<_, String>(5)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
@ -236,8 +238,72 @@ impl Database {
|
||||
}
|
||||
|
||||
impl SoarRepo for Database {
|
||||
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error> {
|
||||
self.list_playbooks_with_actions()
|
||||
fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error> {
|
||||
let rows = self.list_playbooks_with_actions()?;
|
||||
let mut result: Vec<PlaybookView> = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let pb = if let Some(last) = result.last_mut() {
|
||||
if last.id == row.pb_id {
|
||||
last
|
||||
} else {
|
||||
result.push(PlaybookView {
|
||||
id: row.pb_id,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
trigger_event: row.trigger_event,
|
||||
condition_threshold: row.condition_threshold,
|
||||
condition_count: row.condition_count,
|
||||
condition_window_secs: row.condition_window_secs,
|
||||
cooldown_secs: row.cooldown_secs,
|
||||
actions: Vec::new(),
|
||||
conditions: Vec::new(),
|
||||
});
|
||||
// SAFETY: just pushed above, Vec cannot be empty
|
||||
result.last_mut().unwrap_or_else(|| unreachable!())
|
||||
}
|
||||
} else {
|
||||
result.push(PlaybookView {
|
||||
id: row.pb_id,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
trigger_event: row.trigger_event,
|
||||
condition_threshold: row.condition_threshold,
|
||||
condition_count: row.condition_count,
|
||||
condition_window_secs: row.condition_window_secs,
|
||||
cooldown_secs: row.cooldown_secs,
|
||||
actions: Vec::new(),
|
||||
conditions: Vec::new(),
|
||||
});
|
||||
// SAFETY: just pushed above, Vec cannot be empty
|
||||
result.last_mut().unwrap_or_else(|| unreachable!())
|
||||
};
|
||||
|
||||
if let (Some(aid), Some(order), Some(atype), Some(params_str)) =
|
||||
(row.action_id, row.action_order, row.action_type, row.action_params)
|
||||
{
|
||||
pb.actions.push(ActionView {
|
||||
id: aid,
|
||||
action_order: order,
|
||||
action_type: atype,
|
||||
params: serde_json::from_str(¶ms_str).unwrap_or(Value::Null),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load conditions and attach to playbooks
|
||||
let cond_rows = self.list_all_playbook_conditions()?;
|
||||
let mut cond_map: HashMap<i64, Vec<ConditionView>> = HashMap::new();
|
||||
for (pb_id, cond) in cond_rows {
|
||||
cond_map.entry(pb_id).or_default().push(cond);
|
||||
}
|
||||
for pb in &mut result {
|
||||
if let Some(conds) = cond_map.remove(&pb.id) {
|
||||
pb.conditions = conds;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
|
||||
@ -252,23 +318,19 @@ impl SoarRepo for Database {
|
||||
self.seed_default_playbooks()
|
||||
}
|
||||
|
||||
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error> {
|
||||
self.list_all_playbook_conditions()
|
||||
}
|
||||
|
||||
fn count_active_soar_blocks(&self) -> Result<u32, Error> {
|
||||
self.count_active_soar_blocks()
|
||||
}
|
||||
|
||||
fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error> {
|
||||
fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.list_active_soar_blocks()
|
||||
}
|
||||
|
||||
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error> {
|
||||
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
|
||||
self.find_soar_block_by_id(id)
|
||||
}
|
||||
|
||||
fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
|
||||
fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.list_expired_soar_blocks()
|
||||
}
|
||||
|
||||
@ -280,7 +342,7 @@ impl SoarRepo for Database {
|
||||
self.insert_pending_unblock(source_ip)
|
||||
}
|
||||
|
||||
fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
|
||||
fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
|
||||
self.list_pending_unblocks()
|
||||
}
|
||||
|
||||
@ -302,18 +364,13 @@ impl SoarRepo for Database {
|
||||
self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json)
|
||||
}
|
||||
|
||||
fn list_soar_executions(&self, limit: i64) -> Result<Vec<SoarExecutionRow>, Error> {
|
||||
fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
|
||||
self.list_soar_executions(limit)
|
||||
}
|
||||
|
||||
fn insert_playbook_atomic(
|
||||
&self,
|
||||
name: &str,
|
||||
trigger_event: &str,
|
||||
threshold: Option<f64>,
|
||||
count: Option<i64>,
|
||||
window: Option<i64>,
|
||||
cooldown: i64,
|
||||
input: &CreatePlaybookInput,
|
||||
actions: &[(i64, String, String)],
|
||||
conditions: &[(String, String, String, Option<String>)],
|
||||
) -> Result<i64, Error> {
|
||||
@ -321,7 +378,7 @@ impl SoarRepo for Database {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![name, trigger_event, threshold, count, window, cooldown],
|
||||
params![input.name, input.trigger_event, input.condition_threshold, input.condition_count, input.condition_window_secs, input.cooldown_secs],
|
||||
)?;
|
||||
let playbook_id = tx.last_insert_rowid();
|
||||
for (action_order, action_type, params_json) in actions {
|
||||
@ -396,15 +453,24 @@ mod tests {
|
||||
use crate::interface::port::soar::SoarRepo;
|
||||
|
||||
let db = test_db();
|
||||
use crate::domain::response::playbook_data::CreatePlaybookInput;
|
||||
|
||||
let input = CreatePlaybookInput {
|
||||
name: "atom_pb".to_string(),
|
||||
trigger_event: "threat".to_string(),
|
||||
condition_threshold: Some(0.8),
|
||||
condition_count: None,
|
||||
condition_window_secs: None,
|
||||
cooldown_secs: 300,
|
||||
actions: vec![("block_ip".to_string(), "{}".to_string())],
|
||||
conditions: vec![],
|
||||
};
|
||||
let actions = vec![(1i64, "block_ip".to_string(), "{}".to_string())];
|
||||
let conditions = vec![("threshold".to_string(), ">=".to_string(), "0.8".to_string(), None)];
|
||||
let id = db
|
||||
.insert_playbook_atomic("atom_pb", "threat", Some(0.8), None, None, 300, &actions, &conditions)
|
||||
.unwrap();
|
||||
let id = db.insert_playbook_atomic(&input, &actions, &conditions).unwrap();
|
||||
assert!(id > 0);
|
||||
let loaded = db.list_playbooks_with_actions().unwrap();
|
||||
let loaded = db.list_playbooks().unwrap();
|
||||
assert!(!loaded.is_empty());
|
||||
let cond_rows = db.list_all_playbook_conditions().unwrap();
|
||||
assert_eq!(cond_rows.len(), 1);
|
||||
assert_eq!(loaded[0].conditions.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ use rusqlite::params;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::response::playbook_data::{ActiveBlockView, PendingUnblock};
|
||||
use crate::interface::port::db_admin::DbAdminRepo;
|
||||
|
||||
impl Database {
|
||||
@ -28,13 +29,18 @@ impl Database {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
|
||||
pub fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, source_ip, playbook_id FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL",
|
||||
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
|
||||
Ok(ActiveBlockView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
source_ip: row.get::<_, String>(1)?,
|
||||
playbook_id: row.get::<_, i64>(2)?,
|
||||
expires_at: row.get::<_, String>(3)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
@ -43,18 +49,18 @@ impl Database {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get a single SOAR block rule by ID, returning (id, source_ip, playbook_id, expires_at).
|
||||
pub fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error> {
|
||||
/// Get a single SOAR block rule by ID.
|
||||
pub fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1")?;
|
||||
let mut rows = stmt.query_map(params![id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
Ok(ActiveBlockView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
source_ip: row.get::<_, String>(1)?,
|
||||
playbook_id: row.get::<_, i64>(2)?,
|
||||
expires_at: row.get::<_, String>(3)?,
|
||||
})
|
||||
})?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
@ -71,18 +77,18 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error> {
|
||||
pub fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE unblocked_at IS NULL AND expires_at > datetime('now')"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
Ok(ActiveBlockView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
source_ip: row.get::<_, String>(1)?,
|
||||
playbook_id: row.get::<_, i64>(2)?,
|
||||
expires_at: row.get::<_, String>(3)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
@ -100,11 +106,15 @@ impl Database {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
|
||||
pub fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare("SELECT id, source_ip, retry_count FROM pending_unblock ORDER BY id")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
|
||||
Ok(PendingUnblock {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
source_ip: row.get::<_, String>(1)?,
|
||||
retry_count: row.get::<_, i64>(2)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
@ -179,6 +189,7 @@ impl DbAdminRepo for Database {
|
||||
mod tests {
|
||||
use super::super::tests::test_db;
|
||||
use crate::interface::port::db_admin::DbAdminRepo;
|
||||
use crate::interface::port::soar::SoarRepo;
|
||||
|
||||
/// Happy path. Verifies `commit_soar_block_to_db` writes both
|
||||
/// `soar_block_rules` and `acl_rules` atomically.
|
||||
@ -197,9 +208,9 @@ mod tests {
|
||||
assert!(soar_block_id > 0);
|
||||
|
||||
// soar_block_rules has the row
|
||||
let active = db.list_active_soar_blocks().unwrap();
|
||||
let active = SoarRepo::list_active_soar_blocks(&db).unwrap();
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(active[0].1, "10.0.0.99");
|
||||
assert_eq!(active[0].source_ip, "10.0.0.99");
|
||||
|
||||
// acl_rules has the matching row
|
||||
let rules = db.list_acl_rules().unwrap();
|
||||
@ -224,6 +235,6 @@ mod tests {
|
||||
// acl_rules row gone
|
||||
assert!(db.list_acl_rules().unwrap().is_empty());
|
||||
// soar_block_rules row no longer in "active" view (unblocked_at is set)
|
||||
assert!(db.list_active_soar_blocks().unwrap().is_empty());
|
||||
assert!(SoarRepo::list_active_soar_blocks(&db).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,89 +1,13 @@
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use actix_ws::handle;
|
||||
|
||||
use crate::adapter::websocket::ws_bridge;
|
||||
use crate::core::inference::alert::MLAlert;
|
||||
use crate::domain::common::error::http::HttpError;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::common::log::http::HttpLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
|
||||
pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data<MLAlert>) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = ai.subscribe_to_alerts();
|
||||
|
||||
spawn(async move {
|
||||
handle_alert_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = ai.subscribe_to_alerts();
|
||||
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_alert_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<AlertMessage>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(alert) => {
|
||||
if !send_alert(&mut session, &alert).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_alert(session: &mut Session, alert: &AlertMessage) -> bool {
|
||||
match serde_json::to_string(alert) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use actix_ws::handle;
|
||||
|
||||
use super::ws_bridge;
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::domain::common::error::http::HttpError;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::common::log::http::HttpLog;
|
||||
use crate::domain::data_plane::drop_event::DropEventMessage;
|
||||
|
||||
pub async fn websocket_drops(
|
||||
req: HttpRequest,
|
||||
@ -18,76 +11,7 @@ pub async fn websocket_drops(
|
||||
monitor: web::Data<DropMonitor>,
|
||||
) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = monitor.subscribe();
|
||||
|
||||
spawn(async move {
|
||||
handle_drop_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = monitor.subscribe();
|
||||
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_drop_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<DropEventMessage>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(event) => {
|
||||
if !send_drop_event(&mut session, &event).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_drop_event(session: &mut Session, event: &DropEventMessage) -> bool {
|
||||
match serde_json::to_string(event) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,16 +16,13 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use actix_ws::handle;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
use crate::domain::common::error::http::HttpError;
|
||||
use super::ws_bridge;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::common::event::ThreatDetectedEvent;
|
||||
use crate::domain::common::log::http::HttpLog;
|
||||
|
||||
pub async fn websocket_fusion(
|
||||
req: HttpRequest,
|
||||
@ -33,76 +30,12 @@ pub async fn websocket_fusion(
|
||||
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
|
||||
) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = threat_tx.subscribe();
|
||||
|
||||
spawn(async move {
|
||||
handle_fusion_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = threat_tx.subscribe();
|
||||
spawn(ws_bridge::broadcast_loop(session, msg_stream, rx, envelope_with_ts));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_fusion_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<ThreatDetectedEvent>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(event) => {
|
||||
if !send_event(&mut session, &event).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap each event in `{ts, ...event_fields}`. The `ts` is a server-stamped
|
||||
/// unix-seconds value so the client can render "5s ago" without inferring
|
||||
/// the time from the audit chain. All declared fields of
|
||||
/// `ThreatDetectedEvent` flow through verbatim via the event's own
|
||||
/// `Serialize` derive — no field whitelist to drift out of date.
|
||||
async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool {
|
||||
fn envelope_with_ts(event: &ThreatDetectedEvent) -> Option<String> {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
@ -113,21 +46,18 @@ async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool
|
||||
map.insert("ts".to_string(), serde_json::Value::from(ts));
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
// The derived Serialize on a struct always produces an Object —
|
||||
// this branch only fires if the type changes shape in a future
|
||||
// refactor. Falling back to the raw value keeps the stream alive.
|
||||
Ok(other) => other,
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::to_string(&payload) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -170,14 +100,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn envelope_adds_ts_field_to_event_object() {
|
||||
// The `send_event` wire path inserts `ts` into the event's own
|
||||
// serde object; mirror that here without an actix session so the
|
||||
// wrapping logic stays covered when the orchestrator schema evolves.
|
||||
let event = sample_event();
|
||||
let mut value = serde_json::to_value(&event).expect("serialize event");
|
||||
let object = value.as_object_mut().expect("expected object shape");
|
||||
object.insert("ts".to_string(), serde_json::Value::from(1_700_000_000_u64));
|
||||
assert_eq!(value["ts"], 1_700_000_000_u64);
|
||||
let json = envelope_with_ts(&sample_event()).expect("should serialize");
|
||||
let value: serde_json::Value = serde_json::from_str(&json).expect("valid json");
|
||||
assert!(value["ts"].is_u64());
|
||||
assert_eq!(value["attack_type"], "brute_force");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,8 @@
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use actix_ws::handle;
|
||||
|
||||
use crate::domain::common::error::http::HttpError;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::common::log::http::HttpLog;
|
||||
use crate::domain::common::system::health::SystemHealthMetrics;
|
||||
use super::ws_bridge;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
|
||||
pub async fn websocket_system_health(
|
||||
@ -18,76 +11,7 @@ pub async fn websocket_system_health(
|
||||
health: web::Data<SystemHealth>,
|
||||
) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = health.subscribe_to_metrics();
|
||||
|
||||
spawn(async move {
|
||||
handle_health_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = health.subscribe_to_metrics();
|
||||
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_health_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<SystemHealthMetrics>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(metrics) => {
|
||||
if !send_metrics(&mut session, &metrics).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_metrics(session: &mut Session, metrics: &SystemHealthMetrics) -> bool {
|
||||
match serde_json::to_string(metrics) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,3 +4,4 @@ pub mod flow_websocket;
|
||||
pub mod fusion_websocket;
|
||||
pub mod health_websocket;
|
||||
pub mod routes;
|
||||
pub mod ws_bridge;
|
||||
|
||||
86
net-guardia/src/adapter/websocket/ws_bridge.rs
Normal file
86
net-guardia/src/adapter/websocket/ws_bridge.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use actix_ws::{Message, MessageStream, Session};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
use crate::domain::common::error::http::HttpError;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::common::log::http::HttpLog;
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
|
||||
match serde_json::to_string(value) {
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn broadcast_json<T: Serialize + Clone + Send + 'static>(
|
||||
session: Session,
|
||||
msg_stream: MessageStream,
|
||||
rx: broadcast::Receiver<T>,
|
||||
) {
|
||||
broadcast_loop(session, msg_stream, rx, |event| serialize_json(event)).await;
|
||||
}
|
||||
|
||||
pub async fn broadcast_loop<T: Clone + Send + 'static>(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut rx: broadcast::Receiver<T>,
|
||||
to_json: impl Fn(&T) -> Option<String>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(event) => {
|
||||
let Some(json) = to_json(&event) else {
|
||||
break;
|
||||
};
|
||||
if session.text(json).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
@ -14,7 +14,7 @@ use tokio::time::interval;
|
||||
|
||||
use super::alert::MLAlert;
|
||||
use super::drift_detector::DriftDetectorHandle;
|
||||
use super::inference::Inference;
|
||||
use super::runner::Inference;
|
||||
use super::traffic_logger::TrafficLogger;
|
||||
use crate::domain::data_plane::user_packet::UserPacket;
|
||||
use crate::domain::detection::aggregator::AttackAggregator;
|
||||
|
||||
@ -2,8 +2,8 @@ pub mod alert;
|
||||
pub mod config_loader;
|
||||
pub mod drift_detector;
|
||||
pub mod engine;
|
||||
pub mod inference;
|
||||
pub mod manifest;
|
||||
pub mod model_loader;
|
||||
pub mod model_watcher;
|
||||
pub mod runner;
|
||||
pub mod traffic_logger;
|
||||
|
||||
@ -14,8 +14,8 @@ use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use super::inference::Inference;
|
||||
use super::model_loader::build_adapter;
|
||||
use super::runner::Inference;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
|
||||
use crate::domain::detection::error::MLError;
|
||||
|
||||
@ -139,26 +139,7 @@ impl Inference {
|
||||
/// Dispatch on adapter variant.
|
||||
fn infer_batch_inner(&self, adapter: &MLModelAdapter, flows: &[FlowData]) -> Vec<DetectionResult> {
|
||||
match adapter {
|
||||
MLModelAdapter::MultiTask {
|
||||
ae,
|
||||
classifier,
|
||||
batch_size,
|
||||
n_ae,
|
||||
n_cls,
|
||||
labels,
|
||||
normal_idx,
|
||||
c2_idx,
|
||||
} => self.infer_multitask(
|
||||
ae,
|
||||
classifier,
|
||||
*batch_size,
|
||||
*n_ae,
|
||||
*n_cls,
|
||||
labels,
|
||||
*normal_idx,
|
||||
*c2_idx,
|
||||
flows,
|
||||
),
|
||||
MLModelAdapter::MultiTask { .. } => self.infer_multitask(adapter, flows),
|
||||
MLModelAdapter::AutoencoderOnly {
|
||||
model,
|
||||
batch_size,
|
||||
@ -177,18 +158,22 @@ impl Inference {
|
||||
/// MultiTask path. Runs the AE batch → computes per-flow MSE → feeds the
|
||||
/// classifier over (ae_features ++ ae_score) → fires on anomaly OR
|
||||
/// non-Normal classifier agreement OR elevated C2 head.
|
||||
fn infer_multitask(
|
||||
&self,
|
||||
ae: &RunnableModel,
|
||||
classifier: &RunnableModel,
|
||||
batch_size: usize,
|
||||
n_ae: usize,
|
||||
n_cls: usize,
|
||||
labels: &BTreeMap<String, LabelSpec>,
|
||||
normal_idx: Option<usize>,
|
||||
c2_idx: Option<usize>,
|
||||
flows: &[FlowData],
|
||||
) -> Vec<DetectionResult> {
|
||||
fn infer_multitask(&self, adapter: &MLModelAdapter, flows: &[FlowData]) -> Vec<DetectionResult> {
|
||||
let MLModelAdapter::MultiTask {
|
||||
ae,
|
||||
classifier,
|
||||
batch_size,
|
||||
n_ae,
|
||||
n_cls,
|
||||
labels,
|
||||
normal_idx,
|
||||
c2_idx,
|
||||
} = adapter
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let (batch_size, n_ae, n_cls) = (*batch_size, *n_ae, *n_cls);
|
||||
let (normal_idx, c2_idx) = (*normal_idx, *c2_idx);
|
||||
let n = flows.len();
|
||||
|
||||
let all_ae_features: Vec<Vec<f32>> = flows.iter().map(|f| self.preprocess_ae_features(f)).collect();
|
||||
@ -4,7 +4,6 @@ use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use macros::log;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
@ -44,17 +43,29 @@ pub struct SoarEngine {
|
||||
pub(super) secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
}
|
||||
|
||||
pub struct SoarEngineDeps {
|
||||
pub db: Arc<dyn AppRepo>,
|
||||
pub config: Arc<ArcSwap<AppConfig>>,
|
||||
pub access_control: Arc<dyn AccessControlPort>,
|
||||
pub alert_notifier: Option<Arc<dyn AlertNotifier>>,
|
||||
pub geoip: Option<Arc<dyn GeoLookup>>,
|
||||
pub rate_limit: Option<Arc<dyn RateLimitPort>>,
|
||||
pub enforce_level_cache: Arc<AtomicU8>,
|
||||
pub secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
}
|
||||
|
||||
impl SoarEngine {
|
||||
pub fn new(
|
||||
db: Arc<dyn AppRepo>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
access_control: Arc<dyn AccessControlPort>,
|
||||
alert_notifier: Option<Arc<dyn AlertNotifier>>,
|
||||
geoip: Option<Arc<dyn GeoLookup>>,
|
||||
rate_limit: Option<Arc<dyn RateLimitPort>>,
|
||||
enforce_level_cache: Arc<AtomicU8>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
) -> Result<Self, Error> {
|
||||
pub fn new(deps: SoarEngineDeps) -> Result<Self, Error> {
|
||||
let SoarEngineDeps {
|
||||
db,
|
||||
config,
|
||||
access_control,
|
||||
alert_notifier,
|
||||
geoip,
|
||||
rate_limit,
|
||||
enforce_level_cache,
|
||||
secrets,
|
||||
} = deps;
|
||||
let soar_cfg = config.load();
|
||||
let rate_limit_channel = soar_cfg.soar.rate_limit_cmd_channel_capacity;
|
||||
let freq_max_keys = soar_cfg.soar.frequency_max_tracked_keys;
|
||||
@ -77,92 +88,61 @@ impl SoarEngine {
|
||||
|
||||
/// Load playbooks and admin whitelist from DB into memory.
|
||||
pub fn reload_cache(&self) -> Result<(), Error> {
|
||||
// Load playbooks via single JOIN query (no N+1)
|
||||
let rows = self.db.list_playbooks_with_actions()?;
|
||||
let views = self.db.list_playbooks()?;
|
||||
let mut playbooks: Vec<Playbook> = Vec::new();
|
||||
|
||||
for (
|
||||
pb_id,
|
||||
name,
|
||||
enabled,
|
||||
trigger_event,
|
||||
threshold,
|
||||
_count,
|
||||
_window,
|
||||
cooldown,
|
||||
_action_id,
|
||||
action_order,
|
||||
action_type,
|
||||
action_params,
|
||||
) in rows
|
||||
{
|
||||
// Check if this row belongs to the same playbook as the last one
|
||||
let needs_new = playbooks.last().is_none_or(|last| last.id != pb_id);
|
||||
if needs_new {
|
||||
let _ = threshold; // persisted for schema stability; runtime gating comes from the Condition rows
|
||||
playbooks.push(Playbook {
|
||||
id: pb_id,
|
||||
name,
|
||||
enabled,
|
||||
trigger_event,
|
||||
cooldown_secs: cooldown,
|
||||
actions: Vec::new(),
|
||||
conditions: Vec::new(),
|
||||
for view in views {
|
||||
let mut actions: Vec<PlaybookAction> = Vec::new();
|
||||
for a in view.actions {
|
||||
actions.push(PlaybookAction {
|
||||
action_order: a.action_order,
|
||||
action_type: a.action_type,
|
||||
params: a.params,
|
||||
});
|
||||
}
|
||||
// Safe: we just pushed if empty, and last() was Some otherwise
|
||||
let Some(pb) = playbooks.last_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let (Some(order), Some(atype), Some(params_str)) = (action_order, action_type, action_params) {
|
||||
pb.actions.push(PlaybookAction {
|
||||
action_order: order,
|
||||
action_type: atype,
|
||||
params: serde_json::from_str(¶ms_str).unwrap_or_else(|e| {
|
||||
log!(SoarLog::PlaybookError(
|
||||
pb.name.clone(),
|
||||
format!("Malformed action params JSON: {e}"),
|
||||
));
|
||||
Value::Object(Default::default())
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load conditions and attach to playbooks
|
||||
let condition_rows = self.db.list_all_playbook_conditions()?;
|
||||
for (_cid, pb_id, ctype_str, operator, value, value2) in condition_rows {
|
||||
if let Ok(ctype) = ctype_str.parse::<ConditionType>()
|
||||
&& let Some(pb) = playbooks.iter_mut().find(|p| p.id == pb_id)
|
||||
{
|
||||
let mut conditions: Vec<PlaybookCondition> = Vec::new();
|
||||
for c in view.conditions {
|
||||
let Ok(ctype) = c.condition_type.parse::<ConditionType>() else {
|
||||
continue;
|
||||
};
|
||||
// Validate operator at load time to prevent silent fallback to defaults
|
||||
let valid = match ctype {
|
||||
ConditionType::Threshold => matches!(operator.as_str(), ">=" | "<="),
|
||||
ConditionType::Threshold => matches!(c.operator.as_str(), ">=" | "<="),
|
||||
ConditionType::SourceCountry | ConditionType::IpPattern => {
|
||||
matches!(operator.as_str(), "in" | "not_in")
|
||||
matches!(c.operator.as_str(), "in" | "not_in")
|
||||
}
|
||||
ConditionType::RepeatOffender => operator == "==",
|
||||
ConditionType::Frequency => operator == ">=",
|
||||
ConditionType::MultiSourceMin => operator == ">=",
|
||||
ConditionType::SingleSourceHigh => operator == ">=",
|
||||
ConditionType::FusedConfidenceAbove => matches!(operator.as_str(), ">=" | "<="),
|
||||
ConditionType::RepeatOffender => c.operator == "==",
|
||||
ConditionType::Frequency => c.operator == ">=",
|
||||
ConditionType::MultiSourceMin => c.operator == ">=",
|
||||
ConditionType::SingleSourceHigh => c.operator == ">=",
|
||||
ConditionType::FusedConfidenceAbove => matches!(c.operator.as_str(), ">=" | "<="),
|
||||
};
|
||||
if !valid {
|
||||
log!(SoarLog::InvalidConditionOperator(
|
||||
pb.name.clone(),
|
||||
ctype_str.clone(),
|
||||
operator.clone(),
|
||||
view.name.clone(),
|
||||
c.condition_type.clone(),
|
||||
c.operator.clone(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
pb.conditions.push(PlaybookCondition {
|
||||
conditions.push(PlaybookCondition {
|
||||
condition_type: ctype,
|
||||
operator,
|
||||
value,
|
||||
value2,
|
||||
operator: c.operator,
|
||||
value: c.value,
|
||||
value2: c.value2,
|
||||
});
|
||||
}
|
||||
|
||||
playbooks.push(Playbook {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
enabled: view.enabled,
|
||||
trigger_event: view.trigger_event,
|
||||
cooldown_secs: view.cooldown_secs,
|
||||
actions,
|
||||
conditions,
|
||||
});
|
||||
}
|
||||
|
||||
// Warn on playbooks whose trigger_event isn't in the canonical
|
||||
@ -270,10 +250,10 @@ impl SoarEngine {
|
||||
let active_blocks = self.db.list_active_soar_blocks()?;
|
||||
let count = active_blocks.len();
|
||||
|
||||
for (_id, source_ip, _playbook_id, _expires_at) in &active_blocks {
|
||||
for block in &active_blocks {
|
||||
// Preserve original error-swallowing behavior during recovery
|
||||
if let Err(e) = self.access_control.block_ip(source_ip) {
|
||||
log!(SoarLog::RecoveryFailed(source_ip.clone(), e.to_string()));
|
||||
if let Err(e) = self.access_control.block_ip(&block.source_ip) {
|
||||
log!(SoarLog::RecoveryFailed(block.source_ip.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,32 +277,32 @@ impl SoarEngine {
|
||||
}
|
||||
};
|
||||
|
||||
for (id, source_ip, retry_count) in pending {
|
||||
if retry_count >= self.matcher.config.load().soar.max_pending_unblock_retries {
|
||||
for pu in pending {
|
||||
if pu.retry_count >= self.matcher.config.load().soar.max_pending_unblock_retries {
|
||||
log!(SoarLog::EventHandlingFailed(format!(
|
||||
"Giving up on pending unblock for IP {} after {} retries",
|
||||
source_ip, retry_count
|
||||
pu.source_ip, pu.retry_count
|
||||
)));
|
||||
// Remove from queue to avoid infinite retries
|
||||
let _ = self.db.delete_pending_unblock(id);
|
||||
let _ = self.db.delete_pending_unblock(pu.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.access_control.unblock_ip(&source_ip) {
|
||||
match self.access_control.unblock_ip(&pu.source_ip) {
|
||||
Ok(()) => {
|
||||
let _ = self.db.delete_pending_unblock(id);
|
||||
let _ = self.db.delete_pending_unblock(pu.id);
|
||||
log!(SoarLog::EventHandlingFailed(format!(
|
||||
"Successfully unblocked orphan IP {} on retry #{}",
|
||||
source_ip,
|
||||
retry_count + 1
|
||||
pu.source_ip,
|
||||
pu.retry_count + 1
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.db.increment_pending_unblock_retry(id);
|
||||
let _ = self.db.increment_pending_unblock_retry(pu.id);
|
||||
log!(SoarLog::EventHandlingFailed(format!(
|
||||
"Retry #{} failed to unblock orphan IP {}: {}",
|
||||
retry_count + 1,
|
||||
source_ip,
|
||||
pu.retry_count + 1,
|
||||
pu.source_ip,
|
||||
e
|
||||
)));
|
||||
}
|
||||
@ -430,8 +410,17 @@ mod tests {
|
||||
AppConfig::seed_defaults(&*db).expect("seed config defaults");
|
||||
let cfg = AppConfig::from_settings(&*db).expect("load config");
|
||||
let config = Arc::new(ArcSwap::from_pointee(cfg));
|
||||
SoarEngine::new(db as Arc<dyn AppRepo>, config, ac, None, None, None, cache, None)
|
||||
.expect("Failed to create SOAR engine")
|
||||
SoarEngine::new(SoarEngineDeps {
|
||||
db: db as Arc<dyn AppRepo>,
|
||||
config,
|
||||
access_control: ac,
|
||||
alert_notifier: None,
|
||||
geoip: None,
|
||||
rate_limit: None,
|
||||
enforce_level_cache: cache,
|
||||
secrets: None,
|
||||
})
|
||||
.expect("Failed to create SOAR engine")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -524,16 +513,16 @@ mod tests {
|
||||
AppConfig::seed_defaults(&*db).expect("seed config defaults");
|
||||
let cfg = AppConfig::from_settings(&*db).expect("load config");
|
||||
let config = Arc::new(ArcSwap::from_pointee(cfg));
|
||||
let engine = SoarEngine::new(
|
||||
db as Arc<dyn AppRepo>,
|
||||
let engine = SoarEngine::new(SoarEngineDeps {
|
||||
db: db as Arc<dyn AppRepo>,
|
||||
config,
|
||||
mock.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cache,
|
||||
None,
|
||||
)
|
||||
access_control: mock.clone(),
|
||||
alert_notifier: None,
|
||||
geoip: None,
|
||||
rate_limit: None,
|
||||
enforce_level_cache: cache,
|
||||
secrets: None,
|
||||
})
|
||||
.expect("Failed to create engine");
|
||||
engine.recover_active_blocks().await.expect("Recovery should succeed");
|
||||
|
||||
@ -558,16 +547,16 @@ mod tests {
|
||||
AppConfig::seed_defaults(&*db).expect("seed config defaults");
|
||||
let cfg = AppConfig::from_settings(&*db).expect("load config");
|
||||
let config = Arc::new(ArcSwap::from_pointee(cfg));
|
||||
let engine = SoarEngine::new(
|
||||
db as Arc<dyn AppRepo>,
|
||||
let engine = SoarEngine::new(SoarEngineDeps {
|
||||
db: db as Arc<dyn AppRepo>,
|
||||
config,
|
||||
mock.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cache,
|
||||
None,
|
||||
)
|
||||
access_control: mock.clone(),
|
||||
alert_notifier: None,
|
||||
geoip: None,
|
||||
rate_limit: None,
|
||||
enforce_level_cache: cache,
|
||||
secrets: None,
|
||||
})
|
||||
.expect("Failed to create engine");
|
||||
|
||||
// Should not panic — errors are logged, not propagated
|
||||
@ -672,16 +661,16 @@ mod tests {
|
||||
AppConfig::seed_defaults(&*db).expect("seed config defaults");
|
||||
let cfg = AppConfig::from_settings(&*db).expect("load config");
|
||||
let config = Arc::new(ArcSwap::from_pointee(cfg));
|
||||
let engine = SoarEngine::new(
|
||||
db as Arc<dyn AppRepo>,
|
||||
let engine = SoarEngine::new(SoarEngineDeps {
|
||||
db: db as Arc<dyn AppRepo>,
|
||||
config,
|
||||
mock.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cache,
|
||||
None,
|
||||
)
|
||||
access_control: mock.clone(),
|
||||
alert_notifier: None,
|
||||
geoip: None,
|
||||
rate_limit: None,
|
||||
enforce_level_cache: cache,
|
||||
secrets: None,
|
||||
})
|
||||
.expect("Failed to create engine");
|
||||
|
||||
let event = ThreatDetectedEvent {
|
||||
@ -747,16 +736,16 @@ mod tests {
|
||||
AppConfig::seed_defaults(&*db).expect("seed config defaults");
|
||||
let cfg = AppConfig::from_settings(&*db).expect("load config");
|
||||
let config = Arc::new(ArcSwap::from_pointee(cfg));
|
||||
let engine = SoarEngine::new(
|
||||
db.clone() as Arc<dyn AppRepo>,
|
||||
let engine = SoarEngine::new(SoarEngineDeps {
|
||||
db: db.clone() as Arc<dyn AppRepo>,
|
||||
config,
|
||||
mock,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cache,
|
||||
None,
|
||||
)
|
||||
access_control: mock,
|
||||
alert_notifier: None,
|
||||
geoip: None,
|
||||
rate_limit: None,
|
||||
enforce_level_cache: cache,
|
||||
secrets: None,
|
||||
})
|
||||
.expect("Failed to create engine");
|
||||
|
||||
// Should have loaded default playbooks
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::core::response::engine::SoarEngine;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::response::error::SoarError;
|
||||
use crate::domain::response::playbook_data::{
|
||||
ActionView, ActiveBlockView, ConditionView, CreatePlaybookInput, ExecutionView, PlaybookView, UpdatePlaybookInput,
|
||||
ActiveBlockView, CreatePlaybookInput, ExecutionView, PlaybookView, UpdatePlaybookInput,
|
||||
};
|
||||
use crate::interface::port::access_control::AccessControlPort;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
@ -31,93 +28,7 @@ impl PlaybookService {
|
||||
}
|
||||
|
||||
pub fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error> {
|
||||
let rows = self.db.list_playbooks_with_actions()?;
|
||||
let mut result: Vec<PlaybookView> = Vec::new();
|
||||
|
||||
for (
|
||||
pb_id,
|
||||
name,
|
||||
enabled,
|
||||
trigger_event,
|
||||
threshold,
|
||||
count,
|
||||
window,
|
||||
cooldown,
|
||||
action_id,
|
||||
action_order,
|
||||
action_type,
|
||||
action_params,
|
||||
) in rows
|
||||
{
|
||||
// Find or create the playbook entry
|
||||
let pb = if let Some(last) = result.last_mut() {
|
||||
if last.id == pb_id {
|
||||
last
|
||||
} else {
|
||||
result.push(PlaybookView {
|
||||
id: pb_id,
|
||||
name,
|
||||
enabled,
|
||||
trigger_event,
|
||||
condition_threshold: threshold,
|
||||
condition_count: count,
|
||||
condition_window_secs: window,
|
||||
cooldown_secs: cooldown,
|
||||
actions: Vec::new(),
|
||||
conditions: Vec::new(),
|
||||
});
|
||||
// SAFETY: just pushed above, Vec cannot be empty
|
||||
result.last_mut().unwrap_or_else(|| unreachable!())
|
||||
}
|
||||
} else {
|
||||
result.push(PlaybookView {
|
||||
id: pb_id,
|
||||
name,
|
||||
enabled,
|
||||
trigger_event,
|
||||
condition_threshold: threshold,
|
||||
condition_count: count,
|
||||
condition_window_secs: window,
|
||||
cooldown_secs: cooldown,
|
||||
actions: Vec::new(),
|
||||
conditions: Vec::new(),
|
||||
});
|
||||
// SAFETY: just pushed above, Vec cannot be empty
|
||||
result.last_mut().unwrap_or_else(|| unreachable!())
|
||||
};
|
||||
|
||||
// Append action if present (LEFT JOIN may yield NULLs)
|
||||
if let (Some(aid), Some(order), Some(atype), Some(params_str)) =
|
||||
(action_id, action_order, action_type, action_params)
|
||||
{
|
||||
pb.actions.push(ActionView {
|
||||
id: aid,
|
||||
action_order: order,
|
||||
action_type: atype,
|
||||
params: serde_json::from_str(¶ms_str).unwrap_or(Value::Null),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load conditions and attach to playbooks
|
||||
let cond_rows = self.db.list_all_playbook_conditions()?;
|
||||
let mut cond_map: HashMap<i64, Vec<ConditionView>> = HashMap::new();
|
||||
for (cid, pb_id, ctype, operator, value, value2) in cond_rows {
|
||||
cond_map.entry(pb_id).or_default().push(ConditionView {
|
||||
id: cid,
|
||||
condition_type: ctype,
|
||||
operator,
|
||||
value,
|
||||
value2,
|
||||
});
|
||||
}
|
||||
for pb in &mut result {
|
||||
if let Some(conds) = cond_map.remove(&pb.id) {
|
||||
pb.conditions = conds;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
self.db.list_playbooks()
|
||||
}
|
||||
|
||||
pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result<i64, Error> {
|
||||
@ -140,16 +51,7 @@ impl PlaybookService {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let playbook_id = self.db.insert_playbook_atomic(
|
||||
&input.name,
|
||||
&input.trigger_event,
|
||||
input.condition_threshold,
|
||||
input.condition_count,
|
||||
input.condition_window_secs,
|
||||
input.cooldown_secs,
|
||||
&actions,
|
||||
&conditions,
|
||||
)?;
|
||||
let playbook_id = self.db.insert_playbook_atomic(input, &actions, &conditions)?;
|
||||
self.soar_engine.reload_cache()?;
|
||||
Ok(playbook_id)
|
||||
}
|
||||
@ -207,16 +109,7 @@ impl PlaybookService {
|
||||
}
|
||||
|
||||
pub fn list_active_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
let blocks = self.db.list_active_soar_blocks()?;
|
||||
Ok(blocks
|
||||
.into_iter()
|
||||
.map(|(id, ip, pb_id, expires)| ActiveBlockView {
|
||||
id,
|
||||
source_ip: ip,
|
||||
playbook_id: pb_id,
|
||||
expires_at: expires,
|
||||
})
|
||||
.collect())
|
||||
self.db.list_active_soar_blocks()
|
||||
}
|
||||
|
||||
/// Manually unblock an IP: remove from eBPF, atomically clear both DB
|
||||
@ -228,7 +121,7 @@ impl PlaybookService {
|
||||
.db
|
||||
.find_soar_block_by_id(id)?
|
||||
.ok_or_else(|| SoarError::UnblockRuleNotFound(id))?;
|
||||
let source_ip = &block.1;
|
||||
let source_ip = &block.source_ip;
|
||||
|
||||
// Remove from eBPF ACL
|
||||
self.access_control.unblock_ip(source_ip)?;
|
||||
@ -245,20 +138,7 @@ impl PlaybookService {
|
||||
}
|
||||
|
||||
pub fn list_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
|
||||
let rows = self.db.list_soar_executions(limit)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, pb_id, source_ip, trigger_event, actions, created_at)| ExecutionView {
|
||||
id,
|
||||
playbook_id: pb_id,
|
||||
source_ip,
|
||||
trigger_event,
|
||||
actions_executed: serde_json::from_str(&actions).unwrap_or(Value::Null),
|
||||
created_at,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
self.db.list_soar_executions(limit)
|
||||
}
|
||||
|
||||
pub fn list_whitelist(&self) -> Result<Vec<String>, Error> {
|
||||
|
||||
@ -65,34 +65,35 @@ impl TtlScheduler {
|
||||
let mut removed = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
|
||||
for (id, source_ip, _playbook_id) in &expired {
|
||||
for block in &expired {
|
||||
// Check if a manual ACL rule exists for this IP
|
||||
let has_manual_rule = self.db.has_manual_acl_rule(source_ip)?;
|
||||
let has_manual_rule = self.db.has_manual_acl_rule(&block.source_ip)?;
|
||||
|
||||
if has_manual_rule {
|
||||
// Only mark as unblocked in SOAR records, don't remove from eBPF
|
||||
self.db.mark_soar_block_unblocked(*id)?;
|
||||
self.db.mark_soar_block_unblocked(block.id)?;
|
||||
self.soar_engine.decrement_block_count();
|
||||
skipped += 1;
|
||||
log!(SoarLog::WhitelistSkipped(
|
||||
source_ip.clone(),
|
||||
block.source_ip.clone(),
|
||||
"TTL expired but manual ACL exists".to_string()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove from eBPF ACL via AccessControlPort
|
||||
if let Err(e) = self.access_control.unblock_ip(source_ip) {
|
||||
if let Err(e) = self.access_control.unblock_ip(&block.source_ip) {
|
||||
log!(SoarLog::RecoveryFailed(
|
||||
source_ip.clone(),
|
||||
block.source_ip.clone(),
|
||||
format!("unblock failed: {}", e)
|
||||
));
|
||||
}
|
||||
|
||||
// Atomically drop acl_rules entry AND mark soar_block_rules
|
||||
// unblocked in one transaction.
|
||||
let ip_version = ip_version_from_str(source_ip);
|
||||
self.db.commit_soar_unblock_to_db(*id, ip_version, source_ip)?;
|
||||
let ip_version = ip_version_from_str(&block.source_ip);
|
||||
self.db
|
||||
.commit_soar_unblock_to_db(block.id, ip_version, &block.source_ip)?;
|
||||
self.soar_engine.decrement_block_count();
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
@ -31,5 +31,8 @@ pub const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
|
||||
pub const FLOW_TRACE_FILE_MARKER: &str = "flow-trace-";
|
||||
pub const FLOW_TRACE_FILE_EXT: &str = ".csv";
|
||||
|
||||
// ── Permissions ───────────────────────────────────────────────────
|
||||
pub const PERMISSION_SYSTEM_ADMIN: &str = "system:admin";
|
||||
|
||||
// ── Event Channels ────────────────────────────────────────────────
|
||||
pub const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
@ -3,8 +3,6 @@ use std::str::FromStr;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::interface::communication::event::Event;
|
||||
|
||||
// -- Detection Source ---------------------------------------------------------
|
||||
|
||||
/// Identifies which detection subsystem produced a detection.
|
||||
@ -113,8 +111,6 @@ pub struct ThreatDetectedEvent {
|
||||
pub c2_score: f32,
|
||||
}
|
||||
|
||||
impl Event for ThreatDetectedEvent {}
|
||||
|
||||
/// Fired when the ML drift detector finds feature drift beyond 3 sigma.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriftDetectedEvent {
|
||||
@ -122,8 +118,6 @@ pub struct DriftDetectedEvent {
|
||||
pub max_deviation: f64,
|
||||
}
|
||||
|
||||
impl Event for DriftDetectedEvent {}
|
||||
|
||||
// -- Audit Events -------------------------------------------------------------
|
||||
|
||||
/// Fired for auditable actions (enforce mode changes, playbook CRUD, etc.).
|
||||
@ -137,5 +131,3 @@ pub struct AuditEvent {
|
||||
/// JSON string with action-specific details
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Event for AuditEvent {}
|
||||
|
||||
@ -18,9 +18,3 @@ pub enum SuricataHealth {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SuricataHealth {
|
||||
pub fn is_running(&self) -> bool {
|
||||
matches!(self, SuricataHealth::Running { .. })
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ use macros::log;
|
||||
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
|
||||
@ -62,12 +64,12 @@ impl BotnetDetector {
|
||||
set.last_alert = alert.clone();
|
||||
|
||||
if set.sources.len() >= self.threshold {
|
||||
if let Some(last) = set.last_alerted {
|
||||
if now.duration_since(last) < self.window {
|
||||
set.sources.clear();
|
||||
set.window_start = now;
|
||||
return None;
|
||||
}
|
||||
if let Some(last) = set.last_alerted
|
||||
&& now.duration_since(last) < self.window
|
||||
{
|
||||
set.sources.clear();
|
||||
set.window_start = now;
|
||||
return None;
|
||||
}
|
||||
Some(set.sources.len())
|
||||
} else {
|
||||
@ -86,7 +88,7 @@ impl BotnetDetector {
|
||||
// SOAR blocks source_ip, so we must NOT put the victim here.
|
||||
let event = DetectionEvent {
|
||||
source: DetectionSource::Correlation,
|
||||
attack_type: "threat_detected".to_string(),
|
||||
attack_type: CanonicalAttackType::BotActivity.as_str().to_string(),
|
||||
confidence: 0.85,
|
||||
source_ip: alert.src_ip.clone(),
|
||||
dest_ip: key.clone(),
|
||||
@ -110,25 +112,8 @@ impl BotnetDetector {
|
||||
None
|
||||
}
|
||||
|
||||
/// Remove expired entries. Returns number of entries removed.
|
||||
pub fn cleanup(&self) -> usize {
|
||||
let now = Instant::now();
|
||||
let window = self.window;
|
||||
let before = self.state.len();
|
||||
|
||||
self.state
|
||||
.retain(|_, set| now.duration_since(set.window_start) < window);
|
||||
|
||||
// Enforce max capacity by removing oldest entries if over limit
|
||||
if self.state.len() > self.max_tracked {
|
||||
let excess = self.state.len() - self.max_tracked;
|
||||
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
|
||||
for key in keys_to_remove {
|
||||
self.state.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
before.saturating_sub(self.state.len())
|
||||
capped_cleanup(&self.state, self.window, self.max_tracked, |s| s.window_start)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
net-guardia/src/domain/detection/correlation_cleanup.rs
Normal file
26
net-guardia/src/domain/detection/correlation_cleanup.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use std::hash::Hash;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
pub fn capped_cleanup<K: Eq + Hash + Clone, V>(
|
||||
map: &DashMap<K, V>,
|
||||
window: Duration,
|
||||
max_tracked: usize,
|
||||
window_start: impl Fn(&V) -> Instant,
|
||||
) -> usize {
|
||||
let now = Instant::now();
|
||||
let before = map.len();
|
||||
|
||||
map.retain(|_, v| now.duration_since(window_start(v)) < window);
|
||||
|
||||
if map.len() > max_tracked {
|
||||
let excess = map.len() - max_tracked;
|
||||
let keys_to_remove: Vec<K> = map.iter().take(excess).map(|e| e.key().clone()).collect();
|
||||
for key in keys_to_remove {
|
||||
map.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
before.saturating_sub(map.len())
|
||||
}
|
||||
@ -53,15 +53,7 @@ traceable! {
|
||||
#[error("Failed to spawn Suricata subprocess")]
|
||||
SpawnFailed => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Suricata subprocess exited: {reason}")]
|
||||
SubprocessExited { reason: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Failed to open eve.json stream at '{path}'")]
|
||||
EveOpenFailed { path: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Failed to parse eve.json line: {reason}")]
|
||||
EveParseFailed { reason: String } => tracing::Level::WARN,
|
||||
}
|
||||
}
|
||||
|
||||
@ -302,9 +302,9 @@ impl FlowTracker {
|
||||
entry.lock().add_packet(&packet, &self.limits);
|
||||
}
|
||||
|
||||
/// Get all active flows (clone, no drain). Used by WebSocket.
|
||||
pub fn get_flows(&self) -> Vec<FlowData> {
|
||||
self.active.iter().map(|(_, entry)| entry.lock().clone()).collect()
|
||||
/// Extract scalar stats from all active flows without cloning packet vectors.
|
||||
pub fn get_flow_stats<T>(&self, convert: impl Fn(&FlowData) -> T) -> Vec<T> {
|
||||
self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect()
|
||||
}
|
||||
|
||||
/// Get flows that received new packets since their last inference, and
|
||||
|
||||
@ -7,6 +7,8 @@ use macros::log;
|
||||
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
|
||||
@ -62,12 +64,12 @@ impl LateralMovementDetector {
|
||||
set.dests.insert(alert.dst_ip.clone());
|
||||
|
||||
if set.dests.len() >= self.threshold {
|
||||
if let Some(last) = set.last_alerted {
|
||||
if now.duration_since(last) < self.window {
|
||||
set.dests.clear();
|
||||
set.window_start = now;
|
||||
return None;
|
||||
}
|
||||
if let Some(last) = set.last_alerted
|
||||
&& now.duration_since(last) < self.window
|
||||
{
|
||||
set.dests.clear();
|
||||
set.window_start = now;
|
||||
return None;
|
||||
}
|
||||
Some(set.dests.len())
|
||||
} else {
|
||||
@ -84,7 +86,7 @@ impl LateralMovementDetector {
|
||||
|
||||
let event = DetectionEvent {
|
||||
source: DetectionSource::Correlation,
|
||||
attack_type: "threat_detected".to_string(),
|
||||
attack_type: CanonicalAttackType::LateralMovement.as_str().to_string(),
|
||||
confidence: 0.75,
|
||||
source_ip: key.clone(),
|
||||
dest_ip: alert.dst_ip.clone(),
|
||||
@ -108,24 +110,8 @@ impl LateralMovementDetector {
|
||||
None
|
||||
}
|
||||
|
||||
/// Remove expired entries. Returns number of entries removed.
|
||||
pub fn cleanup(&self) -> usize {
|
||||
let now = Instant::now();
|
||||
let window = self.window;
|
||||
let before = self.state.len();
|
||||
|
||||
self.state
|
||||
.retain(|_, set| now.duration_since(set.window_start) < window);
|
||||
|
||||
if self.state.len() > self.max_tracked {
|
||||
let excess = self.state.len() - self.max_tracked;
|
||||
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
|
||||
for key in keys_to_remove {
|
||||
self.state.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
before.saturating_sub(self.state.len())
|
||||
capped_cleanup(&self.state, self.window, self.max_tracked, |s| s.window_start)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ pub mod aggregator;
|
||||
pub mod attack_type;
|
||||
pub mod beaconing;
|
||||
pub mod botnet;
|
||||
pub mod correlation_cleanup;
|
||||
pub mod drift;
|
||||
pub mod drift_detector;
|
||||
pub mod error;
|
||||
|
||||
@ -6,6 +6,7 @@ use macros::log;
|
||||
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
|
||||
@ -61,12 +62,12 @@ impl ScanDetector {
|
||||
set.last_dst_ip = alert.dst_ip.clone();
|
||||
|
||||
if set.ports.len() >= self.threshold {
|
||||
if let Some(last) = set.last_alerted {
|
||||
if now.duration_since(last) < self.window {
|
||||
set.ports.clear();
|
||||
set.window_start = now;
|
||||
return None;
|
||||
}
|
||||
if let Some(last) = set.last_alerted
|
||||
&& now.duration_since(last) < self.window
|
||||
{
|
||||
set.ports.clear();
|
||||
set.window_start = now;
|
||||
return None;
|
||||
}
|
||||
Some((set.ports.len(), set.last_dst_ip.clone()))
|
||||
} else {
|
||||
@ -103,24 +104,8 @@ impl ScanDetector {
|
||||
None
|
||||
}
|
||||
|
||||
/// Remove expired entries. Returns number of entries removed.
|
||||
pub fn cleanup(&self) -> usize {
|
||||
let now = Instant::now();
|
||||
let window = self.window;
|
||||
let before = self.state.len();
|
||||
|
||||
self.state
|
||||
.retain(|_, set| now.duration_since(set.window_start) < window);
|
||||
|
||||
if self.state.len() > self.max_tracked {
|
||||
let excess = self.state.len() - self.max_tracked;
|
||||
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
|
||||
for key in keys_to_remove {
|
||||
self.state.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
before.saturating_sub(self.state.len())
|
||||
capped_cleanup(&self.state, self.window, self.max_tracked, |s| s.window_start)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const ROLE_ADMIN: &str = "admin";
|
||||
pub const ROLE_VIEWER: &str = "viewer";
|
||||
pub const GROUP_ADMIN: &str = "Administrator";
|
||||
pub const GROUP_VIEWER: &str = "Viewer";
|
||||
pub const DEFAULT_ADMIN_USERNAME: &str = "admin";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: i64,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
use serde::Serialize;
|
||||
|
||||
/// Input for updating a playbook row (without actions/conditions).
|
||||
pub struct UpdatePlaybookInput {
|
||||
pub name: String,
|
||||
@ -50,6 +52,7 @@ pub struct CreatePlaybookInput {
|
||||
}
|
||||
|
||||
/// Persisted condition row for API responses.
|
||||
#[derive(Serialize)]
|
||||
pub struct ConditionView {
|
||||
pub id: i64,
|
||||
pub condition_type: String,
|
||||
@ -59,6 +62,7 @@ pub struct ConditionView {
|
||||
}
|
||||
|
||||
/// Flattened playbook representation for API responses.
|
||||
#[derive(Serialize)]
|
||||
pub struct PlaybookView {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
@ -72,6 +76,7 @@ pub struct PlaybookView {
|
||||
pub conditions: Vec<ConditionView>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActionView {
|
||||
pub id: i64,
|
||||
pub action_order: i64,
|
||||
@ -80,6 +85,7 @@ pub struct ActionView {
|
||||
}
|
||||
|
||||
/// Execution record from soar_executions table.
|
||||
#[derive(Serialize)]
|
||||
pub struct ExecutionView {
|
||||
pub id: i64,
|
||||
pub playbook_id: i64,
|
||||
@ -90,9 +96,18 @@ pub struct ExecutionView {
|
||||
}
|
||||
|
||||
/// Active block record from soar_block_rules table.
|
||||
#[derive(Serialize)]
|
||||
pub struct ActiveBlockView {
|
||||
pub id: i64,
|
||||
pub source_ip: String,
|
||||
pub playbook_id: i64,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
/// Pending unblock recovery record.
|
||||
#[derive(Serialize)]
|
||||
pub struct PendingUnblock {
|
||||
pub id: i64,
|
||||
pub source_ip: String,
|
||||
pub retry_count: i64,
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ use tokio::sync::oneshot;
|
||||
use crate::core::inference::alert::MLAlert;
|
||||
use crate::core::inference::drift_detector::DriftDetectorHandle;
|
||||
use crate::core::inference::engine::Engine;
|
||||
use crate::core::inference::inference::Inference;
|
||||
use crate::core::inference::model_loader::build_adapter;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::core::inference::traffic_logger::{RotationPolicy, TrafficLogger};
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR};
|
||||
|
||||
@ -49,6 +49,8 @@ use crate::infrastructure::system::ShutdownHandle;
|
||||
use crate::interface::port::api_key::ApiKeyRepo;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
use crate::interface::port::audit::AuditRepo;
|
||||
use crate::interface::port::drop_stats::DropStatsPort;
|
||||
use crate::interface::port::protocol_filter::ProtocolFilterPort;
|
||||
|
||||
/// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized.
|
||||
pub type ReadyFlag = Arc<AtomicBool>;
|
||||
@ -216,7 +218,7 @@ pub fn start_setup_server(
|
||||
/// Run the full HTTP server with all services.
|
||||
pub async fn run(params: HttpServerParams) -> Result<(), Error> {
|
||||
let access_control = params.ebpf_services.access_control.clone();
|
||||
let protocol_filter = params.ebpf_services.protocol_filter.clone();
|
||||
let protocol_filter: Arc<dyn ProtocolFilterPort> = params.ebpf_services.protocol_filter.clone();
|
||||
let dns_filter = params.ebpf_services.dns_filter.clone();
|
||||
let geo_block = params.ebpf_services.geo_block.clone();
|
||||
let rate_limit = params.ebpf_services.rate_limit.clone();
|
||||
@ -227,6 +229,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
|
||||
let fusion_metrics = params.app_services.fusion_metrics.clone();
|
||||
let flow_statistics = params.app_services.flow_statistics.clone();
|
||||
let drop_monitor = params.ebpf_services.drop_monitor.clone();
|
||||
let drop_stats: Arc<dyn DropStatsPort> = drop_monitor.clone();
|
||||
let app_config = params.app_config;
|
||||
let inference_config = params.inference_config;
|
||||
let db = params.db;
|
||||
@ -278,6 +281,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
|
||||
.app_data(web::Data::from(fusion_metrics.clone()))
|
||||
.app_data(web::Data::from(flow_statistics.clone()))
|
||||
.app_data(web::Data::from(drop_monitor.clone()))
|
||||
.app_data(web::Data::from(drop_stats.clone()))
|
||||
.app_data(web::Data::from(db.clone() as Arc<dyn AppRepo>))
|
||||
.app_data(web::Data::from(db.clone() as Arc<dyn ApiKeyRepo>))
|
||||
.app_data(web::Data::from(db.clone() as Arc<dyn AuditRepo>))
|
||||
|
||||
@ -14,6 +14,7 @@ use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::crypto::CryptoError;
|
||||
use crate::domain::common::log::crypto::CryptoLog;
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
use crate::interface::port::setting::SettingRepo;
|
||||
|
||||
/// AES-256-GCM envelope encryption for sensitive values stored in `app_secrets`.
|
||||
pub struct SecretStore {
|
||||
@ -126,7 +127,8 @@ impl SecretStore {
|
||||
|
||||
impl SecretStorePort for SecretStore {
|
||||
fn get_secret(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
match self.db.get_app_secret(key)? {
|
||||
let repo: &dyn SettingRepo = self.db.as_ref();
|
||||
match repo.get_app_secret(key)? {
|
||||
Some(envelope_json) => Ok(Some(self.decrypt(&envelope_json)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
@ -134,7 +136,8 @@ impl SecretStorePort for SecretStore {
|
||||
|
||||
fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> {
|
||||
let envelope = self.encrypt(plaintext)?;
|
||||
self.db.set_app_secret(key, &envelope)
|
||||
let repo: &dyn SettingRepo = self.db.as_ref();
|
||||
repo.set_app_secret(key, &envelope)
|
||||
}
|
||||
|
||||
fn encrypt_envelope(&self, plaintext: &str) -> Result<String, Error> {
|
||||
|
||||
@ -26,7 +26,7 @@ use crate::core::data_plane::rate_limit_service::RateLimitService;
|
||||
use crate::core::identity::jwt::JwtService;
|
||||
use crate::core::inference::drift_detector::DriftDetectorHandle;
|
||||
use crate::core::reporting::email_scheduler::ReportScheduler;
|
||||
use crate::core::response::engine::SoarEngine;
|
||||
use crate::core::response::engine::{SoarEngine, SoarEngineDeps};
|
||||
use crate::core::response::playbook_service::PlaybookService;
|
||||
use crate::core::response::scheduler::TtlScheduler;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
@ -109,6 +109,13 @@ fn stage_registry() -> HashMap<&'static str, (&'static str, u32)> {
|
||||
])
|
||||
}
|
||||
|
||||
struct EbpfBuild {
|
||||
ingress: Ebpf,
|
||||
egress: Ebpf,
|
||||
program_array: ProgramArray<MapData>,
|
||||
services: EbpfServices,
|
||||
}
|
||||
|
||||
/// Factory responsible for creating and wiring all application services.
|
||||
pub struct ServiceFactory;
|
||||
|
||||
@ -153,7 +160,12 @@ impl ServiceFactory {
|
||||
// the reason.
|
||||
let (ingress_ebpf, egress_ebpf, ingress_program_array, ebpf_services) = match Self::try_build_ebpf(&app_config)
|
||||
{
|
||||
Ok((ingress, egress, pa, services)) => (Some(ingress), Some(egress), Some(pa), Arc::new(services)),
|
||||
Ok(build) => (
|
||||
Some(build.ingress),
|
||||
Some(build.egress),
|
||||
Some(build.program_array),
|
||||
Arc::new(build.services),
|
||||
),
|
||||
Err((stage, err)) => {
|
||||
let health = ebpf_preflight::classify(stage, &err, None);
|
||||
log!(SystemLog::EbpfBringupFailed(format!("{:?}", health)));
|
||||
@ -264,16 +276,16 @@ impl ServiceFactory {
|
||||
|
||||
// Create SOAR engine
|
||||
let rate_limit_port: Arc<dyn RateLimitPort> = ebpf_services.rate_limit.clone();
|
||||
let soar_engine = Arc::new(SoarEngine::new(
|
||||
db.clone(),
|
||||
app_config.clone(),
|
||||
access_control_port.clone(),
|
||||
alert_notifier.clone(),
|
||||
geoip.clone(),
|
||||
Some(rate_limit_port.clone()),
|
||||
let soar_engine = Arc::new(SoarEngine::new(SoarEngineDeps {
|
||||
db: db.clone(),
|
||||
config: app_config.clone(),
|
||||
access_control: access_control_port.clone(),
|
||||
alert_notifier: alert_notifier.clone(),
|
||||
geoip: geoip.clone(),
|
||||
rate_limit: Some(rate_limit_port.clone()),
|
||||
enforce_level_cache,
|
||||
Some(secret_store_port.clone()),
|
||||
)?);
|
||||
secrets: Some(secret_store_port.clone()),
|
||||
})?);
|
||||
|
||||
// Create TTL scheduler
|
||||
let ttl_scheduler = TtlScheduler::new(db.clone(), access_control_port.clone(), soar_engine.clone());
|
||||
@ -361,11 +373,7 @@ impl ServiceFactory {
|
||||
/// ingress pipeline, write queue counts, and hand out map handles to the
|
||||
/// services. Returns the original stage on the first failure so the
|
||||
/// classifier can render targeted diagnostics.
|
||||
fn try_build_ebpf(
|
||||
app_config: &Arc<ArcSwap<AppConfig>>,
|
||||
) -> Result<(Ebpf, Ebpf, ProgramArray<MapData>, EbpfServices), (EbpfFailStage, Error)> {
|
||||
use crate::domain::common::system::health::EbpfFailStage;
|
||||
|
||||
fn try_build_ebpf(app_config: &Arc<ArcSwap<AppConfig>>) -> Result<EbpfBuild, (EbpfFailStage, Error)> {
|
||||
let mut ingress = Self::load_ebpf("ingress").map_err(|e| (EbpfFailStage::Load, e))?;
|
||||
let mut egress = Self::load_ebpf("egress").map_err(|e| (EbpfFailStage::Load, e))?;
|
||||
|
||||
@ -381,7 +389,12 @@ impl ServiceFactory {
|
||||
let services = EbpfServices::new(app_config.clone(), &mut ingress, &mut egress)
|
||||
.map_err(|e| (EbpfFailStage::MapsBind, e))?;
|
||||
|
||||
Ok((ingress, egress, pipeline, services))
|
||||
Ok(EbpfBuild {
|
||||
ingress,
|
||||
egress,
|
||||
program_array: pipeline,
|
||||
services,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_ebpf(name: &str) -> Result<Ebpf, Error> {
|
||||
|
||||
@ -42,7 +42,7 @@ impl FlowStatistics {
|
||||
pub fn get_all_flows(&self) -> Vec<FlowStatsEntry> {
|
||||
let mut entries = Vec::new();
|
||||
for tracker in self.engine.trackers() {
|
||||
entries.extend(tracker.get_flows().iter().map(FlowStatsEntry::from));
|
||||
entries.extend(tracker.get_flow_stats(|flow| FlowStatsEntry::from(flow)));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
pub trait Event: Send + Clone + 'static {}
|
||||
@ -1 +0,0 @@
|
||||
// Types are available via crate::domain::event
|
||||
@ -1,2 +0,0 @@
|
||||
pub mod event;
|
||||
pub mod event_types;
|
||||
@ -1,3 +1,2 @@
|
||||
pub mod communication;
|
||||
pub mod port;
|
||||
pub mod utils;
|
||||
|
||||
5
net-guardia/src/interface/port/drop_stats.rs
Normal file
5
net-guardia/src/interface/port/drop_stats.rs
Normal file
@ -0,0 +1,5 @@
|
||||
use crate::domain::data_plane::drop_event::DropCounters;
|
||||
|
||||
pub trait DropStatsPort: Send + Sync {
|
||||
fn get_counters(&self) -> DropCounters;
|
||||
}
|
||||
@ -7,12 +7,14 @@ pub mod audit;
|
||||
pub mod db_admin;
|
||||
pub mod dns_filter_api;
|
||||
pub mod dns_query_filter;
|
||||
pub mod drop_stats;
|
||||
pub mod enforcement;
|
||||
pub mod geo_block_api;
|
||||
pub mod geo_lookup;
|
||||
pub mod identity;
|
||||
pub mod notification;
|
||||
pub mod packet_sink;
|
||||
pub mod protocol_filter;
|
||||
pub mod rate_limit_api;
|
||||
pub mod secret_store;
|
||||
pub mod setting;
|
||||
|
||||
40
net-guardia/src/interface/port/protocol_filter.rs
Normal file
40
net-guardia/src/interface/port/protocol_filter.rs
Normal file
@ -0,0 +1,40 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
|
||||
|
||||
use common::model::http_method::HttpMethod;
|
||||
|
||||
use crate::domain::common::error::Error;
|
||||
|
||||
pub trait ProtocolFilterPort: Send + Sync {
|
||||
fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>>;
|
||||
fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>>;
|
||||
fn add_ipv4_http_service(&self, address: SocketAddrV4, methods: Vec<HttpMethod>) -> Result<(), Error>;
|
||||
fn add_ipv6_http_service(&self, address: SocketAddrV6, methods: Vec<HttpMethod>) -> Result<(), Error>;
|
||||
fn remove_ipv4_http_service(&self, address: SocketAddrV4, methods: Vec<HttpMethod>) -> Result<(), Error>;
|
||||
fn remove_ipv6_http_service(&self, address: SocketAddrV6, methods: Vec<HttpMethod>) -> Result<(), Error>;
|
||||
|
||||
fn is_ssh_white_list_enable(&self) -> bool;
|
||||
fn enable_ssh_white_list(&self) -> Result<(), Error>;
|
||||
fn disable_ssh_white_list(&self) -> Result<(), Error>;
|
||||
|
||||
fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4>;
|
||||
fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6>;
|
||||
fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error>;
|
||||
fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error>;
|
||||
fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error>;
|
||||
fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error>;
|
||||
|
||||
fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr>;
|
||||
fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr>;
|
||||
fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
|
||||
fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
|
||||
fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
|
||||
fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
|
||||
|
||||
fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr>;
|
||||
fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr>;
|
||||
fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
|
||||
fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
|
||||
fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
|
||||
fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
|
||||
}
|
||||
@ -1,26 +1,7 @@
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::response::playbook_data::UpdatePlaybookInput;
|
||||
|
||||
/// Type alias for playbook+action JOIN rows.
|
||||
/// (id, name, enabled, trigger_event, threshold, count, window, cooldown, action_id, action_order, action_type, params)
|
||||
pub type PlaybookRow = (
|
||||
i64,
|
||||
String,
|
||||
bool,
|
||||
String,
|
||||
Option<f64>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
/// Type alias for SOAR execution log rows.
|
||||
/// (id, playbook_id, source_ip, trigger_event, actions_executed, executed_at)
|
||||
pub type SoarExecutionRow = (i64, i64, Option<String>, String, String, String);
|
||||
use crate::domain::response::playbook_data::{
|
||||
ActiveBlockView, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView, UpdatePlaybookInput,
|
||||
};
|
||||
|
||||
/// Threat Response BC — SOAR aggregate repository.
|
||||
///
|
||||
@ -31,25 +12,21 @@ pub type SoarExecutionRow = (i64, i64, Option<String>, String, String, String);
|
||||
/// `DbAdminRepo::with_transaction` + `TxRepos`.
|
||||
pub trait SoarRepo: Send + Sync {
|
||||
// --- Playbooks ---
|
||||
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error>;
|
||||
fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error>;
|
||||
fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error>;
|
||||
fn delete_playbook(&self, id: i64) -> Result<bool, Error>;
|
||||
fn seed_default_playbooks(&self) -> Result<(), Error>;
|
||||
|
||||
// --- Playbook Conditions ---
|
||||
/// Returns: (condition_id, playbook_id, condition_type, operator, value, value2)
|
||||
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error>;
|
||||
|
||||
// --- Block Rules ---
|
||||
fn count_active_soar_blocks(&self) -> Result<u32, Error>;
|
||||
fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error>;
|
||||
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error>;
|
||||
fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
|
||||
fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error>;
|
||||
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error>;
|
||||
fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error>;
|
||||
fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>;
|
||||
|
||||
// --- Pending Unblock Recovery ---
|
||||
fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error>;
|
||||
fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
|
||||
fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error>;
|
||||
fn delete_pending_unblock(&self, id: i64) -> Result<(), Error>;
|
||||
fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error>;
|
||||
|
||||
@ -61,7 +38,7 @@ pub trait SoarRepo: Send + Sync {
|
||||
trigger_event: &str,
|
||||
actions_json: &str,
|
||||
) -> Result<i64, Error>;
|
||||
fn list_soar_executions(&self, limit: i64) -> Result<Vec<SoarExecutionRow>, Error>;
|
||||
fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error>;
|
||||
|
||||
// --- Intra-aggregate atomic operations ---
|
||||
|
||||
@ -73,12 +50,7 @@ pub trait SoarRepo: Send + Sync {
|
||||
/// `conditions` tuples: `(condition_type, operator, value, value2)`.
|
||||
fn insert_playbook_atomic(
|
||||
&self,
|
||||
name: &str,
|
||||
trigger_event: &str,
|
||||
threshold: Option<f64>,
|
||||
count: Option<i64>,
|
||||
window: Option<i64>,
|
||||
cooldown: i64,
|
||||
input: &CreatePlaybookInput,
|
||||
actions: &[(i64, String, String)],
|
||||
conditions: &[(String, String, String, Option<String>)],
|
||||
) -> Result<i64, Error>;
|
||||
|
||||
@ -25,6 +25,7 @@ use crate::domain::common::config::observability::ObservabilityConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::common::log::system::SystemLog;
|
||||
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, ROLE_ADMIN};
|
||||
use crate::domain::identity::password;
|
||||
use crate::infrastructure::cli::{Cli, handle_subcommand};
|
||||
use crate::infrastructure::http_server;
|
||||
@ -37,10 +38,10 @@ fn seed_default_admin(database: &Arc<Database>) -> Result<(), Error> {
|
||||
if database.user_count().unwrap_or(0) != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let hash = password::hash_password("admin")?;
|
||||
let admin_user_id = database.insert_user("admin", &hash, "admin", false)?;
|
||||
let hash = password::hash_password(DEFAULT_ADMIN_USERNAME)?;
|
||||
let admin_user_id = database.insert_user(DEFAULT_ADMIN_USERNAME, &hash, ROLE_ADMIN, false)?;
|
||||
if let Ok(groups) = database.list_user_groups()
|
||||
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator")
|
||||
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == GROUP_ADMIN)
|
||||
&& let Err(err) = database.set_user_groups(admin_user_id, &[group_id])
|
||||
{
|
||||
log!(SystemError::SetUserGroupsFailed(err));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user