// api/hosts.rs — Server functions for hosts use leptos::prelude::*; use serde::{Deserialize, Serialize}; use crate::models::Host; use crate::models::Port; // ─── Presentation types ─────────────────────────────────────────────────────── // A host row enriched with its network CIDR and pre-computed counts. // Used by the paginated hosts list (get_hosts_page). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HostRow { pub id: i64, pub name: String, pub ip: String, pub network_id: i64, pub network_cidr: String, pub port_count: i64, pub application_count: i64, } // Result of a paginated, filtered host query. #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct HostsPage { pub rows: Vec, pub total: i64, // total rows matching the filters (ignoring pagination) pub page: i64, // current page (1-indexed) pub per_page: i64, // items per page; 0 = all pub total_pages: i64, // ceil(total / per_page); always ≥ 1 } // Full host detail: identity fields + resolved network + open ports. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HostDetail { pub id: i64, pub name: String, pub ip: String, pub network_id: i64, pub network_name: String, pub network_cidr: String, pub ports: Vec, } // ─── Queries ────────────────────────────────────────────────────────────────── /// Returns full detail for a single host: identity, network, and open ports. #[server] pub async fn get_host_detail(id: i64) -> Result { use sqlx::AnyPool; use crate::server::repository::{hosts as host_repo, networks, ports as port_repo}; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; let host = host_repo::find_host(&pool, id) .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))?; let network = networks::find_network(&pool, host.network_id) .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new(format!("Network {} not found", host.network_id)))?; let ports = port_repo::list_ports_for_host(&pool, id) .await .map_err(|e| ServerFnError::new(e.to_string()))?; Ok(HostDetail { id: host.id, name: host.name, ip: host.ip, network_id: host.network_id, network_name: network.name, network_cidr: network.cidr, ports, }) } /// Returns all hosts belonging to a given network. #[server] pub async fn get_hosts_by_network(network_id: i64) -> Result, ServerFnError> { use sqlx::AnyPool; use crate::server::repository::hosts as repo; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; repo::list_hosts_by_network(&pool, network_id) .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Returns a filtered and paginated list of hosts across all networks. /// /// `port_filter` is a comma-separated list of port numbers (e.g. "80,443"). /// A host matches only if it has ALL the specified ports open. /// An empty string means no port filter. /// /// Port conditions are inlined in the SQL as integer literals (safe: values /// are parsed and range-checked before use — no raw user strings are injected). #[server] pub async fn get_hosts_page( name_filter: String, network_id_filter: i64, port_filter: String, application_id_filter: i64, page: i64, per_page: i64, ) -> Result { use sqlx::{AnyPool, Row}; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; let name_like: Option = if name_filter.is_empty() { None } else { Some(format!("%{}%", name_filter)) }; let network_id: Option = if network_id_filter == 0 { None } else { Some(network_id_filter) }; let app_id: Option = if application_id_filter == 0 { None } else { Some(application_id_filter) }; // Parse and validate port numbers from the CSV string. // Inlined as integer literals in SQL — safe because they are range-checked i64s. let ports: Vec = port_filter .split(',') .filter_map(|s| s.trim().parse::().ok()) .filter(|&p| p >= 1 && p <= 65535) .collect(); // One EXISTS clause per required port (AND semantics: host must have ALL ports). let port_conditions: String = ports .iter() .map(|p| format!( " AND EXISTS (SELECT 1 FROM host_ports WHERE host_id = h.id AND port_number = {p})" )) .collect(); // $1 = name_like, $2 = network_id, $3 = app_id // Pagination: $4 = limit, $5 = offset let where_clause = format!( "JOIN networks n ON n.id = h.network_id LEFT JOIN host_ports hp ON hp.host_id = h.id LEFT JOIN application_ports ap ON ap.port_number = hp.port_number WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1)) AND ($2 IS NULL OR h.network_id = $2) AND ($3 IS NULL OR EXISTS ( SELECT 1 FROM host_ports hp2 JOIN application_ports ap2 ON ap2.port_number = hp2.port_number WHERE hp2.host_id = h.id AND ap2.application_id = $3 )) {port_conditions}" ); let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {where_clause}"); let total: i64 = sqlx::query_scalar(&count_sql) .bind(name_like.as_deref()) .bind(network_id) .bind(app_id) .fetch_one(&pool) .await .map_err(|e| ServerFnError::new(e.to_string()))?; let safe_page = page.max(1); let (limit, offset, total_pages) = if per_page <= 0 { (1_000_000_000i64, 0i64, 1i64) } else { let tp = ((total + per_page - 1) / per_page).max(1); (per_page, (safe_page - 1) * per_page, tp) }; let data_sql = format!( "SELECT h.id, h.name, h.ip, h.network_id, n.cidr AS network_cidr, COUNT(DISTINCT hp.port_number) AS port_count, COUNT(DISTINCT ap.application_id) AS application_count FROM hosts h {where_clause} GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr ORDER BY h.name, h.id LIMIT $4 OFFSET $5" ); let rows = sqlx::query(&data_sql) .bind(name_like.as_deref()) .bind(network_id) .bind(app_id) .bind(limit) .bind(offset) .fetch_all(&pool) .await .map_err(|e| ServerFnError::new(e.to_string()))?; let host_rows = rows .into_iter() .map(|row| HostRow { id: row.get("id"), name: row.get("name"), ip: row.get("ip"), network_id: row.get("network_id"), network_cidr: row.get("network_cidr"), port_count: row.get("port_count"), application_count: row.get("application_count"), }) .collect(); Ok(HostsPage { rows: host_rows, total, page: safe_page, per_page, total_pages, }) } // ─── Mutations ──────────────────────────────────────────────────────────────── /// Creates a new host inside the specified network, then opens the given ports. /// /// `ports` is a comma-separated list of port numbers (e.g. "22,80,443"). /// Ports are auto-registered in the global catalog if not already present. /// An empty string means no ports are opened. #[server] pub async fn create_host( name: String, ip: String, network_id: i64, ports: String, ) -> Result { use sqlx::AnyPool; use crate::server::{ repository::{hosts, networks, ports as port_repo}, validation::validate_ip_in_network, }; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; let network = networks::find_network(&pool, network_id) .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?; validate_ip_in_network(&ip, &network.cidr) .map_err(|e| ServerFnError::new(e.to_string()))?; let host = hosts::create_host(&pool, &name, &ip, network_id) .await .map_err(|e| ServerFnError::new(e.to_string()))?; // Parse, validate, and open each port on the new host. let port_numbers: Vec = ports .split(',') .filter_map(|s| s.trim().parse::().ok()) .filter(|&p| p >= 1) .collect(); for port_number in port_numbers { port_repo::add_port_to_host(&pool, host.id, port_number) .await .map_err(|e| ServerFnError::new(e.to_string()))?; } Ok(host) } /// Updates a host's name, IP address, and network assignment. /// /// Validates that the new IP falls within the CIDR of the new network. #[server] pub async fn update_host( id: i64, name: String, ip: String, network_id: i64, ) -> Result { use sqlx::AnyPool; use crate::server::{ repository::{hosts as host_repo, networks}, validation::validate_ip_in_network, }; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; if name.trim().is_empty() { return Err(ServerFnError::new("Name must not be empty")); } let network = networks::find_network(&pool, network_id) .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?; validate_ip_in_network(&ip, &network.cidr) .map_err(|e| ServerFnError::new(e.to_string()))?; host_repo::update_host(&pool, id, name.trim(), ip.trim(), network_id) .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new(format!("Host {id} not found"))) } /// Opens a single port on a host. /// /// Auto-registers the port in the global catalog if not already present. /// If the port is already open on this host, the call is a no-op. #[server] pub async fn add_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> { use sqlx::AnyPool; use crate::server::repository::ports as port_repo; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; if !(1..=65535).contains(&port_number) { return Err(ServerFnError::new("Port number must be between 1 and 65535")); } port_repo::add_port_to_host(&pool, host_id, port_number as u16) .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Closes a port on a host (removes the host-port association). /// /// The port entry in the global catalog is not deleted. /// If the port was not open on this host, the call is a no-op. #[server] pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> { use sqlx::AnyPool; use crate::server::repository::ports as port_repo; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; port_repo::remove_port_from_host(&pool, host_id, port_number as u16) .await .map_err(|e| ServerFnError::new(e.to_string())) } /// Deletes a host by id. /// /// Also removes all its port associations (via `ON DELETE CASCADE`). /// Returns `true` if the host existed and was deleted. #[server] pub async fn delete_host(id: i64) -> Result { use sqlx::AnyPool; use crate::server::repository::hosts as repo; let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; repo::delete_host(&pool, id) .await .map_err(|e| ServerFnError::new(e.to_string())) }