//! Server functions for the onboarding wizard — proxy to the agent's //! `/api/v1/targets` endpoints. use dioxus::prelude::*; use serde::{Deserialize, Serialize}; /// One artifact the wizard collects for a target. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct ArtifactInputDto { pub kind: String, pub source_ref: String, #[serde(skip_serializing_if = "Option::is_none")] pub branch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub plc_format: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TargetsResponse { pub data: Vec, pub total: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TargetResponse { pub data: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ApplicableScansData { #[serde(default)] pub scans: Vec, #[serde(default)] pub pentest_supported: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ApplicableScansResponse { pub data: ApplicableScansData, } /// Validate a target name. The name is used as the clone directory downstream, /// so it must be a single safe segment (no slashes) and free of stray spaces. pub fn validate_target_name(name: &str) -> Option { let n = name.trim(); if n.is_empty() { return Some("Enter a name".to_string()); } if name != n { return Some("Remove the leading/trailing spaces".to_string()); } if n.contains('/') || n.contains('\\') { return Some("No slashes — the name becomes a folder (e.g. stm32f411-blinky)".to_string()); } None } /// Client-side validation of an artifact reference for its kind. Returns an /// error message when the value is obviously wrong for its category, so the /// wizard / editor can flag it up front instead of the scan discovering it. pub fn validate_artifact_ref(kind: &str, source_ref: &str) -> Option { let s = source_ref; if s.trim().is_empty() { return Some("Cannot be empty".to_string()); } if s != s.trim() { return Some("Remove the leading/trailing spaces".to_string()); } let no_space = !s.contains(char::is_whitespace); match kind { "git_repo" => { let looks_git = s.starts_with("https://") || s.starts_with("http://") || s.starts_with("ssh://") || s.starts_with("git://") || (s.contains('@') && s.contains(':')); (!(looks_git && no_space)) .then(|| "Enter a git URL — https://…, ssh://…, or git@host:path".to_string()) } "live_url" => { // http(s) for web/DAST targets; modbus:// and opc.tcp:// for ICS // devices probed by the ICS probe (e.g. modbus://plc:502). let ok = (s.starts_with("https://") || s.starts_with("http://") || s.starts_with("modbus://") || s.starts_with("opc.tcp://")) && no_space; (!ok).then(|| { "Enter a URL — https://app.example.com, or modbus://host:502 for a PLC".to_string() }) } "container_image" => { (!no_space).then(|| "Enter an image ref, e.g. registry/name:tag".to_string()) } "source_archive" | "firmware_image" | "mobile_package" | "plc_project" => { (!no_space).then(|| "Enter a path or URL (no spaces)".to_string()) } // plaintext_description (and anything unknown): accept free-form text. _ => None, } } /// List onboarded targets. #[server] pub async fn fetch_targets() -> Result { let resp = super::agent_client::agent_get("/api/v1/targets") .await? .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Create a target with the collected artifacts. #[server] pub async fn create_target( name: String, target_type: String, description: Option, artifacts: Vec, ) -> Result { let body = serde_json::json!({ "name": name, "target_type": target_type, "description": description, "artifacts": artifacts, }); let resp = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/targets") .await? .json(&body) .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Upload a file artifact (PLC project, firmware image, source archive, mobile /// package) to a target — proxied to the agent as multipart. #[server] pub async fn upload_target_artifact( id: String, kind: String, plc_format: Option, filename: String, bytes: Vec, ) -> Result { let mut form = reqwest::multipart::Form::new().text("kind", kind).part( "file", reqwest::multipart::Part::bytes(bytes).file_name(filename), ); if let Some(pf) = plc_format { form = form.text("plc_format", pf); } let resp = super::agent_client::agent_request( reqwest::Method::POST, &format!("/api/v1/targets/{id}/artifacts/upload"), ) .await? .multipart(form) .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Update a target's name / type / artifacts (dashboard editor). #[server] pub async fn update_target( id: String, name: Option, target_type: Option, artifacts: Option>, ) -> Result { let mut body = serde_json::Map::new(); if let Some(n) = name { body.insert("name".to_string(), serde_json::json!(n)); } if let Some(t) = target_type { body.insert("target_type".to_string(), serde_json::json!(t)); } if let Some(a) = artifacts { body.insert( "artifacts".to_string(), serde_json::to_value(a).map_err(|e| ServerFnError::new(e.to_string()))?, ); } let resp = super::agent_client::agent_request( reqwest::Method::PATCH, &format!("/api/v1/targets/{id}"), ) .await? .json(&serde_json::Value::Object(body)) .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Enable specific opt-in scans on a target by setting `scan_config.enabled_scans`. /// `scans` are serde scan-type names (lowercase, no underscores — e.g. `icsprobe`). #[server] pub async fn enable_target_scans( id: String, scans: Vec, ) -> Result { let body = serde_json::json!({ "scan_config": { "enabled_scans": scans } }); let resp = super::agent_client::agent_request( reqwest::Method::PATCH, &format!("/api/v1/targets/{id}"), ) .await? .json(&body) .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Run kind-based classification on a target. #[server] pub async fn detect_target(id: String) -> Result { let resp = super::agent_client::agent_request( reqwest::Method::POST, &format!("/api/v1/targets/{id}/detect"), ) .await? .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Fetch the scan-applicability matrix for a target. #[server] pub async fn fetch_applicable_scans(id: String) -> Result { let resp = super::agent_client::agent_get(&format!("/api/v1/targets/{id}/applicable-scans")) .await? .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Delete a target (and cascade its findings / SBOM / scan runs / CVE alerts). #[server] pub async fn delete_target(id: String) -> Result { let resp = super::agent_client::agent_request( reqwest::Method::DELETE, &format!("/api/v1/targets/{id}"), ) .await? .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Trigger a scan for a target. #[server] pub async fn trigger_target_scan(id: String) -> Result { let resp = super::agent_client::agent_request( reqwest::Method::POST, &format!("/api/v1/targets/{id}/scan"), ) .await? .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; resp.json() .await .map_err(|e| ServerFnError::new(e.to_string())) }