feat: unclaimed ports + additional generic services

This commit is contained in:
Maya Ferrandiz
2025-12-06 20:23:38 -05:00
parent 76e7a362ec
commit ca14b42f64
28 changed files with 666 additions and 125 deletions
+10 -2
View File
@@ -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)?;
+9 -8
View File
@@ -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
}
});
+51 -37
View File
@@ -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) {
+52 -7
View File
@@ -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(
+20 -1
View File
@@ -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 {
+17 -7
View File
@@ -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 {
+80
View File
@@ -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;
}