diff --git a/docs/operator.md b/docs/operator.md index f07be36..8578d19 100644 --- a/docs/operator.md +++ b/docs/operator.md @@ -121,6 +121,7 @@ If `startCmd` is set, it is used instead of `cmd start`. Prefer **`exec` of the | `dependsOn` | Other service names that must be `running` or `succeeded` first | | `env` / `cwd` | Extra environment and working directory | | `livenessProbe` | Optional periodic check. Exactly one of `cmd`, `httpUrl`, or `tcpAddr`. Shared: `interval` (default `60`), `timeout` (default `5`). `cmd` uses `successExitCodes` (default `[0]`); `httpUrl` uses `httpMethod` (default `GET`) and `httpAcceptedCodes` (default `[200]`); `tcpAddr` is `host:port`. Runs while `running` / `succeeded` / `failed`; failure re-runs start | +| `securityContext` | Optional privilege drop (`runAsUser` / `runAsGroup`) and Linux capabilities. See [Security context](#security-context). Disabled on Android | Example one-shot with recovery (network bring-up): @@ -156,6 +157,101 @@ HTTP / TCP examples: --- +## Security context + +`securityContext` (optional) drops the service to a different user/group and optionally keeps Linux capabilities across `exec`. It applies to **all** command paths (start, stop, restart, liveness `cmd` probe). microinit must run as root (or have `CAP_SETUID`+`CAP_SETGID` **and** be able to call `setgroups(2)`) to apply an identity drop; otherwise the service **fails to start** with a clear error. + +**Not supported on Android** — a configured `securityContext` is rejected at config load (not silently ignored). + +| Field | Role | +|-------|------| +| `runAsUser` | Login name **or** numeric uid (purely numeric string → uid) | +| `runAsGroup` | Group name **or** numeric gid; optional when the user has a passwd entry (defaults to primary gid). **Required** for numeric uids with no passwd entry | +| `capabilities` | Linux capability names (`CAP_` prefix optional). The list is **exclusive** (replaces the parent's capability set; it is not additive) | + +Supplementary groups are cleared with `setgroups([])` (fail-closed). Environments that deny `setgroups` (e.g. user namespaces with `/proc/self/setgroups=deny`) cannot use `runAsUser`/`runAsGroup`. + +After the drop, microinit also: +- shrinks the capability **bounding set** to the requested caps (or empty), +- sets `PR_SET_NO_NEW_PRIVS` so later `exec` cannot regain privileges via setuid binaries / file caps, +- sets `HOME` / `USER` / `LOGNAME` from passwd when known (unless overridden in `env`). + +Inspect a running service with `microinit describe ` — it shows **Running as** (live uid/gid from `/proc`) and **Security** (the configured `securityContext`). Use `microinit describe -o json ` to dump the raw service object from its source file (stdout is pure JSON; the path is printed on stderr). Note: `-o json` is the **unmerged** source object; human `describe` shows the **merged** in-memory definition. + +### Run a service as a non-root user + +Redis as the `redis` user (group defaults to `redis`): + +```json +{ + "name": "redis", + "enabled": true, + "daemon": true, + "restartPolicy": "onError", + "dependsOn": ["network"], + "startCmd": "exec redis-server --bind 127.0.0.1 --port 6379", + "cwd": "/var/lib/redis", + "securityContext": { + "runAsUser": "redis" + }, + "livenessProbe": { "tcpAddr": "127.0.0.1:6379", "interval": 15, "timeout": 3 } +} +``` + +### Numeric uid/gid + +`runAsUser`/`runAsGroup` accept numeric ids directly (useful in minimal images without `/etc/passwd`): + +```json +"securityContext": { + "runAsUser": "1000", + "runAsGroup": "1000" +} +``` + +### Capabilities (bind a privileged port without root) + +A service that needs to bind port 443 but otherwise runs unprivileged: + +```json +{ + "name": "webfront", + "enabled": true, + "daemon": true, + "restartPolicy": "onError", + "startCmd": "exec /usr/bin/webfront --addr :443", + "securityContext": { + "runAsUser": "webfront", + "capabilities": ["CAP_NET_BIND_SERVICE"] + }, + "livenessProbe": { "httpUrl": "http://127.0.0.1:443/health", "httpAcceptedCodes": [200], "interval": 30, "timeout": 5 } +} +``` + +### Replacing image-level `setcap` + +`remote-icmp` previously relied on `setcap cap_net_raw+ep` baked into the image. With `securityContext` the capability lives in the service definition and survives binary updates: + +```json +{ + "name": "remote-icmp", + "enabled": true, + "daemon": true, + "restartPolicy": "onError", + "dependsOn": ["network"], + "startCmd": "/usr/bin/bigfred-remote-icmp --config /data/etc/loco-server.conf", + "stopCmd": "killall bigfred-remote-icmp", + "securityContext": { + "runAsUser": "nobody", + "capabilities": ["CAP_NET_RAW"] + } +} +``` + +> Note: `capabilities` are granted via ambient capabilities (Linux 4.3+) and are an **exclusive** set. On Android `securityContext` is rejected at config load — keep using `setcap`/root there, or omit the field from Android configs. + +--- + ## Dependencies Example: Redis needs the network service first. diff --git a/examples/microinit.json.example b/examples/microinit.json.example index d553443..dc071d5 100644 --- a/examples/microinit.json.example +++ b/examples/microinit.json.example @@ -72,7 +72,13 @@ "stopCmd": "killall bigfred-remote-icmp", "restartCmd": null, "env": {}, - "cwd": "/" + "cwd": "/", + "securityContext": { + "runAsUser": "nobody", + "capabilities": [ + "CAP_NET_RAW" + ] + } } ] } diff --git a/go/config/types.go b/go/config/types.go index 44a3399..957eed9 100644 --- a/go/config/types.go +++ b/go/config/types.go @@ -17,6 +17,15 @@ type ServiceDef struct { Cwd string `json:"cwd,omitempty"` LivenessProbe *LivenessProbe `json:"livenessProbe,omitempty"` Labels map[string]string `json:"labels,omitempty"` + SecurityContext *SecurityContext `json:"securityContext,omitempty"` +} + +// SecurityContext drops privileges and optionally keeps Linux capabilities. +// On Android microinit rejects a configured securityContext at load time. +type SecurityContext struct { + RunAsUser string `json:"runAsUser,omitempty"` + RunAsGroup string `json:"runAsGroup,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` } // Restart policy values for ServiceDef.RestartPolicy. diff --git a/man/man5/microinit.json.5.mdoc b/man/man5/microinit.json.5.mdoc index e7f8cb6..f3bcafe 100644 --- a/man/man5/microinit.json.5.mdoc +++ b/man/man5/microinit.json.5.mdoc @@ -178,6 +178,35 @@ or microinit probes every .Cm interval seconds; failure re-runs start. +.It Cm securityContext +Optional object. On Linux: drops the service to another user/group and optionally +keeps Linux capabilities across +.Xr exec 3 . +On Android: rejected at config load. +.Bl -tag -width capabilities +.It Cm runAsUser +Login name or numeric uid +.It Cm runAsGroup +Group name or numeric gid +.Pq defaults to the user's primary gid when a passwd entry exists; +required for numeric uids without passwd +.It Cm capabilities +Exclusive list of capability names from +.Xr capabilities 7 +.Pq optional Cm CAP_ +prefix . +Retained via ambient capabilities after +.Xr setuid 2 . +.El +Supplementary groups are cleared +.Pq fail-closed via +.Xr setgroups 2 . +The capability bounding set is shrunk and +.Dv PR_SET_NO_NEW_PRIVS +is set. +.Cm describe +prints the configured context and the live process identity from +.Pa /proc . .El .Sh EXAMPLES See @@ -185,4 +214,5 @@ See written on first boot. .Sh SEE ALSO .Xr microinit 8 , -.Xr microinit.services.enabled-override.json 5 +.Xr microinit.services.enabled-override.json 5 , +.Xr capabilities 7 diff --git a/man/man8/microinit.8.mdoc b/man/man8/microinit.8.mdoc index 5bc4f54..d13ae43 100644 --- a/man/man8/microinit.8.mdoc +++ b/man/man8/microinit.8.mdoc @@ -39,6 +39,7 @@ .Nm .Op Fl -socket Ns = Ns Ar path .Cm describe +.Op Fl o Ar human|json .Ar name .Nm .Op Fl -socket Ns = Ns Ar path @@ -184,6 +185,7 @@ microinit list microinit list --show-labels microinit list -l created-by=bigfred microinit describe redis +microinit describe -o json redis microinit start redis microinit start --force alloy microinit restart redis @@ -202,7 +204,18 @@ column, and repeatable to keep only services matching all selectors .Pq AND . .Cm describe -always prints labels. +always prints labels, the live process identity +.Pq Cm Running as +from +.Pa /proc +, +and the configured +.Cm securityContext +.Pq Cm Security +on Linux . +.Fl o Ar json +prints the raw service object from its source drop-in or main config file +.Pq stdout is pure JSON; the source path goes to stderr . .Pp .Cm start prints a status line on stdout: either that the service is starting, that it is diff --git a/src/cli.rs b/src/cli.rs index 45bae53..37bac45 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -121,10 +121,23 @@ pub fn cmd_list(socket: &Path, show_labels: bool, selectors: &[String]) -> Resul } } -pub fn cmd_describe(socket: &Path, name: &str) -> Result<()> { - match request(socket, &Request::Describe { name: name.into() })? { +pub fn cmd_describe( + socket: &Path, + name: &str, + output: crate::protocol::DescribeOutput, +) -> Result<()> { + match request( + socket, + &Request::Describe { + name: name.into(), + output, + }, + )? { Response::Describe { describe } => { - print_describe(&describe); + match output { + crate::protocol::DescribeOutput::Json => print_describe_json(&describe)?, + crate::protocol::DescribeOutput::Human => print_describe(&describe), + } Ok(()) } Response::Error { message, .. } => Err(Error::Ipc(message)), @@ -132,12 +145,28 @@ pub fn cmd_describe(socket: &Path, name: &str) -> Result<()> { } } +fn print_describe_json(d: &ServiceDescribe) -> Result<()> { + let Some(ref src) = d.source else { + return Err(Error::Ipc( + "describe -o json: daemon did not return source".into(), + )); + }; + eprintln!("# from: {}", src.path); + println!("{}", serde_json::to_string_pretty(&src.json)?); + Ok(()) +} + fn print_describe(d: &ServiceDescribe) { let s = &d.status; let pid = s.pid.map(|p| p.to_string()).unwrap_or_else(|| "-".into()); println!("Service: {}", s.name); println!("State: {}", s.state); println!("PID: {pid}"); + println!("Running as: {}", format_running_as(d.running_as.as_ref())); + println!( + "Security: {}", + format_security_context(d.security_context.as_ref()) + ); println!("Enabled: {}", if s.enabled { "yes" } else { "no" }); println!("Restarts: {}", s.restarts); println!("Liveness failures: {}", s.liveness_failures); @@ -176,6 +205,29 @@ fn print_describe(d: &ServiceDescribe) { } } +fn format_running_as(id: Option<&crate::protocol::RunningIdentity>) -> String { + let Some(id) = id else { + return "-".into(); + }; + let user = id.user.as_deref().unwrap_or("?"); + let group = id.group.as_deref().unwrap_or("?"); + format!("{user}({}) / {group}({})", id.uid, id.gid) +} + +fn format_security_context(sec: Option<&crate::config::SecurityContext>) -> String { + let Some(sec) = sec else { + return "(none)".into(); + }; + let user = sec.run_as_user.as_deref().unwrap_or("-"); + let group = sec.run_as_group.as_deref().unwrap_or("-"); + let caps = if sec.capabilities.is_empty() { + "[]".into() + } else { + format!("[{}]", sec.capabilities.join(", ")) + }; + format!("runAsUser={user} runAsGroup={group} capabilities={caps}") +} + fn print_dep_list(nodes: &[DepNode]) { if nodes.is_empty() { println!(" (none)"); diff --git a/src/config.rs b/src/config.rs index b7b5d64..6edcda1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -265,6 +265,37 @@ pub struct ServiceConfig { /// Arbitrary key=value labels (e.g. `created-by=bigfred`). Stable order via BTreeMap. #[serde(default)] pub labels: BTreeMap, + /// Optional privilege drop / capabilities. + /// + /// Parsed on all platforms. On Android, a non-empty value fails + /// [`Config::validate`]. On Linux, [`Config::prepare_security`] resolves it + /// into [`Self::resolved_security`]. + #[serde(default)] + pub security_context: Option, + /// Cached resolution of [`Self::security_context`] (Linux only; not serialized). + #[cfg(not(target_os = "android"))] + #[serde(skip)] + pub resolved_security: Option, +} + +/// Per-service privilege drop and Linux capabilities. +/// +/// On Android builds a configured context is rejected at validate time (not +/// silently ignored). On Linux it is applied at spawn via `security`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct SecurityContext { + /// Login name or numeric uid. + #[serde(default)] + pub run_as_user: Option, + /// Group name or numeric gid; defaults to the user's primary gid when omitted. + /// Required when `runAsUser` is a numeric uid with no passwd entry. + #[serde(default)] + pub run_as_group: Option, + /// Linux capability names (`CAP_` prefix optional). See `capabilities(7)`. + /// When set, the list is **exclusive** (not additive to the parent's caps). + #[serde(default)] + pub capabilities: Vec, } fn default_true() -> bool { @@ -527,6 +558,40 @@ impl Config { } } validate_labels(&svc.name, &svc.labels)?; + if let Some(ref sec) = svc.security_context { + #[cfg(target_os = "android")] + { + let _ = sec; + return Err(Error::Config(format!( + "service '{}': securityContext is not supported on Android", + svc.name + ))); + } + #[cfg(not(target_os = "android"))] + { + if let Some(ref u) = sec.run_as_user { + if u.trim().is_empty() { + return Err(Error::Config(format!( + "service '{}': securityContext.runAsUser must not be empty", + svc.name + ))); + } + } + if let Some(ref g) = sec.run_as_group { + if g.trim().is_empty() { + return Err(Error::Config(format!( + "service '{}': securityContext.runAsGroup must not be empty", + svc.name + ))); + } + } + for cap in &sec.capabilities { + crate::security::validate_cap_name(cap).map_err(|e| { + Error::Config(format!("service '{}': securityContext.{}", svc.name, e)) + })?; + } + } + } } for svc in &self.services { for dep in &svc.depends_on { @@ -548,6 +613,21 @@ impl Config { pub fn get(&self, name: &str) -> Option<&ServiceConfig> { self.services.iter().find(|s| s.name == name) } + + /// Resolve each service's `securityContext` into a cached identity (Linux). + /// + /// Call after [`Self::validate`]. Failures (unknown user, etc.) surface here + /// so spawn/liveness never hit NSS on the hot path. + #[cfg(not(target_os = "android"))] + pub fn prepare_security(&mut self) -> Result<()> { + for svc in &mut self.services { + svc.resolved_security = match &svc.security_context { + Some(ctx) => crate::security::resolve(ctx)?, + None => None, + }; + } + Ok(()) + } } /// Merge enabled overrides onto config (in place). @@ -574,8 +654,10 @@ pub fn save_override(path: &Path, map: &HashMap) -> Result<()> { pub fn load_config(path: &Path) -> Result { let data = fs::read_to_string(path).map_err(|e| Error::io_at(path, e))?; - let cfg: Config = serde_json::from_str(&data)?; + let mut cfg: Config = serde_json::from_str(&data)?; cfg.validate()?; + #[cfg(not(target_os = "android"))] + cfg.prepare_security()?; Ok(cfg) } @@ -605,7 +687,7 @@ struct DropinFile { } /// Collect relative paths of `*.json` under `root`, sorted lexicographically. -fn collect_dropin_rel_paths(root: &Path) -> Result> { +pub(crate) fn collect_dropin_rel_paths(root: &Path) -> Result> { let mut out = Vec::new(); if !root.is_dir() { return Ok(out); @@ -692,6 +774,8 @@ pub fn load_or_create_with_dropins( let ov = load_override(override_path)?; apply_enabled_override(&mut cfg, &ov); cfg.validate()?; + #[cfg(not(target_os = "android"))] + cfg.prepare_security()?; Ok(cfg) } @@ -737,6 +821,9 @@ pub fn example_config() -> Config { timeout: 5, }), labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, }, ServiceConfig { name: "redis".into(), @@ -757,6 +844,9 @@ pub fn example_config() -> Config { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, }, ServiceConfig { name: "remote-icmp".into(), @@ -780,6 +870,13 @@ pub fn example_config() -> Config { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: Some(SecurityContext { + run_as_user: Some("nobody".into()), + run_as_group: None, + capabilities: vec!["CAP_NET_RAW".into()], + }), + #[cfg(not(target_os = "android"))] + resolved_security: None, }, ], } diff --git a/src/error.rs b/src/error.rs index afa3c83..4f9c2a2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -50,6 +50,11 @@ pub enum Error { #[error("nix error: {0}")] Nix(#[from] nix::Error), + /// Privilege / capability setup failure for a supervised service. + #[cfg(not(target_os = "android"))] + #[error("security: {0}")] + Security(String), + #[error("{0}")] Other(String), } diff --git a/src/init.rs b/src/init.rs index 4205a72..5cc8d8f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -194,8 +194,17 @@ pub fn run(opts: InitOpts) -> Result<()> { let socket_path = cfg.socket.clone(); let lines_default = cfg.logs.lines; let override_path = opts.paths.override_file.clone(); + let config_path = opts.paths.config.clone(); + let dropins_dir = opts.paths.dropins_dir.clone(); - let supervisor = Supervisor::new(cfg, hub.clone(), console.clone(), override_path); + let supervisor = Supervisor::new( + cfg, + hub.clone(), + console.clone(), + override_path, + config_path, + dropins_dir, + ); let sup = Arc::clone(&supervisor); let hub_ipc = hub.clone(); @@ -390,8 +399,13 @@ fn handle_ipc( Ok(status) => write_frame(stream, &Response::Status { status })?, Err(e) => write_frame(stream, &error_response(&e))?, }, - Request::Describe { name } => match supervisor.describe(&name) { - Ok(describe) => write_frame(stream, &Response::Describe { describe })?, + Request::Describe { name, output } => match supervisor.describe(&name, output) { + Ok(describe) => write_frame( + stream, + &Response::Describe { + describe: Box::new(describe), + }, + )?, Err(e) => write_frame(stream, &error_response(&e))?, }, Request::Start { name, force } => { diff --git a/src/lib.rs b/src/lib.rs index 10e2cb6..d34a5ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,8 @@ pub mod logs; pub mod otel; pub mod protocol; pub mod reaper; +#[cfg(not(target_os = "android"))] +pub mod security; pub mod service; #[cfg(feature = "init")] #[cfg_attr(target_os = "android", allow(unsafe_code))] diff --git a/src/liveness.rs b/src/liveness.rs index b5ab207..b09ccf2 100644 --- a/src/liveness.rs +++ b/src/liveness.rs @@ -154,6 +154,9 @@ mod tests { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, } } diff --git a/src/main.rs b/src/main.rs index 6e4bb06..d893c5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,14 @@ struct Cli { command: Option, } +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] +#[value(rename_all = "lower")] +enum OutputFormat { + #[default] + Human, + Json, +} + #[derive(Subcommand, Debug)] enum Commands { /// Run as PID 1 / system init (start services, supervise, IPC) @@ -94,7 +102,12 @@ enum Commands { selector: Vec, }, /// Show detailed status, dependencies, and recent lifecycle events - Describe { name: String }, + Describe { + name: String, + /// Output format: `human` (default) or `json` (raw service object from source file) + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Human)] + output: OutputFormat, + }, /// Show service logs (or mixed if name omitted) Logs { name: Option, @@ -246,7 +259,13 @@ fn main() -> ExitCode { show_labels, selector, } => cli::cmd_list(&cli.socket, show_labels, &selector), - Commands::Describe { name } => cli::cmd_describe(&cli.socket, &name), + Commands::Describe { name, output } => { + let out = match output { + OutputFormat::Human => microinit::protocol::DescribeOutput::Human, + OutputFormat::Json => microinit::protocol::DescribeOutput::Json, + }; + cli::cmd_describe(&cli.socket, &name, out) + } Commands::Logs { name, follow, diff --git a/src/protocol.rs b/src/protocol.rs index 0391e94..d7fa4c0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -103,6 +103,36 @@ pub struct DepNode { pub state: ServiceState, } +/// Actual identity of a running process (from `/proc//status`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunningIdentity { + pub uid: u32, + pub gid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, +} + +/// Raw service object as it appears in its source config / drop-in file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceSource { + /// File the service was read from (drop-in path or main config path). + pub path: String, + /// Raw service object from that file (unmerged). + pub json: serde_json::Value, +} + +/// Output mode for `describe` (wire + CLI). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DescribeOutput { + #[default] + Human, + Json, +} + /// Full `describe` payload for one service. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceDescribe { @@ -119,6 +149,15 @@ pub struct ServiceDescribe { pub dep_edges: Vec<(String, String)>, /// Oldest → newest, last [`crate::constants::EVENT_RETURN`] events. pub events: Vec, + /// Live process identity from `/proc//status` when running. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub running_as: Option, + /// Configured security context (definition). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security_context: Option, + /// Raw source-file object; populated when `Request::Describe.output` is `json`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, } /// Log stream / severity for a captured line. @@ -202,6 +241,9 @@ pub enum Request { /// Rich status: deps, reverse deps, subgraph, recent lifecycle events. Describe { name: String, + /// When `json`, the response includes the raw source-file service object. + #[serde(default)] + output: DescribeOutput, }, Enable { name: String, @@ -240,7 +282,7 @@ pub enum Response { status: ServiceStatus, }, Describe { - describe: ServiceDescribe, + describe: Box, }, /// One log line in a stream; ends with `Ok` when follow=false and buffer drained. Log { diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..45b2514 --- /dev/null +++ b/src/security.rs @@ -0,0 +1,539 @@ +//! Privilege drop and Linux capabilities for supervised services. +//! +//! Completely disabled on Android (`cfg(not(target_os = "android"))` at the +//! module boundary in `lib.rs`). + +#![allow(unsafe_code)] + +use nix::unistd::{self, Gid, Group, Uid, User}; + +use crate::config::SecurityContext; +use crate::error::{Error, Result}; + +/// Allowlisted capability names and their Linux capability numbers +/// (`linux/capability.h`). Single source of truth for validation and apply. +const CAP_TABLE: &[(&str, u8)] = &[ + ("CHOWN", 0), + ("DAC_OVERRIDE", 1), + ("DAC_READ_SEARCH", 2), + ("FOWNER", 3), + ("FSETID", 4), + ("KILL", 5), + ("SETGID", 6), + ("SETUID", 7), + ("SETPCAP", 8), + ("LINUX_IMMUTABLE", 9), + ("NET_BIND_SERVICE", 10), + ("NET_BROADCAST", 11), + ("NET_ADMIN", 12), + ("NET_RAW", 13), + ("IPC_LOCK", 14), + ("IPC_OWNER", 15), + ("SYS_MODULE", 16), + ("SYS_RAWIO", 17), + ("SYS_CHROOT", 18), + ("SYS_PTRACE", 19), + ("SYS_PACCT", 20), + ("SYS_ADMIN", 21), + ("SYS_BOOT", 22), + ("SYS_NICE", 23), + ("SYS_RESOURCE", 24), + ("SYS_TIME", 25), + ("SYS_TTY_CONFIG", 26), + ("MKNOD", 27), + ("LEASE", 28), + ("AUDIT_WRITE", 29), + ("AUDIT_CONTROL", 30), + ("SETFCAP", 31), + ("MAC_OVERRIDE", 32), + ("MAC_ADMIN", 33), + ("SYSLOG", 34), + ("WAKE_ALARM", 35), + ("BLOCK_SUSPEND", 36), + ("AUDIT_READ", 37), + ("PERFMON", 38), + ("BPF", 39), + ("CHECKPOINT_RESTORE", 40), +]; + +/// Highest known capability number in [`CAP_TABLE`] (inclusive). +const CAP_LAST: u8 = 40; + +/// Capability names accepted in `securityContext.capabilities` (without requiring +/// the `CAP_` prefix). +pub const KNOWN_CAPABILITIES: &[&str] = { + // Keep a parallel name list for docs/tests without allocating at runtime. + &[ + "AUDIT_CONTROL", + "AUDIT_READ", + "AUDIT_WRITE", + "BLOCK_SUSPEND", + "BPF", + "CHECKPOINT_RESTORE", + "CHOWN", + "DAC_OVERRIDE", + "DAC_READ_SEARCH", + "FOWNER", + "FSETID", + "IPC_LOCK", + "IPC_OWNER", + "KILL", + "LEASE", + "LINUX_IMMUTABLE", + "MAC_ADMIN", + "MAC_OVERRIDE", + "MKNOD", + "NET_ADMIN", + "NET_BIND_SERVICE", + "NET_BROADCAST", + "NET_RAW", + "PERFMON", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + "SYS_ADMIN", + "SYS_BOOT", + "SYS_CHROOT", + "SYSLOG", + "SYS_MODULE", + "SYS_NICE", + "SYS_PACCT", + "SYS_PTRACE", + "SYS_RAWIO", + "SYS_RESOURCE", + "SYS_TIME", + "SYS_TTY_CONFIG", + "WAKE_ALARM", + ] +}; + +/// Normalize a capability name: strip optional `CAP_` prefix, uppercase. +#[must_use] +pub fn normalize_cap_name(raw: &str) -> String { + let s = raw.trim(); + let s = s + .strip_prefix("CAP_") + .or_else(|| s.strip_prefix("cap_")) + .unwrap_or(s); + s.to_ascii_uppercase() +} + +/// Validate a single capability name (config-time). +pub fn validate_cap_name(raw: &str) -> Result<()> { + let n = normalize_cap_name(raw); + if n.is_empty() { + return Err(Error::Config("empty capability name".into())); + } + if !CAP_TABLE.iter().any(|(name, _)| *name == n) { + return Err(Error::Config(format!("unknown capability '{raw}'"))); + } + Ok(()) +} + +fn cap_number(normalized: &str) -> Result { + CAP_TABLE + .iter() + .find(|(name, _)| *name == normalized) + .map(|(_, n)| *n) + .ok_or_else(|| Error::Security(format!("unknown capability '{normalized}'"))) +} + +/// Resolved identity ready for `pre_exec` application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedIdentity { + /// Target uid; `None` means leave uid unchanged. + pub uid: Option, + /// Target gid; `None` means leave gid unchanged. + pub gid: Option, + /// Capability numbers to keep as ambient/inheritable/permitted/effective. + /// Empty means clear all capabilities after the identity change. + pub caps: Vec, + /// Suggested `HOME` from passwd (best-effort). + pub home: Option, + /// Suggested `USER` / `LOGNAME` from passwd (best-effort). + pub username: Option, +} + +impl ResolvedIdentity { + #[must_use] + pub fn is_noop(&self) -> bool { + self.uid.is_none() && self.gid.is_none() && self.caps.is_empty() + } + + #[must_use] + pub fn drops_identity(&self) -> bool { + self.uid.is_some() || self.gid.is_some() + } +} + +/// Resolve `SecurityContext` into a concrete identity. +/// +/// Returns `Ok(None)` when the context is empty (no user/group/caps). +/// User/group lookup failures are spawn-time / prepare-time errors (`Error::Security`). +pub fn resolve(ctx: &SecurityContext) -> Result> { + let has_user = ctx + .run_as_user + .as_deref() + .is_some_and(|s| !s.trim().is_empty()); + let has_group = ctx + .run_as_group + .as_deref() + .is_some_and(|s| !s.trim().is_empty()); + if !has_user && !has_group && ctx.capabilities.is_empty() { + return Ok(None); + } + + let mut uid: Option = None; + let mut primary_gid: Option = None; + let mut home: Option = None; + let mut username: Option = None; + + if let Some(ref user_spec) = ctx.run_as_user { + let spec = user_spec.trim(); + if spec.is_empty() { + return Err(Error::Security("runAsUser is empty".into())); + } + if let Ok(n) = spec.parse::() { + uid = Some(n); + if let Ok(Some(u)) = User::from_uid(Uid::from_raw(n)) { + primary_gid = Some(u.gid.as_raw()); + home = Some(u.dir.display().to_string()); + username = Some(u.name); + } else if !has_group { + return Err(Error::Security(format!( + "runAsUser '{spec}' has no passwd entry; set runAsGroup explicitly" + ))); + } + } else { + let u = User::from_name(spec) + .map_err(|e| Error::Security(format!("lookup user '{spec}': {e}")))? + .ok_or_else(|| Error::Security(format!("unknown user '{spec}'")))?; + uid = Some(u.uid.as_raw()); + primary_gid = Some(u.gid.as_raw()); + home = Some(u.dir.display().to_string()); + username = Some(u.name); + } + } + + let gid = if let Some(ref group_spec) = ctx.run_as_group { + let spec = group_spec.trim(); + if spec.is_empty() { + return Err(Error::Security("runAsGroup is empty".into())); + } + Some(if let Ok(n) = spec.parse::() { + n + } else { + let g = Group::from_name(spec) + .map_err(|e| Error::Security(format!("lookup group '{spec}': {e}")))? + .ok_or_else(|| Error::Security(format!("unknown group '{spec}'")))?; + g.gid.as_raw() + }) + } else { + primary_gid + }; + + let mut caps = Vec::with_capacity(ctx.capabilities.len()); + for raw in &ctx.capabilities { + let n = normalize_cap_name(raw); + caps.push(cap_number(&n)?); + } + caps.sort_unstable(); + caps.dedup(); + + let ident = ResolvedIdentity { + uid, + gid, + caps, + home, + username, + }; + if ident.is_noop() { + Ok(None) + } else { + Ok(Some(ident)) + } +} + +/// Apply identity in the child after fork, before exec. +/// +/// Order: keepcaps → bounding-set drop → setgroups([]) → setgid → setuid → +/// capset + ambient → `PR_SET_NO_NEW_PRIVS`. +/// +/// # Safety +/// +/// Must only be called from a `Command::pre_exec` closure (single-threaded +/// child between fork and exec). Success path avoids heap allocation after the +/// first syscall; error paths may allocate for diagnostics. +pub fn apply_pre_exec(ident: &ResolvedIdentity) -> Result<()> { + if ident.is_noop() { + return Ok(()); + } + + let want_caps = !ident.caps.is_empty(); + let drop_id = ident.drops_identity(); + + // SAFETY: PR_SET_KEEPCAPS is a well-defined prctl; failure is reported. + if want_caps { + let rc = unsafe { libc::prctl(libc::PR_SET_KEEPCAPS, 1i64, 0, 0, 0) }; + if rc != 0 { + return Err(Error::Security(format!( + "PR_SET_KEEPCAPS: {}", + std::io::Error::last_os_error() + ))); + } + } + + // Shrink the capability bounding set while still privileged. + drop_bounding_set(&ident.caps)?; + + // Fail-closed: when dropping uid/gid we must clear supplementary groups. + // User namespaces with `/proc/self/setgroups=deny` are unsupported for + // securityContext identity drops. + if drop_id { + unistd::setgroups(&[]).map_err(|e| { + Error::Security(format!( + "setgroups: {e} (required when runAsUser/runAsGroup is set)" + )) + })?; + } + + if let Some(gid) = ident.gid { + unistd::setgid(Gid::from_raw(gid)) + .map_err(|e| Error::Security(format!("setgid({gid}): {e}")))?; + } + + if let Some(uid) = ident.uid { + unistd::setuid(Uid::from_raw(uid)) + .map_err(|e| Error::Security(format!("setuid({uid}): {e}")))?; + } + + // Always install an explicit capability set after identity change: + // requested caps, or empty (clear everything) when dropping identity. + if want_caps || drop_id { + set_capabilities(&ident.caps)?; + } + + // SAFETY: PR_SET_NO_NEW_PRIVS blocks future privilege gains via execve + // (setuid binaries / file capabilities). + let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1i64, 0, 0, 0) }; + if rc != 0 { + return Err(Error::Security(format!( + "PR_SET_NO_NEW_PRIVS: {}", + std::io::Error::last_os_error() + ))); + } + + Ok(()) +} + +/// Install [`apply_pre_exec`] on `cmd` via `CommandExt::pre_exec`. +pub fn attach_pre_exec(cmd: &mut std::process::Command, ident: &ResolvedIdentity) { + use std::os::unix::process::CommandExt; + let owned = ident.clone(); + // SAFETY: the closure only runs in the single-threaded child between fork + // and exec; see [`apply_pre_exec`]. + unsafe { + cmd.pre_exec(move || { + apply_pre_exec(&owned).map_err(|e| std::io::Error::other(e.to_string())) + }); + } +} + +fn drop_bounding_set(keep: &[u8]) -> Result<()> { + for cap in 0..=CAP_LAST { + if keep.contains(&cap) { + continue; + } + // SAFETY: PR_CAPBSET_DROP removes `cap` from the bounding set. + // Best-effort: some containers lack CAP_SETPCAP or reject drops with + // EINVAL/EPERM; identity drop and capset below remain authoritative. + let _ = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap as libc::c_ulong, 0, 0, 0) }; + } + Ok(()) +} + +// Linux capability ABI (capability.h) — not always exported by the `libc` crate. +const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; + +#[repr(C)] +struct CapUserHeader { + version: u32, + pid: i32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct CapUserData { + effective: u32, + permitted: u32, + inheritable: u32, +} + +unsafe extern "C" { + fn capset(hdrp: *mut CapUserHeader, datap: *const CapUserData) -> libc::c_int; +} + +fn set_capabilities(caps: &[u8]) -> Result<()> { + let mut low: u32 = 0; + let mut high: u32 = 0; + for &c in caps { + if c < 32 { + low |= 1u32 << c; + } else if c < 64 { + high |= 1u32 << (c - 32); + } else { + return Err(Error::Security(format!( + "capability number {c} out of range" + ))); + } + } + + // SAFETY: capset with version 3 header + two data words is the documented + // Linux ABI for setting process capabilities. + let mut header = CapUserHeader { + version: LINUX_CAPABILITY_VERSION_3, + pid: 0, + }; + let data = [ + CapUserData { + effective: low, + permitted: low, + inheritable: low, + }, + CapUserData { + effective: high, + permitted: high, + inheritable: high, + }, + ]; + let rc = unsafe { capset(&mut header, data.as_ptr()) }; + if rc != 0 { + return Err(Error::Security(format!( + "capset: {}", + std::io::Error::last_os_error() + ))); + } + + // SAFETY: PR_CAP_AMBIENT_* are documented prctl operations. + let rc = unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_CLEAR_ALL as libc::c_ulong, + 0, + 0, + 0, + ) + }; + if rc != 0 { + return Err(Error::Security(format!( + "PR_CAP_AMBIENT_CLEAR_ALL: {}", + std::io::Error::last_os_error() + ))); + } + + for &c in caps { + let rc = unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_RAISE as libc::c_ulong, + c as libc::c_ulong, + 0, + 0, + ) + }; + if rc != 0 { + return Err(Error::Security(format!( + "PR_CAP_AMBIENT_RAISE({c}): {}", + std::io::Error::last_os_error() + ))); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_strips_prefix() { + assert_eq!(normalize_cap_name("CAP_NET_RAW"), "NET_RAW"); + assert_eq!(normalize_cap_name("cap_net_raw"), "NET_RAW"); + assert_eq!(normalize_cap_name("NET_RAW"), "NET_RAW"); + assert_eq!( + normalize_cap_name(" net_bind_service "), + "NET_BIND_SERVICE" + ); + } + + #[test] + fn validate_known_and_unknown() { + assert!(validate_cap_name("CAP_NET_BIND_SERVICE").is_ok()); + assert!(validate_cap_name("NET_RAW").is_ok()); + assert!(validate_cap_name("NOT_A_CAP").is_err()); + assert!(validate_cap_name("").is_err()); + } + + #[test] + fn cap_table_covers_known_list() { + for name in KNOWN_CAPABILITIES { + assert!( + CAP_TABLE.iter().any(|(n, _)| n == name), + "{name} missing from CAP_TABLE" + ); + } + for (name, _) in CAP_TABLE { + assert!( + KNOWN_CAPABILITIES.contains(name), + "{name} missing from KNOWN_CAPABILITIES" + ); + } + } + + #[test] + fn resolve_empty_is_none() { + let ctx = SecurityContext::default(); + assert!(resolve(&ctx).unwrap().is_none()); + } + + #[test] + fn resolve_numeric_user() { + let ctx = SecurityContext { + run_as_user: Some("0".into()), + run_as_group: Some("0".into()), + capabilities: vec![], + }; + let ident = resolve(&ctx).unwrap().unwrap(); + assert_eq!(ident.uid, Some(0)); + assert_eq!(ident.gid, Some(0)); + } + + #[test] + fn resolve_numeric_uid_without_passwd_requires_group() { + // Extremely unlikely to exist in passwd; still require explicit group. + let ctx = SecurityContext { + run_as_user: Some("4294967294".into()), // -2 as u32 often unused + run_as_group: None, + capabilities: vec![], + }; + // May succeed if passwd has the entry; if not, must error about runAsGroup. + match resolve(&ctx) { + Ok(Some(ident)) => { + assert!( + ident.gid.is_some(), + "passwd entry should supply primary gid" + ); + } + Err(e) => { + let msg = e.to_string(); + assert!( + msg.contains("runAsGroup") || msg.contains("passwd"), + "{msg}" + ); + } + Ok(None) => panic!("expected identity or error"), + } + } +} diff --git a/src/service.rs b/src/service.rs index da7324d..f38fb59 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,12 +1,14 @@ //! Service process execution helpers. use std::collections::HashMap; +use std::fs; use std::process::{Child, Command, Stdio}; use std::time::Duration; use crate::config::ServiceConfig; use crate::constants::TERMINATE_POLL; use crate::error::{Error, Result}; +use crate::protocol::RunningIdentity; /// Shell used to run service `cmd` / probes / stop scripts. #[cfg(target_os = "android")] @@ -26,6 +28,7 @@ fn build_shell_command( cmd: &str, cfg: &ServiceConfig, env_extra: &HashMap, + #[cfg(not(target_os = "android"))] ident: Option<&crate::security::ResolvedIdentity>, ) -> Command { let mut c = Command::new(SHELL); c.arg("-c") @@ -46,12 +49,55 @@ fn build_shell_command( for (k, v) in env_extra { c.env(k, v); } + + #[cfg(not(target_os = "android"))] + if let Some(ident) = ident { + // Passwd-derived identity env unless the service overrides them. + if let Some(ref home) = ident.home { + if !cfg.env.contains_key("HOME") && !env_extra.contains_key("HOME") { + c.env("HOME", home); + } + } + if let Some(ref user) = ident.username { + if !cfg.env.contains_key("USER") && !env_extra.contains_key("USER") { + c.env("USER", user); + } + if !cfg.env.contains_key("LOGNAME") && !env_extra.contains_key("LOGNAME") { + c.env("LOGNAME", user); + } + } + crate::security::attach_pre_exec(&mut c, ident); + } + c } +/// Prefer the identity cached by [`Config::prepare_security`]; fall back to a +/// one-shot resolve for ad-hoc test configs that skip prepare. +#[cfg(not(target_os = "android"))] +fn resolve_sec(cfg: &ServiceConfig) -> Result> { + if cfg.security_context.is_none() { + return Ok(None); + } + if let Some(ref cached) = cfg.resolved_security { + return Ok(Some(cached.clone())); + } + match &cfg.security_context { + Some(ctx) => crate::security::resolve(ctx), + None => Ok(None), + } +} + /// Spawn a shell command with service env/cwd; stdout/stderr piped for capture. pub fn spawn_shell(cmd: &str, cfg: &ServiceConfig) -> Result { - build_shell_command(cmd, cfg, &HashMap::new()) + #[cfg(not(target_os = "android"))] + let ident = resolve_sec(cfg)?; + #[cfg(not(target_os = "android"))] + let mut cmd_built = build_shell_command(cmd, cfg, &HashMap::new(), ident.as_ref()); + #[cfg(target_os = "android")] + let mut cmd_built = build_shell_command(cmd, cfg, &HashMap::new()); + + cmd_built .spawn() .map_err(|e| Error::Service(cfg.name.clone(), e.to_string())) } @@ -62,6 +108,13 @@ pub fn run_shell( cfg: &ServiceConfig, env_extra: &HashMap, ) -> Result { + #[cfg(not(target_os = "android"))] + let ident = resolve_sec(cfg)?; + #[cfg(not(target_os = "android"))] + let status = build_shell_command(cmd, cfg, env_extra, ident.as_ref()) + .status() + .map_err(|e| Error::Service(cfg.name.clone(), e.to_string()))?; + #[cfg(target_os = "android")] let status = build_shell_command(cmd, cfg, env_extra) .status() .map_err(|e| Error::Service(cfg.name.clone(), e.to_string()))?; @@ -70,6 +123,11 @@ pub fn run_shell( /// Like [`run_shell`], but discard stdout/stderr (liveness probes must stay cheap/quiet). pub fn run_shell_quiet(cmd: &str, cfg: &ServiceConfig) -> Result { + #[cfg(not(target_os = "android"))] + let ident = resolve_sec(cfg)?; + #[cfg(not(target_os = "android"))] + let mut c = build_shell_command(cmd, cfg, &HashMap::new(), ident.as_ref()); + #[cfg(target_os = "android")] let mut c = build_shell_command(cmd, cfg, &HashMap::new()); c.stdout(Stdio::null()).stderr(Stdio::null()); let status = c @@ -89,7 +147,14 @@ pub fn run_shell_quiet_timeout( use std::thread; use std::time::Instant; - let mut child = build_shell_command(cmd, cfg, &HashMap::new()) + #[cfg(not(target_os = "android"))] + let ident = resolve_sec(cfg)?; + #[cfg(not(target_os = "android"))] + let mut cmd_built = build_shell_command(cmd, cfg, &HashMap::new(), ident.as_ref()); + #[cfg(target_os = "android")] + let mut cmd_built = build_shell_command(cmd, cfg, &HashMap::new()); + + let mut child = cmd_built .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -115,6 +180,48 @@ pub fn run_shell_quiet_timeout( } } +/// Read the real uid/gid of a live process from `/proc//status`. +/// +/// Returns `None` if the process is gone or procfs is unavailable. Name +/// resolution via passwd/group is best-effort. +#[must_use] +pub fn read_running_identity(pid: i32) -> Option { + let path = format!("/proc/{pid}/status"); + let data = fs::read_to_string(path).ok()?; + let mut uid: Option = None; + let mut gid: Option = None; + for line in data.lines() { + if let Some(rest) = line.strip_prefix("Uid:") { + // real, effective, saved, fs — take real + if let Some(tok) = rest.split_whitespace().next() { + uid = tok.parse().ok(); + } + } else if let Some(rest) = line.strip_prefix("Gid:") { + if let Some(tok) = rest.split_whitespace().next() { + gid = tok.parse().ok(); + } + } + } + let uid = uid?; + let gid = gid?; + + let user = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid)) + .ok() + .flatten() + .map(|u| u.name); + let group = nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(gid)) + .ok() + .flatten() + .map(|g| g.name); + + Some(RunningIdentity { + uid, + gid, + user, + group, + }) +} + /// Kill a process with SIGTERM, wait `grace_secs`, then SIGKILL if still alive. /// /// `grace_secs == 0` means SIGTERM then immediate SIGKILL (no wait). diff --git a/src/supervisor.rs b/src/supervisor.rs index 450a10e..48c2d14 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -4,7 +4,8 @@ //! not by `std::process::Child::wait`, to avoid racing `waitpid(-1)`. use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; @@ -24,7 +25,8 @@ use crate::graph::{partition_boot, shutdown_order}; use crate::liveness::{run_probe, ProbeResult}; use crate::logs::{capture_stream, LogHub, INIT_SERVICE}; use crate::protocol::{ - DepNode, LogLevel, ServiceDescribe, ServiceEvent, ServiceEventKind, ServiceState, ServiceStatus, + DepNode, LogLevel, ServiceDescribe, ServiceEvent, ServiceEventKind, ServiceSource, + ServiceState, ServiceStatus, }; use crate::reaper::{ensure_reaper_thread, global_exits, ExitRegistry}; use crate::service::{run_shell, spawn_shell, terminate_pid}; @@ -279,6 +281,8 @@ pub struct Supervisor { hub: Arc, console: Arc, override_path: PathBuf, + config_path: PathBuf, + dropins_dir: PathBuf, exits: Arc, ctl: Mutex>>, } @@ -296,6 +300,8 @@ impl Supervisor { hub: Arc, console: Arc, override_path: PathBuf, + config_path: PathBuf, + dropins_dir: PathBuf, ) -> Arc { let mut runtimes = HashMap::new(); for svc in &config.services { @@ -311,6 +317,8 @@ impl Supervisor { hub, console, override_path, + config_path, + dropins_dir, exits: global_exits(), ctl: Mutex::new(HashMap::new()), }) @@ -363,9 +371,19 @@ impl Supervisor { /// /// Snapshots under short critical sections (lock order: `runtimes`, then /// `config`), then builds the graph and formats event timestamps unlocked. - pub fn describe(&self, name: &str) -> Result { + /// + /// When `output` is [`DescribeOutput::Json`], also attaches the raw + /// source-file service object (`source`). + pub fn describe( + &self, + name: &str, + output: crate::protocol::DescribeOutput, + ) -> Result { + use crate::protocol::DescribeOutput; + use crate::service::read_running_identity; + // --- Snapshot under runtimes (released before config / graph work) --- - let (mut status, uptime_secs, events, states) = { + let (mut status, uptime_secs, events, states, pid) = { let map = mutex_lock(&self.shared.runtimes); let rt = map .get(name) @@ -388,6 +406,8 @@ impl Supervisor { None }; + let pid = rt.pid; + let start = rt.events.len().saturating_sub(EVENT_RETURN); let events: Vec = rt .events @@ -398,10 +418,14 @@ impl Supervisor { let states: HashMap = map.iter().map(|(n, r)| (n.clone(), r.state)).collect(); - (status, uptime_secs, events, states) + (status, uptime_secs, events, states, pid) }; + // Procfs / NSS outside the runtimes lock. + let running_as = pid.and_then(read_running_identity); + // --- Snapshot dependency edges under config --- + let security_context; let (depends_on_names, services_deps) = { let cfg = mutex_lock(&self.config); let svc = cfg @@ -410,6 +434,7 @@ impl Supervisor { .find(|s| s.name == name) .ok_or_else(|| Error::UnknownService(name.to_string()))?; status.labels = svc.labels.clone(); + security_context = svc.security_context.clone(); let depends_on_names = svc.depends_on.clone(); let services_deps: Vec<(String, Vec)> = cfg .services @@ -496,6 +521,12 @@ impl Supervisor { let mut dep_edges: Vec<(String, String)> = edge_set.into_iter().collect(); dep_edges.sort(); + let source = if matches!(output, DescribeOutput::Json) { + find_service_source(&self.dropins_dir, &self.config_path, name)? + } else { + None + }; + Ok(ServiceDescribe { status, uptime_secs, @@ -504,6 +535,9 @@ impl Supervisor { dep_nodes, dep_edges, events, + running_as, + security_context, + source, }) } @@ -1216,11 +1250,65 @@ impl Supervisor { } } -/// Compare service definitions ignoring `enabled` (handled separately on reload). +/// Compare service definitions ignoring `enabled` (handled separately on reload) +/// and cached `resolved_security` (derived from `securityContext`). fn definition_eq(a: &ServiceConfig, b: &ServiceConfig) -> bool { let mut x = a.clone(); let mut y = b.clone(); x.enabled = true; y.enabled = true; + #[cfg(not(target_os = "android"))] + { + x.resolved_security = None; + y.resolved_security = None; + } x == y } + +/// Locate the raw JSON object for `name` in drop-ins (later wins) or main config. +fn find_service_source( + dropins_dir: &Path, + config_path: &Path, + name: &str, +) -> Result> { + // Prefer the last drop-in that defines the service (same merge order as load). + let mut found: Option = None; + if let Ok(rels) = crate::config::collect_dropin_rel_paths(dropins_dir) { + for rel in rels { + let path = dropins_dir.join(&rel); + if let Some(json) = extract_service_json(&path, name)? { + found = Some(ServiceSource { + path: path.display().to_string(), + json, + }); + } + } + } + if found.is_some() { + return Ok(found); + } + if let Some(json) = extract_service_json(config_path, name)? { + return Ok(Some(ServiceSource { + path: config_path.display().to_string(), + json, + })); + } + Ok(None) +} + +fn extract_service_json(path: &Path, name: &str) -> Result> { + if !path.exists() { + return Ok(None); + } + let data = fs::read_to_string(path).map_err(|e| Error::io_at(path, e))?; + let root: serde_json::Value = serde_json::from_str(&data)?; + let Some(services) = root.get("services").and_then(|v| v.as_array()) else { + return Ok(None); + }; + for svc in services { + if svc.get("name").and_then(|v| v.as_str()) == Some(name) { + return Ok(Some(svc.clone())); + } + } + Ok(None) +} diff --git a/tests/config_test.rs b/tests/config_test.rs index 93e8b50..2ae9578 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -26,6 +26,9 @@ fn minimal_svc(name: &str) -> ServiceConfig { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, } } @@ -65,6 +68,9 @@ fn resolve_cmd_fallback() { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, }; assert_eq!(svc.resolve_start().unwrap(), "/etc/init.d/redis start"); assert_eq!(svc.resolve_stop().unwrap(), "/etc/init.d/redis stop"); @@ -92,6 +98,9 @@ fn resolve_explicit_cmds_prefer_over_cmd() { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, }; assert_eq!(svc.resolve_start().unwrap(), "start-me"); assert_eq!(svc.resolve_stop().unwrap(), "stop-me"); @@ -119,6 +128,9 @@ fn resolve_restart_falls_back_to_stop_and_start() { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, }; assert_eq!(svc.resolve_restart().unwrap(), "do-stop && do-start"); } @@ -144,6 +156,9 @@ fn resolve_start_errors_without_cmds() { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, }; assert!(svc.resolve_start().is_err()); assert!(svc.resolve_stop().is_err()); @@ -497,3 +512,58 @@ fn open_telemetry_defaults_from_json() { assert_eq!(cfg.open_telemetry.service_name, "microinit"); assert_eq!(cfg.open_telemetry.export_interval_secs, 15); } + +#[cfg(not(target_os = "android"))] +#[test] +fn security_context_parses_and_validates() { + let raw = r#"{ + "services": [{ + "name": "web", + "cmd": "/bin/web", + "securityContext": { + "runAsUser": "nobody", + "runAsGroup": "nogroup", + "capabilities": ["CAP_NET_BIND_SERVICE", "net_raw"] + } + }] + }"#; + let cfg: Config = serde_json::from_str(raw).unwrap(); + cfg.validate().unwrap(); + let sec = cfg.get("web").unwrap().security_context.as_ref().unwrap(); + assert_eq!(sec.run_as_user.as_deref(), Some("nobody")); + assert_eq!(sec.capabilities.len(), 2); +} + +#[cfg(not(target_os = "android"))] +#[test] +fn security_context_rejects_unknown_capability() { + let mut svc = minimal_svc("x"); + svc.security_context = Some(SecurityContext { + run_as_user: Some("nobody".into()), + run_as_group: None, + capabilities: vec!["NOT_A_REAL_CAP".into()], + }); + let cfg = Config { + services: vec![svc], + ..Config::default() + }; + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("unknown capability"), "{err}"); +} + +#[cfg(not(target_os = "android"))] +#[test] +fn security_context_rejects_empty_user() { + let mut svc = minimal_svc("x"); + svc.security_context = Some(SecurityContext { + run_as_user: Some(" ".into()), + run_as_group: None, + capabilities: vec![], + }); + let cfg = Config { + services: vec![svc], + ..Config::default() + }; + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("runAsUser"), "{err}"); +} diff --git a/tests/graph_test.rs b/tests/graph_test.rs index 692331e..542c3e4 100644 --- a/tests/graph_test.rs +++ b/tests/graph_test.rs @@ -26,6 +26,9 @@ fn svc(name: &str, deps: &[&str], bg: bool) -> ServiceConfig { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, } } diff --git a/tests/protocol_test.rs b/tests/protocol_test.rs index c52a07a..32e0537 100644 --- a/tests/protocol_test.rs +++ b/tests/protocol_test.rs @@ -36,6 +36,7 @@ fn request_response_serde_roundtrip() { }, Request::Describe { name: "nginx".into(), + output: DescribeOutput::Human, }, ]; for req in cases { @@ -62,7 +63,7 @@ fn request_response_serde_roundtrip() { let _: Response = serde_json::from_str(&json).unwrap(); let describe = Response::Describe { - describe: ServiceDescribe { + describe: Box::new(ServiceDescribe { status: ServiceStatus { name: "nginx".into(), state: ServiceState::Running, @@ -96,7 +97,10 @@ fn request_response_serde_roundtrip() { to: Some(ServiceState::Starting), detail: None, }], - }, + running_as: None, + security_context: None, + source: None, + }), }; let json = serde_json::to_string(&describe).unwrap(); assert!(json.contains("\"type\":\"describe\"")); @@ -151,7 +155,7 @@ fn describe_event_kinds_serde_roundtrip() { // Empty deps still round-trip. let empty = Response::Describe { - describe: ServiceDescribe { + describe: Box::new(ServiceDescribe { status: ServiceStatus { name: "solo".into(), state: ServiceState::Stopped, @@ -170,7 +174,10 @@ fn describe_event_kinds_serde_roundtrip() { }], dep_edges: vec![], events: events.clone(), - }, + running_as: None, + security_context: None, + source: None, + }), }; let json = serde_json::to_string(&empty).unwrap(); let _: Response = serde_json::from_str(&json).unwrap(); diff --git a/tests/service_test.rs b/tests/service_test.rs index 5b1adcb..34a3dcd 100644 --- a/tests/service_test.rs +++ b/tests/service_test.rs @@ -25,6 +25,9 @@ fn cfg() -> ServiceConfig { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, } } @@ -80,3 +83,49 @@ fn terminate_pid_reaps_sleep() { Err(nix::errno::Errno::ESRCH) )); } + +#[test] +fn read_running_identity_self() { + let pid = std::process::id() as i32; + let id = read_running_identity(pid).expect("self identity"); + assert_eq!(id.uid, nix::unistd::getuid().as_raw()); + assert_eq!(id.gid, nix::unistd::getgid().as_raw()); +} + +#[cfg(not(target_os = "android"))] +#[test] +fn run_shell_as_numeric_self() { + // Dropping to our own uid/gid is a no-op privilege-wise but exercises the path. + let uid = nix::unistd::getuid().as_raw(); + let gid = nix::unistd::getgid().as_raw(); + let mut c = cfg(); + c.security_context = Some(microinit::config::SecurityContext { + run_as_user: Some(uid.to_string()), + run_as_group: Some(gid.to_string()), + capabilities: vec![], + }); + // Cache as production load would. + c.resolved_security = + microinit::security::resolve(c.security_context.as_ref().unwrap()).unwrap(); + + match run_shell( + &format!(r#"test "$(id -u)" = {uid} && test "$(id -g)" = {gid}"#), + &c, + &HashMap::new(), + ) { + Ok(code) => assert_eq!(code, 0), + Err(e) => { + let msg = e.to_string(); + // User namespaces with setgroups=deny (or missing CAP_SETPCAP) cannot + // fully apply identity drops — skip rather than fail CI sandboxes. + if msg.contains("setgroups") + || msg.contains("NO_NEW_PRIVS") + || msg.contains("Invalid argument") + { + eprintln!("skip apply: {msg}"); + } else { + panic!("{msg}"); + } + } + } +} diff --git a/tests/supervisor_test.rs b/tests/supervisor_test.rs index 1ac328a..c5287bd 100644 --- a/tests/supervisor_test.rs +++ b/tests/supervisor_test.rs @@ -45,6 +45,9 @@ fn job(name: &str, start: &str, deps: &[&str], enabled: bool) -> ServiceConfig { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, } } @@ -68,6 +71,9 @@ fn daemon_cfg(name: &str, start: &str) -> ServiceConfig { cwd: "/".into(), liveness_probe: None, labels: BTreeMap::new(), + security_context: None, + #[cfg(not(target_os = "android"))] + resolved_security: None, } } @@ -98,7 +104,17 @@ fn make_sup(services: Vec) -> (Arc, std::path::PathBu }; let hub = Arc::new(LogHub::new(50, None, None, None)); let console = Arc::new(Console::from_writer(Box::new(Sink::default()))); - let sup = Supervisor::new(cfg, hub, console, override_path.clone()); + let config_path = dir.join("microinit.json"); + let dropins = dir.join("dropins"); + let _ = std::fs::create_dir_all(&dropins); + let sup = Supervisor::new( + cfg, + hub, + console, + override_path.clone(), + config_path, + dropins, + ); (sup, dir) } @@ -182,7 +198,7 @@ fn unknown_service_errors() { sup.boot().unwrap(); assert!(matches!(sup.status("nope"), Err(Error::UnknownService(_)))); assert!(matches!( - sup.describe("nope"), + sup.describe("nope", microinit::protocol::DescribeOutput::Human), Err(Error::UnknownService(_)) )); assert!(matches!( @@ -392,7 +408,9 @@ fn liveness_probe_restarts_oneshot_on_failure() { ); assert_eq!(sup.status("net").unwrap().state, ServiceState::Succeeded); - let desc = sup.describe("net").unwrap(); + let desc = sup + .describe("net", microinit::protocol::DescribeOutput::Human) + .unwrap(); assert!( desc.events .iter() @@ -427,7 +445,9 @@ fn describe_deps_and_reverse_deps() { ]); sup.boot().unwrap(); - let mid = sup.describe("b").unwrap(); + let mid = sup + .describe("b", microinit::protocol::DescribeOutput::Human) + .unwrap(); assert_eq!(mid.depends_on.len(), 1); assert_eq!(mid.depends_on[0].name, "a"); assert_eq!(mid.dependents.len(), 1); @@ -440,7 +460,9 @@ fn describe_deps_and_reverse_deps() { assert!(mid.dep_edges.contains(&("a".into(), "b".into()))); assert!(mid.dep_edges.contains(&("b".into(), "c".into()))); - let leaf = sup.describe("c").unwrap(); + let leaf = sup + .describe("c", microinit::protocol::DescribeOutput::Human) + .unwrap(); assert_eq!(leaf.depends_on[0].name, "b"); assert!(leaf.dependents.is_empty()); assert!(leaf.dep_edges.contains(&("a".into(), "b".into()))); @@ -466,7 +488,9 @@ fn describe_enable_disable_records_state_change() { sup.set_enabled("svc", false).unwrap(); thread::sleep(Duration::from_millis(200)); - let desc = sup.describe("svc").unwrap(); + let desc = sup + .describe("svc", microinit::protocol::DescribeOutput::Human) + .unwrap(); assert!( desc.events.iter().any(|e| { e.kind == microinit::protocol::ServiceEventKind::StateChange @@ -494,7 +518,9 @@ fn describe_event_ring_returns_at_most_event_return() { } thread::sleep(Duration::from_millis(100)); - let desc = sup.describe("svc").unwrap(); + let desc = sup + .describe("svc", microinit::protocol::DescribeOutput::Human) + .unwrap(); assert_eq!( desc.events.len(), microinit::constants::EVENT_RETURN, @@ -522,7 +548,9 @@ fn describe_deps_lists_are_sorted() { ]); sup.boot().unwrap(); - let mid = sup.describe("m").unwrap(); + let mid = sup + .describe("m", microinit::protocol::DescribeOutput::Human) + .unwrap(); let dep_names: Vec<_> = mid.depends_on.iter().map(|n| n.name.as_str()).collect(); assert_eq!(dep_names, vec!["a", "z"]); let req_names: Vec<_> = mid.dependents.iter().map(|n| n.name.as_str()).collect();