diff --git a/src/api/hosts.rs b/src/api/hosts.rs index f55b7da..7f3c8ee 100644 --- a/src/api/hosts.rs +++ b/src/api/hosts.rs @@ -48,22 +48,17 @@ pub async fn get_hosts_by_network(network_id: i64) -> Result, ServerFn /// Returns a filtered and paginated list of hosts across all networks. /// -/// Filter parameters use sentinel values (0 / empty string) to mean "no filter": -/// - `name_filter` : substring match on host name (case-insensitive); "" = all -/// - `network_id_filter` : exact network id; 0 = all -/// - `port_filter` : hosts with this port open; 0 = all -/// - `application_id_filter` : hosts linked to this application; 0 = all -/// - `per_page` : items per page; 0 = return everything -/// - `page` : 1-indexed page number +/// `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. /// -/// The SQL uses each bind parameter twice in the WHERE clause -/// (once for the IS NULL guard, once for the actual comparison). -/// Each $N placeholder refers to the N-th bound argument by index. +/// 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: i64, + port_filter: String, application_id_filter: i64, page: i64, per_page: i64, @@ -73,47 +68,55 @@ pub async fn get_hosts_page( let pool = use_context::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; - // Convert sentinel values to Option for SQL NULL binding. - // None → binds as SQL NULL → "$N IS NULL" evaluates to TRUE → filter skipped. 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 port: Option = if port_filter == 0 { None } else { Some(port_filter) }; let app_id: Option = if application_id_filter == 0 { None } else { Some(application_id_filter) }; - // Each filter param is bound twice so the same $N can appear in both - // the IS NULL guard and the comparison without re-declaring parameters. - const WHERE: &str = " - 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 - WHERE host_id = h.id AND port_number = $3 - )) - AND ($4 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 = $4 - ))"; + // 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(); - // Count matching hosts (ignoring pagination). - let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}"); + // 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(port) .bind(app_id) .fetch_one(&pool) .await .map_err(|e| ServerFnError::new(e.to_string()))?; - // Compute pagination bounds. let safe_page = page.max(1); let (limit, offset, total_pages) = if per_page <= 0 { (1_000_000_000i64, 0i64, 1i64) @@ -122,23 +125,21 @@ pub async fn get_hosts_page( (per_page, (safe_page - 1) * per_page, tp) }; - // Fetch the page of hosts with enriched columns. 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} + {where_clause} GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr ORDER BY h.name, h.id - LIMIT $5 OFFSET $6" + LIMIT $4 OFFSET $5" ); let rows = sqlx::query(&data_sql) .bind(name_like.as_deref()) .bind(network_id) - .bind(port) .bind(app_id) .bind(limit) .bind(offset) @@ -170,19 +171,21 @@ pub async fn get_hosts_page( // ─── Mutations ──────────────────────────────────────────────────────────────── -/// Creates a new host inside the specified network. +/// Creates a new host inside the specified network, then opens the given ports. /// -/// Validates that `ip` falls within the CIDR of `network_id`. -/// Returns an error if the network does not exist or the IP is out of range. +/// `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}, + repository::{hosts, networks, ports as port_repo}, validation::validate_ip_in_network, }; @@ -192,16 +195,29 @@ pub async fn create_host( 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")) - })?; + .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()))?; - hosts::create_host(&pool, &name, &ip, network_id) + let host = hosts::create_host(&pool, &name, &ip, network_id) .await - .map_err(|e| ServerFnError::new(e.to_string())) + .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) } /// Deletes a host by id. diff --git a/src/client/hosts.rs b/src/client/hosts.rs index 9712a69..af4763c 100644 --- a/src/client/hosts.rs +++ b/src/client/hosts.rs @@ -2,7 +2,7 @@ // // Displays all hosts across every network with: // - Add button : opens a modal form to create a host inside a chosen network -// - Filter bar : name (substring), network, open port, application +// - Filter bar : name (substring), network, open ports (CSV), application // - Table : name, IP, network, port count, application count, delete // - Pagination : configurable page size (15 / 25 / 50 / 100 / All) // @@ -43,9 +43,7 @@ fn AddHostModal( }); view! { - // Backdrop — click outside the card to close