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 docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Daemon build/runtime snapshot: version (ELF release section or `dev`), build com
| `hostname` | Host name |
| `uptime_secs` | Seconds since supervisor start |
| `socket` | IPC socket path |
| `mode` | `init` (machine reboot/poweroff) or `supervise` (process exit only) |
| `services_total` | Registered services |
| `services_running` | Services in `running` state |
| `otel_enabled` | Effective telemetry on/off |
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ Constraints for a normal Google Play app sandbox:
notification) — a hidden background daemon violates Play policy.
- Bind the control socket under **app-private storage** (e.g. `getFilesDir()`), never
`/run/…`. Pass `--socket` / JSON `socket` accordingly. Same-UID clients only
(`SO_PEERCRED` + `0600`).
(`SO_PEERCRED`; socket `0600` by default, or `0660` + allowlisted uids when
`socketAllowUsers` is set).
- Service shell is `/system/bin/sh` with an Android `PATH`.
- Ordered shutdown **stops services, syncs, and exits** — no `reboot(2)` and no
BusyBox `/sbin/*` fallback.
Expand Down
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ microinit list
Requires **microinit restart** (on PID 1 hosts: reboot):

- `socket` path
- `socketAllowUsers` — optional list of login names allowed to connect to the
control socket in addition to the daemon uid (resolved via passwd at load;
unknown names abort config). When non-empty, the socket is `0660` owned by
`daemon_uid:<group of the first name>` (prefer a group matching the login,
else that user's primary gid — typically `bigfred:bigfred` on the hub).
**Order matters:** later names are allowed by uid peer-check only; they must
still be able to open a `0660` socket for that group (put the intended
socket-group owner first).
- `logs.*` (TTYs, `logToFiles`, buffer size)
- `console`

Expand Down
7 changes: 6 additions & 1 deletion docs/operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,12 @@ HTTP / TCP examples:
| `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`.
Supplementary groups come from **`initgroups(3)`** using the passwd username
when `runAsUser` resolves to a named account (so memberships in `/etc/group`,
e.g. `dialout`, apply). Numeric uids without a passwd entry still use
`setgroups([])` (fail-closed — no inherited root groups). 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),
Expand Down
3 changes: 2 additions & 1 deletion docs/sdk/golang.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ err = config.WriteDropin(dropinDir, "infra", "redis", svc)
| `List()` | All services |
| `Status(name)` | One service |
| `Control(name, start\|stop\|restart)` | Lifecycle |
| `Shutdown()` | Halt-mode shutdown (IPC) |
| `Shutdown()` | Halt-mode shutdown (IPC); alias of `ShutdownMode("halt")` |
| `ShutdownMode(mode)` | Shutdown with `reboot` \| `poweroff` \| `halt` |
| `FollowLogs` / `ReadResponse` | Log stream |
| `ValidateName` / `FormatLogLine` | Helpers |

Expand Down
55 changes: 37 additions & 18 deletions go/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,22 @@ type ServiceStatus struct {

// DaemonInfo mirrors microinit IPC `info` response.
type DaemonInfo struct {
Version string `json:"version"`
TagCommit string `json:"tag_commit"`
BuildCommit string `json:"build_commit"`
BuildTime string `json:"build_time"`
PID uint32 `json:"pid"`
Hostname string `json:"hostname"`
UptimeSecs uint64 `json:"uptime_secs"`
Socket string `json:"socket"`
ServicesTotal int `json:"services_total"`
ServicesRunning int `json:"services_running"`
OtelEnabled bool `json:"otel_enabled"`
OtelEndpoint string `json:"otel_endpoint"`
OtelProtocol string `json:"otel_protocol"`
OtelServiceName string `json:"otel_service_name"`
OtelExportIntervalSecs uint64 `json:"otel_export_interval_secs"`
Version string `json:"version"`
TagCommit string `json:"tag_commit"`
BuildCommit string `json:"build_commit"`
BuildTime string `json:"build_time"`
PID uint32 `json:"pid"`
Hostname string `json:"hostname"`
UptimeSecs uint64 `json:"uptime_secs"`
Socket string `json:"socket"`
Mode string `json:"mode"` // "init" | "supervise"
ServicesTotal int `json:"services_total"`
ServicesRunning int `json:"services_running"`
OtelEnabled bool `json:"otel_enabled"`
OtelEndpoint string `json:"otel_endpoint"`
OtelProtocol string `json:"otel_protocol"`
OtelServiceName string `json:"otel_service_name"`
OtelExportIntervalSecs uint64 `json:"otel_export_interval_secs"`
}

// LogLine is one captured log line from microinit.
Expand Down Expand Up @@ -210,11 +211,29 @@ func (c *Client) Control(name, action string) error {
}
}

// Shutdown requests a halt-mode shutdown (used when stopping a supervise
// instance started by the caller).
// Shutdown requests a halt-mode shutdown. Prefer [Client.ShutdownMode] when
// you need reboot/poweroff (e.g. host power control). Halt is for stopping a
// supervise instance started by the caller (CLI / embedder); BigFred's admin
// HTTP API does not expose halt to the UI.
func (c *Client) Shutdown() error {
return c.ShutdownMode("halt")
}

