mirror of
https://github.com/mayanayza/netvisor.git
synced 2025-12-10 08:24:08 -06:00
feat: unclaimed ports + additional generic services
This commit is contained in:
@@ -19,8 +19,16 @@ use tower_http::{
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.thread_stack_size(4 * 1024 * 1024) // 4MB stack for deep async scanning
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
runtime.block_on(async_main())
|
||||
}
|
||||
|
||||
async fn async_main() -> anyhow::Result<()> {
|
||||
// Parse CLI and load config
|
||||
let cli = DaemonCli::parse();
|
||||
let config = AppConfig::load(cli)?;
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
discovery::r#impl::types::{DiscoveryType, HostNamingFallback},
|
||||
groups::r#impl::base::Group,
|
||||
services::{
|
||||
definitions::docker_container::DockerContainer,
|
||||
definitions::{docker_container::DockerContainer, open_ports::OpenPorts},
|
||||
r#impl::{
|
||||
base::{
|
||||
DiscoverySessionServiceMatchParams, ServiceMatchBaselineParams,
|
||||
@@ -462,15 +462,16 @@ pub trait DiscoversNetworkedEntities:
|
||||
sorted_service_definitions.sort_by_key(|s| {
|
||||
if !ServiceDefinitionExt::is_generic(s) {
|
||||
0 // Highest priority - non-generic services
|
||||
} else if ServiceDefinitionExt::is_generic(s)
|
||||
&& s.id() != DockerContainer.id()
|
||||
&& s.id() != Gateway.id()
|
||||
{
|
||||
1 // Generic services that aren't Docker Container or Gateway
|
||||
} else {
|
||||
// Docker Containers and Gateways need to go last
|
||||
} else if s.id() == OpenPorts.id() {
|
||||
// Catch-all for open ports, should be dead last
|
||||
3
|
||||
} else if s.id() == DockerContainer.id() || s.id() == Gateway.id() {
|
||||
// Docker Containers and Gateways need to go second to last last
|
||||
// Other generic services should be able to get matched first
|
||||
2
|
||||
} else {
|
||||
// Generic services that aren't Docker Container or Gateway
|
||||
1
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::daemon::discovery::service::base::{
|
||||
};
|
||||
use crate::daemon::discovery::types::base::{DiscoveryCriticalError, DiscoverySessionUpdate};
|
||||
use crate::daemon::utils::scanner::{
|
||||
arp_scan_host, scan_endpoints, scan_tcp_ports, scan_udp_ports,
|
||||
arp_scan_host, can_arp_scan, scan_endpoints, scan_tcp_ports, scan_udp_ports,
|
||||
};
|
||||
use crate::server::discovery::r#impl::types::{DiscoveryType, HostNamingFallback};
|
||||
use crate::server::hosts::r#impl::{
|
||||
@@ -195,14 +195,21 @@ impl DiscoveryRunner<NetworkScanDiscovery> {
|
||||
.map(|p| p.number())
|
||||
.collect();
|
||||
|
||||
// Partition IPs by whether their subnet is interfaced
|
||||
let (interfaced_ips, non_interfaced_ips): (Vec<_>, Vec<_>) =
|
||||
// Check ARP capability once before partitioning
|
||||
let arp_available = can_arp_scan();
|
||||
|
||||
// Partition IPs - only use ARP path if we have capability
|
||||
let (interfaced_ips, non_interfaced_ips): (Vec<_>, Vec<_>) = if arp_available {
|
||||
all_ips_with_subnets.into_iter().partition(|(_, subnet)| {
|
||||
subnet_cidr_to_mac
|
||||
.get(&subnet.base.cidr)
|
||||
.and_then(|m| *m)
|
||||
.is_some()
|
||||
});
|
||||
})
|
||||
} else {
|
||||
// No ARP capability - treat all as non-interfaced (port scan only)
|
||||
(Vec::new(), all_ips_with_subnets)
|
||||
};
|
||||
|
||||
// =============================================================
|
||||
// PHASE 1: Responsiveness check (0-50%)
|
||||
@@ -374,45 +381,46 @@ impl DiscoveryRunner<NetworkScanDiscovery> {
|
||||
|
||||
let phase2_batches_done = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// In scan_and_process_hosts, box the deep scan futures
|
||||
let results = stream::iter(responsive_hosts)
|
||||
.map(|(ip, subnet, mac, phase1_ports)| {
|
||||
let cancel = cancel.clone();
|
||||
let gateway_ips = gateway_ips.clone();
|
||||
let phase2_batches_done = phase2_batches_done.clone();
|
||||
.map(|(ip, subnet, mac, phase1_ports)| {
|
||||
let cancel = cancel.clone();
|
||||
let gateway_ips = gateway_ips.clone();
|
||||
let batches_done = phase2_batches_done.clone();
|
||||
|
||||
async move {
|
||||
let result = self
|
||||
.deep_scan_host(DeepScanParams{
|
||||
ip,
|
||||
subnet: &subnet,
|
||||
mac,
|
||||
phase1_ports,
|
||||
cancel,
|
||||
port_scan_batch_size: ports_per_host_batch,
|
||||
gateway_ips: &gateway_ips,
|
||||
batches_done: &phase2_batches_done,
|
||||
total_batches,
|
||||
})
|
||||
.await;
|
||||
Box::pin(async move {
|
||||
let result = self
|
||||
.deep_scan_host(DeepScanParams {
|
||||
ip,
|
||||
subnet: &subnet,
|
||||
mac,
|
||||
phase1_ports,
|
||||
cancel,
|
||||
port_scan_batch_size: ports_per_host_batch,
|
||||
gateway_ips: &gateway_ips,
|
||||
batches_done: &batches_done,
|
||||
total_batches,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(host)) => Some(host),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
if DiscoveryCriticalError::is_critical_error(e.to_string()) {
|
||||
tracing::error!(ip = %ip, error = %e, "Critical error in deep scan");
|
||||
} else {
|
||||
tracing::warn!(ip = %ip, error = %e, "Deep scan failed");
|
||||
}
|
||||
None
|
||||
match result {
|
||||
Ok(Some(host)) => Some(host),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
if DiscoveryCriticalError::is_critical_error(e.to_string()) {
|
||||
tracing::error!(ip = %ip, error = %e, "Critical error in deep scan");
|
||||
} else {
|
||||
tracing::warn!(ip = %ip, error = %e, "Deep scan failed");
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(deep_scan_concurrency)
|
||||
.filter_map(|x| async { x })
|
||||
.collect::<Vec<Host>>()
|
||||
.await;
|
||||
})
|
||||
.buffer_unordered(deep_scan_concurrency)
|
||||
.filter_map(|x| async { x })
|
||||
.collect::<Vec<Host>>()
|
||||
.await;
|
||||
|
||||
self.report_discovery_update(DiscoverySessionUpdate::scanning(100))
|
||||
.await?;
|
||||
@@ -513,7 +521,7 @@ impl DiscoveryRunner<NetworkScanDiscovery> {
|
||||
all_tcp_ports.extend(open_ports);
|
||||
|
||||
let done = batches_done.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let pct = (50 + done * 50 / total_batches.max(1)) as u8;
|
||||
let pct = (50 + done * 40 / total_batches.max(1)) as u8; // 50-90%
|
||||
let _ = self.report_scanning_progress(pct).await;
|
||||
}
|
||||
|
||||
@@ -539,6 +547,9 @@ impl DiscoveryRunner<NetworkScanDiscovery> {
|
||||
.await?;
|
||||
open_ports.extend(udp_ports);
|
||||
|
||||
self.report_discovery_update(DiscoverySessionUpdate::scanning(95))
|
||||
.await?;
|
||||
|
||||
let mut ports_to_check = open_ports.clone();
|
||||
let endpoint_only_ports = Service::endpoint_only_ports();
|
||||
ports_to_check.extend(endpoint_only_ports);
|
||||
@@ -554,6 +565,9 @@ impl DiscoveryRunner<NetworkScanDiscovery> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.report_discovery_update(DiscoverySessionUpdate::scanning(98))
|
||||
.await?;
|
||||
|
||||
for endpoint_response in &endpoint_responses {
|
||||
let port = endpoint_response.endpoint.port_base;
|
||||
if !open_ports.contains(&port) {
|
||||
|
||||
@@ -21,6 +21,7 @@ use rsntp::AsyncSntpClient;
|
||||
use snmp2::{AsyncSession, Oid};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::{net::TcpStream, time::timeout};
|
||||
@@ -57,22 +58,22 @@ where
|
||||
{
|
||||
let mut results = Vec::new();
|
||||
let mut item_iter = items.into_iter();
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
// Fill initial batch
|
||||
let mut futures: FuturesUnordered<Pin<Box<dyn Future<Output = Option<O>> + Send>>> =
|
||||
FuturesUnordered::new();
|
||||
|
||||
for _ in 0..batch_size {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(item) = item_iter.next() {
|
||||
futures.push(scan_fn(item));
|
||||
futures.push(Box::pin(scan_fn(item)));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Process results and maintain constant parallelism
|
||||
while let Some(result) = futures.next().await {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
@@ -82,11 +83,9 @@ where
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
// Immediately add next item(s) to maintain batch size
|
||||
// Keep adding until we're back at batch_size or out of items
|
||||
while futures.len() < batch_size && !cancel.is_cancelled() {
|
||||
if let Some(item) = item_iter.next() {
|
||||
futures.push(scan_fn(item));
|
||||
futures.push(Box::pin(scan_fn(item)));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -96,6 +95,52 @@ where
|
||||
results
|
||||
}
|
||||
|
||||
/// Check if ARP scanning is available (requires elevated privileges on some OSes)
|
||||
pub fn can_arp_scan() -> bool {
|
||||
// Try to open a datalink channel on any suitable interface
|
||||
let interfaces = datalink::interfaces();
|
||||
|
||||
let suitable_interface = interfaces
|
||||
.into_iter()
|
||||
.find(|iface| iface.is_up() && !iface.is_loopback() && iface.mac.is_some());
|
||||
|
||||
let Some(interface) = suitable_interface else {
|
||||
tracing::debug!("No suitable interface found for ARP capability check");
|
||||
return false;
|
||||
};
|
||||
|
||||
let config = pnet::datalink::Config {
|
||||
read_timeout: Some(Duration::from_millis(100)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match datalink::channel(&interface, config) {
|
||||
Ok(_) => {
|
||||
tracing::debug!(interface = %interface.name, "ARP scanning available");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string().to_lowercase();
|
||||
if err_str.contains("permission")
|
||||
|| err_str.contains("operation not permitted")
|
||||
|| err_str.contains("access denied")
|
||||
|| err_str.contains("requires root")
|
||||
{
|
||||
tracing::info!(
|
||||
error = %e,
|
||||
"ARP scanning unavailable (insufficient privileges), falling back to port scanning"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"ARP scanning unavailable, falling back to port scanning"
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send ARP request to a single IP and wait for response
|
||||
/// Returns the MAC address if the host responds, None otherwise
|
||||
pub async fn arp_scan_host(
|
||||
|
||||
@@ -122,12 +122,31 @@ impl DaemonService {
|
||||
path: "/api/discovery/initiate".to_string(),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
daemon_id = %daemon_id,
|
||||
endpoint = %endpoint,
|
||||
session_id = %request.session_id,
|
||||
"Attempting to send discovery request to daemon"
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}", endpoint))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
daemon_id = %daemon_id,
|
||||
endpoint = %endpoint,
|
||||
error = %e,
|
||||
error_debug = ?e,
|
||||
is_connect = %e.is_connect(),
|
||||
is_timeout = %e.is_timeout(),
|
||||
"Failed to connect to daemon"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Amqp;
|
||||
|
||||
impl ServiceDefinition for Amqp {
|
||||
fn name(&self) -> &'static str {
|
||||
"AMQP"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Advanced Message Queuing Protocol"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::MessageQueue
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::AnyOf(vec![
|
||||
Pattern::Port(PortBase::AMQP),
|
||||
Pattern::Port(PortBase::AMQPTls),
|
||||
])
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<Amqp>));
|
||||
@@ -46,6 +46,7 @@ impl ServiceDefinition for DockerContainer {
|
||||
_ => true,
|
||||
})
|
||||
},
|
||||
|_| Vec::new(),
|
||||
"No other services with this container's ID have been matched",
|
||||
"A service with this container's ID has already been matched",
|
||||
MatchConfidence::Low,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Elasticsearch;
|
||||
|
||||
impl ServiceDefinition for Elasticsearch {
|
||||
fn name(&self) -> &'static str {
|
||||
"Elasticsearch"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Distributed search and analytics engine"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::Database
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Endpoint(PortBase::Elasticsearch, "/", "lucene", None)
|
||||
}
|
||||
fn logo_url(&self) -> &'static str {
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/elasticsearch.svg"
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(
|
||||
create_service::<Elasticsearch>
|
||||
));
|
||||
@@ -28,6 +28,7 @@ impl ServiceDefinition for Gateway {
|
||||
.iter()
|
||||
.any(|s| !s.base.service_definition.is_gateway())
|
||||
},
|
||||
|_| Vec::new(),
|
||||
"No other gateway services matched",
|
||||
"A gateway service has already been matched",
|
||||
MatchConfidence::Low,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Kerberos;
|
||||
|
||||
impl ServiceDefinition for Kerberos {
|
||||
fn name(&self) -> &'static str {
|
||||
"Kerberos"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Kerberos authentication service"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::IdentityAndAccess
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Kerberos)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<Kerberos>));
|
||||
@@ -18,7 +18,10 @@ impl ServiceDefinition for LDAP {
|
||||
ServiceCategory::IdentityAndAccess
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Ldap)
|
||||
Pattern::AnyOf(vec![
|
||||
Pattern::Port(PortBase::Ldap),
|
||||
Pattern::Port(PortBase::Ldaps),
|
||||
])
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
|
||||
@@ -55,7 +55,12 @@ impl ServiceDefinitionRegistry {
|
||||
// NetworkCore
|
||||
pub mod dhcp_server;
|
||||
pub mod gateway;
|
||||
pub mod ntp;
|
||||
pub mod rdp;
|
||||
pub mod snmp;
|
||||
pub mod ssh;
|
||||
pub mod switch;
|
||||
pub mod telnet;
|
||||
|
||||
// NetworkAccess
|
||||
pub mod access_point;
|
||||
@@ -87,7 +92,9 @@ pub mod unbound;
|
||||
|
||||
// VPN
|
||||
pub mod cloudflared;
|
||||
pub mod openvpn;
|
||||
pub mod wg_dashboard;
|
||||
pub mod wireguard;
|
||||
|
||||
// ReverseProxy
|
||||
pub mod caddy;
|
||||
@@ -166,6 +173,7 @@ pub mod rancher;
|
||||
// Database
|
||||
pub mod cassandra;
|
||||
pub mod couchdb;
|
||||
pub mod elasticsearch;
|
||||
pub mod influxdb;
|
||||
pub mod mariadb;
|
||||
pub mod mongodb;
|
||||
@@ -177,6 +185,7 @@ pub mod redis_db;
|
||||
|
||||
// Message Queues
|
||||
pub mod activemq;
|
||||
pub mod ampq;
|
||||
pub mod kafka;
|
||||
pub mod mqtt;
|
||||
pub mod ntfy;
|
||||
@@ -216,6 +225,7 @@ pub mod active_directory;
|
||||
pub mod authentik;
|
||||
pub mod bitwarden;
|
||||
pub mod freeipa;
|
||||
pub mod kerberos;
|
||||
pub mod keycloak;
|
||||
pub mod ldap;
|
||||
pub mod vault;
|
||||
@@ -319,3 +329,4 @@ pub mod wizarr;
|
||||
// Netvisor
|
||||
pub mod netvisor_daemon;
|
||||
pub mod netvisor_server;
|
||||
pub mod open_ports;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct NtpServer;
|
||||
|
||||
impl ServiceDefinition for NtpServer {
|
||||
fn name(&self) -> &'static str {
|
||||
"NTP Server"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Network Time Protocol server"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::NetworkCore
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Ntp)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<NtpServer>));
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::{MatchConfidence, Pattern};
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct OpenPorts;
|
||||
|
||||
impl ServiceDefinition for OpenPorts {
|
||||
fn name(&self) -> &'static str {
|
||||
"Unclaimed Open Ports"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Unclaimed open ports. Reassign to the correct service if known."
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::OpenPorts
|
||||
}
|
||||
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Custom(
|
||||
|p| !p.service_params.unbound_ports.is_empty(),
|
||||
|p| p.service_params.unbound_ports.to_vec(),
|
||||
"Has unbound open ports",
|
||||
"No unbound ports remaining",
|
||||
MatchConfidence::NotApplicable,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<OpenPorts>));
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct OpenVpn;
|
||||
|
||||
impl ServiceDefinition for OpenVpn {
|
||||
fn name(&self) -> &'static str {
|
||||
"OpenVPN"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"OpenVPN server"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::VPN
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::OpenVPN)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn logo_url(&self) -> &'static str {
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openvpn.svg"
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<OpenVpn>));
|
||||
@@ -0,0 +1,30 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct RemoteDesktop;
|
||||
|
||||
impl ServiceDefinition for RemoteDesktop {
|
||||
fn name(&self) -> &'static str {
|
||||
"Remote Desktop"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Remote Desktop Protocol (RDP)"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::NetworkCore
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Rdp)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(
|
||||
create_service::<RemoteDesktop>
|
||||
));
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Snmp;
|
||||
|
||||
impl ServiceDefinition for Snmp {
|
||||
fn name(&self) -> &'static str {
|
||||
"SNMP"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Simple Network Management Protocol"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::NetworkCore
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Snmp)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<Snmp>));
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Ssh;
|
||||
|
||||
impl ServiceDefinition for Ssh {
|
||||
fn name(&self) -> &'static str {
|
||||
"SSH"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Secure Shell remote access"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::NetworkCore
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Ssh)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<Ssh>));
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Telnet;
|
||||
|
||||
impl ServiceDefinition for Telnet {
|
||||
fn name(&self) -> &'static str {
|
||||
"Telnet"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Telnet remote access"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::NetworkCore
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Telnet)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<Telnet>));
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::server::hosts::r#impl::ports::PortBase;
|
||||
use crate::server::services::definitions::{ServiceDefinitionFactory, create_service};
|
||||
use crate::server::services::r#impl::categories::ServiceCategory;
|
||||
use crate::server::services::r#impl::definitions::ServiceDefinition;
|
||||
use crate::server::services::r#impl::patterns::Pattern;
|
||||
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Wireguard;
|
||||
|
||||
impl ServiceDefinition for Wireguard {
|
||||
fn name(&self) -> &'static str {
|
||||
"WireGuard"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"WireGuard VPN"
|
||||
}
|
||||
fn category(&self) -> ServiceCategory {
|
||||
ServiceCategory::VPN
|
||||
}
|
||||
fn discovery_pattern(&self) -> Pattern<'_> {
|
||||
Pattern::Port(PortBase::Wireguard)
|
||||
}
|
||||
fn is_generic(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn logo_url(&self) -> &'static str {
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wireguard.svg"
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(ServiceDefinitionFactory::new(create_service::<Wireguard>));
|
||||
@@ -3,6 +3,7 @@ use strum_macros::{Display, EnumDiscriminants, EnumIter, IntoStaticStr};
|
||||
|
||||
use crate::server::shared::{
|
||||
concepts::Concept,
|
||||
entities::EntityDiscriminants,
|
||||
types::metadata::{EntityMetadataProvider, HasId},
|
||||
};
|
||||
|
||||
@@ -61,6 +62,7 @@ pub enum ServiceCategory {
|
||||
Unknown,
|
||||
Custom,
|
||||
Netvisor,
|
||||
OpenPorts,
|
||||
}
|
||||
|
||||
impl HasId for ServiceCategory {
|
||||
@@ -108,9 +110,10 @@ impl EntityMetadataProvider for ServiceCategory {
|
||||
ServiceCategory::IdentityAndAccess => "KeyRound",
|
||||
ServiceCategory::Communication => "Speech",
|
||||
|
||||
// Unknown
|
||||
// Special
|
||||
ServiceCategory::Netvisor => "Zap",
|
||||
ServiceCategory::Custom => "Sparkle",
|
||||
ServiceCategory::OpenPorts => EntityDiscriminants::Port.icon(),
|
||||
ServiceCategory::Unknown => "CircleQuestionMark",
|
||||
}
|
||||
}
|
||||
@@ -156,6 +159,7 @@ impl EntityMetadataProvider for ServiceCategory {
|
||||
// Unknown
|
||||
ServiceCategory::Netvisor => "purple",
|
||||
ServiceCategory::Custom => "rose",
|
||||
ServiceCategory::OpenPorts => EntityDiscriminants::Port.color(),
|
||||
ServiceCategory::Unknown => "gray",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,10 @@ pub trait ServiceDefinitionExt {
|
||||
|
||||
impl ServiceDefinitionExt for Box<dyn ServiceDefinition> {
|
||||
fn can_be_manually_added(&self) -> bool {
|
||||
!matches!(ServiceDefinition::category(self), ServiceCategory::Netvisor)
|
||||
!matches!(
|
||||
ServiceDefinition::category(self),
|
||||
ServiceCategory::Netvisor | ServiceCategory::OpenPorts
|
||||
)
|
||||
}
|
||||
|
||||
fn is_generic(&self) -> bool {
|
||||
|
||||
@@ -114,7 +114,7 @@ pub enum Pattern<'a> {
|
||||
/// Whether the subnet that the host was found on matches a subnet type
|
||||
SubnetIsType(SubnetType),
|
||||
|
||||
/// Whether the host IP is found in the daemon's routing table. WARNING: Using this will automatically classify the service as a Layer3 service, and the service will only be able to bind to interfaces (ports and port bindings will be ignored)
|
||||
/// Whether the host IP is found in the daemon's routing table.
|
||||
IsGateway,
|
||||
|
||||
/// Whether the vendor derived from the mac address (https://gist.github.com/aallan/b4bb86db86079509e6159810ae9bd3e4) matches the provided str
|
||||
@@ -127,6 +127,7 @@ pub enum Pattern<'a> {
|
||||
/// MatchConfdence - confidence level that match uniquely identifies service
|
||||
Custom(
|
||||
fn(&DiscoverySessionServiceMatchParams) -> bool,
|
||||
fn(&DiscoverySessionServiceMatchParams) -> Vec<PortBase>,
|
||||
&'a str,
|
||||
&'a str,
|
||||
MatchConfidence,
|
||||
@@ -176,11 +177,12 @@ impl PartialEq for Pattern<'_> {
|
||||
(Pattern::IsGateway, Pattern::IsGateway) => true,
|
||||
(Pattern::MacVendor(a), Pattern::MacVendor(b)) => a == b,
|
||||
(
|
||||
Pattern::Custom(fn_a, match_a, no_match_a, conf_a),
|
||||
Pattern::Custom(fn_b, match_b, no_match_b, conf_b),
|
||||
Pattern::Custom(con_fn_a, port_fn_a, match_a, no_match_a, conf_a),
|
||||
Pattern::Custom(con_fn_b, port_fn_b, match_b, no_match_b, conf_b),
|
||||
) => {
|
||||
// Compare function pointers by address and compare other fields
|
||||
(*fn_a as usize) == (*fn_b as usize)
|
||||
(*con_fn_a as usize) == (*con_fn_b as usize)
|
||||
&& (*port_fn_a as usize) == (*port_fn_b as usize)
|
||||
&& match_a == match_b
|
||||
&& no_match_a == no_match_b
|
||||
&& conf_a == conf_b
|
||||
@@ -254,7 +256,9 @@ impl Display for Pattern<'_> {
|
||||
"Host IP is a gateway in daemon's routing tables, or ends in .1 or .254."
|
||||
),
|
||||
Pattern::MacVendor(vendor) => write!(f, "MAC Address belongs to {}", vendor),
|
||||
Pattern::Custom(_, _, _, _) => write!(f, "A custom match pattern evaluated at runtime"),
|
||||
Pattern::Custom(_, _, _, _, _) => {
|
||||
write!(f, "A custom match pattern evaluated at runtime")
|
||||
}
|
||||
Pattern::DockerContainer => write!(f, "Service is running in a docker container"),
|
||||
Pattern::None => write!(f, "No match pattern provided"),
|
||||
}
|
||||
@@ -738,10 +742,16 @@ impl Pattern<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
Pattern::Custom(constraint_function, reason, no_match_reason, confidence) => {
|
||||
Pattern::Custom(
|
||||
constraint_function,
|
||||
port_function,
|
||||
reason,
|
||||
no_match_reason,
|
||||
confidence,
|
||||
) => {
|
||||
if constraint_function(params) {
|
||||
Ok(MatchResult {
|
||||
ports: vec![],
|
||||
ports: port_function(params),
|
||||
endpoint: None,
|
||||
mac_vendor: None,
|
||||
details: MatchDetails {
|
||||
|
||||
@@ -246,6 +246,86 @@ fn test_no_duplicate_discovery_patterns() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_protocol_ports_have_generic_service() {
|
||||
use std::collections::HashSet;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use crate::server::{
|
||||
hosts::r#impl::ports::PortBase,
|
||||
services::{
|
||||
definitions::ServiceDefinitionRegistry,
|
||||
r#impl::{definitions::ServiceDefinition, patterns::Pattern},
|
||||
},
|
||||
};
|
||||
|
||||
// Ports to skip - discovered via other mechanisms or require multi-signal matching
|
||||
let skip_ports: HashSet<PortBase> =
|
||||
HashSet::from([PortBase::Docker, PortBase::DockerTls, PortBase::Kubernetes]);
|
||||
|
||||
// Get all well-known ports (non-Custom, non-Http*)
|
||||
let well_known_ports: Vec<PortBase> = PortBase::iter()
|
||||
.filter(|port| {
|
||||
if matches!(port, PortBase::Custom(_)) {
|
||||
return false;
|
||||
}
|
||||
if skip_ports.contains(port) {
|
||||
return false;
|
||||
}
|
||||
let name = format!("{:?}", port);
|
||||
if name.starts_with("Http") || name.starts_with("Https") {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
let generic_services: Vec<_> = ServiceDefinitionRegistry::all_service_definitions()
|
||||
.into_iter()
|
||||
.filter(|s| s.is_generic())
|
||||
.collect();
|
||||
|
||||
fn pattern_matches_port_alone(pattern: &Pattern, target_port: &PortBase) -> bool {
|
||||
match pattern {
|
||||
Pattern::Port(port) => port == target_port,
|
||||
Pattern::Endpoint(port, _, _, _) => port == target_port,
|
||||
Pattern::AnyOf(patterns) => patterns
|
||||
.iter()
|
||||
.any(|p| pattern_matches_port_alone(p, target_port)),
|
||||
Pattern::AllOf(_) => false,
|
||||
Pattern::Not(_) => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
let mut uncovered_ports: Vec<PortBase> = Vec::new();
|
||||
|
||||
for port in &well_known_ports {
|
||||
let has_coverage = generic_services
|
||||
.iter()
|
||||
.any(|service| pattern_matches_port_alone(&service.discovery_pattern(), port));
|
||||
|
||||
if !has_coverage {
|
||||
uncovered_ports.push(*port);
|
||||
}
|
||||
}
|
||||
|
||||
if !uncovered_ports.is_empty() {
|
||||
let port_list: Vec<String> = uncovered_ports
|
||||
.iter()
|
||||
.map(|p| format!(" - {:?} ({})", p, p))
|
||||
.collect();
|
||||
|
||||
panic!(
|
||||
"The following protocol ports have no generic service definition:\n{}\n\n\
|
||||
Each protocol port needs a generic service (is_generic=true) with either:\n\
|
||||
- Pattern::Port(PortBase::X)\n\
|
||||
- Pattern::Endpoint(PortBase::X, ...)\n\
|
||||
- Pattern::AnyOf containing one of the above",
|
||||
port_list.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn test_service_patterns_use_appropriate_port_types() {
|
||||
let registry = ServiceDefinitionRegistry::all_service_definitions();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@
|
||||
import { field } from 'svelte-forms';
|
||||
import { required } from 'svelte-forms/validators';
|
||||
import { config } from '$lib/shared/stores/config';
|
||||
import InlineInfo from '$lib/shared/components/feedback/InlineInfo.svelte';
|
||||
|
||||
export let isOpen = false;
|
||||
export let onClose: () => void;
|
||||
@@ -50,7 +51,7 @@
|
||||
}
|
||||
|
||||
const installCommand = `bash -c "$(curl -fsSL https://raw.githubusercontent.com/mayanayza/netvisor/refs/heads/main/install.sh)"`;
|
||||
$: runCommand = `netvisor-daemon --server-url ${serverUrl} ${!daemon ? `--network-id ${selectedNetworkId}` : ''} ${key ? `--daemon-api-key ${key} --mode ${$daemonModeField.value.toLowerCase()}` : ''}`;
|
||||
$: runCommand = `sudo netvisor-daemon --server-url ${serverUrl} ${!daemon ? `--network-id ${selectedNetworkId}` : ''} ${key ? `--daemon-api-key ${key} --mode ${$daemonModeField.value.toLowerCase()}` : ''}`;
|
||||
|
||||
let dockerCompose = '';
|
||||
$: if (key) {
|
||||
@@ -170,6 +171,11 @@
|
||||
|
||||
<CodeContainer language="bash" expandable={false} code={runCommand} />
|
||||
|
||||
<InlineInfo
|
||||
title="sudo + privileged: true"
|
||||
body="The Daemon requires privileged access to system resources to perform ARP scanning. If you don't run with sudo (binary) or include privileged: true (docker), the daemon will not be able to detect all hosts on the network."
|
||||
/>
|
||||
|
||||
<div class="text-secondary mt-3"><b>Option 2.</b> Run this docker-compose</div>
|
||||
|
||||
<CodeContainer language="yaml" expandable={false} code={dockerCompose} />
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<ListManager
|
||||
label="Ports"
|
||||
helpText="Manage ports for this host"
|
||||
placeholder="Add well-known or IANA registered port..."
|
||||
placeholder="Add well-known or registered port..."
|
||||
emptyMessage="No ports on this host. Add one to get started."
|
||||
allowReorder={false}
|
||||
allowCreateNew={true}
|
||||
@@ -97,7 +97,7 @@
|
||||
{:else if selectedItem && selectedItem.type != 'Custom'}
|
||||
<EntityConfigEmpty
|
||||
title="Well-known or registered Port"
|
||||
subtitle="This is designated by the IANA (Internet Assigned Numbers Authority) as a well-known or registered port, and can't be edited"
|
||||
subtitle="This is a well-known or registered port, and can't be edited"
|
||||
/>
|
||||
{:else}
|
||||
<EntityConfigEmpty
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
:global(.prism--code-container pre),
|
||||
:global(.prism--code-container code) {
|
||||
white-space: pre-wrap !important;
|
||||
font-size: 0.875rem;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user