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
96 changes: 96 additions & 0 deletions docs/operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand 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 <name>` — it shows **Running as** (live uid/gid from `/proc`) and **Security** (the configured `securityContext`). Use `microinit describe -o json <name>` 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.
Expand Down
8 changes: 7 additions & 1 deletion examples/microinit.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,13 @@
"stopCmd": "killall bigfred-remote-icmp",
"restartCmd": null,
"env": {},
"cwd": "/"
"cwd": "/",
"securityContext": {
"runAsUser": "nobody",
"capabilities": [
"CAP_NET_RAW"
]
}
}
]
}
9 changes: 9 additions & 0 deletions go/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 31 additions & 1 deletion man/man5/microinit.json.5.mdoc
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,41 @@ 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
.Pa /data/etc/microinit.json.example
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
15 changes: 14 additions & 1 deletion man/man8/microinit.8.mdoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
58 changes: 55 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,23 +121,52 @@ 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)),
other => Err(Error::Ipc(format!("unexpected response: {other:?}"))),
}
}

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);
Expand Down Expand Up @@ -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)");
Expand Down
Loading
Loading