From d26c1171e1a308be6cd699118454d32cc8e09620 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Tue, 8 Sep 2026 11:59:03 +0200 Subject: [PATCH] optional service host pinning and automatic proxy TCP/UDP listen-port firewall allowances --- CHANGELOG.md | 1 + SETUP.md | 5 + members/nullnet-client/src/ebpf/mod.rs | 61 ++++-- members/nullnet-client/src/main.rs | 22 +- .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 4 +- .../src/proto/nullnet_grpc.rs | 4 + .../down.sql | 1 + .../2026-09-08-000001_service_host_ip/up.sql | 1 + members/nullnet-server/src/db/models.rs | 2 + members/nullnet-server/src/db/schema.rs | 1 + members/nullnet-server/src/db/stacks.rs | 4 + .../nullnet-server/src/http_server/config.rs | 28 ++- .../nullnet-server/src/nullnet_grpc_impl.rs | 191 +++++++++++++++--- members/nullnet-server/src/services/input.rs | 18 ++ .../nullnet-server/ui/src/pages/Config.tsx | 18 +- members/nullnet-server/ui/src/types.ts | 1 + 16 files changed, 312 insertions(+), 50 deletions(-) create mode 100644 members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/down.sql create mode 100644 members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/up.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index a23f72ce..c68172e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All Nullnet releases with the relative changes are documented in this file. ## [UNRELEASED] ### Added +- Optional service host pinning and automatic proxy TCP/UDP listen-port firewall allowances ([#184](https://github.com/NullNet-ai/nullnet/pull/184) — fixes [#177](https://github.com/NullNet-ai/nullnet/issues/177)) - Per-service egress/ingress traffic filters: arbitrary AND/OR/group combinations of Country, Organization, Src IP (ingress), and Dst IP (egress) conditions, evaluated via `rpn-predicate-interpreter` — replaces the country-only egress/ingress policy ([#171](https://github.com/NullNet-ai/nullnet/pull/171) — fixes [#143](https://github.com/NullNet-ai/nullnet/issues/143)) - Persist ingress and egress sessions to SQLite and show the full history on the Sessions page, filterable by status, service, direction, and policy verdict, with its own retention window ([#170](https://github.com/NullNet-ai/nullnet/pull/170) — fixes [#156](https://github.com/NullNet-ai/nullnet/issues/156)) ### Changed diff --git a/SETUP.md b/SETUP.md index ee69431a..6bfd1949 100644 --- a/SETUP.md +++ b/SETUP.md @@ -218,6 +218,8 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip the container name; matched against a running container - `process_path` — a listening process's exe path (`/proc//exe`); matched against a host (non-Docker) service +- `host_ip` optionally limits either match to one node's control-channel IPv4 address + (for example, `host_ip = "192.168.1.103"` for that host's SSH service). Omit it to match all hosts. - `timeout` controls proxy-reachability: when present the service is a proxy-reachable entry point with that per-client idle timeout in seconds (`0` disables the timeout); omit it to keep the service off the proxy (backend-only) @@ -233,6 +235,9 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip the external port nullnet-proxy binds directly and forwards raw traffic from. `listen_port` must be globally unique per protocol across every stack (the server refuses to start, or rejects a hot-reload, if two services claim the same `protocol`/`listen_port` pair) +- TCP/UDP `listen_port` values are automatically allowed through the proxy host's eBPF ingress + firewall at startup and refreshed within the client's 10-second service-report interval. + Backend `port` values are not opened; explicit firewall allowlists still apply. - `egress_filter`/`ingress_filter` restrict traffic with an AND/OR combination of conditions, evaluated via `rpn-predicate-interpreter` (the same postfix-expression engine `appguard-server/src/firewall/` uses). `groups` is OR-of-ANDs: every condition within a group must diff --git a/members/nullnet-client/src/ebpf/mod.rs b/members/nullnet-client/src/ebpf/mod.rs index 5ffe64cf..ac8d22a3 100644 --- a/members/nullnet-client/src/ebpf/mod.rs +++ b/members/nullnet-client/src/ebpf/mod.rs @@ -5,8 +5,8 @@ //! interface. Structural allow is nullnet control-plane (gRPC) + data-plane //! (VXLAN/forward to known peers) + ARP; a CT map then permits established //! returns. ICMP is always allowed (both directions). Everything else is an -//! explicit, env-driven allow: the four `{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTS` -//! lists (→ `ALLOW_PORTS` map). On the egress-gateway host all outbound is +//! server-decided allow: explicit port lists plus TCP/UDP proxy listen ports +//! (→ `ALLOW_PORTS` map). On the egress-gateway host all outbound is //! additionally allowed and tracked. Peers are added/removed from the `PEERS` map, //! and each VXLAN tunnel's per-tunnel dstport (paired with its specific peer) from //! the `VXLAN_PORTS` map, by the control channel as edges come and go. @@ -18,7 +18,7 @@ use aya::Ebpf; use aya::maps::{HashMap as AyaHashMap, MapData}; use nullnet_liberror::{Error, ErrorHandler, Location, location}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::Ipv4Addr; use std::sync::{Arc, Mutex}; @@ -28,10 +28,9 @@ const EGRESS_PROG: &str = "nullnet_fw_egress"; const PROTO_TCP: u8 = 6; const PROTO_UDP: u8 = 17; -/// Explicit firewall allow policy, decided globally by the server and delivered -/// in the `NetworkType` response at startup. Nothing is hardcoded: every -/// host-service port a node accepts or initiates to is listed here. nullnet's -/// own control/data plane and CT returns are always allowed. +/// Server-decided firewall policy delivered at startup. Ingress allowances +/// are refreshed with service reports; control/data plane and CT returns +/// are always allowed. pub struct FirewallConfig { pub server_ip: Ipv4Addr, pub control_port: u16, @@ -65,6 +64,7 @@ pub struct Firewall { #[allow(dead_code)] bpf: Ebpf, pub peers: Arc, + pub allow_ports: Arc>, pub vxlan_ports: Arc, } @@ -225,7 +225,7 @@ pub fn enable(iface: &str, cfg: &FirewallConfig) -> Result { cfg.egress_gateway ); - populate_allow_ports(&mut bpf, cfg)?; + let allow_ports = populate_allow_ports(&mut bpf, cfg)?; let peers_map: AyaHashMap = bpf .take_map("PEERS") @@ -243,6 +243,7 @@ pub fn enable(iface: &str, cfg: &FirewallConfig) -> Result { Ok(Firewall { bpf, + allow_ports: Arc::new(Mutex::new(allow_ports)), peers: Arc::new(FirewallPeers::new(peers_map)), vxlan_ports: Arc::new(FirewallVxlanPorts::new(vxlan_ports_map)), }) @@ -267,10 +268,37 @@ fn attach_classifier( Ok(()) } -/// Fill the ALLOW_PORTS map from the four explicit env lists. Each port is keyed -/// by direction + protocol (see `allow_key`); nothing is added implicitly. The map -/// is taken only to populate it; the attached programs keep it alive kernel-side. -fn populate_allow_ports(bpf: &mut Ebpf, cfg: &FirewallConfig) -> Result<(), Error> { +/// Retained map handle for refreshing server-decided ingress allowances. +pub struct FirewallAllowPorts { + map: AyaHashMap, + ingress: HashSet, +} + +impl FirewallAllowPorts { + pub fn update_ingress(&mut self, tcp: &[u32], udp: &[u32]) -> Result<(), Error> { + let mut desired = HashSet::new(); + for (proto, ports) in [(PROTO_TCP, tcp), (PROTO_UDP, udp)] { + for &port in ports { + let port = u16::try_from(port).handle_err(location!())?; + desired.insert(allow_key(false, proto, port)); + } + } + let removed: Vec<_> = self.ingress.difference(&desired).copied().collect(); + for key in removed { + self.map.remove(&key).handle_err(location!())?; + self.ingress.remove(&key); + } + for key in desired { + if !self.ingress.contains(&key) { + self.map.insert(key, 0u8, 0).handle_err(location!())?; + self.ingress.insert(key); + } + } + Ok(()) + } +} + +fn populate_allow_ports(bpf: &mut Ebpf, cfg: &FirewallConfig) -> Result { let mut map: AyaHashMap = bpf .take_map("ALLOW_PORTS") .ok_or("ALLOW_PORTS map not found in bytecode") @@ -283,12 +311,17 @@ fn populate_allow_ports(bpf: &mut Ebpf, cfg: &FirewallConfig) -> Result<(), Erro (true, PROTO_TCP, &cfg.egress_tcp), (true, PROTO_UDP, &cfg.egress_udp), ]; + let mut ingress = HashSet::new(); for (is_egress, proto, ports) in sets { for &p in ports { - let _ = map.insert(allow_key(is_egress, proto, p), 0u8, 0); + let key = allow_key(is_egress, proto, p); + map.insert(key, 0u8, 0).handle_err(location!())?; + if !is_egress { + ingress.insert(key); + } } } - Ok(()) + Ok(FirewallAllowPorts { map, ingress }) } fn raise_memlock_rlimit() { diff --git a/members/nullnet-client/src/main.rs b/members/nullnet-client/src/main.rs index 0a61d101..f88f48cc 100644 --- a/members/nullnet-client/src/main.rs +++ b/members/nullnet-client/src/main.rs @@ -224,10 +224,12 @@ async fn main() -> Result<(), Error> { liveness, ); + let firewall_allow_ports = ebpf_firewall.allow_ports.clone(); + // declare services + push the port→trigger-owners map to the NFQUEUE // listener on each refresh. tokio::spawn(async move { - declare_services(grpc_server, config_tx, docker_changed) + declare_services(grpc_server, config_tx, docker_changed, firewall_allow_ports) .await .expect("Failed to declare services"); }); @@ -336,6 +338,7 @@ async fn declare_services( grpc_server: NullnetGrpcInterface, config_tx: UnboundedSender, docker_changed: Arc, + firewall_allow_ports: Arc>, ) -> Result<(), Error> { let mut last_snapshot: Vec = Vec::new(); loop { @@ -399,6 +402,23 @@ async fn declare_services( }); } Ok(response) => { + let firewall_result = firewall_allow_ports.lock().unwrap().update_ingress( + &response.ingress_allow_tcp_ports, + &response.ingress_allow_udp_ports, + ); + if let Err(e) = firewall_result { + eprintln!("Failed to refresh eBPF ingress ports: {e:?}"); + let _ = grpc_server + .report_event(AgentEvent { + event: Some(AgentEventKind::FirewallRulesLoadFailed( + AgentFirewallRulesLoadFailed { + path: "ebpf ingress ports".to_string(), + error_message: format!("{e:?}"), + }, + )), + }) + .await; + } if snapshot != last_snapshot { last_snapshot = snapshot; let grpc = grpc_server.clone(); diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index c45c67f7..143b08b4 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -263,6 +263,8 @@ message Listener { // these ports, the client fires BackendTrigger(service_name, port). message ServicesListResponse { repeated ServiceTrigger service_triggers = 1; + repeated uint32 ingress_allow_tcp_ports = 2; + repeated uint32 ingress_allow_udp_ports = 3; } message ServiceTrigger { @@ -580,4 +582,4 @@ message AgentProxyRequestRouted { string service_name = 1; string client_ip message AgentTcpListenerBindFailed { uint32 listen_port = 1; string service_name = 2; string error_message = 3; } message AgentUdpListenerBindFailed { uint32 listen_port = 1; string service_name = 2; string error_message = 3; } message AgentTcpUpstreamConnectFailed { string service_name = 1; string client_ip = 2; string error_message = 3; } -message AgentUdpUpstreamConnectFailed { string service_name = 1; string client_ip = 2; string error_message = 3; } \ No newline at end of file +message AgentUdpUpstreamConnectFailed { string service_name = 1; string client_ip = 2; string error_message = 3; } diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index 985b524e..c73a786b 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -228,6 +228,10 @@ pub struct Listener { pub struct ServicesListResponse { #[prost(message, repeated, tag = "1")] pub service_triggers: ::prost::alloc::vec::Vec, + #[prost(uint32, repeated, tag = "2")] + pub ingress_allow_tcp_ports: ::prost::alloc::vec::Vec, + #[prost(uint32, repeated, tag = "3")] + pub ingress_allow_udp_ports: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ServiceTrigger { diff --git a/members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/down.sql b/members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/down.sql new file mode 100644 index 00000000..ccd6112f --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/down.sql @@ -0,0 +1 @@ +ALTER TABLE services DROP COLUMN host_ip; diff --git a/members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/up.sql b/members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/up.sql new file mode 100644 index 00000000..c03603fa --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-09-08-000001_service_host_ip/up.sql @@ -0,0 +1 @@ +ALTER TABLE services ADD COLUMN host_ip TEXT; diff --git a/members/nullnet-server/src/db/models.rs b/members/nullnet-server/src/db/models.rs index ad4a6eec..2abf5f96 100644 --- a/members/nullnet-server/src/db/models.rs +++ b/members/nullnet-server/src/db/models.rs @@ -71,6 +71,7 @@ pub(crate) struct ServiceRow { pub(crate) name: String, pub(crate) docker_container: Option, pub(crate) process_path: Option, + pub(crate) host_ip: Option, pub(crate) port: Option, pub(crate) timeout: Option, pub(crate) max_networks: Option, @@ -89,6 +90,7 @@ pub(crate) struct NewServiceRow<'a> { pub(crate) name: &'a str, pub(crate) docker_container: Option<&'a str>, pub(crate) process_path: Option<&'a str>, + pub(crate) host_ip: Option<&'a str>, pub(crate) port: Option, pub(crate) timeout: Option, pub(crate) max_networks: Option, diff --git a/members/nullnet-server/src/db/schema.rs b/members/nullnet-server/src/db/schema.rs index 48cc0ea4..a678f0c7 100644 --- a/members/nullnet-server/src/db/schema.rs +++ b/members/nullnet-server/src/db/schema.rs @@ -32,6 +32,7 @@ diesel::table! { name -> Text, docker_container -> Nullable, process_path -> Nullable, + host_ip -> Nullable, port -> Nullable, timeout -> Nullable, max_networks -> Nullable, diff --git a/members/nullnet-server/src/db/stacks.rs b/members/nullnet-server/src/db/stacks.rs index f9b56085..46ec27c3 100644 --- a/members/nullnet-server/src/db/stacks.rs +++ b/members/nullnet-server/src/db/stacks.rs @@ -18,6 +18,7 @@ pub(crate) struct ServiceInsert<'a> { pub(crate) name: &'a str, pub(crate) docker_container: Option<&'a str>, pub(crate) process_path: Option<&'a str>, + pub(crate) host_ip: Option<&'a str>, pub(crate) port: Option, pub(crate) timeout: Option, pub(crate) max_networks: Option, @@ -168,6 +169,7 @@ impl StackRepository { name: s.name, docker_container: s.docker_container, process_path: s.process_path, + host_ip: s.host_ip, port: s.port, timeout: s.timeout, max_networks: s.max_networks, @@ -283,6 +285,7 @@ mod tests { name, docker_container: Some("my-app_web"), process_path: None, + host_ip: Some("192.0.2.1"), port: Some(8080), timeout: Some(0), max_networks: None, @@ -306,6 +309,7 @@ mod tests { let services = repo.services_for("alpha").await.unwrap(); assert_eq!(services.len(), 1); assert_eq!(services[0].name, "web"); + assert_eq!(services[0].host_ip.as_deref(), Some("192.0.2.1")); assert_eq!(services[0].docker_container.as_deref(), Some("my-app_web")); let ids: Vec = services.iter().map(|s| s.id).collect(); diff --git a/members/nullnet-server/src/http_server/config.rs b/members/nullnet-server/src/http_server/config.rs index de192095..f4beea05 100644 --- a/members/nullnet-server/src/http_server/config.rs +++ b/members/nullnet-server/src/http_server/config.rs @@ -7,10 +7,12 @@ use super::AppState; use crate::events::Event as ServerEvent; +use crate::services::changes::{ServiceChange, apply_changes}; use crate::services::input::{ RouteMap, ServicesToml, StackMap, apply_config_update, detect_name_conflicts, detect_port_conflicts, detect_route_conflicts, }; +use crate::services::service_info::ServiceInfo; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use nullnet_liberror::Error; @@ -130,11 +132,35 @@ pub(super) async fn reload_and_apply(state: &AppState) -> Result<(), Error> { let route_conflicts = detect_route_conflicts(&loaded_routes); let name_conflicts = detect_name_conflicts(&loaded_services); if conflicts.is_empty() && route_conflicts.is_empty() && name_conflicts.is_empty() { + let mut index = state.match_index.write().await; { let mut services_mut = state.services.write().await; apply_config_update(&mut services_mut, loaded_services, &state.orchestrator).await; + for (stack, entries) in &loaded_index { + let stack_map = services_mut.get_mut(stack).unwrap(); + let mut changes = Vec::new(); + for entry in entries { + if let Some(host_ip) = entry.host_ip + && let Some(ServiceInfo::Registered(reg)) = stack_map.get(&entry.name) + { + for replica in reg.replicas() { + if replica.ip() != std::net::IpAddr::V4(host_ip) { + changes.push(ServiceChange::ReplicaRemoved { + name: entry.name.clone(), + ip: replica.ip(), + docker_container: replica + .docker_container() + .map(str::to_string), + }); + } + } + } + } + apply_changes(changes, stack_map, None, &state.orchestrator, stack).await; + } } - *state.match_index.write().await = loaded_index; + *index = loaded_index; + drop(index); *state.routes.write().await = loaded_routes; state.config_changed.notify_one(); state.port_mappings_changed.notify_one(); diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index dd8a0b18..3929fccc 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -124,6 +124,27 @@ fn build_port_mapping_bundle(stacks: &StackMap) -> PortMappingBundle { PortMappingBundle { mappings } } +fn ingress_allow_ports(stacks: &StackMap, proxy: bool) -> (Vec, Vec) { + let mut tcp = INGRESS_ALLOW_TCP_PORTS.clone(); + let mut udp = INGRESS_ALLOW_UDP_PORTS.clone(); + if proxy { + for info in stacks.values().flat_map(HashMap::values) { + if let Some(port) = info.listen_port() { + match info.protocol() { + ServiceProtocol::Tcp => tcp.push(u32::from(port)), + ServiceProtocol::Udp => udp.push(u32::from(port)), + ServiceProtocol::Http => {} + } + } + } + } + tcp.sort_unstable(); + tcp.dedup(); + udp.sort_unstable(); + udp.dedup(); + (tcp, udp) +} + /// Build the live HTTP `(host, path)` → target route table from the current /// `StackMap`/`RouteMap`: every explicit `[[route]]` entry, plus an implicit /// `{host = name, path = "/"} -> Service(name)` fallback for every @@ -749,45 +770,47 @@ impl NullnetGrpcImpl { // match registers a replica. let mut service_list_by_stack: HashMap)>> = HashMap::new(); - { - let index = self.match_index.read().await; - for (stack, entries) in index.iter() { - for entry in entries { - if let Some(key) = &entry.docker_container { - for c in report.containers.iter().filter(|c| &c.match_key == key) { - // Docker services need VXLAN: VLAN setup only puts a - // veth IP on the host, not into the container's netns. - if *NET_TYPE == Net::Vlan { - self.orchestrator - .events - .emit(Event::service_declaration_skipped( - sender_ip.to_string(), - entry.name.clone(), - "Docker services require VXLAN network type".to_string(), - )) - .await; - continue; - } - service_list_by_stack - .entry(stack.clone()) - .or_default() - .push((entry.name.clone(), entry.port, Some(c.real_name.clone()))); + let index = self.match_index.read().await; + for (stack, entries) in index.iter() { + for entry in entries { + if entry.host_ip.is_some_and(|ip| sender_ip != IpAddr::V4(ip)) { + continue; + } + if let Some(key) = &entry.docker_container { + for c in report.containers.iter().filter(|c| &c.match_key == key) { + // Docker services need VXLAN: VLAN setup only puts a + // veth IP on the host, not into the container's netns. + if *NET_TYPE == Net::Vlan { + self.orchestrator + .events + .emit(Event::service_declaration_skipped( + sender_ip.to_string(), + entry.name.clone(), + "Docker services require VXLAN network type".to_string(), + )) + .await; + continue; } - } - if let Some(path) = &entry.process_path - && report.listeners.iter().any(|l| &l.path == path) - { service_list_by_stack .entry(stack.clone()) .or_default() - .push((entry.name.clone(), entry.port, None)); + .push((entry.name.clone(), entry.port, Some(c.real_name.clone()))); } } + if let Some(path) = &entry.process_path + && report.listeners.iter().any(|l| &l.path == path) + { + service_list_by_stack + .entry(stack.clone()) + .or_default() + .push((entry.name.clone(), entry.port, None)); + } } } self.apply_services_list_by_stack(sender_ip, &service_list_by_stack) .await?; + drop(index); // Reap egress edges whose initiator container is no longer running on // this node (container died / dereg'd while the node stayed up). @@ -803,7 +826,13 @@ impl NullnetGrpcImpl { let guard = self.services.read().await; let service_triggers = build_service_triggers(&guard, &service_list_by_stack); - Ok(Response::new(ServicesListResponse { service_triggers })) + let (ingress_allow_tcp_ports, ingress_allow_udp_ports) = + ingress_allow_ports(&guard, Some(sender_ip) == *PROXY_IP); + Ok(Response::new(ServicesListResponse { + service_triggers, + ingress_allow_tcp_ports, + ingress_allow_udp_ports, + })) } pub(crate) async fn new_proxy_chain( @@ -2396,10 +2425,13 @@ impl NullnetGrpc for NullnetGrpcImpl { (Some(caller), Some(proxy)) => caller == proxy, _ => false, }; + let stacks = self.services.read().await; + let (ingress_allow_tcp_ports, ingress_allow_udp_ports) = + ingress_allow_ports(&stacks, egress_gateway); Ok(Response::new(NetType { net: (*NET_TYPE).into(), - ingress_allow_tcp_ports: INGRESS_ALLOW_TCP_PORTS.clone(), - ingress_allow_udp_ports: INGRESS_ALLOW_UDP_PORTS.clone(), + ingress_allow_tcp_ports, + ingress_allow_udp_ports, egress_allow_tcp_ports: EGRESS_ALLOW_TCP_PORTS.clone(), egress_allow_udp_ports: EGRESS_ALLOW_UDP_PORTS.clone(), egress_gateway, @@ -2905,3 +2937,100 @@ proxy_dependencies = [["color.com"]] ); } } + +#[cfg(test)] +mod host_pin_tests { + use super::*; + use crate::services::input::validate_stack_toml; + use nullnet_grpc_lib::nullnet_grpc::Listener; + use tonic::transport::server::TcpConnectInfo; + + #[tokio::test] + async fn ssh_host_pins_separate_identical_listeners() { + let (services, entries, _) = validate_stack_toml( + r#" +[[services]] +name = "ssh-a" +process_path = "/usr/sbin/sshd" +port = 22 +host_ip = "192.0.2.1" +[[services]] +name = "ssh-b" +process_path = "/usr/sbin/sshd" +port = 22 +host_ip = "192.0.2.2" +[[services]] +name = "shared" +process_path = "/usr/sbin/sshd" +port = 22 +"#, + ) + .unwrap(); + let server = NullnetGrpcImpl::new_for_test(HashMap::from([("pin".into(), services)])); + *server.match_index.write().await = HashMap::from([("pin".into(), entries)]); + for ip in ["192.0.2.1", "192.0.2.2"] { + let mut request = Request::new(ServiceReport { + containers: vec![], + listeners: vec![Listener { + path: "/usr/sbin/sshd".into(), + }], + }); + request.extensions_mut().insert(TcpConnectInfo { + local_addr: None, + remote_addr: Some(format!("{ip}:50000").parse().unwrap()), + }); + server.services_list_impl(request).await.unwrap(); + } + let stacks = server.services.read().await; + for (name, expected) in [ + ("ssh-a", vec!["192.0.2.1"]), + ("ssh-b", vec!["192.0.2.2"]), + ("shared", vec!["192.0.2.1", "192.0.2.2"]), + ] { + let ServiceInfo::Registered(reg) = &stacks["pin"][name] else { + panic!("unregistered {name}") + }; + let mut actual: Vec<_> = reg.replicas().iter().map(|r| r.ip().to_string()).collect(); + actual.sort(); + assert_eq!(actual, expected); + } + } + + #[test] + fn listen_ports_are_allowed_only_on_proxy_and_keep_explicit_ports() { + let (services, _, _) = validate_stack_toml( + r#" +[[services]] +name = "ssh" +protocol = "tcp" +listen_port = 22177 +[[services]] +name = "dns" +protocol = "udp" +listen_port = 53177 +[[services]] +name = "web" +process_path = "/usr/bin/web" +port = 49177 +"#, + ) + .unwrap(); + let stacks = HashMap::from([("ports".into(), services)]); + let (base_tcp, base_udp) = ingress_allow_ports(&stacks, false); + let (tcp, udp) = ingress_allow_ports(&stacks, true); + let mut expected_tcp = base_tcp; + expected_tcp.push(22177); + expected_tcp.sort_unstable(); + expected_tcp.dedup(); + let mut expected_udp = base_udp; + expected_udp.push(53177); + expected_udp.sort_unstable(); + expected_udp.dedup(); + assert_eq!(tcp, expected_tcp); + assert_eq!(udp, expected_udp); + assert_eq!( + ingress_allow_ports(&StackMap::new(), true), + ingress_allow_ports(&stacks, false) + ); + } +} diff --git a/members/nullnet-server/src/services/input.rs b/members/nullnet-server/src/services/input.rs index 03b0614b..5293cd58 100644 --- a/members/nullnet-server/src/services/input.rs +++ b/members/nullnet-server/src/services/input.rs @@ -24,6 +24,7 @@ pub(crate) struct MatchEntry { pub(crate) port: u16, pub(crate) docker_container: Option, pub(crate) process_path: Option, + pub(crate) host_ip: Option, } /// Stack name → its services' match entries. Rebuilt on every load/reload. @@ -217,6 +218,15 @@ pub(crate) fn detect_route_conflicts(routes: &RouteMap) -> Vec { fn build_match_entries(services: &[ServiceToml]) -> Result, Error> { let mut entries = Vec::new(); for s in services { + let host_ip = s + .host_ip + .as_deref() + .map(|ip| { + ip.parse::() + .map_err(|_| format!("service '{}': invalid host_ip '{ip}'", s.name)) + }) + .transpose() + .handle_err(location!())?; if s.docker_container.is_none() && s.process_path.is_none() { continue; } @@ -232,6 +242,7 @@ fn build_match_entries(services: &[ServiceToml]) -> Result, Erro port, docker_container: s.docker_container.clone(), process_path: s.process_path.clone(), + host_ip, }); } Ok(entries) @@ -704,6 +715,7 @@ fn services_from_rows( name: row.name, docker_container: row.docker_container, process_path: row.process_path, + host_ip: row.host_ip, port: row.port.and_then(|p| u16::try_from(p).ok()), timeout: row.timeout.and_then(|t| u64::try_from(t).ok()), max_networks: row.max_networks.and_then(|m| u32::try_from(m).ok()), @@ -730,6 +742,7 @@ pub(crate) fn services_to_inserts(services: &[ServiceToml]) -> Vec/exe`). A listener with this path registers a replica. process_path: Option, + /// Optional node IPv4 address, as seen by the control channel. + host_ip: Option, /// Backend port the overlay/proxy connects to on this service's replicas. /// Required when any host-match key is set. Distinct from `listen_port` /// (the proxy's external tcp/udp front port). @@ -1780,6 +1795,7 @@ proxy_dependencies = [["api"]] name: name.to_string(), docker_container: None, process_path: None, + host_ip: None, port: None, timeout: None, proxy_dependencies: Vec::new(), @@ -1803,6 +1819,7 @@ proxy_dependencies = [["api"]] fn service_row_conversion_round_trips_every_field() { let services = vec![ServiceToml { docker_container: Some("my-app_color".to_string()), + host_ip: Some("192.0.2.1".to_string()), port: Some(3001), timeout: Some(0), proxy_dependencies: vec![vec!["a.dep".to_string(), "b.dep".to_string()]], @@ -1842,6 +1859,7 @@ proxy_dependencies = [["api"]] name: insert.name.to_string(), docker_container: insert.docker_container.map(str::to_string), process_path: insert.process_path.map(str::to_string), + host_ip: insert.host_ip.map(str::to_string), port: insert.port, timeout: insert.timeout, max_networks: insert.max_networks, diff --git a/members/nullnet-server/ui/src/pages/Config.tsx b/members/nullnet-server/ui/src/pages/Config.tsx index 7aed8661..dd139e94 100644 --- a/members/nullnet-server/ui/src/pages/Config.tsx +++ b/members/nullnet-server/ui/src/pages/Config.tsx @@ -240,6 +240,7 @@ interface ServiceFormState { name: string; matchKind: MatchKind; matchValue: string; + hostIp: string; port: string; reachable: boolean; timeout: string; @@ -256,6 +257,7 @@ const EMPTY_FORM: ServiceFormState = { name: '', matchKind: 'docker', matchValue: '', + hostIp: '', port: '', reachable: false, timeout: '0', @@ -358,6 +360,7 @@ function serviceToForm(s: ServiceConfigJson): ServiceFormState { // won't validate until a real host match is filled in. matchKind: s.process_path ? 'process' : 'docker', matchValue: s.docker_container ?? s.process_path ?? '', + hostIp: s.host_ip ?? '', port: s.port != null ? String(s.port) : '', reachable: s.timeout != null, timeout: s.timeout != null ? String(s.timeout) : '0', @@ -380,6 +383,7 @@ function formToService(f: ServiceFormState): ServiceConfigJson { name: f.name.trim(), docker_container: f.matchKind === 'docker' ? f.matchValue.trim() : null, process_path: f.matchKind === 'process' ? f.matchValue.trim() : null, + host_ip: f.hostIp.trim() || null, port: f.port.trim() !== '' ? Number(f.port) : null, timeout: f.reachable ? Number(f.timeout || '0') : null, proxy_dependencies: f.dependencies.map(chain).filter(branch => branch.length > 0), @@ -395,8 +399,8 @@ function formToService(f: ServiceFormState): ServiceConfigJson { } function matchLabel(s: ServiceConfigJson): string { - if (s.docker_container) return `docker: ${s.docker_container}`; - if (s.process_path) return `process: ${s.process_path}`; + if (s.docker_container) return `docker: ${s.docker_container}${s.host_ip ? ` @ ${s.host_ip}` : ''}`; + if (s.process_path) return `process: ${s.process_path}${s.host_ip ? ` @ ${s.host_ip}` : ''}`; return '—'; } @@ -809,6 +813,16 @@ export default function Config() { spellCheck={false} /> +