- Create installer.rs for 'botserver install protection' command - Requires root to install packages and create sudoers config - Sudoers uses exact commands (no wildcards) for security - Update all tool files (lynis, rkhunter, chkrootkit, suricata, lmd) to use sudo - Update manager.rs service management to use sudo - Add 'sudo' and 'visudo' to command_guard.rs whitelist - Update CLI with install/remove/status protection commands Security model: - Installation requires root (sudo botserver install protection) - Runtime uses sudoers NOPASSWD for specific commands only - No wildcards in sudoers - exact command specifications - Tools run on host system, not in containers
403 lines
13 KiB
Rust
403 lines
13 KiB
Rust
use axum::{
|
|
extract::Path,
|
|
http::StatusCode,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::OnceLock;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use tracing::warn;
|
|
|
|
use super::manager::{ProtectionConfig, ProtectionManager, ProtectionTool, ScanResult, ToolStatus};
|
|
|
|
static PROTECTION_MANAGER: OnceLock<Arc<RwLock<ProtectionManager>>> = OnceLock::new();
|
|
|
|
fn get_manager() -> &'static Arc<RwLock<ProtectionManager>> {
|
|
PROTECTION_MANAGER.get_or_init(|| {
|
|
Arc::new(RwLock::new(ProtectionManager::new(ProtectionConfig::default())))
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ApiResponse<T> {
|
|
success: bool,
|
|
data: Option<T>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
impl<T: Serialize> ApiResponse<T> {
|
|
fn success(data: T) -> Self {
|
|
Self {
|
|
success: true,
|
|
data: Some(data),
|
|
error: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ApiResponse<()> {
|
|
fn error(message: impl Into<String>) -> Self {
|
|
Self {
|
|
success: false,
|
|
data: None,
|
|
error: Some(message.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AllStatusResponse {
|
|
tools: Vec<ToolStatus>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AutoToggleRequest {
|
|
enabled: bool,
|
|
setting: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ActionResponse {
|
|
success: bool,
|
|
message: String,
|
|
}
|
|
|
|
pub fn configure_protection_routes() -> Router {
|
|
Router::new()
|
|
.route("/api/v1/security/protection/status", get(get_all_status))
|
|
.route(
|
|
"/api/v1/security/protection/:tool/status",
|
|
get(get_tool_status),
|
|
)
|
|
.route(
|
|
"/api/v1/security/protection/:tool/install",
|
|
post(install_tool),
|
|
)
|
|
.route(
|
|
"/api/v1/security/protection/:tool/uninstall",
|
|
post(uninstall_tool),
|
|
)
|
|
.route("/api/v1/security/protection/:tool/start", post(start_service))
|
|
.route("/api/v1/security/protection/:tool/stop", post(stop_service))
|
|
.route(
|
|
"/api/v1/security/protection/:tool/enable",
|
|
post(enable_service),
|
|
)
|
|
.route(
|
|
"/api/v1/security/protection/:tool/disable",
|
|
post(disable_service),
|
|
)
|
|
.route("/api/v1/security/protection/:tool/run", post(run_scan))
|
|
.route("/api/v1/security/protection/:tool/report", get(get_report))
|
|
.route(
|
|
"/api/v1/security/protection/:tool/update",
|
|
post(update_definitions),
|
|
)
|
|
.route("/api/v1/security/protection/:tool/auto", post(toggle_auto))
|
|
.route(
|
|
"/api/v1/security/protection/clamav/quarantine",
|
|
get(get_quarantine),
|
|
)
|
|
.route(
|
|
"/api/v1/security/protection/clamav/quarantine/:id",
|
|
post(remove_from_quarantine),
|
|
)
|
|
}
|
|
|
|
fn parse_tool(tool_name: &str) -> Result<ProtectionTool, (StatusCode, Json<ApiResponse<()>>)> {
|
|
ProtectionTool::from_str(tool_name).ok_or_else(|| {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ApiResponse::error(format!("Unknown tool: {tool_name}"))),
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn get_all_status() -> Result<Json<ApiResponse<AllStatusResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let manager = get_manager().read().await;
|
|
let status_map = manager.get_all_status().await;
|
|
let tools: Vec<ToolStatus> = status_map.into_values().collect();
|
|
|
|
Ok(Json(ApiResponse::success(AllStatusResponse { tools })))
|
|
}
|
|
|
|
async fn get_tool_status(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ToolStatus>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.check_tool_status(tool).await {
|
|
Ok(status) => Ok(Json(ApiResponse::success(status))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to get tool status");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to get tool status"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn install_tool(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.install_tool(tool).await {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("{tool} installed successfully"),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to install tool");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to install tool"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn uninstall_tool(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
|
|
Err((
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
Json(ApiResponse::error(format!(
|
|
"Uninstall not yet implemented for {tool}"
|
|
))),
|
|
))
|
|
}
|
|
|
|
async fn start_service(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.start_service(tool).await {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("{tool} service started"),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to start service");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to start service"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn stop_service(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.stop_service(tool).await {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("{tool} service stopped"),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to stop service");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to stop service"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn enable_service(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.enable_service(tool).await {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("{tool} service enabled"),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to enable service");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to enable service"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn disable_service(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.disable_service(tool).await {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("{tool} service disabled"),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to disable service");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to disable service"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_scan(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ScanResult>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.run_scan(tool).await {
|
|
Ok(result) => Ok(Json(ApiResponse::success(result))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to run scan");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to run scan"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn get_report(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<String>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.get_report(tool).await {
|
|
Ok(report) => Ok(Json(ApiResponse::success(report))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to get report");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to get report"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn update_definitions(
|
|
Path(tool_name): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().read().await;
|
|
|
|
match manager.update_definitions(tool).await {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("{tool} definitions updated"),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to update definitions");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to update definitions"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn toggle_auto(
|
|
Path(tool_name): Path<String>,
|
|
Json(request): Json<AutoToggleRequest>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
let tool = parse_tool(&tool_name)?;
|
|
let manager = get_manager().write().await;
|
|
|
|
let setting = request.setting.as_deref().unwrap_or("update");
|
|
|
|
let result = match setting {
|
|
"update" => manager.set_auto_update(tool, request.enabled).await,
|
|
"remediate" => manager.set_auto_remediate(tool, request.enabled).await,
|
|
_ => manager.set_auto_update(tool, request.enabled).await,
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("{tool} {setting} set to {}", request.enabled),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to toggle auto setting");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to toggle auto setting"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn get_quarantine() -> Result<Json<ApiResponse<Vec<super::lmd::QuarantinedFile>>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
match super::lmd::list_quarantined().await {
|
|
Ok(files) => Ok(Json(ApiResponse::success(files))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to get quarantine list");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to get quarantine list"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn remove_from_quarantine(
|
|
Path(file_id): Path<String>,
|
|
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
|
|
match super::lmd::restore_file(&file_id).await {
|
|
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
|
|
success: true,
|
|
message: format!("File {file_id} restored from quarantine"),
|
|
}))),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to restore file from quarantine");
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to restore file from quarantine"))))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_tool_valid() {
|
|
assert!(parse_tool("lynis").is_ok());
|
|
assert!(parse_tool("rkhunter").is_ok());
|
|
assert!(parse_tool("clamav").is_ok());
|
|
assert!(parse_tool("LYNIS").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_tool_invalid() {
|
|
assert!(parse_tool("unknown").is_err());
|
|
assert!(parse_tool("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_api_response_success() {
|
|
let response = ApiResponse::success("test data");
|
|
assert!(response.success);
|
|
assert!(response.data.is_some());
|
|
assert!(response.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_api_response_error() {
|
|
let response: ApiResponse<()> = ApiResponse::error("test error".to_string());
|
|
assert!(!response.success);
|
|
assert!(response.data.is_none());
|
|
assert!(response.error.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_response() {
|
|
let response = ActionResponse {
|
|
success: true,
|
|
message: "Test message".to_string(),
|
|
};
|
|
assert!(response.success);
|
|
assert_eq!(response.message, "Test message");
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_toggle_request_deserialize() {
|
|
let json = r#"{"enabled": true, "setting": "update"}"#;
|
|
let request: AutoToggleRequest = serde_json::from_str(json).unwrap();
|
|
assert!(request.enabled);
|
|
assert_eq!(request.setting, Some("update".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_toggle_request_minimal() {
|
|
let json = r#"{"enabled": false}"#;
|
|
let request: AutoToggleRequest = serde_json::from_str(json).unwrap();
|
|
assert!(!request.enabled);
|
|
assert!(request.setting.is_none());
|
|
}
|
|
}
|