From c4bf0d442e75184968ee226ac3bc4c31a1013f21 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Tue, 21 Jul 2026 18:57:54 +0200 Subject: [PATCH 1/9] server-side join services, drop client services.toml --- Cargo.lock | 1 - README.md | 83 +++++++------- members/nullnet-client/Cargo.toml | 1 - members/nullnet-client/services.toml | 13 --- members/nullnet-client/src/main.rs | 98 ++++++++--------- members/nullnet-grpc-lib/build.rs | 2 - .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 27 +++-- members/nullnet-grpc-lib/src/lib.rs | 7 +- .../src/proto/nullnet_grpc.rs | 52 +++++---- .../nullnet-server/services/sample-stack.toml | 4 + .../nullnet-server/src/nullnet_grpc_impl.rs | 101 ++++++++++-------- members/nullnet-server/src/services/input.rs | 81 +++++++++++--- 12 files changed, 268 insertions(+), 202 deletions(-) delete mode 100644 members/nullnet-client/services.toml diff --git a/Cargo.lock b/Cargo.lock index 8af985b6..fcfd7a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2816,7 +2816,6 @@ dependencies = [ "rtnetlink", "serde", "tokio", - "toml", "tun-rs", ] diff --git a/README.md b/README.md index 81ce3bae..88a31e8c 100644 --- a/README.md +++ b/README.md @@ -68,20 +68,57 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip `members/nullnet-server/services/`. The filename (minus `.toml`) is the stack name. For example, to define a stack called `my-app`, create `services/my-app.toml`: ``` - [[services]] + [[services]] # http entry point, backed by a Docker container name = "color.com" timeout = 0 + docker_container = "my-app_color" + port = 3001 proxy_dependencies = [["fs.color.com"]] [[services.triggers]] port = 5555 chain = ["ts.color.com"] - [[services]] + [[services]] # backend-only dep of color.com name = "fs.color.com" - ... + docker_container = "my-app_fs" + port = 8080 + + [[services]] # backend trigger target — port matches the trigger (5555) + name = "ts.color.com" + docker_container = "my-app_ts" + port = 5555 + + [[services]] # host (non-Docker) service, matched by process + name = "metrics.com" + timeout = 0 + process_path = "/usr/local/bin/metrics-exporter" + port = 9090 + + [[services]] # raw tcp — proxy binds listen_port and forwards + name = "redis.internal" + timeout = 0 + docker_container = "my-app_redis" + port = 6379 + protocol = "tcp" + listen_port = 6379 + + [[services]] # egress country policy + name = "api.internal" + timeout = 0 + docker_container = "my-app_api" + port = 8000 + blocked_countries = ["RU", "CN"] ``` +- a service is **hostable** when it declares a match key plus a `port` (the backend port replicas + are reached on). Clients hold no service file: each reports its raw local observations and the + server matches them against these keys. A container/process may match several services across + stacks; every match registers a replica: + - `docker_container` — the Swarm service label (`com.docker.swarm.service.name`) or, standalone, + 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 - `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) @@ -97,21 +134,7 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip `Host` header on the shared 80/443 listeners) or `tcp`/`udp`, which each require `listen_port` — 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): - ``` - [[services]] - name = "redis.internal" - timeout = 0 - protocol = "tcp" - listen_port = 6379 - - [[services]] - name = "dns.internal" - timeout = 0 - protocol = "udp" - listen_port = 53 - ``` - + hot-reload, if two services claim the same `protocol`/`listen_port` pair) - `blocked_countries` / `allowed_countries` restrict where a service may reach on the internet, by ISO alpha-2 country code (the destination IP is geo-resolved server-side). `blocked_countries` denies the listed countries and allows everything else (including IPs with unknown geo); @@ -119,13 +142,7 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip included). The two are mutually exclusive — setting both is a hard config error. Enforcement is at the initiator's nullnet-client: the first packet of each new external flow is held and verdicted, denied destinations show a `BLOCKED` chip in the topology UI, and editing the policy at runtime - tears down already-established flows that the new policy forbids: - ``` - [[services]] - name = "api.internal" - timeout = 0 - blocked_countries = ["RU", "CN"] - ``` + tears down already-established flows that the new policy forbids - run the project as a daemon (from the repo root) ``` @@ -188,22 +205,6 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip (and `22`) in `INGRESS_ALLOW_TCP_PORTS` there. Every other (strict) node obeys the allowlist in both directions. -- service configuration must be stored at `members/nullnet-client/services.toml`. Each entry - must declare its `stack` (which must match a `services/.toml` on the server, otherwise - the declaration is dropped): - ``` - # services = [] # use this if you don't want to declare any service - - [[services]] - name = "color.com" - port = 3001 - docker_container = "stack-name_container-name" # should correspond to the label "com.docker.swarm.service.name" - stack = "my-app" - - [[services]] - ... - ``` - - run the project as a daemon (from the repo root) ``` ./setup-client.sh diff --git a/members/nullnet-client/Cargo.toml b/members/nullnet-client/Cargo.toml index dcbb664a..43e52dba 100644 --- a/members/nullnet-client/Cargo.toml +++ b/members/nullnet-client/Cargo.toml @@ -14,7 +14,6 @@ serde = { version = "1.0.228", default-features = false, features = ["derive", " nullnet-liberror.workspace = true nullnet-grpc-lib.workspace = true ipnetwork = { workspace = true, features = ["serde"] } -toml.workspace = true listeners = "0.5.1" rtnetlink = "0.20.0" futures = "0.3.32" diff --git a/members/nullnet-client/services.toml b/members/nullnet-client/services.toml deleted file mode 100644 index 1631d2ee..00000000 --- a/members/nullnet-client/services.toml +++ /dev/null @@ -1,13 +0,0 @@ -services = [] - -#[[services]] -#name = "color.com" -#port = 3001 -#docker_container = "my-stack_actix-sample" -#stack = "my-stack" - -#[[services]] -#name = "fs.color.com" -#port = 8080 -#docker_container = "my-stack_fileserver" -#stack = "my-stack" diff --git a/members/nullnet-client/src/main.rs b/members/nullnet-client/src/main.rs index 15f52c74..c10cc79f 100644 --- a/members/nullnet-client/src/main.rs +++ b/members/nullnet-client/src/main.rs @@ -15,8 +15,8 @@ use crate::triggers::TriggersState; use clap::Parser; use nullnet_grpc_lib::NullnetGrpcInterface; use nullnet_grpc_lib::nullnet_grpc::{ - AgentEvent, AgentServicesListUpdateFailed, AgentServicesListUpdated, Net, Services, - agent_event::Event as AgentEventKind, + AgentEvent, AgentServicesListUpdateFailed, AgentServicesListUpdated, Container, Listener, Net, + ServiceReport, agent_event::Event as AgentEventKind, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::HashMap; @@ -281,60 +281,50 @@ async fn declare_services( config_tx: UnboundedSender>, docker_changed: Arc, ) -> Result<(), Error> { - let mut last_declared: Vec = Vec::new(); + let mut last_snapshot: Vec = Vec::new(); loop { - // read services from file - let services_toml = tokio::fs::read_to_string("services.toml") + // Report raw local observations; the server joins them against its + // per-stack config to decide what this node hosts. Running containers: + // logical (Swarm label / name) -> real container name(s). + let containers: Vec = get_running_docker_containers() .await - .handle_err(location!())?; - let mut services: Services = toml::from_str(&services_toml).handle_err(location!())?; - - // get the map of logical name -> real container name (supports both standalone and Swarm) - let running_containers = get_running_docker_containers().await; - // get the list of actively listening ports on the host - let listeners = listeners::get_all().handle_err(location!())?; - - // only declare services that are actually running - // For Swarm, a single service name may map to multiple containers (replicas), - // so we expand each service entry into one entry per running container. - let file_services = services.services; - services.services = Vec::new(); - for service in file_services { - if let Some(container) = &service.docker_container { - if let Some(real_names) = running_containers.get(container.as_str()) { - for real_name in real_names { - let mut s = service.clone(); - s.docker_container = Some(real_name.clone()); - services.services.push(s); - } - } - } else { - // Host services: only declare if the port is actively listening - if listeners - .iter() - .any(|listener| u32::from(listener.socket.port()) == service.port) - { - services.services.push(service); - } - } - } - - println!("Declaring services to gRPC server: {services:?}"); - let num_services = services.services.len() as u32; + .into_iter() + .flat_map(|(match_key, real_names)| { + real_names.into_iter().map(move |real_name| Container { + match_key: match_key.clone(), + real_name, + }) + }) + .collect(); + + // One Listener per distinct listening process, keyed by exe path. + let mut paths: Vec = listeners::get_all() + .handle_err(location!())? + .into_iter() + .map(|l| l.process.path) + .collect(); + paths.sort_unstable(); + paths.dedup(); + let report = ServiceReport { + containers, + listeners: paths.into_iter().map(|path| Listener { path }).collect(), + }; + + println!("Reporting to gRPC server: {report:?}"); + let num_services = (report.containers.len() + report.listeners.len()) as u32; // canonical snapshot for change detection (order-independent) - let mut current = services.services.clone(); - current.sort_by(|a, b| { - a.name - .cmp(&b.name) - .then(a.stack.cmp(&b.stack)) - .then(a.port.cmp(&b.port)) - .then(a.docker_container.cmp(&b.docker_container)) - }); - - // send services to gRPC server; response carries the trigger ports - // attached to the services we just declared as hosting. - match grpc_server.services_list(services).await { + let mut snapshot: Vec = report + .containers + .iter() + .map(|c| format!("c:{}|{}", c.match_key, c.real_name)) + .chain(report.listeners.iter().map(|l| format!("l:{}", l.path))) + .collect(); + snapshot.sort_unstable(); + + // send observations to gRPC server; response carries the trigger ports + // attached to the services the server matched us to. + match grpc_server.services_list(report).await { Err(e) => { eprintln!("services_list failed: {e}"); let grpc = grpc_server.clone(); @@ -353,8 +343,8 @@ async fn declare_services( }); } Ok(response) => { - if current != last_declared { - last_declared = current; + if snapshot != last_snapshot { + last_snapshot = snapshot; let grpc = grpc_server.clone(); tokio::spawn(async move { let _ = grpc diff --git a/members/nullnet-grpc-lib/build.rs b/members/nullnet-grpc-lib/build.rs index 3ba0ae5a..c305546f 100644 --- a/members/nullnet-grpc-lib/build.rs +++ b/members/nullnet-grpc-lib/build.rs @@ -4,8 +4,6 @@ const PROTOBUF_DIR_PATH: &str = "./proto"; fn main() { tonic_prost_build::configure() .out_dir("./src/proto") - .type_attribute("nullnet_grpc.Services", "#[derive(serde::Deserialize)]") - .type_attribute("nullnet_grpc.Service", "#[derive(serde::Deserialize)]") .compile_protos(&[NULLNET_GRPC_PATH], &[PROTOBUF_DIR_PATH]) .expect("Protobuf files generation failed"); } diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 78f47fae..15fd3884 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -8,9 +8,11 @@ service NullnetGrpc { // Network type declaration rpc NetworkType(Empty) returns (NetType); - // Services list — response carries the trigger ports the caller should - // observe locally for the services it just declared as hosting. - rpc ServicesList(Services) returns (ServicesListResponse); + // Host report — the client sends its raw local observations (running + // containers + listening processes); the server joins them against the + // per-stack service config. The response carries the trigger ports the + // caller should observe locally for the services it was matched to. + rpc ServicesList(ServiceReport) returns (ServicesListResponse); // Control channel rpc ControlChannel(stream MsgId) returns (stream NetMessage); @@ -186,15 +188,20 @@ message MsgId { string id = 1; } -message Services { - repeated Service services = 1; +// Raw local observations a client reports; the server joins them against the +// match keys in services/.toml. One observation may match many services. +message ServiceReport { + repeated Container containers = 1; + repeated Listener listeners = 2; } -message Service { - string name = 1; - uint32 port = 2; - optional string docker_container = 3; - string stack = 4; +message Container { + string match_key = 1; // matched against a service's docker_container (Swarm label / container name) + string real_name = 2; // actual container name; stored as the replica identity +} + +message Listener { + string path = 1; // /proc//exe of the listening process; matched against process_path } // Response to ServicesList: per-declared-service, the set of trigger ports diff --git a/members/nullnet-grpc-lib/src/lib.rs b/members/nullnet-grpc-lib/src/lib.rs index ab77a28b..770bd6e6 100644 --- a/members/nullnet-grpc-lib/src/lib.rs +++ b/members/nullnet-grpc-lib/src/lib.rs @@ -4,7 +4,7 @@ use crate::nullnet_grpc::nullnet_grpc_client::NullnetGrpcClient; use crate::nullnet_grpc::{ AgentEvent, BackendTriggerRequest, CertBundle, EgressDestinationEntry, EgressDestinationReport, EgressPolicyCheck, EgressTriggerRequest, Empty, MsgId, NetMessage, NetType, PortMappingBundle, - ProxyRequest, Services, ServicesListResponse, Upstream, + ProxyRequest, ServiceReport, ServicesListResponse, Upstream, }; pub use proto::*; use tokio::sync::mpsc; @@ -91,7 +91,10 @@ impl NullnetGrpcInterface { } #[allow(clippy::missing_errors_doc)] - pub async fn services_list(&self, message: Services) -> Result { + pub async fn services_list( + &self, + message: ServiceReport, + ) -> Result { self.client .clone() .services_list(Request::new(message)) diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index ab5263df..b0157c95 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -170,23 +170,29 @@ pub struct MsgId { #[prost(string, tag = "1")] pub id: ::prost::alloc::string::String, } -#[derive(serde::Deserialize)] +/// Raw local observations a client reports; the server joins them against the +/// match keys in services/.toml. One observation may match many services. #[derive(Clone, PartialEq, ::prost::Message)] -pub struct Services { +pub struct ServiceReport { #[prost(message, repeated, tag = "1")] - pub services: ::prost::alloc::vec::Vec, + pub containers: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "2")] + pub listeners: ::prost::alloc::vec::Vec, } -#[derive(serde::Deserialize)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct Service { +pub struct Container { + /// matched against a service's docker_container (Swarm label / container name) #[prost(string, tag = "1")] - pub name: ::prost::alloc::string::String, - #[prost(uint32, tag = "2")] - pub port: u32, - #[prost(string, optional, tag = "3")] - pub docker_container: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, tag = "4")] - pub stack: ::prost::alloc::string::String, + pub match_key: ::prost::alloc::string::String, + /// actual container name; stored as the replica identity + #[prost(string, tag = "2")] + pub real_name: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Listener { + /// /proc//exe of the listening process; matched against process_path + #[prost(string, tag = "1")] + pub path: ::prost::alloc::string::String, } /// Response to ServicesList: per-declared-service, the set of trigger ports /// the client should observe via eBPF. When traffic is observed on one of @@ -824,11 +830,13 @@ pub mod nullnet_grpc_client { .insert(GrpcMethod::new("nullnet_grpc.NullnetGrpc", "NetworkType")); self.inner.unary(req, path, codec).await } - /// Services list — response carries the trigger ports the caller should - /// observe locally for the services it just declared as hosting. + /// Host report — the client sends its raw local observations (running + /// containers + listening processes); the server joins them against the + /// per-stack service config. The response carries the trigger ports the + /// caller should observe locally for the services it was matched to. pub async fn services_list( &mut self, - request: impl tonic::IntoRequest, + request: impl tonic::IntoRequest, ) -> std::result::Result< tonic::Response, tonic::Status, @@ -1102,11 +1110,13 @@ pub mod nullnet_grpc_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; - /// Services list — response carries the trigger ports the caller should - /// observe locally for the services it just declared as hosting. + /// Host report — the client sends its raw local observations (running + /// containers + listening processes); the server joins them against the + /// per-stack service config. The response carries the trigger ports the + /// caller should observe locally for the services it was matched to. async fn services_list( &self, - request: tonic::Request, + request: tonic::Request, ) -> std::result::Result< tonic::Response, tonic::Status, @@ -1320,7 +1330,9 @@ pub mod nullnet_grpc_server { "/nullnet_grpc.NullnetGrpc/ServicesList" => { #[allow(non_camel_case_types)] struct ServicesListSvc(pub Arc); - impl tonic::server::UnaryService + impl< + T: NullnetGrpc, + > tonic::server::UnaryService for ServicesListSvc { type Response = super::ServicesListResponse; type Future = BoxFuture< @@ -1329,7 +1341,7 @@ pub mod nullnet_grpc_server { >; fn call( &mut self, - request: tonic::Request, + request: tonic::Request, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { diff --git a/members/nullnet-server/services/sample-stack.toml b/members/nullnet-server/services/sample-stack.toml index d003f4ca..ed316acf 100644 --- a/members/nullnet-server/services/sample-stack.toml +++ b/members/nullnet-server/services/sample-stack.toml @@ -1,6 +1,8 @@ [[services]] name = "color.com" timeout = 0 +docker_container = "sample-stack_color" +port = 3001 proxy_dependencies = [["fs.color.com"]] max_networks = 2 @@ -11,3 +13,5 @@ chain = ["ts.color.com"] [[services]] name = "fs.color.com" timeout = 30 +docker_container = "sample-stack_fs" +port = 8080 \ No newline at end of file diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 72edea64..795a713f 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -9,14 +9,14 @@ use crate::services::changes::{ }; use crate::services::clients::{Client, ClientInfo}; use crate::services::edge::{Edge, RegisteredEdge}; -use crate::services::input::{ServicesToml, StackMap}; +use crate::services::input::{MatchIndex, ServicesToml, StackMap}; use crate::services::service_info::{EgressPolicy, ServiceInfo, backend_involved_services}; use crate::timeout::check_timeouts; use nullnet_grpc_lib::nullnet_grpc::nullnet_grpc_server::NullnetGrpc; use nullnet_grpc_lib::nullnet_grpc::{ AgentEvent, BackendTriggerRequest, CertBundle, EgressDestinationReport, EgressPolicyCheck, EgressPolicyVerdict, EgressTriggerRequest, Empty, MsgId, Net, NetMessage, NetType, PortMapping, - PortMappingBundle, ProxyRequest, ServiceTrigger, Services, ServicesListResponse, Upstream, + PortMappingBundle, ProxyRequest, ServiceReport, ServiceTrigger, ServicesListResponse, Upstream, agent_event::Event as AgentEventKind, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; @@ -31,6 +31,9 @@ use tonic::{Request, Response, Status, Streaming}; pub(crate) struct NullnetGrpcImpl { /// The available services, partitioned by stack name. services: Arc>, + /// Host-match index (stack → match entries), rebuilt alongside `services`. + /// Used to join a client's raw observations to the services it hosts. + match_index: Arc>, /// Orchestrator to manage TAP-based clients and NET setups orchestrator: Orchestrator, /// Latest TLS certificate set, kept in sync with `./certs` by a watcher. @@ -81,7 +84,9 @@ fn find_service_stack<'a>(services: &'a StackMap, service_name: &str) -> Option< impl NullnetGrpcImpl { pub async fn new() -> Result { - let services = Arc::new(RwLock::new(ServicesToml::load_validated().await?)); + let (stacks, index) = ServicesToml::load_validated().await?; + let services = Arc::new(RwLock::new(stacks)); + let match_index = Arc::new(RwLock::new(index)); // regenerate the service graphviz periodically for debugging let services_2 = services.clone(); @@ -98,12 +103,14 @@ impl NullnetGrpcImpl { // keep services up to date with the services.toml file let services_2 = services.clone(); + let match_index_2 = match_index.clone(); let orchestrator_2 = orchestrator.clone(); let config_changed_2 = config_changed.clone(); let port_mappings_changed_2 = port_mappings_changed.clone(); tokio::spawn(async move { if let Err(e) = ServicesToml::watch( &services_2, + &match_index_2, orchestrator_2, config_changed_2, port_mappings_changed_2, @@ -145,6 +152,7 @@ impl NullnetGrpcImpl { Ok(NullnetGrpcImpl { services, + match_index, orchestrator, certs: certs_rx, port_mappings: port_mappings_rx, @@ -311,7 +319,7 @@ impl NullnetGrpcImpl { async fn services_list_impl( &self, - request: Request, + request: Request, ) -> Result, Error> { let sender_ip = request .remote_addr() @@ -319,53 +327,55 @@ impl NullnetGrpcImpl { .handle_err(location!())? .ip(); - let req = request.into_inner(); + let report = request.into_inner(); println!( - "Received services list from '{}': {:?}", - sender_ip, req.services + "Received service report from '{}': {} container(s), {} listener(s)", + sender_ip, + report.containers.len(), + report.listeners.len() ); - // Skip malformed/unsupported entries per-entry (emitting a warning - // event so the UI surfaces it) rather than failing the whole batch — - // one bad entry must not stop a node's valid services from registering. + // Join the sender's raw observations against the config match index. A + // container/listener may match several services across stacks; every + // match registers a replica. let mut service_list_by_stack: HashMap)>> = HashMap::new(); - for s in req.services { - let skip = |reason: String| { - Event::service_declaration_skipped(sender_ip.to_string(), s.name.clone(), reason) - }; - if s.stack.is_empty() { - self.orchestrator - .events - .emit(skip("missing required 'stack' field".to_string())) - .await; - continue; - } - // Docker services can only be wired into an overlay via VXLAN (the - // container's netns is plumbed by vxlan-setup); VLAN setup only puts - // a veth IP on the host. - if *NET_TYPE == Net::Vlan && s.docker_container.is_some() { - self.orchestrator - .events - .emit(skip( - "Docker services require VXLAN network type".to_string(), - )) - .await; - continue; + { + 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()))); + } + } + 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)); + } + } } - let Ok(port) = u16::try_from(s.port) else { - self.orchestrator - .events - .emit(skip(format!("port {} out of range", s.port))) - .await; - continue; - }; - service_list_by_stack.entry(s.stack).or_default().push(( - s.name, - port, - s.docker_container, - )); } self.apply_services_list_by_stack(sender_ip, &service_list_by_stack) @@ -1361,6 +1371,7 @@ impl NullnetGrpcImpl { let (_, port_mappings) = watch::channel(PortMappingBundle::default()); NullnetGrpcImpl { services: Arc::new(RwLock::new(services)), + match_index: Arc::new(RwLock::new(MatchIndex::new())), orchestrator: Orchestrator::new(), certs, port_mappings, @@ -1389,7 +1400,7 @@ impl NullnetGrpc for NullnetGrpcImpl { async fn services_list( &self, - req: Request, + req: Request, ) -> Result, Status> { self.services_list_impl(req) .await diff --git a/members/nullnet-server/src/services/input.rs b/members/nullnet-server/src/services/input.rs index 875235d6..7f40cc34 100644 --- a/members/nullnet-server/src/services/input.rs +++ b/members/nullnet-server/src/services/input.rs @@ -20,20 +20,60 @@ const SERVICES_DIR: &str = "./services"; /// Top-level state: stack name → per-stack service map. pub(crate) type StackMap = HashMap>; +/// A service's host-match keys + backend port, used to join client observations +/// to `(stack, service)`. Built from config; an observation may match several. +#[derive(Clone, Debug)] +pub(crate) struct MatchEntry { + pub(crate) name: String, + pub(crate) port: u16, + pub(crate) docker_container: Option, + pub(crate) process_path: Option, +} + +/// Stack name → its services' match entries. Rebuilt on every load/reload. +pub(crate) type MatchIndex = HashMap>; + +/// Match entries for one file: only services with a match key are hostable, and +/// such a service must declare a `port`. +fn build_match_entries(services: &[ServiceToml]) -> Result, Error> { + let mut entries = Vec::new(); + for s in services { + if s.docker_container.is_none() && s.process_path.is_none() { + continue; + } + let Some(port) = s.port else { + return Err(format!( + "service '{}': has a host-match key (docker_container/process_path) but no 'port'", + s.name + )) + .handle_err(location!()); + }; + entries.push(MatchEntry { + name: s.name.clone(), + port, + docker_container: s.docker_container.clone(), + process_path: s.process_path.clone(), + }); + } + Ok(entries) +} + #[derive(Deserialize)] pub(crate) struct ServicesToml { services: Vec, } impl ServicesToml { - pub(crate) async fn load() -> Result { + pub(crate) async fn load() -> Result<(StackMap, MatchIndex), Error> { Self::load_from_dir(SERVICES_DIR).await } /// Load every `*.toml` file under `dir`; the file stem is the stack name. - pub(crate) async fn load_from_dir(dir: &str) -> Result { + /// Returns the service map and the parallel host-match index. + pub(crate) async fn load_from_dir(dir: &str) -> Result<(StackMap, MatchIndex), Error> { let mut entries = tokio::fs::read_dir(dir).await.handle_err(location!())?; let mut stacks: StackMap = HashMap::new(); + let mut index: MatchIndex = HashMap::new(); while let Some(entry) = entries.next_entry().await.handle_err(location!())? { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("toml") { @@ -46,24 +86,25 @@ impl ServicesToml { else { continue; }; - let services = parse_file(&path).await?; + let (services, match_entries) = parse_file(&path).await?; println!("Loaded stack '{stack}': {services:?}"); - stacks.insert(stack, services); + stacks.insert(stack.clone(), services); + index.insert(stack, match_entries); } - Ok(stacks) + Ok((stacks, index)) } /// Load a single file as one stack's service map. Used by tests. #[cfg(test)] pub(crate) async fn load_file(path: &str) -> Result, Error> { - parse_file(Path::new(path)).await + Ok(parse_file(Path::new(path)).await?.0) } /// Load every stack and fail loudly if any `(protocol, listen_port)` pair /// is claimed by more than one service — ports are global on the proxy, /// unlike service names which only need to be unique within a stack. - pub(crate) async fn load_validated() -> Result { - let stacks = Self::load().await?; + pub(crate) async fn load_validated() -> Result<(StackMap, MatchIndex), Error> { + let (stacks, index) = Self::load().await?; let conflicts = detect_port_conflicts(&stacks); if let Some(first) = conflicts.first() { return Err(format!( @@ -79,7 +120,7 @@ impl ServicesToml { )) .handle_err(location!()); } - Ok(stacks) + Ok((stacks, index)) } /// `config_changed` and `port_mappings_changed` are separate `Notify`s — @@ -89,6 +130,7 @@ impl ServicesToml { /// them race for each wake-up instead of both reliably observing it. pub(crate) async fn watch( services: &Arc>, + match_index: &Arc>, orchestrator: Orchestrator, config_changed: Arc, port_mappings_changed: Arc, @@ -126,12 +168,13 @@ impl ServicesToml { // ensure file changes are propagated tokio::time::sleep(Duration::from_millis(100)).await; match ServicesToml::load().await { - Ok(loaded_services) => { + Ok((loaded_services, loaded_index)) => { let conflicts = detect_port_conflicts(&loaded_services); if conflicts.is_empty() { let services_mut = &mut *services.write().await; apply_config_update(services_mut, loaded_services, &orchestrator) .await; + *match_index.write().await = loaded_index; config_changed.notify_one(); port_mappings_changed.notify_one(); } else { @@ -299,12 +342,13 @@ fn upper(list: Vec) -> Vec { list.into_iter().map(|c| c.to_uppercase()).collect() } -async fn parse_file(path: &Path) -> Result, Error> { +async fn parse_file(path: &Path) -> Result<(HashMap, Vec), Error> { let str_repr = tokio::fs::read_to_string(path) .await .handle_err(location!())?; let parsed: ServicesToml = toml::from_str(&str_repr).handle_err(location!())?; - parsed.services_map() + let match_entries = build_match_entries(&parsed.services)?; + Ok((parsed.services_map()?, match_entries)) } /// All non-default egress policies, keyed by (stack, service) — the comparable @@ -380,6 +424,17 @@ pub(crate) async fn apply_config_update( #[derive(Deserialize)] struct ServiceToml { name: String, + /// Host-match key for a Docker service: the Swarm service label + /// (`com.docker.swarm.service.name`) or, in standalone mode, the container + /// name. A running container whose label equals this registers a replica. + docker_container: Option, + /// Host-match key for a non-Docker service: the listening process's exe path + /// (`/proc//exe`). A listener with this path registers a replica. + process_path: 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). + port: Option, /// Proxy-reachability for this service. Present → reachable entry point /// with this per-client timeout in seconds (0 disables the timeout). /// Omitted → not proxy-reachable (backend-only); the service is still @@ -556,7 +611,7 @@ timeout = 30 .await .unwrap(); - let stacks = ServicesToml::load_from_dir(dir.to_str().unwrap()) + let (stacks, _index) = ServicesToml::load_from_dir(dir.to_str().unwrap()) .await .expect("load"); From 5d20b4292941f0766aea2a07ee2103fd736a81cf Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 22 Jul 2026 12:39:56 +0200 Subject: [PATCH 2/9] config: decide eBPF firewall allowlist server-side, push via NetworkType --- README.md | 35 +++++++------ members/nullnet-client/src/ebpf/mod.rs | 7 +-- members/nullnet-client/src/env.rs | 49 ------------------- members/nullnet-client/src/main.rs | 22 ++++++--- .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 10 ++++ .../src/proto/nullnet_grpc.rs | 17 ++++++- members/nullnet-server/src/env.rs | 29 +++++++++++ .../nullnet-server/src/nullnet_grpc_impl.rs | 18 ++++++- 8 files changed, 108 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 88a31e8c..c405cd35 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip CERT_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars> PROXY_IP=192.168.1.100 ENCRYPTION_ENABLED=true + INGRESS_ALLOW_TCP_PORTS=22,8080 # inbound TCP listeners every node accepts + INGRESS_ALLOW_UDP_PORTS= # inbound UDP listeners (e.g. Swarm gossip 7946) + EGRESS_ALLOW_TCP_PORTS= # outbound TCP dsts (e.g. 80,443 for updates) + EGRESS_ALLOW_UDP_PORTS=53,123 # outbound UDP dsts (DNS, NTP) ``` `CERT_ENCRYPTION_KEY` is **required** — the server refuses to start without it. It encrypts TLS certificate private keys (and the DNS-provider credentials of ACME-issued certs) at rest; @@ -55,6 +59,15 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip for VXLAN) and **defaults to `true`** — omit it to keep encryption on. Set it to `false`/`0`/`no` to run tunnels unencrypted instead (a bare vxlan/veth link, no XFRM SA/policy or MACsec). + The four `{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTS` lists are the **global** host-NIC firewall + allowlist — decided here once (single point of decision) and delivered to every node in its + `NetworkType` response at startup. They apply uniformly to all clients (matched on destination + port). **Put `22` in `INGRESS_ALLOW_TCP_PORTS`** or every strict node loses SSH the moment its + client starts. A node that needs name resolution / time sync needs `EGRESS_ALLOW_UDP_PORTS=53,123`; + DHCP renewal needs `67` (and inbound `68` for broadcast replies). The gateway node (the one whose + IP equals `PROXY_IP`) is switched to gateway posture automatically — all outbound allowed and + tracked — so no per-node flag is needed; inbound there still obeys these lists, so include `80,443`. + - TLS certificates are issued from Let's Encrypt via a DNS-01 challenge (UI: *Certificates* page). Each cert stores its DNS-provider credentials encrypted at rest and is **renewed automatically** before expiry. The renewal scan is tunable via optional env vars (defaults shown): @@ -184,26 +197,16 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip ``` CONTROL_SERVICE_ADDR=192.168.1.100 CONTROL_SERVICE_PORT=50051 - INGRESS_ALLOW_TCP_PORTS=22,8080 # inbound TCP listeners - INGRESS_ALLOW_UDP_PORTS= # inbound UDP listeners (e.g. Swarm gossip 7946) - EGRESS_ALLOW_TCP_PORTS= # outbound TCP dsts (e.g. 80,443 for updates) - EGRESS_ALLOW_UDP_PORTS=53,123 # outbound UDP dsts (DNS, NTP) - EGRESS_GATEWAY=true # only on the host that runs nullnet-proxy ``` > **⚠️ The client attaches a default-deny eBPF firewall to the uplink NIC on startup.** It permits > only the nullnet control plane (gRPC to the server), data plane (VXLAN to peers), established - > returns, ICMP (always, both directions — echo + PMTUD), and whatever you list in the four - > `{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTS` variables (matched on destination port). **Put `22` in - > `INGRESS_ALLOW_TCP_PORTS`** or you will lose SSH the moment the client starts — enabling it over an - > SSH session with `22` missing kills the session. A host that needs name resolution / time sync needs - > `EGRESS_ALLOW_UDP_PORTS=53,123`; DHCP renewal needs `67` (and inbound `68` for broadcast replies). - - `EGRESS_GATEWAY=true` is set **only on the gateway host** (the one running `nullnet-proxy`). It - switches that node's firewall to gateway posture — all outbound is allowed (and tracked) and it - forwards brokered egress to the internet; inbound still obeys the allowlist, so list `80,443` - (and `22`) in `INGRESS_ALLOW_TCP_PORTS` there. Every other (strict) node obeys the allowlist in - both directions. + > returns, ICMP (always, both directions — echo + PMTUD), and the port allowlist. That allowlist is + > **no longer set here** — it is decided globally on `nullnet-server` (the four + > `{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTS` variables) and delivered to the client in its + > `NetworkType` response at startup, so there is a single point of decision. Make sure `22` is in the + > server's `INGRESS_ALLOW_TCP_PORTS` before starting a client over SSH, or the session dies. The + > gateway-vs-strict posture is likewise derived server-side from `PROXY_IP` — no per-client flag. - run the project as a daemon (from the repo root) ``` diff --git a/members/nullnet-client/src/ebpf/mod.rs b/members/nullnet-client/src/ebpf/mod.rs index cd71a6ba..0093478b 100644 --- a/members/nullnet-client/src/ebpf/mod.rs +++ b/members/nullnet-client/src/ebpf/mod.rs @@ -28,9 +28,10 @@ const EGRESS_PROG: &str = "nullnet_fw_egress"; const PROTO_TCP: u8 = 6; const PROTO_UDP: u8 = 17; -/// Explicit firewall allow policy, all env-driven (see `crate::env`). 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. +/// 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. pub struct FirewallConfig { pub server_ip: Ipv4Addr, pub control_port: u16, diff --git a/members/nullnet-client/src/env.rs b/members/nullnet-client/src/env.rs index daf00d3a..20c599ea 100644 --- a/members/nullnet-client/src/env.rs +++ b/members/nullnet-client/src/env.rs @@ -13,52 +13,3 @@ pub static CONTROL_SERVICE_PORT: std::sync::LazyLock = std::sync::LazyLock: str.parse().unwrap_or(50051) }); - -/// Set to `true`/`1` on the host running nullnet-proxy. This node is the egress -/// boundary: it terminates ingress and forwards brokered egress to the internet. -/// Its eBPF firewall runs in **stateful** mode — all outbound is allowed and -/// tracked in the CT map, while inbound obeys the configured allowlist + CT -/// returns. Not the old all-IPv4 permissive mode. -pub static EGRESS_GATEWAY: std::sync::LazyLock = - std::sync::LazyLock::new(|| env_bool("EGRESS_GATEWAY")); - -/// Explicit destination-port allowlists for the stateful firewall — nothing is -/// hardcoded, so every host-service allow is opt-in. Comma-separated ports, keyed -/// by direction and protocol: -/// - `INGRESS_ALLOW_TCP_PORTS` — local TCP listeners (e.g. `22,80,443`). -/// - `INGRESS_ALLOW_UDP_PORTS` — local UDP listeners (e.g. Swarm gossip `7946`). -/// - `EGRESS_ALLOW_TCP_PORTS` — TCP dsts the node may initiate to. -/// - `EGRESS_ALLOW_UDP_PORTS` — UDP dsts the node may initiate to (e.g. DNS -/// `53`, NTP `123`). Replies come back via the CT map, no ingress entry needed. -/// -/// ICMP is portless and always allowed (both directions). CT-tracked established -/// returns and nullnet's own control/data plane are always allowed too. -pub static INGRESS_ALLOW_TCP_PORTS: std::sync::LazyLock> = - std::sync::LazyLock::new(|| env_ports("INGRESS_ALLOW_TCP_PORTS")); -pub static INGRESS_ALLOW_UDP_PORTS: std::sync::LazyLock> = - std::sync::LazyLock::new(|| env_ports("INGRESS_ALLOW_UDP_PORTS")); -pub static EGRESS_ALLOW_TCP_PORTS: std::sync::LazyLock> = - std::sync::LazyLock::new(|| env_ports("EGRESS_ALLOW_TCP_PORTS")); -pub static EGRESS_ALLOW_UDP_PORTS: std::sync::LazyLock> = - std::sync::LazyLock::new(|| env_ports("EGRESS_ALLOW_UDP_PORTS")); - -/// Parse a comma-separated port list from `name` (blank/invalid entries skipped). -fn env_ports(name: &str) -> Vec { - std::env::var(name) - .unwrap_or_default() - .split(',') - .filter_map(|p| p.trim().parse::().ok()) - .collect() -} - -/// Parse a boolean env var (`1`/`true`/`yes`, case-insensitive). -fn env_bool(name: &str) -> bool { - matches!( - std::env::var(name) - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str(), - "1" | "true" | "yes" - ) -} diff --git a/members/nullnet-client/src/main.rs b/members/nullnet-client/src/main.rs index c10cc79f..49313bba 100644 --- a/members/nullnet-client/src/main.rs +++ b/members/nullnet-client/src/main.rs @@ -16,7 +16,7 @@ use clap::Parser; use nullnet_grpc_lib::NullnetGrpcInterface; use nullnet_grpc_lib::nullnet_grpc::{ AgentEvent, AgentServicesListUpdateFailed, AgentServicesListUpdated, Container, Listener, Net, - ServiceReport, agent_event::Event as AgentEventKind, + NetType, ServiceReport, agent_event::Event as AgentEventKind, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::HashMap; @@ -99,7 +99,7 @@ async fn main() -> Result<(), Error> { // control channel learns peers so its add()/remove() land in the PEERS map. // Held alive for the whole run (drop = detach). Fails closed: any error // aborts startup rather than running unprotected. - let ebpf_firewall = match setup_ebpf_firewall(&rtnetlink_handle).await { + let ebpf_firewall = match setup_ebpf_firewall(&rtnetlink_handle, &net_type).await { Ok(fw) => { println!("eBPF host firewall enabled (strict nullnet-only)"); fw @@ -205,7 +205,10 @@ fn print_info(net: Net) { /// Resolve, attach, and return the host-NIC eBPF firewall. Fails closed: any /// problem (unresolvable server, missing NIC, load error) aborts startup rather /// than running unprotected. -async fn setup_ebpf_firewall(rtnetlink_handle: &RtNetLinkHandle) -> Result { +async fn setup_ebpf_firewall( + rtnetlink_handle: &RtNetLinkHandle, + net_type: &NetType, +) -> Result { let server_ip = resolve_server_ip() .ok_or("could not resolve CONTROL_SERVICE_ADDR to an IPv4 address") .handle_err(location!())?; @@ -225,14 +228,17 @@ async fn setup_ebpf_firewall(rtnetlink_handle: &RtNetLinkHandle) -> Result>(); let cfg = ebpf::FirewallConfig { server_ip, control_port: *CONTROL_SERVICE_PORT, - egress_gateway: *crate::env::EGRESS_GATEWAY, - ingress_tcp: crate::env::INGRESS_ALLOW_TCP_PORTS.clone(), - ingress_udp: crate::env::INGRESS_ALLOW_UDP_PORTS.clone(), - egress_tcp: crate::env::EGRESS_ALLOW_TCP_PORTS.clone(), - egress_udp: crate::env::EGRESS_ALLOW_UDP_PORTS.clone(), + egress_gateway: net_type.egress_gateway, + ingress_tcp: to_u16(&net_type.ingress_allow_tcp_ports), + ingress_udp: to_u16(&net_type.ingress_allow_udp_ports), + egress_tcp: to_u16(&net_type.egress_allow_tcp_ports), + egress_udp: to_u16(&net_type.egress_allow_udp_ports), }; if cfg.egress_gateway { println!( diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 15fd3884..5296e059 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -65,6 +65,16 @@ service NullnetGrpc { message NetType { Net net = 1; + // Host-NIC eBPF firewall policy, decided globally by the server (single point + // of decision) and applied by the client at startup. Ports are u16 on the + // wire's uint32. `egress_gateway` is derived server-side: true iff the caller + // is the configured PROXY_IP (the egress boundary node), so clients no longer + // carry an EGRESS_GATEWAY flag of their own. + repeated uint32 ingress_allow_tcp_ports = 2; + repeated uint32 ingress_allow_udp_ports = 3; + repeated uint32 egress_allow_tcp_ports = 4; + repeated uint32 egress_allow_udp_ports = 5; + bool egress_gateway = 6; } enum Net { diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index b0157c95..87ff144b 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -1,8 +1,23 @@ // This file is @generated by prost-build. -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct NetType { #[prost(enumeration = "Net", tag = "1")] pub net: i32, + /// Host-NIC eBPF firewall policy, decided globally by the server (single point + /// of decision) and applied by the client at startup. Ports are u16 on the + /// wire's uint32. `egress_gateway` is derived server-side: true iff the caller + /// is the configured PROXY_IP (the egress boundary node), so clients no longer + /// carry an EGRESS_GATEWAY flag of their own. + #[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, + #[prost(uint32, repeated, tag = "4")] + pub egress_allow_tcp_ports: ::prost::alloc::vec::Vec, + #[prost(uint32, repeated, tag = "5")] + pub egress_allow_udp_ports: ::prost::alloc::vec::Vec, + #[prost(bool, tag = "6")] + pub egress_gateway: bool, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct NetMessage { diff --git a/members/nullnet-server/src/env.rs b/members/nullnet-server/src/env.rs index 443d38f6..7bd55933 100644 --- a/members/nullnet-server/src/env.rs +++ b/members/nullnet-server/src/env.rs @@ -39,3 +39,32 @@ pub static ENCRYPTION_ENABLED: std::sync::LazyLock = Ok(s) => !matches!(s.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no"), Err(_) => true, }); + +/// Global destination-port allowlists for every client's host-NIC eBPF firewall. +/// Decided once here (single point of decision) and pushed to each node in the +/// `NetworkType` response; nothing is hardcoded, so every host-service allow is +/// opt-in. Comma-separated ports, keyed by direction and protocol: +/// - `INGRESS_ALLOW_TCP_PORTS` — local TCP listeners (e.g. `22,80,443`). +/// - `INGRESS_ALLOW_UDP_PORTS` — local UDP listeners (e.g. Swarm gossip `7946`). +/// - `EGRESS_ALLOW_TCP_PORTS` — TCP dsts a node may initiate to. +/// - `EGRESS_ALLOW_UDP_PORTS` — UDP dsts a node may initiate to (e.g. DNS `53`). +/// ICMP is portless and always allowed; CT-tracked returns and nullnet's own +/// control/data plane are always allowed too. +pub static INGRESS_ALLOW_TCP_PORTS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| env_ports("INGRESS_ALLOW_TCP_PORTS")); +pub static INGRESS_ALLOW_UDP_PORTS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| env_ports("INGRESS_ALLOW_UDP_PORTS")); +pub static EGRESS_ALLOW_TCP_PORTS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| env_ports("EGRESS_ALLOW_TCP_PORTS")); +pub static EGRESS_ALLOW_UDP_PORTS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| env_ports("EGRESS_ALLOW_UDP_PORTS")); + +/// Parse a comma-separated port list from `name` (blank/invalid entries skipped). +/// Kept as `u32` to match the proto wire type; the client casts to `u16`. +fn env_ports(name: &str) -> Vec { + std::env::var(name) + .unwrap_or_default() + .split(',') + .filter_map(|p| p.trim().parse::().ok().map(u32::from)) + .collect() +} diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 795a713f..831d3693 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -1,4 +1,7 @@ -use crate::env::{ENCRYPTION_ENABLED, NET_TYPE, PROXY_IP}; +use crate::env::{ + EGRESS_ALLOW_TCP_PORTS, EGRESS_ALLOW_UDP_PORTS, ENCRYPTION_ENABLED, INGRESS_ALLOW_TCP_PORTS, + INGRESS_ALLOW_UDP_PORTS, NET_TYPE, PROXY_IP, +}; use crate::events::Event; use crate::graphviz::generate_graphviz; use crate::net::EgressRole; @@ -1392,9 +1395,20 @@ impl NullnetGrpcImpl { #[tonic::async_trait] impl NullnetGrpc for NullnetGrpcImpl { - async fn network_type(&self, _: Request) -> Result, Status> { + async fn network_type(&self, req: Request) -> Result, Status> { + // The caller is the egress gateway iff its address matches the configured + // PROXY_IP — so the client no longer needs its own EGRESS_GATEWAY flag. + let egress_gateway = match (req.remote_addr().map(|a| a.ip()), *PROXY_IP) { + (Some(caller), Some(proxy)) => caller == proxy, + _ => false, + }; 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(), + egress_allow_tcp_ports: EGRESS_ALLOW_TCP_PORTS.clone(), + egress_allow_udp_ports: EGRESS_ALLOW_UDP_PORTS.clone(), + egress_gateway, })) } From fe17bea0c75aba08fb443f7c1b4731f984aeb4c2 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 22 Jul 2026 14:39:21 +0200 Subject: [PATCH 3/9] ui: live-edit stack config with validate-before-write + parse status --- .../nullnet-server/src/http_server/config.rs | 92 ++++++++++++++++++- members/nullnet-server/src/http_server/mod.rs | 5 +- members/nullnet-server/src/services/input.rs | 24 ++++- members/nullnet-server/ui/src/index.css | 8 ++ .../nullnet-server/ui/src/pages/Config.tsx | 84 ++++++++++++++++- 5 files changed, 202 insertions(+), 11 deletions(-) diff --git a/members/nullnet-server/src/http_server/config.rs b/members/nullnet-server/src/http_server/config.rs index 76dbc859..fc6f48e7 100644 --- a/members/nullnet-server/src/http_server/config.rs +++ b/members/nullnet-server/src/http_server/config.rs @@ -1,10 +1,19 @@ -use axum::extract::Path; +use super::AppState; +use crate::services::input::{detect_port_conflicts, validate_stack_toml}; +use axum::extract::{Path, State}; use axum::http::{StatusCode, header}; -use axum::response::Response; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; +/// A stack name must be a single bare identifier — no path separators or dots — +/// so it maps to exactly `./services/.toml` with no traversal. +fn valid_stack_name(stack: &str) -> bool { + !stack.is_empty() && !stack.contains(['/', '\\', '.']) +} + +/// GET the raw TOML of a stack's service configuration. pub(super) async fn config_handler(Path(stack): Path) -> Response { - // Reject path traversal / nested paths: stack must be a single bare name. - if stack.is_empty() || stack.contains(['/', '\\', '.']) { + if !valid_stack_name(&stack) { return Response::builder() .status(StatusCode::BAD_REQUEST) .body(axum::body::Body::empty()) @@ -23,3 +32,78 @@ pub(super) async fn config_handler(Path(stack): Path) -> Response { .unwrap(), } } + +#[derive(Serialize)] +struct SaveResult { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn rejected(status: StatusCode, error: impl Into) -> Response { + ( + status, + axum::Json(SaveResult { + ok: false, + error: Some(error.into()), + }), + ) + .into_response() +} + +/// POST a new raw TOML for a stack. The body is validated the same way the loader +/// validates on reload (syntax + semantic rules + cross-stack port conflicts). On +/// success the file is written and the existing `./services` watcher hot-reloads +/// it live; on failure nothing is written, so the last valid config keeps running +/// and the response carries the parse error for the UI's status indicator. +pub(super) async fn save_handler( + Path(stack): Path, + State(state): State, + body: String, +) -> Response { + if !valid_stack_name(&stack) { + return rejected(StatusCode::BAD_REQUEST, "invalid stack name"); + } + + // 1. Syntax + semantic validation (mirrors the loader). + let parsed = match validate_stack_toml(&body) { + Ok(map) => map, + Err(e) => return rejected(StatusCode::UNPROCESSABLE_ENTITY, e), + }; + + // 2. Cross-stack port conflicts: check against the live set with this stack + // swapped in, so an edit can't collide with a listen_port owned elsewhere. + let mut candidate = state.services.read().await.clone(); + candidate.insert(stack.clone(), parsed); + if let Some(c) = detect_port_conflicts(&candidate) + .into_iter() + .find(|c| c.stack_a == stack || c.stack_b == stack) + { + let (other_stack, other_service) = if c.stack_a == stack { + (c.stack_b, c.service_b) + } else { + (c.stack_a, c.service_a) + }; + return rejected( + StatusCode::UNPROCESSABLE_ENTITY, + format!( + "listen_port {} ({:?}) already used by service '{other_service}' in stack '{other_stack}'", + c.listen_port, c.protocol + ), + ); + } + + // 3. Valid → persist. The services watcher picks up the write and applies it. + let path = format!("./services/{stack}.toml"); + if tokio::fs::write(&path, body).await.is_err() { + return rejected( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to write configuration file", + ); + } + axum::Json(SaveResult { + ok: true, + error: None, + }) + .into_response() +} diff --git a/members/nullnet-server/src/http_server/mod.rs b/members/nullnet-server/src/http_server/mod.rs index 88fb7e52..4c55ee07 100644 --- a/members/nullnet-server/src/http_server/mod.rs +++ b/members/nullnet-server/src/http_server/mod.rs @@ -35,7 +35,10 @@ pub async fn serve(state: AppState) { .route("/api/stacks", get(stacks::stacks_handler)) .route("/api/services/{stack}", get(services::services_handler)) .route("/api/nodes/{stack}", get(nodes::nodes_handler)) - .route("/api/config/{stack}", get(config::config_handler)) + .route( + "/api/config/{stack}", + get(config::config_handler).post(config::save_handler), + ) .route("/api/graph/{stack}", get(graph::graph_handler)) .route("/api/sessions/{stack}", get(sessions::list_handler)) .route( diff --git a/members/nullnet-server/src/services/input.rs b/members/nullnet-server/src/services/input.rs index 7f40cc34..5732af35 100644 --- a/members/nullnet-server/src/services/input.rs +++ b/members/nullnet-server/src/services/input.rs @@ -342,13 +342,31 @@ fn upper(list: Vec) -> Vec { list.into_iter().map(|c| c.to_uppercase()).collect() } +/// Single source of truth for "is this stack file valid?": TOML syntax, then the +/// host-match/port, protocol/listen_port, and country-list rules. Shared by the +/// disk loader ([`parse_file`]) and the UI save handler ([`validate_stack_toml`]). +/// Cross-stack port conflicts are separate — see [`detect_port_conflicts`]. +fn parse_stack_content( + content: &str, +) -> Result<(HashMap, Vec), Error> { + let parsed: ServicesToml = toml::from_str(content).handle_err(location!())?; + let match_entries = build_match_entries(&parsed.services)?; + Ok((parsed.services_map()?, match_entries)) +} + +/// Validate raw TOML the same way the loader does, returning the per-service map +/// on success or a human-readable error for the UI's parse-status indicator. +pub(crate) fn validate_stack_toml(content: &str) -> Result, String> { + parse_stack_content(content) + .map(|(services, _)| services) + .map_err(|e| e.to_str().to_string()) +} + async fn parse_file(path: &Path) -> Result<(HashMap, Vec), Error> { let str_repr = tokio::fs::read_to_string(path) .await .handle_err(location!())?; - let parsed: ServicesToml = toml::from_str(&str_repr).handle_err(location!())?; - let match_entries = build_match_entries(&parsed.services)?; - Ok((parsed.services_map()?, match_entries)) + parse_stack_content(&str_repr) } /// All non-default egress policies, keyed by (stack, service) — the comparable diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index 986b7453..c82ad163 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -270,6 +270,14 @@ body { /* ── Config ── */ .cfg-pre { font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.7; color: var(--t1); background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 12px; padding: 20px 24px; overflow-x: auto; white-space: pre; } +.cfg-edit { display: block; width: 100%; box-sizing: border-box; min-height: 460px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.7; color: var(--t1); background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 12px; padding: 20px 24px; white-space: pre; tab-size: 2; outline: none; transition: border-color .12s; } +.cfg-edit:focus { border-color: rgba(91,156,246,.5); } +.cfg-actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; flex-wrap: wrap; } +.save-btn { padding: 6px 16px; background: var(--green-g); border: 1px solid rgba(52,211,153,.3); color: var(--green); font-family: 'Plus Jakarta Sans', sans-serif; font-size: 12px; font-weight: 600; border-radius: 8px; cursor: pointer; transition: background .12s; } +.save-btn:hover:not(:disabled) { background: rgba(52,211,153,.18); } +.save-btn:disabled { opacity: .4; cursor: default; } +.cfg-err { display: inline-flex; align-items: center; gap: 8px; } +.cfg-err-msg { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--red); } /* ── Animations ── */ @keyframes gp { 0%, 100% { opacity: 1; } 50% { opacity: .3; } } diff --git a/members/nullnet-server/ui/src/pages/Config.tsx b/members/nullnet-server/ui/src/pages/Config.tsx index 1289b075..217d3394 100644 --- a/members/nullnet-server/ui/src/pages/Config.tsx +++ b/members/nullnet-server/ui/src/pages/Config.tsx @@ -1,16 +1,62 @@ +import { useEffect, useState } from 'react'; import Layout from '../components/Layout'; import { useApiText } from '../hooks/useApi'; import { useStack } from '../StackContext'; +type Status = + | { kind: 'idle' } + | { kind: 'saving' } + | { kind: 'ok' } + | { kind: 'error'; msg: string }; + export default function Config() { const { stack } = useStack(); const { text, loading, error } = useApiText(`/api/config/${stack}`); + const [content, setContent] = useState(''); + const [original, setOriginal] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + + // Sync the editor when the loaded file (or stack) changes. + useEffect(() => { + if (text !== null) { + setContent(text); + setOriginal(text); + setStatus({ kind: 'idle' }); + } + }, [text]); + + const dirty = content !== original; + + async function save() { + setStatus({ kind: 'saving' }); + try { + const res = await fetch(`/api/config/${stack}`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: content, + }); + const data = await res.json().catch(() => ({ ok: res.ok, error: `HTTP ${res.status}` })); + if (res.ok && data.ok) { + setOriginal(content); + setStatus({ kind: 'ok' }); + } else { + setStatus({ kind: 'error', msg: data.error ?? `HTTP ${res.status}` }); + } + } catch (e) { + setStatus({ kind: 'error', msg: String(e) }); + } + } + return (
Configuration
-
Raw service configuration for stack {stack}
+
+ Live service configuration for stack{' '} + {stack} + {' '}— edits are validated and applied without a restart. +
{loading &&
Loading…
} @@ -20,8 +66,40 @@ export default function Config() {
)} - {text && ( -
{text}
+ {!loading && !error && ( + <> +