- Repository: add update_host (name, IP, network reassignment with CIDR validation) - API: get_host_detail (host + resolved network + ports), update_host, add_host_port, remove_host_port server functions - Client: HostDetailPage at /hosts/:id — identity form, ports list with per-port Remove button, Add port input, delete confirmation modal with navigation back to /hosts on success - CSS: detail-section cards, port-row list, btn-danger-solid, back-link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
362 lines
12 KiB
Rust
362 lines
12 KiB
Rust
// 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<HostRow>,
|
|
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<Port>,
|
|
}
|
|
|
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
|
|
|
/// Returns full detail for a single host: identity, network, and open ports.
|
|
#[server]
|
|
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
|
use sqlx::AnyPool;
|
|
use crate::server::repository::{hosts as host_repo, networks, ports as port_repo};
|
|
|
|
let pool = use_context::<AnyPool>()
|
|
.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<Vec<Host>, ServerFnError> {
|
|
use sqlx::AnyPool;
|
|
use crate::server::repository::hosts as repo;
|
|
|
|
let pool = use_context::<AnyPool>()
|
|
.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<HostsPage, ServerFnError> {
|
|
use sqlx::{AnyPool, Row};
|
|
|
|
let pool = use_context::<AnyPool>()
|
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
|
|
|
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 app_id: Option<i64> = 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<i64> = port_filter
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse::<i64>().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<Host, ServerFnError> {
|
|
use sqlx::AnyPool;
|
|
use crate::server::{
|
|
repository::{hosts, networks, ports as port_repo},
|
|
validation::validate_ip_in_network,
|
|
};
|
|
|
|
let pool = use_context::<AnyPool>()
|
|
.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<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)
|
|
}
|
|
|
|
/// 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<Host, ServerFnError> {
|
|
use sqlx::AnyPool;
|
|
use crate::server::{
|
|
repository::{hosts as host_repo, networks},
|
|
validation::validate_ip_in_network,
|
|
};
|
|
|
|
let pool = use_context::<AnyPool>()
|
|
.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::<AnyPool>()
|
|
.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::<AnyPool>()
|
|
.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<bool, ServerFnError> {
|
|
use sqlx::AnyPool;
|
|
use crate::server::repository::hosts as repo;
|
|
|
|
let pool = use_context::<AnyPool>()
|
|
.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()))
|
|
}
|