feat(hosts): multi-port filter and port list on host creation

- Network dropdowns now show "Name - CIDR" in both filter bar and add modal
- Port filter accepts comma-separated ports (e.g. "80, 443"); a host must
  have ALL listed ports open to match (AND semantics)
- Add host modal has a new "Open ports" field (comma-separated); ports are
  registered in the catalog and linked to the host on creation
- Port conditions are inlined as validated integers in SQL (no injection risk)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 01:50:26 +02:00
parent e0ddf58a17
commit 6018874aa4
3 changed files with 93 additions and 70 deletions

View File

@@ -48,22 +48,17 @@ pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, 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::<AnyPool>()
.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<String> = if name_filter.is_empty() {
None
} else {
Some(format!("%{}%", name_filter))
};
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
let port: Option<i64> = if port_filter == 0 { None } else { Some(port_filter) };
let app_id: Option<i64> = 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<i64> = port_filter
.split(',')
.filter_map(|s| s.trim().parse::<i64>().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<Host, ServerFnError> {
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<u16> = ports
.split(',')
.filter_map(|s| s.trim().parse::<u16>().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.