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..e992ef72 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): @@ -68,20 +81,58 @@ 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]] # country policies (egress + ingress) + name = "api.internal" + timeout = 0 + docker_container = "my-app_api" + port = 8000 + egress_blocked_countries = ["RU", "CN"] + ingress_allowed_countries = ["US", "IT"] + ``` + +- 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,35 +148,22 @@ 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 - ``` - -- `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); - `allowed_countries` permits only the listed countries and denies everything else (unknown geo - 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"] - ``` + hot-reload, if two services claim the same `protocol`/`listen_port` pair) +- country policies restrict traffic by ISO alpha-2 country code (the peer IP is geo-resolved + server-side, from one shared geo cache). `*_blocked_countries` denies the listed countries and + allows everything else (including IPs with unknown geo); `*_allowed_countries` permits only the + listed countries and denies everything else (unknown geo included). Within each direction the two + are mutually exclusive — setting both is a hard config error. Two directions: + - **egress** (`egress_blocked_countries` / `egress_allowed_countries`) — where a service may reach + on the internet (destination country). Enforced 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 the new + policy forbids. + - **ingress** (`ingress_blocked_countries` / `ingress_allowed_countries`) — which external clients + may reach a **proxy-reachable** service (client source country). Enforced server-side at the + nullnet-proxy chokepoint: HTTP denials get a `403`, raw tcp/udp denials close the connection. + Only valid on a service with a `timeout` (an entry point) — the server rejects an ingress policy + on a backend-only service. - run the project as a daemon (from the repo root) ``` @@ -167,42 +205,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. - -- 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]] - ... - ``` + > 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/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/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 15f52c74..558c83b6 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, + 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,23 @@ 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!( @@ -281,60 +293,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 +355,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..fd7bd789 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); @@ -41,6 +43,13 @@ service NullnetGrpc { // service's allowed_/blocked_countries lists. The client caches verdicts. rpc CheckEgressDestination(EgressPolicyCheck) returns (EgressPolicyVerdict); + // Ingress country-policy check for a proxy-reachable service: the proxy asks + // whether an external client (by source IP) may reach the named service. The + // server resolves the client IP's country and evaluates the service's ingress + // allowed_/blocked_countries lists. HTTP denials become a 403; raw tcp/udp + // denials close the connection. + rpc CheckIngress(IngressPolicyCheck) returns (IngressPolicyVerdict); + // Report an event from a client or proxy agent back to the server. rpc ReportEvent(AgentEvent) returns (Empty); @@ -63,6 +72,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 { @@ -186,15 +205,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 @@ -315,6 +339,15 @@ message EgressPolicyVerdict { bool allowed = 1; } +message IngressPolicyCheck { + string service_name = 1; + string client_ip = 2; +} + +message IngressPolicyVerdict { + bool allowed = 1; +} + // TLS certificates ---------------------------------------------------------------------------------------------------- // A single TLS certificate, keyed by the SNI name it serves (exact, e.g. diff --git a/members/nullnet-grpc-lib/src/lib.rs b/members/nullnet-grpc-lib/src/lib.rs index ab77a28b..84892fa6 100644 --- a/members/nullnet-grpc-lib/src/lib.rs +++ b/members/nullnet-grpc-lib/src/lib.rs @@ -3,8 +3,8 @@ mod proto; 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, + EgressPolicyCheck, EgressTriggerRequest, Empty, IngressPolicyCheck, MsgId, NetMessage, NetType, + PortMappingBundle, 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)) @@ -175,6 +178,26 @@ impl NullnetGrpcInterface { .map_err(|e| e.to_string()) } + /// Ask whether the ingress country policy allows an external client at + /// `client_ip` to reach `service_name`. Called by the proxy per request/ + /// connection before resolving an upstream. + #[allow(clippy::missing_errors_doc)] + pub async fn check_ingress( + &self, + service_name: String, + client_ip: String, + ) -> Result { + self.client + .clone() + .check_ingress(Request::new(IngressPolicyCheck { + service_name, + client_ip, + })) + .await + .map(|resp| resp.into_inner().allowed) + .map_err(|e| e.to_string()) + } + #[allow(clippy::missing_errors_doc)] pub async fn report_event(&self, event: AgentEvent) -> Result<(), String> { self.client diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index ab5263df..c5259447 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 { @@ -170,23 +185,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 @@ -323,6 +344,18 @@ pub struct EgressPolicyVerdict { #[prost(bool, tag = "1")] pub allowed: bool, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct IngressPolicyCheck { + #[prost(string, tag = "1")] + pub service_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub client_ip: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct IngressPolicyVerdict { + #[prost(bool, tag = "1")] + pub allowed: bool, +} /// A single TLS certificate, keyed by the SNI name it serves (exact, e.g. /// "color.com", or wildcard, e.g. "\*.color.com"). #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -824,11 +857,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, @@ -1002,6 +1037,35 @@ pub mod nullnet_grpc_client { ); self.inner.unary(req, path, codec).await } + /// Ingress country-policy check for a proxy-reachable service: the proxy asks + /// whether an external client (by source IP) may reach the named service. The + /// server resolves the client IP's country and evaluates the service's ingress + /// allowed\_/blocked_countries lists. HTTP denials become a 403; raw tcp/udp + /// denials close the connection. + pub async fn check_ingress( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/nullnet_grpc.NullnetGrpc/CheckIngress", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("nullnet_grpc.NullnetGrpc", "CheckIngress")); + self.inner.unary(req, path, codec).await + } /// Report an event from a client or proxy agent back to the server. pub async fn report_event( &mut self, @@ -1102,11 +1166,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, @@ -1160,6 +1226,18 @@ pub mod nullnet_grpc_server { tonic::Response, tonic::Status, >; + /// Ingress country-policy check for a proxy-reachable service: the proxy asks + /// whether an external client (by source IP) may reach the named service. The + /// server resolves the client IP's country and evaluates the service's ingress + /// allowed\_/blocked_countries lists. HTTP denials become a 403; raw tcp/udp + /// denials close the connection. + async fn check_ingress( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Report an event from a client or proxy agent back to the server. async fn report_event( &self, @@ -1320,7 +1398,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 +1409,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 { @@ -1635,6 +1715,51 @@ pub mod nullnet_grpc_server { }; Box::pin(fut) } + "/nullnet_grpc.NullnetGrpc/CheckIngress" => { + #[allow(non_camel_case_types)] + struct CheckIngressSvc(pub Arc); + impl< + T: NullnetGrpc, + > tonic::server::UnaryService + for CheckIngressSvc { + type Response = super::IngressPolicyVerdict; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::check_ingress(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = CheckIngressSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/nullnet_grpc.NullnetGrpc/ReportEvent" => { #[allow(non_camel_case_types)] struct ReportEventSvc(pub Arc); diff --git a/members/nullnet-proxy/src/main.rs b/members/nullnet-proxy/src/main.rs index 3b8046e4..31c5346c 100644 --- a/members/nullnet-proxy/src/main.rs +++ b/members/nullnet-proxy/src/main.rs @@ -38,6 +38,26 @@ impl ProxyHttp for NullnetProxy { fn new_ctx(&self) -> Self::CTX {} async fn request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result { + // Ingress country policy (both HTTP and HTTPS listeners), enforced before + // we touch the backend. Best-effort: if host or client IP can't be read + // we let upstream_peer handle it; only an explicit server deny 403s, and a + // check RPC error is logged and allowed through (upstream lookup will fail + // anyway if the control plane is down). + if let (Some(service), Some(client_ip)) = + (ingress_host(session), ingress_client_ip(session)) + { + match self.server.check_ingress(service.clone(), client_ip).await { + Ok(false) => { + let mut resp = ResponseHeader::build(403, None)?; + resp.insert_header("content-length", "0")?; + session.write_response_header(Box::new(resp), true).await?; + return Ok(true); + } + Ok(true) => {} + Err(e) => eprintln!("[ingress] policy check failed for '{service}': {e}"), + } + } + // only the HTTP listener redirects, and only for hosts we can serve over TLS if self.tls { return Ok(false); @@ -222,6 +242,24 @@ impl ProxyHttp for NullnetProxy { } } +/// The requested service name (Host header, or HTTP/2 `:authority` via the URI), +/// with any port stripped. `None` if neither carries a host. +fn ingress_host(session: &Session) -> Option { + let req = session.req_header(); + let raw = req + .headers + .get("host") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .or_else(|| req.uri.host().map(str::to_string))?; + Some(raw.split(':').next().unwrap_or(&raw).to_string()) +} + +/// The external client's source IP as a string, if it is an inet address. +fn ingress_client_ip(session: &Session) -> Option { + session.client_addr()?.as_inet().map(|a| a.ip().to_string()) +} + #[tokio::main] async fn main() -> Result<(), nullnet_liberror::Error> { // let _gag1: gag::Redirect; diff --git a/members/nullnet-proxy/src/tcp_relay.rs b/members/nullnet-proxy/src/tcp_relay.rs index 9e8a8b4a..9d4d0640 100644 --- a/members/nullnet-proxy/src/tcp_relay.rs +++ b/members/nullnet-proxy/src/tcp_relay.rs @@ -73,6 +73,27 @@ async fn handle_connection( client_ip: client_ip.clone(), service_name: entry.service_name.clone(), }; + // Ingress country policy: an explicit deny closes the connection. A check + // error is logged and allowed through (upstream lookup fails anyway if the + // control plane is down). + match proxy + .server + .check_ingress(entry.service_name.clone(), client_ip.clone()) + .await + { + Ok(false) => { + println!( + "[tcp] ingress policy denied {client_addr} -> '{}'", + entry.service_name + ); + return; + } + Ok(true) => {} + Err(e) => eprintln!( + "[tcp] ingress check failed for '{}': {e}", + entry.service_name + ), + } let upstream = match proxy.get_or_add_upstream(proxy_req).await { Ok(u) => { println!( diff --git a/members/nullnet-proxy/src/udp_relay.rs b/members/nullnet-proxy/src/udp_relay.rs index 3e6575fc..dc711b22 100644 --- a/members/nullnet-proxy/src/udp_relay.rs +++ b/members/nullnet-proxy/src/udp_relay.rs @@ -103,6 +103,26 @@ async fn handle_datagram( client_ip: src.ip().to_string(), service_name: entry.service_name.clone(), }; + // Ingress country policy: an explicit deny drops the datagram (no session). + // A check error is logged and allowed through. + match proxy + .server + .check_ingress(entry.service_name.clone(), src.ip().to_string()) + .await + { + Ok(false) => { + println!( + "[udp] ingress policy denied {src} -> '{}'", + entry.service_name + ); + return; + } + Ok(true) => {} + Err(e) => eprintln!( + "[udp] ingress check failed for '{}': {e}", + entry.service_name + ), + } let upstream_addr = match proxy.get_or_add_upstream(proxy_req).await { Ok(u) => { println!( diff --git a/members/nullnet-server/graphs/sample-stack.dot b/members/nullnet-server/graphs/sample-stack.dot new file mode 100644 index 00000000..6a3a1897 --- /dev/null +++ b/members/nullnet-server/graphs/sample-stack.dot @@ -0,0 +1,11 @@ +digraph G { + bgcolor=grey10; + node [color=white, fontcolor=white]; + edge [color=white, fontcolor=white, fontsize=9, labelangle=180, labeldistance=0.8]; + + "color.com" [label=] [style=solid, color=red]; + + "fs.color.com" [label=] [style=solid, color=red]; + + "ts.color.com" [label=] [style=dashed, color=red]; +} 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/env.rs b/members/nullnet-server/src/env.rs index 443d38f6..5566fb47 100644 --- a/members/nullnet-server/src/env.rs +++ b/members/nullnet-server/src/env.rs @@ -39,3 +39,33 @@ 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/http_server/config.rs b/members/nullnet-server/src/http_server/config.rs index 76dbc859..c1354fd3 100644 --- a/members/nullnet-server/src/http_server/config.rs +++ b/members/nullnet-server/src/http_server/config.rs @@ -1,10 +1,25 @@ -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 so it maps to exactly +/// `./services/.toml` with no traversal. Restricted to the same charset +/// the UI enforces (`[A-Za-z0-9_-]+`) — this also rejects path separators, dots, +/// NUL, whitespace, and control characters, which would traverse, fail the write +/// with a 500, or create ghost files. +fn valid_stack_name(stack: &str) -> bool { + !stack.is_empty() + && stack + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// 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 +38,102 @@ 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() +} + +/// DELETE a stack's config file. The `./services` watcher sees the removal and +/// tears the stack's services down (`apply_config_update`). Creating a stack is +/// just a `save_handler` POST to a name that has no file yet. +pub(super) async fn delete_handler(Path(stack): Path) -> Response { + if !valid_stack_name(&stack) { + return rejected(StatusCode::BAD_REQUEST, "invalid stack name"); + } + let path = format!("./services/{stack}.toml"); + match tokio::fs::remove_file(&path).await { + Ok(()) => axum::Json(SaveResult { + ok: true, + error: None, + }) + .into_response(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + rejected(StatusCode::NOT_FOUND, "stack not found") + } + Err(_) => rejected( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to delete configuration file", + ), + } +} diff --git a/members/nullnet-server/src/http_server/mod.rs b/members/nullnet-server/src/http_server/mod.rs index 88fb7e52..d0770c71 100644 --- a/members/nullnet-server/src/http_server/mod.rs +++ b/members/nullnet-server/src/http_server/mod.rs @@ -35,7 +35,12 @@ 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) + .delete(config::delete_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/http_server/sessions.rs b/members/nullnet-server/src/http_server/sessions.rs index ec4c8c09..fb815571 100644 --- a/members/nullnet-server/src/http_server/sessions.rs +++ b/members/nullnet-server/src/http_server/sessions.rs @@ -5,6 +5,7 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use serde::Serialize; +use std::net::Ipv4Addr; use std::time::UNIX_EPOCH; #[derive(Serialize)] @@ -17,6 +18,13 @@ struct SessionJson { service: String, chain_depth: usize, created_at: u64, + // Geo/ASN of the external client IP (flag + ASN in the UI), like egress. + #[serde(skip_serializing_if = "Option::is_none")] + country_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + asn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + org: Option, } #[derive(Serialize)] @@ -40,10 +48,20 @@ pub(super) async fn list_handler( .into_iter() .filter_map(|(c, ci, _, _)| { c.is_proxy()?; + let client_ip = c.name().to_string(); + // Enrich + read the client IP's geo (cached, once-per-IP). + let geo = client_ip + .parse::() + .ok() + .and_then(|ip| { + state.orchestrator.ensure_geo(ip); + state.orchestrator.geo_get(ip) + }) + .unwrap_or_default(); Some(SessionJson { id: ci.net_id(), network_id: ci.net_id(), - client_ip: c.name().to_string(), + client_ip, client_net: ci.client_net().to_string(), server_net: ci.server_net().to_string(), service: name.clone(), @@ -53,6 +71,9 @@ pub(super) async fn list_handler( .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(), + country_code: geo.country_code, + asn: geo.asn, + org: geo.org, }) }) .collect::>() diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index 5cf0bc47..2a2fabbb 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -41,6 +41,17 @@ async fn main() -> Result<(), Error> { // cert private keys are encrypted at rest with this key; fail fast if absent crypto::init_from_env()?; + // The firewall allowlist is now global (single point of decision). An empty + // ingress-TCP list means every client's host firewall drops ALL inbound TCP — + // including SSH — on startup, so warn loudly rather than silently lock out the + // fleet on the next client restart. + if env::INGRESS_ALLOW_TCP_PORTS.is_empty() { + println!( + "WARNING: INGRESS_ALLOW_TCP_PORTS is empty — every client firewall will drop all \ + inbound TCP (including SSH/22). Set it in the server .env before starting clients." + ); + } + // TODO(grpc-tls): the gRPC server is plaintext, but WatchCertificates // streams private keys. Enable TLS here (Server::builder().tls_config(..)), // ideally mTLS, so keys never travel in clear; until then keep the control diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 72edea64..f7a9ef02 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; @@ -9,15 +12,15 @@ 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::service_info::{EgressPolicy, ServiceInfo, backend_involved_services}; +use crate::services::input::{MatchIndex, ServicesToml, StackMap}; +use crate::services::service_info::{CountryPolicy, 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, - agent_event::Event as AgentEventKind, + EgressPolicyVerdict, EgressTriggerRequest, Empty, IngressPolicyCheck, IngressPolicyVerdict, + MsgId, Net, NetMessage, NetType, PortMapping, PortMappingBundle, ProxyRequest, ServiceReport, + ServiceTrigger, ServicesListResponse, Upstream, agent_event::Event as AgentEventKind, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::{HashMap, HashSet}; @@ -31,6 +34,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 +87,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 +106,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 +155,7 @@ impl NullnetGrpcImpl { Ok(NullnetGrpcImpl { services, + match_index, orchestrator, certs: certs_rx, port_mappings: port_mappings_rx, @@ -311,7 +322,7 @@ impl NullnetGrpcImpl { async fn services_list_impl( &self, - request: Request, + request: Request, ) -> Result, Error> { let sender_ip = request .remote_addr() @@ -319,53 +330,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) @@ -878,7 +891,7 @@ impl NullnetGrpcImpl { // One pass over the stacks: find the registered replica and its // service's policy together (mirrors resolve_registered_replica). - let resolved: Option<(String, EgressPolicy)> = { + let resolved: Option<(String, CountryPolicy)> = { let guard = self.services.read().await; guard.values().find_map(|stack_map| { stack_map.iter().find_map(|(name, si)| { @@ -902,7 +915,7 @@ impl NullnetGrpcImpl { Err("No registered replica matches the egress policy check").handle_err(location!())? }; - let allowed = if policy == EgressPolicy::None { + let allowed = if policy == CountryPolicy::None { true } else { let country = self.orchestrator.destination_country(dst_ip).await; @@ -918,6 +931,50 @@ impl NullnetGrpcImpl { Ok(Response::new(EgressPolicyVerdict { allowed })) } + async fn check_ingress_impl( + &self, + request: Request, + ) -> Result, Error> { + let req = request.into_inner(); + + // Warm the geo cache for this ingress IP so the UI (Sessions/Internet) and + // the reload-time teardown scan can read its country without a network hit. + if let Ok(ip) = req.client_ip.parse::() { + self.orchestrator.ensure_geo(ip); + } + + // The service's ingress policy, or None if the service is unknown (the + // proxy will fail to resolve an upstream anyway — don't block here). + let policy = { + let guard = self.services.read().await; + find_service_stack(&guard, &req.service_name) + .map(|stack| guard[stack][&req.service_name].ingress_policy().clone()) + .unwrap_or_default() + }; + + let allowed = if policy == CountryPolicy::None { + true + } else { + // Unresolvable/non-IPv4 source → unknown country, evaluated by policy + // (allow-list unknown → deny; block-list unknown → allow), mirroring egress. + let country = match req.client_ip.parse::() { + Ok(ip) => self.orchestrator.destination_country(ip).await, + Err(_) => None, + }; + let allowed = policy.allows(country.as_deref()); + if !allowed { + println!( + "[ingress-policy] deny {} ({}) -> '{}'", + req.client_ip, + country.as_deref().unwrap_or("unknown country"), + req.service_name + ); + } + allowed + }; + Ok(Response::new(IngressPolicyVerdict { allowed })) + } + pub(crate) fn services(&self) -> &Arc> { &self.services } @@ -1361,6 +1418,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, @@ -1381,15 +1439,26 @@ 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, })) } async fn services_list( &self, - req: Request, + req: Request, ) -> Result, Status> { self.services_list_impl(req) .await @@ -1456,6 +1525,15 @@ impl NullnetGrpc for NullnetGrpcImpl { .map_err(|err| Status::internal(err.to_str())) } + async fn check_ingress( + &self, + req: Request, + ) -> Result, Status> { + self.check_ingress_impl(req) + .await + .map_err(|err| Status::internal(err.to_str())) + } + type WatchCertificatesStream = ReceiverStream>; async fn watch_certificates( diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index df5b1ac2..30b773fd 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -308,6 +308,18 @@ impl Orchestrator { Ok(true) } + /// Kick off (cached, once-per-IP) geo/ASN enrichment for an ingress client IP. + /// Fire-and-forget; the resolved value is read later via `geo_get`. + pub(crate) fn ensure_geo(&self, ip: Ipv4Addr) { + self.geo.ensure(ip); + } + + /// Cached geo/ASN for `ip`, if resolved yet — inlined into session JSON so the + /// UI can render an ingress IP's flag + ASN (mirrors egress destinations). + pub(crate) fn geo_get(&self, ip: Ipv4Addr) -> Option { + self.geo.get(ip) + } + /// Snapshot the live egress edges (initiator replica -> proxy) for topology /// rendering. Reservations that never completed (`net_id == 0`) are omitted. pub(crate) async fn egress_edges_snapshot(&self) -> Vec { diff --git a/members/nullnet-server/src/services/changes.rs b/members/nullnet-server/src/services/changes.rs index d6369c53..79618348 100644 --- a/members/nullnet-server/src/services/changes.rs +++ b/members/nullnet-server/src/services/changes.rs @@ -229,6 +229,7 @@ async fn teardown_invalidated_service( let protocol = si.protocol(); let listen_port = si.listen_port(); let egress_policy = si.egress_policy().clone(); + let ingress_policy = si.ingress_policy().clone(); services.insert( invalidated_service.to_string(), ServiceInfo::new( @@ -239,6 +240,7 @@ async fn teardown_invalidated_service( protocol, listen_port, egress_policy, + ingress_policy, ), ); } diff --git a/members/nullnet-server/src/services/input.rs b/members/nullnet-server/src/services/input.rs index 875235d6..a578df54 100644 --- a/members/nullnet-server/src/services/input.rs +++ b/members/nullnet-server/src/services/input.rs @@ -1,12 +1,14 @@ use crate::events::Event as ServerEvent; use crate::orchestrator::Orchestrator; -use crate::services::changes::{apply_changes, detect_config_changes}; -use crate::services::service_info::{EgressPolicy, ServiceInfo}; +use crate::services::changes::{ServiceChange, apply_changes, detect_config_changes}; +use crate::services::clients::Client; +use crate::services::service_info::{CountryPolicy, ServiceInfo}; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use nullnet_grpc_lib::nullnet_grpc::ServiceProtocol; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use serde::Deserialize; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::net::Ipv4Addr; use std::ops::Sub; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -20,20 +22,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,40 +88,45 @@ 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 (mut stacks, mut index) = Self::load().await?; + // Don't brick the control plane on a port conflict (e.g. a bad UI edit or + // hand-edited file left two stacks claiming the same listen_port). Drop the + // offending stacks and start anyway, logging loudly — the reload path is + // likewise tolerant, and an operator can fix the files without downtime. let conflicts = detect_port_conflicts(&stacks); - if let Some(first) = conflicts.first() { - return Err(format!( - "port mapping conflict: {} other conflict(s); stack '{}' service '{}' and stack '{}' \ - service '{}' both claim {:?}/{}", - conflicts.len() - 1, - first.stack_a, - first.service_a, - first.stack_b, - first.service_b, - first.protocol, - first.listen_port - )) - .handle_err(location!()); + let mut bad: HashSet = HashSet::new(); + for c in &conflicts { + eprintln!( + "[config] port conflict on startup: {:?}/{} claimed by '{}'/'{}' and '{}'/'{}' — \ + dropping these stacks until fixed", + c.protocol, c.listen_port, c.stack_a, c.service_a, c.stack_b, c.service_b + ); + bad.insert(c.stack_a.clone()); + bad.insert(c.stack_b.clone()); } - Ok(stacks) + for stack in &bad { + stacks.remove(stack); + index.remove(stack); + } + Ok((stacks, index)) } /// `config_changed` and `port_mappings_changed` are separate `Notify`s — @@ -89,6 +136,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 +174,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 { @@ -191,7 +240,8 @@ impl ServicesToml { None, ServiceProtocol::Http, None, - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); } @@ -221,18 +271,28 @@ impl ServicesToml { } _ => {} } - let egress_policy = match (s.blocked_countries, s.allowed_countries) { - (Some(_), Some(_)) => { - return Err(format!( - "service '{}': 'blocked_countries' and 'allowed_countries' are mutually exclusive", - s.name - )) - .handle_err(location!()); - } - (Some(list), None) => EgressPolicy::Blocked(upper(list)), - (None, Some(list)) => EgressPolicy::Allowed(upper(list)), - (None, None) => EgressPolicy::None, - }; + let egress_policy = country_policy( + s.egress_blocked_countries, + s.egress_allowed_countries, + "egress", + &s.name, + )?; + let ingress_policy = country_policy( + s.ingress_blocked_countries, + s.ingress_allowed_countries, + "ingress", + &s.name, + )?; + // Ingress policy is enforced at the proxy, which only routes reachable + // entry points. On a backend-only service it would be dead config, so + // reject it rather than silently ignore. + if ingress_policy != CountryPolicy::None && s.timeout.is_none() { + return Err(format!( + "service '{}': ingress country policy requires a proxy-reachable service (set a 'timeout')", + s.name + )) + .handle_err(location!()); + } let triggers = s.triggers.into_iter().map(|t| (t.port, t.chain)).collect(); ret_val.insert( s.name, @@ -244,6 +304,7 @@ impl ServicesToml { protocol, s.listen_port, egress_policy, + ingress_policy, ), ); } @@ -299,27 +360,141 @@ fn upper(list: Vec) -> Vec { list.into_iter().map(|c| c.to_uppercase()).collect() } -async fn parse_file(path: &Path) -> Result, Error> { +/// Build a `CountryPolicy` from one direction's blocked/allowed lists. The two +/// lists are mutually exclusive; `dir` ("ingress"/"egress") only shapes the +/// error message so it names the offending fields. +fn country_policy( + blocked: Option>, + allowed: Option>, + dir: &str, + service: &str, +) -> Result { + match (blocked, allowed) { + (Some(_), Some(_)) => Err(format!( + "service '{service}': '{dir}_blocked_countries' and '{dir}_allowed_countries' are mutually exclusive" + )) + .handle_err(location!()), + (Some(list), None) => Ok(CountryPolicy::Blocked(upper(list))), + (None, Some(list)) => Ok(CountryPolicy::Allowed(upper(list))), + (None, None) => Ok(CountryPolicy::None), + } +} + +/// 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!())?; - parsed.services_map() + parse_stack_content(&str_repr) } /// All non-default egress policies, keyed by (stack, service) — the comparable /// footprint used to detect a policy change across a reload. -fn egress_policies(services: &StackMap) -> BTreeMap<(String, String), EgressPolicy> { +fn egress_policies(services: &StackMap) -> BTreeMap<(String, String), CountryPolicy> { services .iter() .flat_map(|(stack, map)| { map.iter() - .filter(|(_, si)| *si.egress_policy() != EgressPolicy::None) + .filter(|(_, si)| *si.egress_policy() != CountryPolicy::None) .map(|(name, si)| ((stack.clone(), name.clone()), si.egress_policy().clone())) }) .collect() } +/// All non-default ingress policies, keyed by (stack, service) — the comparable +/// footprint used to detect an ingress-policy change across a reload. +fn ingress_policies(services: &StackMap) -> BTreeMap<(String, String), CountryPolicy> { + services + .iter() + .flat_map(|(stack, map)| { + map.iter() + .filter(|(_, si)| *si.ingress_policy() != CountryPolicy::None) + .map(|(name, si)| ((stack.clone(), name.clone()), si.ingress_policy().clone())) + }) + .collect() +} + +/// Tear down every active proxy session whose client country the current ingress +/// policy denies — the same `ForceSessionTeardown` the manual teardown button +/// uses, so the nullnet overlay is cleaned up. tcp/udp streams close when their +/// path drops; HTTP/S is already 403'd per-request. +/// +/// Only the services in `changed` (whose ingress policy actually differs on this +/// reload) are re-evaluated, so an unrelated policy edit can't disturb others. +/// Country comes from the cached geo (`geo_get`, no network I/O) — this runs +/// under the `services` write lock, so it must not block; ingress IPs are warmed +/// by `check_ingress`, and an unresolved one is treated per policy (allow-list +/// unknown → deny), consistent with the door check that re-evaluates on reconnect. +async fn teardown_ingress_denied_sessions( + services: &mut StackMap, + orchestrator: &Orchestrator, + changed: &HashSet<(String, String)>, +) { + // Phase 1: collect (stack, service, client) the reloaded policy now denies. + let mut denied: Vec<(String, String, Client)> = Vec::new(); + for (stack, stack_map) in services.iter() { + for (svc_name, info) in stack_map { + if !changed.contains(&(stack.clone(), svc_name.clone())) { + continue; + } + let policy = info.ingress_policy(); + if *policy == CountryPolicy::None { + continue; + } + let ServiceInfo::Registered(reg) = info else { + continue; + }; + for (client, _ci, _ip, _docker) in reg.all_clients_owned() { + if client.is_proxy().is_none() { + continue; + } + let Ok(ip) = client.name().parse::() else { + continue; + }; + let country = orchestrator + .geo_get(ip) + .and_then(|g| g.country_code) + .map(|c| c.to_uppercase()); + if !policy.allows(country.as_deref()) { + denied.push((stack.clone(), svc_name.clone(), client)); + } + } + } + } + // Phase 2: tear each down through the shared teardown path. + for (stack, name, client) in denied { + if let Some(stack_map) = services.get_mut(&stack) { + apply_changes( + vec![ServiceChange::ForceSessionTeardown { name, client }], + stack_map, + None, + orchestrator, + &stack, + ) + .await; + } + } +} + pub(crate) async fn apply_config_update( services: &mut StackMap, loaded_services: StackMap, @@ -327,6 +502,8 @@ pub(crate) async fn apply_config_update( ) { let policies_before = egress_policies(services); let policies_after = egress_policies(&loaded_services); + let ingress_before = ingress_policies(services); + let ingress_after = ingress_policies(&loaded_services); // Stacks that disappeared from config: tear down everything in them. let removed_stacks: Vec = services .keys() @@ -375,11 +552,34 @@ pub(crate) async fn apply_config_update( if policies_before != policies_after { orchestrator.broadcast_egress_policy_changed().await; } + + // Services whose ingress policy actually changed (added or altered — a removed + // policy is now None and denies nothing, so it needs no teardown). Only these + // are re-evaluated, so editing one service's policy can't disturb others. + let changed_ingress: HashSet<(String, String)> = ingress_after + .iter() + .filter(|(k, v)| ingress_before.get(*k) != Some(*v)) + .map(|(k, _)| k.clone()) + .collect(); + if !changed_ingress.is_empty() { + teardown_ingress_denied_sessions(services, orchestrator, &changed_ingress).await; + } } #[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 @@ -406,11 +606,15 @@ struct ServiceToml { /// External port the proxy listens on for this service. Required (and /// only meaningful) when `protocol` is `tcp` or `udp`. listen_port: Option, - /// Egress country policy (ISO alpha-2 codes), mutually exclusive: - /// `blocked_countries` denies the listed countries (unknown → allow); - /// `allowed_countries` permits only the listed ones (unknown → deny). - blocked_countries: Option>, - allowed_countries: Option>, + /// Country policies (ISO alpha-2 codes). Within each direction the blocked/ + /// allowed lists are mutually exclusive: `*_blocked_countries` denies the + /// listed countries (unknown → allow); `*_allowed_countries` permits only the + /// listed ones (unknown → deny). Egress = destination country of the service's + /// outbound traffic; ingress = source country of proxy clients reaching it. + egress_blocked_countries: Option>, + egress_allowed_countries: Option>, + ingress_blocked_countries: Option>, + ingress_allowed_countries: Option>, } #[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq)] @@ -556,7 +760,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"); @@ -644,7 +848,8 @@ listen_port = 6379 None, ServiceProtocol::Tcp, Some(6379), - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); let mut bravo = HashMap::new(); @@ -657,7 +862,8 @@ listen_port = 6379 None, ServiceProtocol::Tcp, Some(6379), - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); let stacks: StackMap = @@ -681,7 +887,8 @@ listen_port = 6379 None, ServiceProtocol::Tcp, Some(53), - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); alpha.insert( @@ -693,7 +900,8 @@ listen_port = 6379 None, ServiceProtocol::Udp, Some(53), - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); let stacks: StackMap = HashMap::from([("alpha".to_string(), alpha)]); diff --git a/members/nullnet-server/src/services/service_info.rs b/members/nullnet-server/src/services/service_info.rs index ff11714f..501925c6 100644 --- a/members/nullnet-server/src/services/service_info.rs +++ b/members/nullnet-server/src/services/service_info.rs @@ -28,25 +28,26 @@ pub(crate) fn backend_involved_services( pinned } -/// Per-service egress country policy from the stack TOML (ISO alpha-2 codes, -/// stored uppercase). `Blocked` denies the listed countries (unknown → allow); -/// `Allowed` permits only the listed ones (unknown → deny). +/// Per-service country policy from the stack TOML (ISO alpha-2 codes, stored +/// uppercase). Used for both directions: egress (destination country) and +/// ingress (proxy-client source country). `Blocked` denies the listed countries +/// (unknown → allow); `Allowed` permits only the listed ones (unknown → deny). #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) enum EgressPolicy { +pub(crate) enum CountryPolicy { #[default] None, Blocked(Vec), Allowed(Vec), } -impl EgressPolicy { +impl CountryPolicy { /// Whether a destination in `country` (uppercase alpha-2; `None` = unknown) /// may be contacted under this policy. pub(crate) fn allows(&self, country: Option<&str>) -> bool { match self { - EgressPolicy::None => true, - EgressPolicy::Blocked(list) => country.is_none_or(|c| !list.iter().any(|b| b == c)), - EgressPolicy::Allowed(list) => country.is_some_and(|c| list.iter().any(|a| a == c)), + CountryPolicy::None => true, + CountryPolicy::Blocked(list) => country.is_none_or(|c| !list.iter().any(|b| b == c)), + CountryPolicy::Allowed(list) => country.is_some_and(|c| list.iter().any(|a| a == c)), } } } @@ -66,7 +67,8 @@ impl ServiceInfo { max_networks: Option, protocol: ServiceProtocol, listen_port: Option, - egress_policy: EgressPolicy, + egress_policy: CountryPolicy, + ingress_policy: CountryPolicy, ) -> Self { ServiceInfo::Unregistered(UnregisteredServiceInfo::new( proxy_deps, @@ -76,6 +78,7 @@ impl ServiceInfo { protocol, listen_port, egress_policy, + ingress_policy, )) } @@ -90,6 +93,7 @@ impl ServiceInfo { protocol: unreg.protocol, listen_port: unreg.listen_port, egress_policy: unreg.egress_policy.clone(), + ingress_policy: unreg.ingress_policy.clone(), replicas: vec![Replica::new(ip, port, docker_container)], }); } @@ -131,6 +135,7 @@ impl ServiceInfo { reg.protocol, reg.listen_port, reg.egress_policy.clone(), + reg.ingress_policy.clone(), )); } } @@ -151,6 +156,7 @@ impl ServiceInfo { reg.protocol, reg.listen_port, reg.egress_policy.clone(), + reg.ingress_policy.clone(), )); } } @@ -169,6 +175,7 @@ impl ServiceInfo { let loaded_protocol = loaded.protocol(); let loaded_listen_port = loaded.listen_port(); let loaded_egress_policy = loaded.egress_policy().clone(); + let loaded_ingress_policy = loaded.ingress_policy().clone(); match self { ServiceInfo::Unregistered(unreg) => { unreg.proxy_deps = loaded.proxy_deps().to_vec(); @@ -178,6 +185,7 @@ impl ServiceInfo { unreg.protocol = loaded_protocol; unreg.listen_port = loaded_listen_port; unreg.egress_policy = loaded_egress_policy; + unreg.ingress_policy = loaded_ingress_policy; } ServiceInfo::Registered(reg) => { reg.proxy_deps = loaded.proxy_deps().to_vec(); @@ -187,18 +195,28 @@ impl ServiceInfo { reg.protocol = loaded_protocol; reg.listen_port = loaded_listen_port; reg.egress_policy = loaded_egress_policy; + reg.ingress_policy = loaded_ingress_policy; } } } /// Egress country policy declared for this service (default: none). - pub(crate) fn egress_policy(&self) -> &EgressPolicy { + pub(crate) fn egress_policy(&self) -> &CountryPolicy { match self { ServiceInfo::Unregistered(unreg) => &unreg.egress_policy, ServiceInfo::Registered(reg) => ®.egress_policy, } } + /// Ingress country policy declared for this service — evaluated against the + /// proxy client's source country for proxy-reachable services (default: none). + pub(crate) fn ingress_policy(&self) -> &CountryPolicy { + match self { + ServiceInfo::Unregistered(unreg) => &unreg.ingress_policy, + ServiceInfo::Registered(reg) => ®.ingress_policy, + } + } + pub(crate) fn max_networks(&self) -> Option { match self { ServiceInfo::Unregistered(unreg) => unreg.max_networks, @@ -263,7 +281,9 @@ pub(crate) struct UnregisteredServiceInfo { /// External port the proxy binds to for `Tcp`/`Udp` services. listen_port: Option, /// Egress country policy for this service's external traffic. - egress_policy: EgressPolicy, + egress_policy: CountryPolicy, + /// Ingress country policy for external clients reaching this service via the proxy. + ingress_policy: CountryPolicy, } impl UnregisteredServiceInfo { @@ -275,7 +295,8 @@ impl UnregisteredServiceInfo { max_networks: Option, protocol: ServiceProtocol, listen_port: Option, - egress_policy: EgressPolicy, + egress_policy: CountryPolicy, + ingress_policy: CountryPolicy, ) -> Self { Self { proxy_deps, @@ -285,6 +306,7 @@ impl UnregisteredServiceInfo { protocol, listen_port, egress_policy, + ingress_policy, } } } @@ -373,7 +395,9 @@ pub(crate) struct RegisteredServiceInfo { /// External port the proxy binds to for `Tcp`/`Udp` services. listen_port: Option, /// Egress country policy for this service's external traffic. - egress_policy: EgressPolicy, + egress_policy: CountryPolicy, + /// Ingress country policy for external clients reaching this service via the proxy. + ingress_policy: CountryPolicy, /// Replicas of this service. replicas: Vec, } diff --git a/members/nullnet-server/src/tests.rs b/members/nullnet-server/src/tests.rs index b5531269..82e984fe 100644 --- a/members/nullnet-server/src/tests.rs +++ b/members/nullnet-server/src/tests.rs @@ -3,7 +3,7 @@ use crate::graphviz::render_graphviz; use crate::nullnet_grpc_impl::NullnetGrpcImpl; use crate::services::input::{ServicesToml, StackMap, apply_config_update}; -use crate::services::service_info::{EgressPolicy, ServiceInfo}; +use crate::services::service_info::{CountryPolicy, ServiceInfo}; use crate::timeout::apply_timeouts; use nullnet_grpc_lib::nullnet_grpc::{NetMessage, ServiceProtocol, net_message}; use std::collections::{HashMap, HashSet}; @@ -2773,7 +2773,8 @@ fn suspend_test_server() -> NullnetGrpcImpl { None, ServiceProtocol::Http, None, - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); inner.insert( @@ -2785,7 +2786,8 @@ fn suspend_test_server() -> NullnetGrpcImpl { None, ServiceProtocol::Http, None, - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); NullnetGrpcImpl::new_for_test(into_stack_map(inner)) @@ -2856,7 +2858,8 @@ async fn backend_involved_replicas_never_suspended() { None, ServiceProtocol::Http, None, - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); // dep is named in the trigger chain (so it "is a backend dep") @@ -2869,7 +2872,8 @@ async fn backend_involved_replicas_never_suspended() { None, ServiceProtocol::Http, None, - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); // a plain entry-point service with no backend involvement (control) @@ -2882,7 +2886,8 @@ async fn backend_involved_replicas_never_suspended() { None, ServiceProtocol::Http, None, - EgressPolicy::None, + CountryPolicy::None, + CountryPolicy::None, ), ); let server = NullnetGrpcImpl::new_for_test(into_stack_map(inner)); diff --git a/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx b/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx index bf64f896..4b6b2fe7 100644 --- a/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx +++ b/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { spRow, spKey, SpSep } from './panelStyles'; import { useTopologyData, useTopologyUI } from './TopologyContext'; +import { flagEmoji, countryName } from '../../geo'; function formatTime(unix: number): string { return new Date(unix * 1000).toLocaleTimeString([], { hour12: false }); @@ -61,9 +62,14 @@ export default function InternetPanel() {
+ {flagEmoji(s.country_code) && ( + {flagEmoji(s.country_code)} + )} {s.client_ip}
-
{s.service}
+
+ {s.service}{s.org ? ` · ${s.org}` : ''} +
diff --git a/members/nullnet-server/ui/src/geo.ts b/members/nullnet-server/ui/src/geo.ts new file mode 100644 index 00000000..568446e1 --- /dev/null +++ b/members/nullnet-server/ui/src/geo.ts @@ -0,0 +1,20 @@ +/** ISO alpha-2 country code → flag emoji (regional-indicator letters). Empty + * string for anything that isn't two ASCII letters. */ +export function flagEmoji(cc?: string): string { + if (!cc || !/^[A-Za-z]{2}$/.test(cc)) return ''; + const base = 0x1f1e6; + const u = cc.toUpperCase(); + return String.fromCodePoint(base + u.charCodeAt(0) - 65, base + u.charCodeAt(1) - 65); +} + +const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); + +/** Full country name for a flag tooltip; falls back to the raw code. */ +export function countryName(cc?: string): string { + if (!cc) return ''; + try { + return regionNames.of(cc.toUpperCase()) ?? cc.toUpperCase(); + } catch { + return cc.toUpperCase(); + } +} diff --git a/members/nullnet-server/ui/src/hooks/useApi.ts b/members/nullnet-server/ui/src/hooks/useApi.ts index 63499d72..47ef1fbd 100644 --- a/members/nullnet-server/ui/src/hooks/useApi.ts +++ b/members/nullnet-server/ui/src/hooks/useApi.ts @@ -30,7 +30,7 @@ export function useApi(url: string, refreshMs?: number): ApiState & { refe return { ...state, refetch: load }; } -export function useApiText(url: string, refreshMs?: number): { text: string | null; loading: boolean; error: string | null } { +export function useApiText(url: string, refreshMs?: number): { text: string | null; loading: boolean; error: string | null; refetch: () => void } { const [state, setState] = useState<{ text: string | null; loading: boolean; error: string | null }>({ text: null, loading: true, error: null }); const load = useCallback(async () => { @@ -45,11 +45,15 @@ export function useApiText(url: string, refreshMs?: number): { text: string | nu }, [url]); useEffect(() => { + // Reset on URL (e.g. stack) change so we show "Loading…" rather than briefly + // rendering the previous URL's content. Polling calls `load` directly and + // doesn't hit this reset. + setState({ text: null, loading: true, error: null }); load(); if (!refreshMs) return; const id = setInterval(load, refreshMs); return () => clearInterval(id); }, [load, refreshMs]); - return state; + return { ...state, refetch: load }; } diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index 986b7453..3c795864 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -270,6 +270,17 @@ 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); } +.cfg-empty { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 24px; background: rgba(0,0,0,.3); border: 1px dashed var(--gb); border-radius: 12px; } +.cfg-name { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--t1); background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 8px; padding: 6px 12px; outline: none; transition: border-color .12s; } +.cfg-name:focus { border-color: rgba(91,156,246,.5); } /* ── 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..3130347e 100644 --- a/members/nullnet-server/ui/src/pages/Config.tsx +++ b/members/nullnet-server/ui/src/pages/Config.tsx @@ -1,27 +1,216 @@ +import { useEffect, useState } from 'react'; import Layout from '../components/Layout'; -import { useApiText } from '../hooks/useApi'; +import { useApi, useApiText } from '../hooks/useApi'; import { useStack } from '../StackContext'; +type Status = + | { kind: 'idle' } + | { kind: 'saving' } + | { kind: 'ok' } + | { kind: 'error'; msg: string }; + +const STARTER_TEMPLATE = `# Stack configuration — one [[services]] block per service. +# This example is a proxy-reachable Docker service; uncomment the +# optional fields below as needed. +[[services]] +name = "example" # service identifier (unique within the stack) +docker_container = "example" # host-match key: Swarm service / container name +port = 8080 # backend port on the service's replicas +timeout = 60 # proxy-reachable entry point; idle timeout seconds (0 = none) + +# --- optional --- +# process_path = "/usr/bin/example" # non-Docker match key (use instead of docker_container) +# max_networks = 2 # cap proxy networks; extra clients reuse one +# proxy_dependencies = [["db.example"]] # dep chains brought up on proxy setup +# protocol = "tcp" # "http" (default) | "tcp" | "udp" +# listen_port = 5432 # external proxy port; required for tcp/udp +# egress_blocked_countries = ["RU", "CN"] # egress deny-list by client dest country +# egress_allowed_countries = ["US", "IT"] # egress allow-list (exclusive with the deny-list) +# ingress_blocked_countries = ["RU"] # ingress deny-list by client source country +# ingress_allowed_countries = ["US", "IT"] # ingress allow-list (needs a timeout / entry point) + +# [[services.triggers]] # backend-triggered chain: observed port -> chain +# port = 5555 +# chain = ["worker.example"] +`; + +// A stack name maps to a bare filename, so keep it to safe identifier chars. +const validName = (n: string) => /^[A-Za-z0-9_-]+$/.test(n); + export default function Config() { - const { stack } = useStack(); - const { text, loading, error } = useApiText(`/api/config/${stack}`); + const { stack, setStack } = useStack(); + const { text, loading, error, refetch } = useApiText(`/api/config/${stack}`); + const { data: stacks, refetch: refetchStacks } = useApi('/api/stacks', 10000); + + const [content, setContent] = useState(''); + const [original, setOriginal] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + const [newName, setNewName] = useState(''); + + // Sync the editor when the loaded file (or stack) changes. + useEffect(() => { + if (text !== null) { + setContent(text); + setOriginal(text); + setStatus({ kind: 'idle' }); + } + }, [text]); + + const noStack = !stack.trim(); + const notFound = !noStack && !loading && !!error && error.includes('404'); + const dirty = content !== original; + + async function postConfig(name: string, body: string): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch(`/api/config/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body, + }); + const data = await res.json().catch(() => ({ ok: res.ok, error: `HTTP ${res.status}` })); + return { ok: res.ok && data.ok, error: data.error }; + } catch (e) { + return { ok: false, error: String(e) }; + } + } + + async function save() { + setStatus({ kind: 'saving' }); + const r = await postConfig(stack, content); + if (r.ok) { + setOriginal(content); + setStatus({ kind: 'ok' }); + } else { + setStatus({ kind: 'error', msg: r.error ?? 'unknown error' }); + } + } + + async function createStack(name: string) { + setStatus({ kind: 'saving' }); + const r = await postConfig(name, STARTER_TEMPLATE); + if (r.ok) { + setStatus({ kind: 'idle' }); + setNewName(''); + refetchStacks(); + if (name === stack) refetch(); + else setStack(name); + } else { + setStatus({ kind: 'error', msg: r.error ?? 'unknown error' }); + } + } + + async function removeStack() { + if (!confirm(`Delete stack "${stack}"? Its services are torn down immediately.`)) return; + const res = await fetch(`/api/config/${stack}`, { method: 'DELETE' }); + if (res.ok) { + const others = (stacks ?? []).filter(s => s !== stack); + refetchStacks(); + setStack(others[0] ?? ''); + } else { + const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + setStatus({ kind: 'error', msg: data.error ?? `HTTP ${res.status}` }); + } + } + + const errorLine = status.kind === 'error' && ( + + Error + {status.msg} + + ); return (
Configuration
-
Raw service configuration for stack {stack}
+
+ {noStack + ? 'No stacks configured yet.' + : <>Live service configuration for stack{' '} + {stack} + {' '}— edits are validated and applied without a restart.} +
+ + {noStack && ( +
+
Name a stack to create it:
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && validName(newName)) createStack(newName); }} + /> + + {errorLine} +
+ )} - {loading &&
Loading…
} + {!noStack && loading &&
Loading…
} - {error && ( + {notFound && ( +
+
+ Stack {stack} doesn't exist yet. +
+ + {errorLine} +
+ )} + + {!noStack && error && !notFound && (
Failed to load config: {error}
)} - {text && ( -
{text}
+ {!noStack && !loading && !error && ( + <> +