Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All Nullnet releases with the relative changes are documented in this file.

## [UNRELEASED]
### Added
- Add an opt-in “Pausable when idle” checkbox for Docker services, persisted in configuration with a false default ([#187](https://github.com/NullNet-ai/nullnet/pull/187) — fixes [#180](https://github.com/NullNet-ai/nullnet/issues/180))
- Optional service host pinning and automatic proxy TCP/UDP listen-port firewall allowances ([#184](https://github.com/NullNet-ai/nullnet/pull/184) — fixes [#177](https://github.com/NullNet-ai/nullnet/issues/177))
- Per-service egress/ingress traffic filters: arbitrary AND/OR/group combinations of Country, Organization, Src IP (ingress), and Dst IP (egress) conditions, evaluated via `rpn-predicate-interpreter` — replaces the country-only egress/ingress policy ([#171](https://github.com/NullNet-ai/nullnet/pull/171) — fixes [#143](https://github.com/NullNet-ai/nullnet/issues/143))
- Persist ingress and egress sessions to SQLite and show the full history on the Sessions page, filterable by status, service, direction, and policy verdict, with its own retention window ([#170](https://github.com/NullNet-ai/nullnet/pull/170) — fixes [#156](https://github.com/NullNet-ai/nullnet/issues/156))
Expand Down
5 changes: 5 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
(non-Docker) service
- `host_ip` optionally limits either match to one node's control-channel IPv4 address
(for example, `host_ip = "192.168.1.103"` for that host's SSH service). Omit it to match all hosts.
- `pausable = true` opts a Docker service into pausing when idle (the Config checkbox).
It defaults to `false`, including existing DB rows. Backend services follow the same setting.
Live chains and egress sessions keep containers running; disabling pause starts an asynchronous resume of a paused replica.
If several declarations share a container, all must opt in. Paused initiators cannot start work
themselves; use this only when incoming traffic can wake them.
- `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)
Expand Down
78 changes: 40 additions & 38 deletions members/nullnet-client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,16 +345,7 @@ async fn declare_services(
// 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<Container> = get_running_docker_containers()
.await
.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();
let containers = get_running_docker_containers().await;

// One Listener per distinct listening process, keyed by exe path.
let mut paths: Vec<String> = listeners::get_all()
Expand Down Expand Up @@ -465,43 +456,40 @@ async fn declare_services(
}
}

/// Returns a map of logical name -> real container names for all running Docker containers.
///
/// Supports both standalone Docker (name -> [name]) and Swarm mode (swarm service label -> [replicas]).
async fn get_running_docker_containers() -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();

// Query container name and Swarm service label together
/// Report running and paused containers, using Swarm labels when present.
async fn get_running_docker_containers() -> Vec<Container> {
let output = tokio::process::Command::new("docker")
.args([
"ps",
"--format",
"{{.Names}}\t{{.Label \"com.docker.swarm.service.name\"}}",
"{{.Names}}\t{{.Label \"com.docker.swarm.service.name\"}}\t{{.State}}",
])
.output()
.await;
let Ok(out) = output else {
return Vec::new();
};
parse_container_report(&String::from_utf8_lossy(&out.stdout))
}

if let Ok(out) = output {
for line in String::from_utf8_lossy(&out.stdout).lines() {
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
let real_name = parts[0].to_string();
let swarm_label = parts.get(1).unwrap_or(&"").trim();
if swarm_label.is_empty() {
// standalone: logical name = container name
map.entry(real_name.clone()).or_default().push(real_name);
} else {
// Swarm: logical name = swarm service label, may have multiple replicas
map.entry(swarm_label.to_string())
.or_default()
.push(real_name);
fn parse_container_report(output: &str) -> Vec<Container> {
output
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let real_name = parts.next()?.trim();
if real_name.is_empty() {
return None;
}
}
}

map
let label = parts.next()?.trim();
let state = parts.next()?.trim();
Some(Container {
match_key: if label.is_empty() { real_name } else { label }.to_string(),
real_name: real_name.to_string(),
paused: state == "paused",
})
})
.collect()
}

async fn setup_tap(
Expand Down Expand Up @@ -567,3 +555,17 @@ async fn setup_tap(
// }
// None
// }

#[cfg(test)]
mod container_report_tests {
#[test]
fn reports_pause_state_for_standalone_and_swarm() {
let report =
super::parse_container_report("web\t\trunning\nworker.1.x\tstack_worker\tpaused\n");
assert_eq!(report.len(), 2);
assert_eq!(report[0].match_key, "web");
assert!(!report[0].paused);
assert_eq!(report[1].match_key, "stack_worker");
assert!(report[1].paused);
}
}
1 change: 1 addition & 0 deletions members/nullnet-grpc-lib/proto/nullnet_grpc.proto
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ message ServiceReport {
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
bool paused = 3;
}

message Listener {
Expand Down
2 changes: 2 additions & 0 deletions members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ pub struct Container {
/// actual container name; stored as the replica identity
#[prost(string, tag = "2")]
pub real_name: ::prost::alloc::string::String,
#[prost(bool, tag = "3")]
pub paused: bool,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct Listener {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE services DROP COLUMN pausable;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE services ADD COLUMN pausable BOOLEAN NOT NULL DEFAULT FALSE;
2 changes: 2 additions & 0 deletions members/nullnet-server/src/db/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub(crate) struct ServiceRow {
pub(crate) docker_container: Option<String>,
pub(crate) process_path: Option<String>,
pub(crate) host_ip: Option<String>,
pub(crate) pausable: bool,
pub(crate) port: Option<i32>,
pub(crate) timeout: Option<i64>,
pub(crate) max_networks: Option<i32>,
Expand All @@ -91,6 +92,7 @@ pub(crate) struct NewServiceRow<'a> {
pub(crate) docker_container: Option<&'a str>,
pub(crate) process_path: Option<&'a str>,
pub(crate) host_ip: Option<&'a str>,
pub(crate) pausable: bool,
pub(crate) port: Option<i32>,
pub(crate) timeout: Option<i64>,
pub(crate) max_networks: Option<i32>,
Expand Down
1 change: 1 addition & 0 deletions members/nullnet-server/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ diesel::table! {
docker_container -> Nullable<Text>,
process_path -> Nullable<Text>,
host_ip -> Nullable<Text>,
pausable -> Bool,
port -> Nullable<Integer>,
timeout -> Nullable<BigInt>,
max_networks -> Nullable<Integer>,
Expand Down
4 changes: 4 additions & 0 deletions members/nullnet-server/src/db/stacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub(crate) struct ServiceInsert<'a> {
pub(crate) docker_container: Option<&'a str>,
pub(crate) process_path: Option<&'a str>,
pub(crate) host_ip: Option<&'a str>,
pub(crate) pausable: bool,
pub(crate) port: Option<i32>,
pub(crate) timeout: Option<i64>,
pub(crate) max_networks: Option<i32>,
Expand Down Expand Up @@ -170,6 +171,7 @@ impl StackRepository {
docker_container: s.docker_container,
process_path: s.process_path,
host_ip: s.host_ip,
pausable: s.pausable,
port: s.port,
timeout: s.timeout,
max_networks: s.max_networks,
Expand Down Expand Up @@ -286,6 +288,7 @@ mod tests {
docker_container: Some("my-app_web"),
process_path: None,
host_ip: Some("192.0.2.1"),
pausable: true,
port: Some(8080),
timeout: Some(0),
max_networks: None,
Expand All @@ -310,6 +313,7 @@ mod tests {
assert_eq!(services.len(), 1);
assert_eq!(services[0].name, "web");
assert_eq!(services[0].host_ip.as_deref(), Some("192.0.2.1"));
assert!(services[0].pausable);
assert_eq!(services[0].docker_container.as_deref(), Some("my-app_web"));

let ids: Vec<i32> = services.iter().map(|s| s.id).collect();
Expand Down
Loading