// ShutdownMode requests a machine/process shutdown with mode reboot|poweroff|halt.
// In supervise mode microinit ignores the machine power aspect and exits after
// stopping services; in init mode it finalizes via reboot(2).
//
// Modes:
// - reboot / poweroff — host power (init mode); used by BigFred admin UI
// - halt — process exit after stopping services; for direct SDK/CLI use only
func (c *Client) ShutdownMode(mode string) error {
switch mode {
case "reboot", "poweroff", "halt":
default:
return fmt.Errorf("invalid shutdown mode %q (want reboot|poweroff|halt)", mode)
}
var resp Response
if err := c.roundTrip(request{Type: "shutdown", Mode: "halt"}, &resp); err != nil {
if err := c.roundTrip(request{Type: "shutdown", Mode: mode}, &resp); err != nil {
return err
}
switch resp.Type {
Expand Down
1 change: 1 addition & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ fn print_daemon_info(info: &DaemonInfo) {
print!(" {}", info.build_time);
}
println!();
println!("Mode: {}", info.mode);
println!("PID: {}", info.pid);
println!("Hostname: {}", info.hostname);
println!("Uptime: {}", format_uptime(Some(info.uptime_secs)));
Expand Down
52 changes: 52 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,10 @@ pub struct Config {
pub socket: String,
#[serde(default = "default_console")]
pub console: String,
/// Extra Unix-socket peer uids allowed besides the daemon's own uid.
/// Login names resolved against `/etc/passwd` at load time (fail-closed).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub socket_allow_users: Vec<String>,
#[serde(default)]
pub open_telemetry: OpenTelemetryConfig,
#[serde(default)]
Expand All @@ -486,6 +490,7 @@ impl Default for Config {
logs: LogsConfig::default(),
socket: default_socket(),
console: default_console(),
socket_allow_users: Vec::new(),
open_telemetry: OpenTelemetryConfig::default(),
services: Vec::new(),
}
Expand Down Expand Up @@ -603,9 +608,55 @@ impl Config {
}
}
}
// Fail-closed: unknown names in socketAllowUsers abort config load.
let _ = self.resolved_ipc_allow()?;
Ok(())
}

/// Resolve [`Self::socket_allow_users`] into IPC peer allowlist + socket group.
///
/// Empty allowlist → only the daemon uid may connect; socket stays `0600`.
/// Non-empty → those uids (plus daemon uid) may connect; socket is `0660`
/// owned by `daemon_uid:<socket_gid>`.
///
/// **`socket_gid` is taken from the first entry** in `socketAllowUsers`:
/// prefer a group whose name matches the login (e.g. `bigfred:bigfred`),
/// else the user's primary gid from passwd. Later entries are allowed by
/// uid check only — they must share that group (or be root) to open a
/// `0660` socket. Put the intended socket group owner first.
pub fn resolved_ipc_allow(&self) -> Result<crate::ipc::IpcAllow> {
use nix::unistd::{Group, Uid, User};
let mut allow_uids = Vec::new();
let mut socket_gid: Option<u32> = None;
for raw in &self.socket_allow_users {
let name = raw.trim();
if name.is_empty() {
return Err(Error::Config(
"socketAllowUsers entry must not be empty".into(),
));
}
let u = User::from_name(name)
.map_err(|e| Error::Config(format!("socketAllowUsers lookup '{name}': {e}")))?
.ok_or_else(|| Error::Config(format!("socketAllowUsers: unknown user '{name}'")))?;
allow_uids.push(u.uid.as_raw());
if socket_gid.is_none() {
// Prefer the user's primary group name matching the login when
// present (e.g. bigfred:bigfred); otherwise use passwd gid.
let gid = Group::from_name(name)
.ok()
.flatten()
.map(|g| g.gid.as_raw())
.unwrap_or_else(|| u.gid.as_raw());
socket_gid = Some(gid);
}
}
Ok(crate::ipc::IpcAllow {
daemon_uid: Uid::current().as_raw(),
allow_uids,
socket_gid,
})
}

pub fn get_mut(&mut self, name: &str) -> Option<&mut ServiceConfig> {
self.services.iter_mut().find(|s| s.name == name)
}
Expand Down Expand Up @@ -791,6 +842,7 @@ pub fn example_config() -> Config {
},
socket: DEFAULT_SOCKET.to_string(),
console: DEFAULT_CONSOLE.to_string(),
socket_allow_users: Vec::new(),
open_telemetry: OpenTelemetryConfig::default(),
services: vec![
ServiceConfig {
Expand Down
8 changes: 8 additions & 0 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,28 @@ pub fn run(opts: InitOpts) -> Result<()> {
let config_path = opts.paths.config.clone();
let dropins_dir = opts.paths.dropins_dir.clone();

let mode = if opts.machine_shutdown {
crate::protocol::DaemonMode::Init
} else {
crate::protocol::DaemonMode::Supervise
};
let ipc_allow = cfg.resolved_ipc_allow()?;
let supervisor = Supervisor::new(
cfg,
hub.clone(),
console.clone(),
override_path,
config_path,
dropins_dir,
mode,
);

let sup = Arc::clone(&supervisor);
let hub_ipc = hub.clone();
ipc::serve(
Path::new(&socket_path),
Arc::new(move |req, stream| handle_ipc(req, stream, &sup, &hub_ipc, lines_default)),
ipc_allow,
)?;
hub.emit_init(LogLevel::Info, format!("IPC listening on {socket_path}"));

Expand Down
85 changes: 73 additions & 12 deletions src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,36 @@ pub fn request(socket_path: &Path, req: &Request) -> Result<Response> {
read_frame(&mut stream)
}

/// Peer credential check: peer uid must match the daemon's uid (root when PID 1).
fn peer_allowed(stream: &UnixStream) -> bool {
/// Peer allowlist for the control socket (from `socketAllowUsers`).
#[derive(Debug, Clone)]
pub struct IpcAllow {
/// Daemon uid (always allowed). Captured at config resolve time.
pub daemon_uid: u32,
/// Extra uids allowed besides [`Self::daemon_uid`].
pub allow_uids: Vec<u32>,
/// When set with a non-empty allowlist: socket mode `0660`, owner
/// `daemon_uid:socket_gid`.
pub socket_gid: Option<u32>,
}

impl Default for IpcAllow {
fn default() -> Self {
Self {
daemon_uid: nix::unistd::Uid::current().as_raw(),
allow_uids: Vec::new(),
socket_gid: None,
}
}
}

/// Peer credential check: daemon uid, or an entry in `allow_uids`.
fn peer_allowed(stream: &UnixStream, daemon_uid: u32, allow_uids: &[u32]) -> bool {
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
use nix::unistd::Uid;
match getsockopt(stream, PeerCredentials) {
Ok(cred) => cred.uid() == Uid::current().as_raw(),
Ok(cred) => {
let uid = cred.uid();
uid == daemon_uid || allow_uids.contains(&uid)
}
Err(_) => false,
}
}
Expand All @@ -107,7 +131,11 @@ pub type Handler = Arc<dyn Fn(Request, &mut UnixStream) -> Result<()> + Send + S
///
/// Concurrent handlers are capped at [`MAX_IPC_CLIENTS`]; excess clients receive
/// an immediate error response.
pub fn serve(socket_path: &Path, handler: Handler) -> Result<()> {
///
/// When `allow.allow_uids` is non-empty, the socket is immediately set to
/// `0660` and `chown`ed to `daemon_uid:<allow.socket_gid>` (fail-closed if
/// gid missing). Otherwise the socket stays `0600` (daemon-uid-only).
pub fn serve(socket_path: &Path, handler: Handler, allow: IpcAllow) -> Result<()> {
if let Some(parent) = socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?;
Expand All @@ -119,19 +147,16 @@ pub fn serve(socket_path: &Path, handler: Handler) -> Result<()> {
Err(e) => return Err(Error::io_at(socket_path, e)),
}
let listener = UnixListener::bind(socket_path).map_err(|e| Error::io_at(socket_path, e))?;
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(socket_path)
.map_err(|e| Error::io_at(socket_path, e))?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(socket_path, perms).map_err(|e| Error::io_at(socket_path, e))?;
apply_socket_perms(socket_path, &allow)?;

let path = socket_path.to_path_buf();
let daemon_uid = allow.daemon_uid;
let allow_uids = allow.allow_uids;
thread::spawn(move || {
for conn in listener.incoming() {
match conn {
Ok(mut stream) => {
if !peer_allowed(&stream) {
if !peer_allowed(&stream, daemon_uid, &allow_uids) {
let _ = write_frame(
&mut stream,
&Response::Error {
Expand Down Expand Up @@ -181,3 +206,39 @@ pub fn serve(socket_path: &Path, handler: Handler) -> Result<()> {
});
Ok(())
}

fn apply_socket_perms(socket_path: &Path, allow: &IpcAllow) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if allow.allow_uids.is_empty() {
let mut perms = std::fs::metadata(socket_path)
.map_err(|e| Error::io_at(socket_path, e))?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(socket_path, perms).map_err(|e| Error::io_at(socket_path, e))?;
return Ok(());
}
let gid = allow.socket_gid.ok_or_else(|| {
Error::Config("socketAllowUsers set but no socket group could be resolved".into())
})?;
// chmod + chown immediately after bind — no window with 0600 for allowlisted peers.
let mut perms = std::fs::metadata(socket_path)
.map_err(|e| Error::io_at(socket_path, e))?
.permissions();
perms.set_mode(0o660);
std::fs::set_permissions(socket_path, perms).map_err(|e| Error::io_at(socket_path, e))?;
use nix::unistd::{chown, Gid, Uid};
// Owner is the daemon uid (usually 0 on hub), not hardcoded root — so
// non-root supervise / tests can still chown successfully.
chown(
socket_path,
Some(Uid::from_raw(allow.daemon_uid)),
Some(Gid::from_raw(gid)),
)
.map_err(|e| {
Error::io_at(
socket_path,
std::io::Error::other(format!("chown socket: {e}")),
)
})?;
Ok(())
}
Loading
Loading