mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
refactor: move core/infrastructure/ to infrastructure/, fix layer violations
- Move app_config, health, statistics, geoip from core/infrastructure/ to infrastructure/ — completes hexagonal layer separation - Rename MLService to AppServices (name reflected actual contents: health, statistics, ML engine, not just ML) - Fix core/ → adapter/ dependency violations: jwt.rs, email/scheduler.rs, email/report.rs now use dyn RepositoryPort trait instead of concrete Database - Move misplaced data types to model/: - Claims → model/auth.rs - AlertMessage → model/ml_detection.rs - LicensePayload + LicenseInfo → model/license.rs - DropEventMessage + DropCounters → model/drop_event.rs - InferenceConfig (ML JSON) → model/config.rs as MLInferenceConfig - Wire CommunicationManager: enforce mode flow now goes through CQRS (ChangeEnforceModeCommand + GetEnforceModeQuery via EnforceModeHandler) - Add GitHub Actions CI workflow (cargo check + test + clippy) - Add 9 new tests (38 total): enforce mode handler (3), auth validation (6) - core/ now contains only business logic with no adapter imports (except #[cfg(test)] blocks which need concrete types) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39ebb15be3
commit
04141a8cc9
98
.github/workflows/ci.yml
vendored
Normal file
98
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,98 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, dalaw2-dev]
|
||||
pull_request:
|
||||
branches: [master, dalaw2-dev]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ secrets.SUBMODULE_PAT }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
gcc m4 clang llvm \
|
||||
libelf-dev zlib1g-dev pkg-config
|
||||
|
||||
- name: Install Rust stable toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Install Rust nightly toolchain (for bpf-linker)
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rust-src
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-on-failure: true
|
||||
|
||||
# bpf-linker is required by build.rs to compile eBPF programs.
|
||||
# Use cargo-binstall for a faster pre-built binary install when available,
|
||||
# otherwise fall back to building from source (can take ~30 min).
|
||||
- name: Install bpf-linker
|
||||
run: |
|
||||
cargo install cargo-binstall --locked 2>/dev/null || true
|
||||
if command -v cargo-binstall &>/dev/null; then
|
||||
cargo binstall bpf-linker --no-confirm --locked || cargo install bpf-linker --locked
|
||||
else
|
||||
cargo install bpf-linker --locked
|
||||
fi
|
||||
timeout-minutes: 45
|
||||
|
||||
- name: cargo check
|
||||
run: cargo check --package net-guardia
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test --package net-guardia
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --package net-guardia -- -D warnings -A dead_code
|
||||
|
||||
integration-test:
|
||||
name: Integration Test (placeholder)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-test
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Integration tests require a podman-based multi-container network
|
||||
# environment (netguardia, router, external, internal containers) that
|
||||
# is not available in GitHub Actions runners.
|
||||
#
|
||||
# The real integration tests are run on the dev server via:
|
||||
# /home/dalaw2/test_netguardia.sh
|
||||
#
|
||||
# That script tests XDP packet forwarding, Web API endpoints, ACL
|
||||
# blocking, rate limiting, ML engine, WebSocket, GeoIP country
|
||||
# blocking, and DNS blacklist functionality across the container
|
||||
# network topology.
|
||||
- name: Integration test reminder
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Integration tests are not run in CI."
|
||||
echo " They require the podman test environment."
|
||||
echo ""
|
||||
echo " Run on the dev server:"
|
||||
echo " bash /home/dalaw2/test_netguardia.sh"
|
||||
echo "============================================"
|
||||
@ -1,7 +1,8 @@
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse, Responder, Scope};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::auth::jwt::{Claims, JwtService};
|
||||
use crate::core::auth::jwt::JwtService;
|
||||
use crate::model::auth::Claims;
|
||||
use crate::core::auth::password;
|
||||
use crate::interface::port::repository::RepositoryPort;
|
||||
|
||||
@ -220,3 +221,47 @@ async fn change_password(
|
||||
.json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_valid() {
|
||||
assert!(validate_username("admin").is_ok());
|
||||
assert!(validate_username("user_123").is_ok());
|
||||
assert!(validate_username("a").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_empty() {
|
||||
assert!(validate_username("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_too_long() {
|
||||
let long = "a".repeat(33);
|
||||
assert!(validate_username(&long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_special_chars() {
|
||||
assert!(validate_username("admin@host").is_err());
|
||||
assert!(validate_username("user name").is_err());
|
||||
assert!(validate_username("user-name").is_err());
|
||||
assert!(validate_username("用戶").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_valid() {
|
||||
assert!(validate_password("12345678").is_ok());
|
||||
assert!(validate_password("a very long password").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_too_short() {
|
||||
assert!(validate_password("").is_err());
|
||||
assert!(validate_password("1234567").is_err());
|
||||
assert!(validate_password("a").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Scope};
|
||||
|
||||
use crate::core::infrastructure::health::SystemHealth;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/health")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Scope};
|
||||
|
||||
use crate::core::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::core::infrastructure::statistics::FlowStatistics;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/stats")
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Scope};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
|
||||
use crate::interface::communication::query_types::GetEnforceModeQuery;
|
||||
use crate::interface::port::repository::RepositoryPort;
|
||||
|
||||
type Repo = dyn RepositoryPort;
|
||||
@ -27,10 +30,9 @@ async fn get_boot_time() -> impl Responder {
|
||||
HttpResponse::Ok().json(crate::utils::boot_time::boot_time())
|
||||
}
|
||||
|
||||
async fn get_enforce_mode(db: web::Data<Repo>) -> impl Responder {
|
||||
match db.get_setting("enforce_mode") {
|
||||
Ok(Some(mode)) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
|
||||
Ok(None) => HttpResponse::Ok().json(serde_json::json!({"mode": "monitor"})),
|
||||
async fn get_enforce_mode(comm: web::Data<CommunicationManager>) -> impl Responder {
|
||||
match comm.send_query(GetEnforceModeQuery).await {
|
||||
Ok(mode) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
|
||||
Err(e) => HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
@ -38,7 +40,7 @@ async fn get_enforce_mode(db: web::Data<Repo>) -> impl Responder {
|
||||
|
||||
async fn set_enforce_mode(
|
||||
body: web::Json<EnforceModeRequest>,
|
||||
db: web::Data<Repo>,
|
||||
comm: web::Data<CommunicationManager>,
|
||||
) -> impl Responder {
|
||||
let mode = &body.mode;
|
||||
if mode != "monitor" && mode != "enforce" {
|
||||
@ -46,9 +48,8 @@ async fn set_enforce_mode(
|
||||
.json(serde_json::json!({"error": "Mode must be 'monitor' or 'enforce'"}));
|
||||
}
|
||||
|
||||
match db.set_setting("enforce_mode", mode) {
|
||||
match comm.send_command(ChangeEnforceModeCommand { mode: mode.clone() }).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("Enforce mode changed to: {}", mode);
|
||||
HttpResponse::Ok().json(serde_json::json!({"mode": mode}))
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError()
|
||||
|
||||
@ -4,7 +4,8 @@ use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::core::ml::alert::{MLAlert, AlertMessage};
|
||||
use crate::core::ml::alert::MLAlert;
|
||||
use crate::model::ml_detection::AlertMessage;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::log::http::HttpLog;
|
||||
|
||||
@ -4,7 +4,8 @@ use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::core::ebpf::drop_monitor::{DropMonitor, DropEventMessage};
|
||||
use crate::core::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::model::drop_event::DropEventMessage;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::log::http::HttpLog;
|
||||
|
||||
@ -5,7 +5,7 @@ use actix_ws::Message;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::core::infrastructure::statistics::FlowStatistics;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
use crate::model::flow_stats::FlowSubscription;
|
||||
|
||||
/// Default subscription: all flows, no filter, 5 second interval
|
||||
|
||||
@ -4,7 +4,7 @@ use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::core::infrastructure::health::SystemHealth;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::log::http::HttpLog;
|
||||
|
||||
@ -3,8 +3,8 @@ use serde::Deserialize;
|
||||
|
||||
use crate::core::auth::jwt::JwtService;
|
||||
use crate::core::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::core::infrastructure::health::SystemHealth;
|
||||
use crate::core::infrastructure::statistics::FlowStatistics;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
use crate::core::ml::alert::MLAlert;
|
||||
use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket};
|
||||
|
||||
|
||||
@ -1,18 +1,10 @@
|
||||
use jsonwebtoken::{decode, encode, errors::ErrorKind, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::interface::port::repository::RepositoryPort;
|
||||
use crate::model::auth::Claims;
|
||||
use crate::model::error::auth::AuthError;
|
||||
use crate::model::error::Error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
pub struct JwtService {
|
||||
encoding_key: EncodingKey,
|
||||
decoding_key: DecodingKey,
|
||||
@ -20,7 +12,7 @@ pub struct JwtService {
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
pub fn new(db: &Database, expiry_hours: u64) -> Result<Self, Error> {
|
||||
pub fn new(db: &dyn RepositoryPort, expiry_hours: u64) -> Result<Self, Error> {
|
||||
let secret = match db.get_setting("jwt_secret")? {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
@ -81,6 +73,7 @@ fn hex_encode(data: &[u8]) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::persistence::Database;
|
||||
|
||||
fn test_jwt_service() -> JwtService {
|
||||
let db = Database::new(":memory:").unwrap();
|
||||
|
||||
@ -3,40 +3,16 @@ use std::mem;
|
||||
use std::time::Duration;
|
||||
|
||||
use aya::maps::{MapData, RingBuf};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
|
||||
use common::define::drop_reason::*;
|
||||
use common::model::drop_event::DropEvent as RawDropEvent;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::model::drop_event::{DropCounters, DropEventMessage};
|
||||
|
||||
const DROP_CHANNEL_CAPACITY: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DropEventMessage {
|
||||
pub timestamp_ns: u64,
|
||||
pub src_ip: String,
|
||||
pub dst_ip: String,
|
||||
pub src_port: u16,
|
||||
pub dst_port: u16,
|
||||
pub protocol: u8,
|
||||
pub reason: String,
|
||||
pub ip_version: u8,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
pub struct DropCounters {
|
||||
pub acl_blacklist: u64,
|
||||
pub rate_limit_pkt: u64,
|
||||
pub rate_limit_syn: u64,
|
||||
pub rate_limit_udp: u64,
|
||||
pub rate_limit_dns: u64,
|
||||
pub protocol_filter: u64,
|
||||
pub dns_blacklist: u64,
|
||||
pub geo_block: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
pub struct DropMonitor {
|
||||
broadcast_tx: broadcast::Sender<DropEventMessage>,
|
||||
counters: Mutex<DropCounters>,
|
||||
|
||||
@ -8,7 +8,7 @@ use ipnetwork::IpNetwork;
|
||||
use maxminddb::{geoip2, Reader};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::error::Error;
|
||||
|
||||
@ -22,7 +22,7 @@ use crate::core::ebpf::geo_block::GeoBlock;
|
||||
use crate::core::ebpf::rate_limit::RateLimitConfig;
|
||||
use crate::core::ebpf::protocol_filter::ProtocolFilter;
|
||||
use crate::core::ebpf::xsk_manager::XskManager;
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::core::ml::engine::Engine;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::error::system::SystemError;
|
||||
|
||||
@ -17,7 +17,7 @@ use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, So
|
||||
use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem};
|
||||
|
||||
use crate::core::ebpf::dns_filter::DnsFilter;
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::core::ml::engine::Engine;
|
||||
use crate::core::ml::flow_tracker::FlowTracker;
|
||||
use crate::model::config::NetworkConfig;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::interface::port::repository::RepositoryPort;
|
||||
use crate::model::error::Error;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Generate an HTML weekly report email body.
|
||||
///
|
||||
@ -14,7 +13,7 @@ use std::sync::Arc;
|
||||
///
|
||||
/// If a key is missing the report falls back to placeholder data so it can
|
||||
/// be exercised before the ML aggregation pipeline is wired up.
|
||||
pub fn generate_weekly_report(db: &Arc<Database>) -> Result<String, Error> {
|
||||
pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error> {
|
||||
let threats_count = db
|
||||
.get_setting("weekly_threats_count")
|
||||
?
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::interface::port::repository::RepositoryPort;
|
||||
use crate::model::error::database::DatabaseError;
|
||||
use crate::model::error::Error;
|
||||
use lettre::message::header::ContentType;
|
||||
@ -22,7 +22,7 @@ impl SmtpClient {
|
||||
///
|
||||
/// Returns `None` if any required setting (`smtp_host`, `smtp_port`,
|
||||
/// `smtp_username`, `smtp_password`) is missing.
|
||||
pub fn from_database(db: &Database) -> Result<Option<Self>, Error> {
|
||||
pub fn from_database(db: &dyn RepositoryPort) -> Result<Option<Self>, Error> {
|
||||
let host = match db.get_setting("smtp_host")? {
|
||||
Some(v) if !v.is_empty() => v,
|
||||
_ => return Ok(None),
|
||||
@ -100,11 +100,11 @@ impl SmtpClient {
|
||||
/// Scheduler that checks once per hour whether it is time to send the weekly
|
||||
/// report (Monday 08:00 local time) and dispatches it via SMTP.
|
||||
pub struct ReportScheduler {
|
||||
db: Arc<Database>,
|
||||
db: Arc<dyn RepositoryPort>,
|
||||
}
|
||||
|
||||
impl ReportScheduler {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
@ -123,7 +123,7 @@ impl ReportScheduler {
|
||||
|
||||
info!("Weekly report window reached — preparing report");
|
||||
|
||||
let smtp = match SmtpClient::from_database(&db) {
|
||||
let smtp = match SmtpClient::from_database(&*db) {
|
||||
Ok(Some(client)) => client,
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
@ -146,7 +146,7 @@ impl ReportScheduler {
|
||||
}
|
||||
};
|
||||
|
||||
let html = match super::report::generate_weekly_report(&db) {
|
||||
let html = match super::report::generate_weekly_report(&*db) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Failed to generate weekly report: {e}");
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
pub mod app_config;
|
||||
pub mod geoip;
|
||||
pub mod health;
|
||||
pub mod statistics;
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod validator;
|
||||
|
||||
pub use validator::LicenseInfo;
|
||||
pub use crate::model::license::LicenseInfo;
|
||||
|
||||
@ -3,40 +3,15 @@ use std::path::Path;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use ed25519_dalek::{Signature, VerifyingKey, Verifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::error::license::LicenseError;
|
||||
use crate::model::license::{LicenseInfo, LicensePayload};
|
||||
|
||||
/// Public key auto-embedded from license_pub.key at compile time.
|
||||
/// Generate with: cd license-generator && cargo run -- keygen
|
||||
/// Then place license_pub.key in the repo root.
|
||||
const PUBLIC_KEY_HEX: &str = env!("LICENSE_PUBLIC_KEY");
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicensePayload {
|
||||
pub ingress_mac: String,
|
||||
pub egress_mac: String,
|
||||
pub expires: String,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicenseInfo {
|
||||
pub payload: Option<LicensePayload>,
|
||||
pub valid: bool,
|
||||
pub days_remaining: i64,
|
||||
}
|
||||
|
||||
impl LicenseInfo {
|
||||
pub fn unlicensed() -> Self {
|
||||
Self {
|
||||
payload: None,
|
||||
valid: false,
|
||||
days_remaining: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_license(license_path: &str, ingress_ifname: &str, egress_ifname: &str) -> Result<LicenseInfo, crate::model::error::Error> {
|
||||
// Guard against builds where the license feature was not configured
|
||||
if PUBLIC_KEY_HEX == "DISABLED" {
|
||||
|
||||
@ -1,50 +1,11 @@
|
||||
use macros::log;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::model::log::ml::MLLog;
|
||||
use crate::model::ml_detection::DetectionResult;
|
||||
use crate::model::ml_detection::{AlertMessage, DetectionResult};
|
||||
|
||||
const ALERT_CHANNEL_CAPACITY: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AlertMessage {
|
||||
pub timestamp: u64,
|
||||
pub flow_key: String,
|
||||
pub src_ip: String,
|
||||
pub dst_ip: String,
|
||||
pub src_port: u16,
|
||||
pub dst_port: u16,
|
||||
pub protocol: u8,
|
||||
pub is_attack: bool,
|
||||
pub attack_type: Option<String>,
|
||||
pub confidence: f32,
|
||||
pub ae_score: f32,
|
||||
}
|
||||
|
||||
impl AlertMessage {
|
||||
pub fn from_detection_result(result: &DetectionResult) -> Self {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
timestamp,
|
||||
flow_key: result.flow_key.clone(),
|
||||
src_ip: result.flow_key_raw.src_ip_string(),
|
||||
dst_ip: result.flow_key_raw.dst_ip_string(),
|
||||
src_port: result.flow_key_raw.src_port,
|
||||
dst_port: result.flow_key_raw.dst_port,
|
||||
protocol: result.flow_key_raw.protocol,
|
||||
is_attack: result.is_attack,
|
||||
attack_type: result.attack_type.clone(),
|
||||
confidence: result.confidence,
|
||||
ae_score: result.ae_score,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MLAlert {
|
||||
broadcast_tx: broadcast::Sender<AlertMessage>,
|
||||
}
|
||||
|
||||
@ -1,30 +1,19 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::error::ml::MLError;
|
||||
use crate::model::ml_detection::ClipParams;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InferenceConfig {
|
||||
pub ae_feature_names: Vec<String>,
|
||||
pub ae_clip_params: HashMap<String, ClipParams>,
|
||||
pub ae_scaler_mean: Vec<f64>,
|
||||
pub ae_scaler_std: Vec<f64>,
|
||||
pub ae_post_clip_min: f64,
|
||||
pub ae_post_clip_max: f64,
|
||||
pub ae_threshold: f32,
|
||||
pub classifier_feature_names: Vec<String>,
|
||||
pub attack_labels: HashMap<String, String>,
|
||||
}
|
||||
pub use crate::model::config::MLInferenceConfig;
|
||||
|
||||
impl InferenceConfig {
|
||||
/// Backward-compatible alias so existing `use config_loader::InferenceConfig` paths still compile.
|
||||
pub type InferenceConfig = MLInferenceConfig;
|
||||
|
||||
impl MLInferenceConfig {
|
||||
pub fn load_file(file: &str) -> Result<Self, MLError> {
|
||||
let path = PathBuf::from("models").join(file);
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|_| MLError::ConfigLoadFailed(path.to_path_buf()))?;
|
||||
let config: InferenceConfig = serde_json::from_str(&content)
|
||||
let config: MLInferenceConfig = serde_json::from_str(&content)
|
||||
.map_err(|e| MLError::ConfigParseFailed(e.to_string()))?;
|
||||
if config.ae_feature_names.is_empty() {
|
||||
return Err(MLError::ConfigParseFailed("ae_feature_names is empty"));
|
||||
@ -37,17 +26,4 @@ impl InferenceConfig {
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn num_ae_features(&self) -> usize {
|
||||
self.ae_feature_names.len()
|
||||
}
|
||||
|
||||
pub fn num_classifier_features(&self) -> usize {
|
||||
self.classifier_feature_names.len()
|
||||
}
|
||||
|
||||
pub fn num_attack_types(&self) -> usize {
|
||||
self.attack_labels.len()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use tract_onnx::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::model::error::ml::MLError;
|
||||
use crate::model::ml_detection::RunnableModel;
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod email;
|
||||
pub mod ebpf;
|
||||
pub mod infrastructure;
|
||||
#[cfg(feature = "license")]
|
||||
pub mod license;
|
||||
pub mod ml;
|
||||
|
||||
@ -7,8 +7,9 @@ use macros::log;
|
||||
use crate::core::auth::jwt::JwtService;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::core::ebpf::EbpfServices;
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_services::AppServices;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::core::ml::config_loader::InferenceConfig;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::core::license::LicenseInfo;
|
||||
@ -30,6 +31,7 @@ pub struct System {
|
||||
pub app_services: Arc<AppServices>,
|
||||
pub db: Arc<Database>,
|
||||
pub jwt_service: Arc<JwtService>,
|
||||
pub comm: Arc<CommunicationManager>,
|
||||
#[cfg(feature = "license")]
|
||||
pub license_info: Arc<LicenseInfo>,
|
||||
pub ingress_ebpf: Ebpf,
|
||||
@ -48,6 +50,7 @@ impl System {
|
||||
app_services: state.app_services,
|
||||
db: state.db,
|
||||
jwt_service: state.jwt_service,
|
||||
comm: state.comm,
|
||||
#[cfg(feature = "license")]
|
||||
license_info: state.license_info,
|
||||
ingress_ebpf: state.ingress_ebpf,
|
||||
@ -122,6 +125,7 @@ impl System {
|
||||
app_services: self.app_services.clone(),
|
||||
db: self.db.clone(),
|
||||
jwt_service: self.jwt_service.clone(),
|
||||
comm: self.comm.clone(),
|
||||
#[cfg(feature = "license")]
|
||||
license_info: self.license_info.clone(),
|
||||
};
|
||||
|
||||
@ -5,10 +5,10 @@ use crossbeam::queue::SegQueue;
|
||||
use macros::log;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::core::infrastructure::health::SystemHealth;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
use crate::core::ml::alert::MLAlert;
|
||||
use crate::core::infrastructure::statistics::FlowStatistics;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
use crate::core::ml::config_loader::InferenceConfig;
|
||||
use crate::core::ml::engine::Engine;
|
||||
use crate::model::ml_detection::EngineConfig;
|
||||
|
||||
84
net-guardia/src/infrastructure/enforce_mode_handler.rs
Normal file
84
net-guardia/src/infrastructure/enforce_mode_handler.rs
Normal file
@ -0,0 +1,84 @@
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::interface::communication::command::CommandHandler;
|
||||
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
|
||||
use crate::interface::communication::query::QueryHandler;
|
||||
use crate::interface::communication::query_types::GetEnforceModeQuery;
|
||||
use crate::interface::port::repository::RepositoryPort;
|
||||
use crate::model::error::Error;
|
||||
|
||||
/// Handles enforce-mode commands and queries by delegating to the repository.
|
||||
pub struct EnforceModeHandler {
|
||||
db: Arc<dyn RepositoryPort>,
|
||||
}
|
||||
|
||||
impl EnforceModeHandler {
|
||||
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandHandler<ChangeEnforceModeCommand> for EnforceModeHandler {
|
||||
async fn handle_command(&self, command: ChangeEnforceModeCommand) -> Result<(), Error> {
|
||||
self.db.set_setting("enforce_mode", &command.mode)?;
|
||||
tracing::info!("Enforce mode changed to: {}", command.mode);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QueryHandler<GetEnforceModeQuery> for EnforceModeHandler {
|
||||
async fn handle_query(&self, _query: GetEnforceModeQuery) -> Result<String, Error> {
|
||||
match self.db.get_setting("enforce_mode")? {
|
||||
Some(mode) => Ok(mode),
|
||||
None => Ok("monitor".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
|
||||
use crate::interface::communication::query_types::GetEnforceModeQuery;
|
||||
|
||||
fn test_handler() -> (Arc<EnforceModeHandler>, Arc<CommunicationManager>) {
|
||||
let db = Arc::new(Database::new(":memory:").unwrap()) as Arc<dyn RepositoryPort>;
|
||||
let handler = Arc::new(EnforceModeHandler::new(db));
|
||||
let comm = Arc::new(CommunicationManager::new());
|
||||
let _ = comm.clone()
|
||||
.with_service(handler.clone())
|
||||
.command::<ChangeEnforceModeCommand>()
|
||||
.query::<GetEnforceModeQuery>()
|
||||
.build();
|
||||
(handler, comm)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_mode_is_monitor() {
|
||||
let (_, comm) = test_handler();
|
||||
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
|
||||
assert_eq!(mode, "monitor");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_to_enforce() {
|
||||
let (_, comm) = test_handler();
|
||||
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }).await.unwrap();
|
||||
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
|
||||
assert_eq!(mode, "enforce");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_back_to_monitor() {
|
||||
let (_, comm) = test_handler();
|
||||
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }).await.unwrap();
|
||||
comm.send_command(ChangeEnforceModeCommand { mode: "monitor".into() }).await.unwrap();
|
||||
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
|
||||
assert_eq!(mode, "monitor");
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ use tokio::sync::{broadcast, oneshot, RwLock};
|
||||
use tokio::time::interval;
|
||||
use macros::log;
|
||||
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::model::log::health::Health;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::health::{
|
||||
@ -6,8 +6,9 @@ use actix_web::{web, App, HttpServer};
|
||||
use crate::core::auth::jwt::JwtService;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::core::ebpf::EbpfServices;
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_services::AppServices;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::core::ml::config_loader::InferenceConfig;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::core::license::LicenseInfo;
|
||||
@ -25,6 +26,7 @@ pub struct HttpServerParams {
|
||||
pub app_services: Arc<AppServices>,
|
||||
pub db: Arc<Database>,
|
||||
pub jwt_service: Arc<JwtService>,
|
||||
pub comm: Arc<CommunicationManager>,
|
||||
#[cfg(feature = "license")]
|
||||
pub license_info: Arc<LicenseInfo>,
|
||||
}
|
||||
@ -45,6 +47,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
|
||||
let inference_config = params.inference_config;
|
||||
let db = params.db;
|
||||
let jwt_service = params.jwt_service;
|
||||
let comm = params.comm;
|
||||
#[cfg(feature = "license")]
|
||||
let license_info = params.license_info;
|
||||
let port = app_config.http.http_server_bind_port;
|
||||
@ -70,7 +73,8 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
|
||||
.app_data(web::Data::from(flow_statistics.clone()))
|
||||
.app_data(web::Data::from(drop_monitor.clone()))
|
||||
.app_data(web::Data::from(db.clone() as Arc<dyn RepositoryPort>))
|
||||
.app_data(web::Data::from(jwt_service.clone()));
|
||||
.app_data(web::Data::from(jwt_service.clone()))
|
||||
.app_data(web::Data::from(comm.clone()));
|
||||
#[cfg(feature = "license")]
|
||||
let app = app.app_data(web::Data::from(license_info.clone()));
|
||||
app.service(
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
pub mod app_config;
|
||||
pub mod app_services;
|
||||
pub mod communication_manager;
|
||||
pub mod enforce_mode_handler;
|
||||
pub mod geoip;
|
||||
pub mod health;
|
||||
pub mod http_server;
|
||||
pub mod service_factory;
|
||||
pub mod statistics;
|
||||
|
||||
@ -12,8 +12,13 @@ use crate::core::auth::jwt::JwtService;
|
||||
use crate::core::auth::password;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::core::ebpf::EbpfServices;
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_services::AppServices;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
|
||||
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
|
||||
use crate::interface::communication::query_types::GetEnforceModeQuery;
|
||||
use crate::interface::port::repository::RepositoryPort;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::core::license::LicenseInfo;
|
||||
#[cfg(feature = "license")]
|
||||
@ -33,6 +38,7 @@ pub struct AppState {
|
||||
pub app_services: Arc<AppServices>,
|
||||
pub db: Arc<Database>,
|
||||
pub jwt_service: Arc<JwtService>,
|
||||
pub comm: Arc<CommunicationManager>,
|
||||
#[cfg(feature = "license")]
|
||||
pub license_info: Arc<LicenseInfo>,
|
||||
pub ingress_ebpf: Ebpf,
|
||||
@ -93,7 +99,7 @@ impl ServiceFactory {
|
||||
db.set_setting("enforce_mode", "monitor")?;
|
||||
}
|
||||
|
||||
let jwt_service = Arc::new(JwtService::new(&db, app_config.http.jwt_expiry_hours)?);
|
||||
let jwt_service = Arc::new(JwtService::new(db.as_ref(), app_config.http.jwt_expiry_hours)?);
|
||||
|
||||
let ebpf_services = Arc::new(EbpfServices::new(
|
||||
app_config.clone(),
|
||||
@ -103,6 +109,15 @@ impl ServiceFactory {
|
||||
|
||||
let app_services = Arc::new(AppServices::new(app_config.clone(), inference_config.clone())?);
|
||||
|
||||
// Create CommunicationManager and register enforce-mode handler
|
||||
let comm = Arc::new(CommunicationManager::new());
|
||||
let enforce_handler = Arc::new(EnforceModeHandler::new(db.clone() as Arc<dyn RepositoryPort>));
|
||||
let _ = comm.clone()
|
||||
.with_service(enforce_handler)
|
||||
.command::<ChangeEnforceModeCommand>()
|
||||
.query::<GetEnforceModeQuery>()
|
||||
.build();
|
||||
|
||||
// Restore persisted state from database
|
||||
Self::restore_dns_blacklist(&db, &ebpf_services);
|
||||
Self::restore_geo_countries(&db, &ebpf_services);
|
||||
@ -116,6 +131,7 @@ impl ServiceFactory {
|
||||
app_services,
|
||||
db,
|
||||
jwt_service,
|
||||
comm,
|
||||
#[cfg(feature = "license")]
|
||||
license_info,
|
||||
ingress_ebpf,
|
||||
|
||||
9
net-guardia/src/model/auth.rs
Normal file
9
net-guardia/src/model/auth.rs
Normal file
@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::ml_detection::ClipParams;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppConfigTable {
|
||||
#[serde(rename = "Http")]
|
||||
@ -76,3 +80,30 @@ pub struct PipelineConfig {
|
||||
pub ingress: Vec<String>,
|
||||
pub egress: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MLInferenceConfig {
|
||||
pub ae_feature_names: Vec<String>,
|
||||
pub ae_clip_params: HashMap<String, ClipParams>,
|
||||
pub ae_scaler_mean: Vec<f64>,
|
||||
pub ae_scaler_std: Vec<f64>,
|
||||
pub ae_post_clip_min: f64,
|
||||
pub ae_post_clip_max: f64,
|
||||
pub ae_threshold: f32,
|
||||
pub classifier_feature_names: Vec<String>,
|
||||
pub attack_labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MLInferenceConfig {
|
||||
pub fn num_ae_features(&self) -> usize {
|
||||
self.ae_feature_names.len()
|
||||
}
|
||||
|
||||
pub fn num_classifier_features(&self) -> usize {
|
||||
self.classifier_feature_names.len()
|
||||
}
|
||||
|
||||
pub fn num_attack_types(&self) -> usize {
|
||||
self.attack_labels.len()
|
||||
}
|
||||
}
|
||||
|
||||
26
net-guardia/src/model/drop_event.rs
Normal file
26
net-guardia/src/model/drop_event.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DropEventMessage {
|
||||
pub timestamp_ns: u64,
|
||||
pub src_ip: String,
|
||||
pub dst_ip: String,
|
||||
pub src_port: u16,
|
||||
pub dst_port: u16,
|
||||
pub protocol: u8,
|
||||
pub reason: String,
|
||||
pub ip_version: u8,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
pub struct DropCounters {
|
||||
pub acl_blacklist: u64,
|
||||
pub rate_limit_pkt: u64,
|
||||
pub rate_limit_syn: u64,
|
||||
pub rate_limit_udp: u64,
|
||||
pub rate_limit_dns: u64,
|
||||
pub protocol_filter: u64,
|
||||
pub dns_blacklist: u64,
|
||||
pub geo_block: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
26
net-guardia/src/model/license.rs
Normal file
26
net-guardia/src/model/license.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicensePayload {
|
||||
pub ingress_mac: String,
|
||||
pub egress_mac: String,
|
||||
pub expires: String,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicenseInfo {
|
||||
pub payload: Option<LicensePayload>,
|
||||
pub valid: bool,
|
||||
pub days_remaining: i64,
|
||||
}
|
||||
|
||||
impl LicenseInfo {
|
||||
pub fn unlicensed() -> Self {
|
||||
Self {
|
||||
payload: None,
|
||||
valid: false,
|
||||
days_remaining: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -139,3 +139,41 @@ impl InferenceStats {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AlertMessage {
|
||||
pub timestamp: u64,
|
||||
pub flow_key: String,
|
||||
pub src_ip: String,
|
||||
pub dst_ip: String,
|
||||
pub src_port: u16,
|
||||
pub dst_port: u16,
|
||||
pub protocol: u8,
|
||||
pub is_attack: bool,
|
||||
pub attack_type: Option<String>,
|
||||
pub confidence: f32,
|
||||
pub ae_score: f32,
|
||||
}
|
||||
|
||||
impl AlertMessage {
|
||||
pub fn from_detection_result(result: &DetectionResult) -> Self {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
timestamp,
|
||||
flow_key: result.flow_key.clone(),
|
||||
src_ip: result.flow_key_raw.src_ip_string(),
|
||||
dst_ip: result.flow_key_raw.dst_ip_string(),
|
||||
src_port: result.flow_key_raw.src_port,
|
||||
dst_port: result.flow_key_raw.dst_port,
|
||||
protocol: result.flow_key_raw.protocol,
|
||||
is_attack: result.is_attack,
|
||||
attack_type: result.attack_type.clone(),
|
||||
confidence: result.confidence,
|
||||
ae_score: result.ae_score,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod direction;
|
||||
pub mod drop_event;
|
||||
pub mod error;
|
||||
pub mod flow_stats;
|
||||
pub mod health;
|
||||
pub mod ip_address;
|
||||
#[cfg(feature = "license")]
|
||||
pub mod license;
|
||||
pub mod list_type;
|
||||
pub mod log;
|
||||
pub mod ml_detection;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user