diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d296e3c..95231c9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -13,5 +13,5 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] } tower-http = { version = "0.6", features = ["cors", "trace"] } -utoipa = { version = "4", features = ["axum_extras"] } -utoipa-swagger-ui = { version = "7", features = ["axum"] } +utoipa = { version = "5", features = ["axum_extras"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..3cd8fcc --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,52 @@ +-- Agents enregistrés (scan-network, metric) +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + agent_type TEXT NOT NULL CHECK(agent_type IN ('scan-network', 'metric')), + ip TEXT NOT NULL, + version TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'online' CHECK(status IN ('online', 'offline')), + last_seen TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- Équipements réseau découverts +CREATE TABLE IF NOT EXISTS devices ( + ip TEXT PRIMARY KEY, + mac TEXT, + hostname TEXT, + vendor TEXT, + state TEXT NOT NULL DEFAULT 'unknown' CHECK(state IN ('online', 'offline', 'sleep', 'unknown')), + services TEXT NOT NULL DEFAULT '[]', -- JSON array + open_ports TEXT NOT NULL DEFAULT '[]', -- JSON array + last_seen TEXT NOT NULL, + first_seen TEXT NOT NULL +); + +-- Métriques système (dernière valeur par agent) +CREATE TABLE IF NOT EXISTS metrics ( + agent_id TEXT NOT NULL REFERENCES agents(id), + timestamp TEXT NOT NULL, + cpu_percent REAL, + ram_percent REAL, + load_avg REAL, + temperature_c REAL, + disk_percent REAL, + net_rx_bps INTEGER, + net_tx_bps INTEGER, + extra TEXT DEFAULT '{}', -- JSON pour champs futurs + PRIMARY KEY (agent_id) +); + +-- Événements système +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + event_type TEXT NOT NULL, + timestamp TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}' -- JSON +); + +CREATE INDEX IF NOT EXISTS idx_events_agent ON events(agent_id); +CREATE INDEX IF NOT EXISTS idx_events_ts ON events(timestamp); +CREATE INDEX IF NOT EXISTS idx_devices_state ON devices(state); diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..050c01f --- /dev/null +++ b/backend/src/db.rs @@ -0,0 +1,12 @@ +use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; + +pub async fn connect(database_url: &str) -> anyhow::Result { + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(database_url) + .await?; + + sqlx::migrate!("./migrations").run(&pool).await?; + + Ok(pool) +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..7105c71 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,20 @@ +use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use serde_json::json; + +pub struct AppError(anyhow::Error); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let code = StatusCode::INTERNAL_SERVER_ERROR; + let body = Json(json!({ "error": self.0.to_string() })); + (code, body).into_response() + } +} + +impl> From for AppError { + fn from(e: E) -> Self { + Self(e.into()) + } +} + +pub type Result = std::result::Result; diff --git a/backend/src/main.rs b/backend/src/main.rs index af066f3..e8f2280 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,24 +1,65 @@ -use axum::{routing::get, Router}; +use axum::{routing::get, Json}; +use tower_http::cors::CorsLayer; use tracing::info; use tracing_subscriber::EnvFilter; +use utoipa::OpenApi; + +mod db; +mod error; +mod models; +mod routes; + +#[derive(OpenApi)] +#[openapi( + paths( + routes::health::health, + routes::agents::list, + routes::agents::register, + routes::agents::get_one, + routes::network::list, + routes::network::push, + routes::network::get_one, + routes::metrics::list, + routes::metrics::push, + routes::metrics::get_one, + routes::events::list, + routes::events::push, + routes::widgets::network, + routes::widgets::metrics, + ), + components(schemas( + models::Agent, models::RegisterAgent, + models::Device, models::PushDevices, models::DeviceUpdate, + models::Metric, models::PushMetrics, + models::Event, models::PushEvent, + )), + info(title = "SentinelMesh API", version = "0.1.0", description = "API centrale SentinelMesh") +)] +struct ApiDoc; #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) + .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse()?)) .init(); - let app = Router::new().route("/api/v1/health", get(health)); + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite://sentinelmesh.sqlite".into()); - let addr = "0.0.0.0:8080"; - info!("SentinelMesh backend démarré sur {addr}"); + let db = db::connect(&database_url).await?; + info!("Base de données connectée : {database_url}"); - let listener = tokio::net::TcpListener::bind(addr).await?; + let spec = ApiDoc::openapi(); + let app = routes::api_router(db) + .route("/api-docs/openapi.json", get(move || async move { Json(spec) })) + .layer(CorsLayer::permissive()); + + let addr = std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into()); + info!("SentinelMesh backend démarré sur http://{addr}"); + info!("OpenAPI spec : http://{addr}/api-docs/openapi.json"); + + let listener = tokio::net::TcpListener::bind(&addr).await?; axum::serve(listener, app).await?; Ok(()) } - -async fn health() -> &'static str { - "ok" -} diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..998b24b --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct Agent { + pub id: String, + pub hostname: String, + pub agent_type: String, + pub ip: String, + pub version: String, + pub status: String, + pub last_seen: String, + pub created_at: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RegisterAgent { + pub id: String, + pub hostname: String, + pub agent_type: String, + pub ip: String, + pub version: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct Device { + pub ip: String, + pub mac: Option, + pub hostname: Option, + pub vendor: Option, + pub state: String, + pub services: String, // JSON serialisé + pub open_ports: String, // JSON serialisé + pub last_seen: String, + pub first_seen: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct PushDevices { + pub agent_id: String, + pub devices: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct DeviceUpdate { + pub ip: String, + pub mac: Option, + pub hostname: Option, + pub vendor: Option, + pub state: String, + pub services: Vec, + pub open_ports: Vec, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct Metric { + pub agent_id: String, + pub timestamp: String, + pub cpu_percent: Option, + pub ram_percent: Option, + pub load_avg: Option, + pub temperature_c: Option, + pub disk_percent: Option, + pub net_rx_bps: Option, + pub net_tx_bps: Option, + pub extra: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct PushMetrics { + pub agent_id: String, + pub cpu_percent: Option, + pub ram_percent: Option, + pub load_avg: Option, + pub temperature_c: Option, + pub disk_percent: Option, + pub net_rx_bps: Option, + pub net_tx_bps: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct Event { + pub id: i64, + pub agent_id: String, + pub event_type: String, + pub timestamp: String, + pub data: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct PushEvent { + pub agent_id: String, + pub event_type: String, + pub data: Option, +} diff --git a/backend/src/routes/agents.rs b/backend/src/routes/agents.rs new file mode 100644 index 0000000..df3a729 --- /dev/null +++ b/backend/src/routes/agents.rs @@ -0,0 +1,56 @@ +use axum::{extract::{Path, State}, Json}; +use chrono::Utc; +use sqlx::SqlitePool; +use utoipa::path as oapath; + +use crate::{error::Result, models::{Agent, RegisterAgent}}; + +#[oapath(get, path = "/api/v1/agents", + responses((status = 200, body = Vec)))] +pub async fn list(State(db): State) -> Result>> { + let agents = sqlx::query_as::<_, Agent>("SELECT * FROM agents ORDER BY last_seen DESC") + .fetch_all(&db) + .await?; + Ok(Json(agents)) +} + +#[oapath(post, path = "/api/v1/agents", + request_body = RegisterAgent, + responses((status = 200, body = Agent)))] +pub async fn register( + State(db): State, + Json(body): Json, +) -> Result> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO agents (id, hostname, agent_type, ip, version, status, last_seen, created_at) + VALUES (?, ?, ?, ?, ?, 'online', ?, ?) + ON CONFLICT(id) DO UPDATE SET + hostname = excluded.hostname, ip = excluded.ip, + version = excluded.version, status = 'online', last_seen = excluded.last_seen", + ) + .bind(&body.id).bind(&body.hostname).bind(&body.agent_type) + .bind(&body.ip).bind(&body.version).bind(&now).bind(&now) + .execute(&db) + .await?; + + let agent = sqlx::query_as::<_, Agent>("SELECT * FROM agents WHERE id = ?") + .bind(&body.id) + .fetch_one(&db) + .await?; + Ok(Json(agent)) +} + +#[oapath(get, path = "/api/v1/agents/{id}", + params(("id" = String, Path, description = "Identifiant de l'agent")), + responses((status = 200, body = Agent), (status = 404, description = "Agent inconnu")))] +pub async fn get_one( + State(db): State, + Path(id): Path, +) -> Result> { + let agent = sqlx::query_as::<_, Agent>("SELECT * FROM agents WHERE id = ?") + .bind(&id) + .fetch_one(&db) + .await?; + Ok(Json(agent)) +} diff --git a/backend/src/routes/events.rs b/backend/src/routes/events.rs new file mode 100644 index 0000000..a91ff68 --- /dev/null +++ b/backend/src/routes/events.rs @@ -0,0 +1,35 @@ +use axum::{extract::State, Json}; +use chrono::Utc; +use sqlx::SqlitePool; +use utoipa::path as oapath; + +use crate::{error::Result, models::{Event, PushEvent}}; + +#[oapath(get, path = "/api/v1/events", + responses((status = 200, body = Vec)))] +pub async fn list(State(db): State) -> Result>> { + let events = sqlx::query_as::<_, Event>( + "SELECT * FROM events ORDER BY timestamp DESC LIMIT 200", + ) + .fetch_all(&db) + .await?; + Ok(Json(events)) +} + +#[oapath(post, path = "/api/v1/events", + request_body = PushEvent, + responses((status = 200, description = "Événement enregistré")))] +pub async fn push( + State(db): State, + Json(body): Json, +) -> Result> { + let now = Utc::now().to_rfc3339(); + let data = body.data.map(|v| v.to_string()).unwrap_or_else(|| "{}".into()); + let id: i64 = sqlx::query_scalar( + "INSERT INTO events (agent_id, event_type, timestamp, data) VALUES (?, ?, ?, ?) RETURNING id", + ) + .bind(&body.agent_id).bind(&body.event_type).bind(&now).bind(&data) + .fetch_one(&db) + .await?; + Ok(Json(serde_json::json!({ "id": id }))) +} diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs new file mode 100644 index 0000000..f69f8f7 --- /dev/null +++ b/backend/src/routes/health.rs @@ -0,0 +1,9 @@ +use axum::Json; +use serde_json::{json, Value}; +use utoipa::path as oapath; + +#[oapath(get, path = "/api/v1/health", + responses((status = 200, description = "Backend opérationnel")))] +pub async fn health() -> Json { + Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })) +} diff --git a/backend/src/routes/metrics.rs b/backend/src/routes/metrics.rs new file mode 100644 index 0000000..4ea1319 --- /dev/null +++ b/backend/src/routes/metrics.rs @@ -0,0 +1,56 @@ +use axum::{extract::{Path, State}, Json}; +use chrono::Utc; +use sqlx::SqlitePool; +use utoipa::path as oapath; + +use crate::{error::Result, models::{Metric, PushMetrics}}; + +#[oapath(get, path = "/api/v1/metrics", + responses((status = 200, body = Vec)))] +pub async fn list(State(db): State) -> Result>> { + let metrics = sqlx::query_as::<_, Metric>("SELECT * FROM metrics") + .fetch_all(&db) + .await?; + Ok(Json(metrics)) +} + +#[oapath(post, path = "/api/v1/metrics", + request_body = PushMetrics, + responses((status = 200, description = "Métriques enregistrées")))] +pub async fn push( + State(db): State, + Json(body): Json, +) -> Result> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO metrics (agent_id, timestamp, cpu_percent, ram_percent, load_avg, + temperature_c, disk_percent, net_rx_bps, net_tx_bps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(agent_id) DO UPDATE SET + timestamp = excluded.timestamp, cpu_percent = excluded.cpu_percent, + ram_percent = excluded.ram_percent, load_avg = excluded.load_avg, + temperature_c = excluded.temperature_c, disk_percent = excluded.disk_percent, + net_rx_bps = excluded.net_rx_bps, net_tx_bps = excluded.net_tx_bps", + ) + .bind(&body.agent_id).bind(&now) + .bind(body.cpu_percent).bind(body.ram_percent).bind(body.load_avg) + .bind(body.temperature_c).bind(body.disk_percent) + .bind(body.net_rx_bps).bind(body.net_tx_bps) + .execute(&db) + .await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +#[oapath(get, path = "/api/v1/metrics/{agent_id}", + params(("agent_id" = String, Path, description = "Identifiant de l'agent")), + responses((status = 200, body = Metric), (status = 404, description = "Agent inconnu")))] +pub async fn get_one( + State(db): State, + Path(agent_id): Path, +) -> Result> { + let metric = sqlx::query_as::<_, Metric>("SELECT * FROM metrics WHERE agent_id = ?") + .bind(&agent_id) + .fetch_one(&db) + .await?; + Ok(Json(metric)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..b89195b --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,24 @@ +use axum::{routing::get, Router}; +use sqlx::SqlitePool; + +pub mod agents; +pub mod events; +pub mod health; +pub mod metrics; +pub mod network; +pub mod widgets; + +pub fn api_router(db: SqlitePool) -> Router { + Router::new() + .route("/api/v1/health", get(health::health)) + .route("/api/v1/agents", get(agents::list).post(agents::register)) + .route("/api/v1/agents/{id}", get(agents::get_one)) + .route("/api/v1/network", get(network::list).post(network::push)) + .route("/api/v1/network/{ip}", get(network::get_one)) + .route("/api/v1/metrics", get(metrics::list).post(metrics::push)) + .route("/api/v1/metrics/{agent_id}", get(metrics::get_one)) + .route("/api/v1/events", get(events::list).post(events::push)) + .route("/api/v1/widgets/network", get(widgets::network)) + .route("/api/v1/widgets/metrics", get(widgets::metrics)) + .with_state(db) +} diff --git a/backend/src/routes/network.rs b/backend/src/routes/network.rs new file mode 100644 index 0000000..9864d2b --- /dev/null +++ b/backend/src/routes/network.rs @@ -0,0 +1,64 @@ +use axum::{extract::{Path, State}, Json}; +use chrono::Utc; +use sqlx::SqlitePool; +use utoipa::path as oapath; + +use crate::{error::Result, models::{Device, PushDevices}}; + +#[oapath(get, path = "/api/v1/network", + responses((status = 200, body = Vec)))] +pub async fn list(State(db): State) -> Result>> { + let devices = sqlx::query_as::<_, Device>( + "SELECT * FROM devices ORDER BY state, ip", + ) + .fetch_all(&db) + .await?; + Ok(Json(devices)) +} + +#[oapath(post, path = "/api/v1/network", + request_body = PushDevices, + responses((status = 200, description = "Données enregistrées")))] +pub async fn push( + State(db): State, + Json(body): Json, +) -> Result> { + let now = Utc::now().to_rfc3339(); + // Mise à jour last_seen de l'agent + sqlx::query("UPDATE agents SET last_seen = ?, status = 'online' WHERE id = ?") + .bind(&now).bind(&body.agent_id) + .execute(&db) + .await + .ok(); + for d in &body.devices { + let services = serde_json::to_string(&d.services).unwrap_or_default(); + let ports = serde_json::to_string(&d.open_ports).unwrap_or_default(); + sqlx::query( + "INSERT INTO devices (ip, mac, hostname, vendor, state, services, open_ports, last_seen, first_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + mac = excluded.mac, hostname = excluded.hostname, vendor = excluded.vendor, + state = excluded.state, services = excluded.services, + open_ports = excluded.open_ports, last_seen = excluded.last_seen", + ) + .bind(&d.ip).bind(&d.mac).bind(&d.hostname).bind(&d.vendor) + .bind(&d.state).bind(&services).bind(&ports).bind(&now).bind(&now) + .execute(&db) + .await?; + } + Ok(Json(serde_json::json!({ "inserted": body.devices.len() }))) +} + +#[oapath(get, path = "/api/v1/network/{ip}", + params(("ip" = String, Path, description = "Adresse IP de l'équipement")), + responses((status = 200, body = Device), (status = 404, description = "Équipement inconnu")))] +pub async fn get_one( + State(db): State, + Path(ip): Path, +) -> Result> { + let device = sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE ip = ?") + .bind(&ip) + .fetch_one(&db) + .await?; + Ok(Json(device)) +} diff --git a/backend/src/routes/widgets.rs b/backend/src/routes/widgets.rs new file mode 100644 index 0000000..0e43d86 --- /dev/null +++ b/backend/src/routes/widgets.rs @@ -0,0 +1,100 @@ +use axum::{extract::State, Json}; +use serde::Serialize; +use serde_json::Value; +use sqlx::SqlitePool; +use utoipa::path as oapath; + +use crate::{error::Result, models::Device}; + +// --- Widget réseau --- + +#[derive(Serialize)] +pub struct NetworkWidget { + pub last_scan_at: Option, + pub total: usize, + pub online: usize, + pub devices: Vec, +} + +#[derive(Serialize)] +pub struct DeviceSummary { + pub ip: String, + pub hostname: Option, + pub vendor: Option, + pub state: String, + pub services: Value, +} + +#[oapath(get, path = "/api/v1/widgets/network", + responses((status = 200, description = "Données widget réseau pour Glance")))] +pub async fn network(State(db): State) -> Result> { + let devices = sqlx::query_as::<_, Device>("SELECT * FROM devices ORDER BY state, ip") + .fetch_all(&db) + .await?; + + let online = devices.iter().filter(|d| d.state == "online").count(); + let summary = devices + .iter() + .map(|d| DeviceSummary { + ip: d.ip.clone(), + hostname: d.hostname.clone(), + vendor: d.vendor.clone(), + state: d.state.clone(), + services: serde_json::from_str(&d.services).unwrap_or(Value::Array(vec![])), + }) + .collect(); + + let last_scan = devices.iter().map(|d| d.last_seen.clone()).max(); + + Ok(Json(NetworkWidget { + last_scan_at: last_scan, + total: devices.len(), + online, + devices: summary, + })) +} + +// --- Widget métriques --- + +#[derive(Serialize)] +pub struct MetricsWidget { + pub agents: Vec, +} + +#[derive(Serialize)] +pub struct AgentMetricSummary { + pub agent_id: String, + pub hostname: String, + pub status: String, + pub cpu_percent: Option, + pub ram_percent: Option, + pub disk_percent: Option, + pub temperature_c: Option, + pub last_seen: String, +} + +#[oapath(get, path = "/api/v1/widgets/metrics", + responses((status = 200, description = "Données widget métriques pour Glance")))] +pub async fn metrics(State(db): State) -> Result> { + let rows = sqlx::query_as::<_, ( + String, String, String, Option, Option, Option, Option, String, + )>( + "SELECT a.id, a.hostname, a.status, m.cpu_percent, m.ram_percent, + m.disk_percent, m.temperature_c, a.last_seen + FROM agents a LEFT JOIN metrics m ON m.agent_id = a.id + WHERE a.agent_type = 'metric' + ORDER BY a.hostname", + ) + .fetch_all(&db) + .await?; + + let agents = rows + .into_iter() + .map(|(id, hostname, status, cpu, ram, disk, temp, last_seen)| AgentMetricSummary { + agent_id: id, hostname, status, cpu_percent: cpu, ram_percent: ram, + disk_percent: disk, temperature_c: temp, last_seen, + }) + .collect(); + + Ok(Json(MetricsWidget { agents })) +}