From 8b4f34824594e92378c581245af60c9a5c1d56bd Mon Sep 17 00:00:00 2001 From: "Youri T. K. K. Mattar" Date: Sat, 3 Oct 2026 22:24:31 -0300 Subject: [PATCH 1/3] H4 and H5 for hideOS: the hardware watchdog, user managers, and a shutdown that cannot hang - oxinit-watchdog: feeds /dev/watchdog from the loop, a boot deadline on a confirming unit, let go last on the way down. - oxinit --user: the same supervisor run as a person, with its own sockets, units and logs; oxctl --user, oxlogd --user. - A stray --version on the kernel command line no longer returns from PID 1. - Shutdown: a daemon that left its cgroup (elogind) is signalled and killed directly, its exit ends the stop, and the whole-shutdown deadline wakes the loop instead of waiting for an event that never comes. - Timers no longer fire once the machine is going down. --- .github/workflows/ci.yml | 2 +- ARCHITECTURE.md | 272 ++++++++++- CLAUDE.md | 7 + CONTRIBUTING.md | 6 +- Cargo.lock | 25 + Cargo.toml | 2 + README.md | 11 +- ROADMAP.md | 102 ++++ crates/oxctl/Cargo.toml | 1 + crates/oxctl/src/main.rs | 64 ++- crates/oxinit-ipc/Cargo.toml | 1 + crates/oxinit-ipc/src/lib.rs | 5 +- crates/oxinit-log/Cargo.toml | 8 +- crates/oxinit-log/src/lib.rs | 8 +- crates/oxinit-paths/Cargo.toml | 16 + crates/oxinit-paths/src/lib.rs | 303 ++++++++++++ crates/oxinit-service/src/lib.rs | 74 ++- crates/oxinit-unit/src/error.rs | 12 + crates/oxinit-unit/src/lib.rs | 9 +- crates/oxinit-unit/src/load.rs | 94 +++- crates/oxinit-unit/src/unit.rs | 99 +++- crates/oxinit-user/src/lib.rs | 28 ++ crates/oxinit-watchdog/Cargo.toml | 19 + crates/oxinit-watchdog/src/lib.rs | 756 ++++++++++++++++++++++++++++++ crates/oxinit/Cargo.toml | 2 + crates/oxinit/src/cgroup.rs | 2 +- crates/oxinit/src/container.rs | 19 +- crates/oxinit/src/control.rs | 21 +- crates/oxinit/src/event.rs | 18 +- crates/oxinit/src/logs.rs | 15 +- crates/oxinit/src/main.rs | 193 ++++++-- crates/oxinit/src/notify.rs | 14 +- crates/oxinit/src/shutdown.rs | 58 ++- crates/oxinit/src/supervisor.rs | 161 ++++++- crates/oxinit/src/sys/raw.rs | 49 +- crates/oxinit/src/user.rs | 235 ++++++++++ crates/oxinit/src/watchdog.rs | 264 +++++++++++ crates/oxlogd/Cargo.toml | 1 + crates/oxlogd/src/main.rs | 34 +- crates/xtask/src/main.rs | 393 +++++++++++++++- docs/UNIT_FORMAT.md | 36 +- units/default.toml | 1 + units/user-manager.toml | 11 + units/user-runtime.toml | 8 + units/user/default.toml | 6 + units/user/greet.toml | 8 + units/user/user-probe.toml | 9 + 47 files changed, 3317 insertions(+), 165 deletions(-) create mode 100644 crates/oxinit-paths/Cargo.toml create mode 100644 crates/oxinit-paths/src/lib.rs create mode 100644 crates/oxinit-watchdog/Cargo.toml create mode 100644 crates/oxinit-watchdog/src/lib.rs create mode 100644 crates/oxinit/src/user.rs create mode 100644 crates/oxinit/src/watchdog.rs create mode 100644 units/user-manager.toml create mode 100644 units/user-runtime.toml create mode 100644 units/user/default.toml create mode 100644 units/user/greet.toml create mode 100644 units/user/user-probe.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ade4f3..943ad75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: run: python3 .github/scripts/check-release-notes.py - name: Host tests - run: cargo test -p oxinit-unit -p oxinit-graph -p oxinit-service -p oxinit-cgroup -p oxinit-user -p oxinit-ipc -p oxinit-log -p oxinit-timer + run: cargo test -p oxinit-unit -p oxinit-graph -p oxinit-service -p oxinit-cgroup -p oxinit-user -p oxinit-ipc -p oxinit-log -p oxinit-timer -p oxinit-paths -p oxinit-watchdog msrv: name: builds on the declared MSRV diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3e62559..4b27bf5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,6 +17,9 @@ Everything else is a separate process talking to PID 1 over a unix socket: - `oxctl` — the CLI client. - `oxlogd` — reads service output, writes it somewhere. See [Logs](#logs). - Device management — uevent handling, if it ever exists. +- A user's services — run by that user's own manager, `oxinit --user`, which + is the same supervisor as a separate process. See + [User managers](#user-managers). The reason is blast radius. A bug in a log shipper kills a log shipper. A bug in PID 1 kills the machine. Code that does not need to run in PID 1 does not run in @@ -77,14 +80,21 @@ it: a container whose event loop has broken falls back to a shell like every other machine, because a bug in the supervisor is not a reason to take the container down. +**A user manager is not PID 1,** and the rules above are about PID 1. It holds +itself to the same lints and the same `catch_unwind` — it is the same code — +but where PID 1 must never exit, a user manager whose loop cannot go on exits +with a failure rather than spawning a shell on a console it does not own. That +is the second and last `exit()` in the crate, and it is reachable only with +`--user`. See [User managers](#user-managers). + ## Unsafe policy Syscalls go through [`rustix`](https://docs.rs/rustix), which wraps the raw interface in safe Rust. Prefer it over `libc` everywhere. Some things `rustix` does not cover — `fork` semantics between fork and exec, -`signalfd_siginfo` decoding, `clone` flags. That code lives in one module, -`oxinit::sys::raw`, and nowhere else. Every `unsafe` block in it carries a +`signalfd_siginfo` decoding, `clone` flags, the typed argument of a watchdog +`ioctl`. That code lives in one module, `oxinit::sys::raw`, and nowhere else. Every `unsafe` block in it carries a `// SAFETY:` comment stating the invariant that makes it sound and why the invariant holds at that call site. @@ -164,6 +174,9 @@ This is the milestone 0 path, in order. create the control socket, notify socket, and timerfd. 9. **Load units** from the unit directories, build the graph, reject cycles. 10. **Start the default target.** +11. **Open the hardware watchdog,** if one is configured — last, so that the + first write to it is the loop's own first turn. See + [Hardware watchdog](#hardware-watchdog). Steps 1–3 are the whole of milestone 0 alongside a reap loop and a shell. @@ -176,6 +189,9 @@ what it already does. What it needs is to stop assuming it booted a machine. **Exactly one thing keys off the answer.** On a machine, a shutdown ends in `reboot(2)`. In a container it ends by exiting, because the machine is not oxinit's to reboot and the container lives exactly as long as its PID 1 does. +(The hardware watchdog follows from the same answer: a container leaves the +host's alone. A third environment, the user manager, is not detected but asked +for with `--user`, and also ends by exiting.) A supervisor that stopped every unit and then stayed running is a container that will not stop, and `docker stop` ends that with `SIGKILL` and exit 137 ten seconds later. @@ -246,6 +262,11 @@ it, and it is the whole of the contract `docker stop` and a Kubernetes pod deletion rely on. On a machine it is not a signal anyone sends to PID 1 by accident. `SIGINT` is what the kernel delivers for Ctrl-Alt-Del. +A user manager has nothing to power off, reboot or halt. `SIGTERM`, `SIGINT`, +`SIGPWR`, `SIGUSR1` and `SIGHUP` all mean the same to it — stop every unit, +then exit — and `SIGHUP` is in that list because it is what a session that +ended sends. + ## Shutdown Shutdown is a **state the supervisor is in**, not a function that runs to @@ -271,10 +292,20 @@ only a process can produce — and a shutdown waiting on that never finishes. A unit waiting out a restart backoff is likewise not running and not coming back, so its pending restart is abandoned rather than waited on. +A daemon can move itself out of the cgroup it was started in — elogind does, +into the hierarchy it manages as a cgroup controller. Its cgroup is then empty +and signalling the cgroup reaches nothing, so the stop also signals the +process oxinit forked whenever `/proc` puts it outside the unit's cgroup, and +the escalation at `stop-sec` sends that process `SIGKILL` when `cgroup.kill` +cannot have reached it. Its exit then ends the stop: the `populated` event it +took with it is not coming. + There is a whole-shutdown deadline on top of each unit's `stop-sec`. It is not the normal mechanism — it is the backstop for a unit whose stop path is itself broken. A machine that will not power off is worse than one that loses a -service's last write. +service's last write. The loop wakes for it like it does for the watchdog: a +unit that never stops sends no event, and a deadline only checked on events +was a machine that waited for ever. Then `sync`, remount `/` read-only, and `reboot(2)` with the matching command. The order is the whole of it: an unflushed filesystem is what makes a reboot @@ -290,6 +321,10 @@ exiting, with a status that tells the runtime which way. See If the kernel refuses to reboot a machine, oxinit stays up with everything stopped and says so. It does not exit: PID 1 exiting is a kernel panic. +A hardware watchdog is let go of last, immediately before `reboot(2)` — +disarmed for a power off or a halt, left running into a reboot. See +[Hardware watchdog](#hardware-watchdog). + Children get the mask reset between `fork` and `exec`. An inherited full block mask breaks nearly every daemon, and it breaks the service manager too: a service that starts with `SIGTERM` blocked cannot be stopped, only killed. @@ -301,6 +336,116 @@ before `pre_exec` runs, where nothing oxinit writes can observe whether it happened. A requirement this load-bearing does not rest on an implementation detail. +## Hardware watchdog + +A hung PID 1 is the one failure nothing on the machine can recover from. A +crashed service has PID 1 to restart it; PID 1 has nothing above it, and a +PID 1 stuck in a handler that never returns does not crash — it just stops +reaping, restarting and answering. A hardware watchdog is outside it: a timer +in the chipset that resets the machine unless it is written to within its +timeout. oxinit is what writes to it. + +**Configuration.** Off unless configured. `/usr/lib/oxinit/watchdog.toml`, +replaced wholly by `/etc/oxinit/watchdog.toml` exactly as a unit file in +`/etc` replaces the packaged one, then the kernel command line, key by key: + +```toml +device = "/dev/watchdog0" # the default +timeout-sec = "30s" # required; "0s" is no watchdog +boot-unit = "boot-ok" # optional, with boot-sec +boot-sec = "3min" +``` + +| Key | Command line | Meaning | +|---------------|----------------------------------|--------------------------------------------------| +| `device` | `oxinit.watchdog.device=` | The device. Default `/dev/watchdog0`. | +| `timeout-sec` | `oxinit.watchdog.timeout-sec=` | Asked of the driver. Whole seconds; `0s` is off. | +| `boot-unit` | `oxinit.watchdog.boot-unit=` | The unit whose coming up confirms a boot. | +| `boot-sec` | `oxinit.watchdog.boot-sec=` | How long it has, from oxinit's start. `0s` is no deadline. | + +The command line is per key where the files are whole, because they answer +different questions: a file is an installation's configuration, and a command +line entry is one boot's exception to it — a boot manager entry, or a test +image that wants the deadline short. Values are durations as in units, an +unknown key under the prefix is an error, and an error in any of it is said +on the console and leaves the watchdog unopened. + +**Fed from the loop, not from a handler.** The write happens in the loop body, +once per wake-up, after every handler has run — not as an alarm on the +deadline heap and not inside any handler. What keeps the machine alive is +therefore exactly the loop coming round: a handler that never returns, a heap +that stops firing, a loop that has given up and fallen back to the console +shell all stop the writes. `epoll_wait` is given a timeout for this and only +this; nothing else in oxinit wakes without a descriptor. + +The interval is half the timeout the driver settled on. `WDIOC_SETTIMEOUT` +returns what the hardware can actually count, which need not be what was +asked, and the interval follows the answer. A driver that will neither take a +timeout nor report its own is written to every second. The write is one byte +that is not `V`, the keepalive every driver has understood since before the +ioctl existed. + +**Opened last, and looked for again.** The device is opened after the +default target has been started, so the first write is the loop's first turn. +A device that is not there — a driver built as a module that nothing has +loaded yet — is not an error: it is tried again every interval until it +appears, and said once. The descriptor is `CLOEXEC`, because a service that +inherited it could keep a hung machine alive, or reset a working one by +closing it. + +A watchdog an initramfs started before oxinit keeps counting until then, and +opening it takes it over. A watchdog device is single-open, so while oxinit +holds it nothing else can stop or feed it. + +**The boot deadline.** With `boot-unit` and `boot-sec`, a boot has to confirm +itself: the named unit has to *come up* — reach `Active`, or, for a +`oneshot`, which never does, run to a clean exit — within `boot-sec` of +oxinit starting. Until it has, the watchdog is fed only up to the deadline. +Past it, oxinit stops writing for good and the hardware resets the machine +one timeout later. That is what lets a boot manager that counts attempts see +a boot that hung — in a unit, in the graph, anywhere short of the unit that +says "this boot worked" — as a failed attempt, the same as a kernel panic. + +- *Final.* A unit that comes up after the deadline does not rescue it: the + decision was made, the timer is running, and feeding it again would make + the outcome depend on how long the reset happened to take. +- *Stopping and reset are different answers.* Overdue, oxinit stops feeding + rather than rebooting cleanly. The deadline exists for a boot that went + wrong, and the reset is the one path that does not depend on whatever went + wrong. +- *A shutdown suspends it.* A boot interrupted by an operator is not a boot + that hung, and the shutdown has its own deadline. The writes continue. +- *A boot unit that does not exist* is said at boot, and the deadline still + applies: the configuration asked for a confirmed boot, and nothing can + confirm this one. Running without the deadline would be a different + configuration, not a smaller version of it. +- *Measured from oxinit's start.* Firmware, kernel and initramfs come before + it, and bounding those is the job of whatever armed the watchdog first. + +**Shutdown.** The writes continue through the ordered stop — a shutdown that +hangs is a hung PID 1. At the end, after `sync` and the read-only remount and +immediately before `reboot(2)`: + +| Ends in | The watchdog | +|---------------------|--------------------------------------------------------------------| +| power off, halt | Disarmed: `V`, then close — the magic close. A halted machine must stay halted. | +| reboot | Written once more and left running. A kernel that hangs on the way down is reset into the reboot it was asked for. | + +A kernel built with `CONFIG_WATCHDOG_NOWAYOUT` refuses the magic close; such a +machine halts and is reset one timeout later, and the kernel log says why. +Many drivers stop themselves at the kernel's reboot notifier, which runs +after the point where the hang above could happen. + +**Never in a container, never in a user manager.** A privileged container can +open the host's `/dev/watchdog`, and whether the host resets is not a +container's call. A user manager has no business with hardware at all. + +`oxinit-watchdog` holds the configuration and every timing decision — when to +open, when to write, when the deadline has passed, what to do at the end — as +a state machine that takes the time since oxinit started as an argument and +makes no syscall. The device, the two ioctls (in `sys::raw`) and the writes +are in the `oxinit` crate. + ## Reaping PID 1 inherits every orphaned process on the machine and must reap them, or the @@ -353,6 +498,11 @@ Kernel notification that the `populated` key changed. See cgroup v2 below. oxinit does not accept it; it starts the service, which does. See socket activation above. +`epoll_wait` blocks with no timeout, with one exception: with a hardware +watchdog configured, it is bounded by when the watchdog is next due. That is +the only thing in oxinit that wakes the loop without a descriptor becoming +ready, and the reason is in [Hardware watchdog](#hardware-watchdog). + epoll hands back one `u64` per registration and nothing else, so the token has to carry both what kind of descriptor woke the loop and which one: the high half is the kind, the low half is the id. A flat numbering with reserved ranges @@ -901,6 +1051,106 @@ running process would describe something that is not what is running. A socket unit is not re-bound either: its descriptors were bound and registered with epoll once, at boot. +## User managers + +A desktop expects services that run as the logged-in user rather than as a +system account: an audio server and its session manager, portals, agents. +They belong to one user, they need that user's session — its runtime +directory and its bus — and they should stop when the user is done. + +**The same supervisor, run by the user.** `oxinit --user` is the `oxinit` +binary with a different entry point, not a second program. Units, the graph, +the state machine and restart policy, timers, socket activation, `sd_notify`, +the control protocol and log pipes are all the same code, so a user unit means +exactly what the same file means to PID 1, and the rules the crate holds +itself to apply to it unchanged. A separate binary would be a second copy of +the event loop, and the copy is what would drift. + +What differs, and why: + +| | PID 1 | `oxinit --user` | +|---|---|---| +| Runs as | root, pid 1 | the user; refused as root, and as pid 1 | +| Mounts, console, hostname, environment detection | yes | none: not its machine | +| Sockets | `/run/oxinit/` | `$XDG_RUNTIME_DIR/oxinit/`, `0700` | +| Units | `/usr/lib/oxinit/units`, `/etc/oxinit/units` | `/usr/lib/oxinit/user-units`, `/etc/oxinit/user-units`, `$XDG_CONFIG_HOME/oxinit/user-units` | +| `user`, `tty` | as specified | refused at load; every service runs as the user, `%u` is the user | +| cgroups | the hierarchy | its own cgroup's subtree if delegated to the user; otherwise none | +| Orphans | reparented to it | reparented to it: it is a child subreaper | +| Stop signals | power off, reboot, halt | stop every unit and exit; `SIGHUP` too | +| End of a shutdown | `reboot(2)` | exit 0 | +| Loop cannot continue | console shell, never exits | exit 1 | +| Hardware watchdog | if configured | never | + +*`user` refused rather than ignored*, because a user manager has no one else +to run a service as, and a unit that says `user = "root"` and silently gets +the user is a unit that does not do what it says. *`tty` refused* because the +terminal belongs to the session, and a service claiming it would take it from +the user's own shell. + +*cgroups only if delegated.* A user manager can only create cgroups under one +it may write to, which means something above it chowned one to the user. +Nothing does on a machine without systemd-logind, so the usual case is none, +and that is PID 1's own degraded mode: every service runs, and what needs a +cgroup — `[resources]`, `forking` readiness, `cgroup.kill` — fails on the +unit that asked for it, saying why. Being a subreaper keeps the rest of the +supervision honest without one: a daemon that double-forks is still this +manager's child to reap. + +*Exit, not a shell,* when the loop breaks — see [Failure policy](#failure-policy). +Its services are left running, reparented upward; stopping them would need +the loop that just failed. + +**One per user, and the control socket is the lock.** A second `oxinit +--user` for the same user — a second session — first connects to the control +socket. If a manager answers, it says so and exits 0: the services that +session wants are already running. A socket file nobody answers on is a +manager that died, and is replaced. + +**It manages services, not sessions.** oxinit does not manage logins, so it +does not decide when a user manager starts or stops: whatever starts the +session starts it, with the session's environment, and signals it when the +session ends — `SIGTERM` or `SIGHUP`. It needs `XDG_RUNTIME_DIR` (a login +session has it from `pam_elogind`, `pam_rundir` or `pam_systemd`) and `HOME`. +Its services inherit its environment minus the protocol variables, as system +services inherit PID 1's, so it should be started after the session bus +exists and with `DBUS_SESSION_BUS_ADDRESS` set. + +A user manager without a session — services that outlive logout — is a system +unit with `user = ""` running `oxinit --user` with those two variables +set. That is how the test image runs one. + +**Logs.** `output = "log"` works as it does for PID 1, with `oxlogd --user` as +one of the user's own units: the socket is under the runtime directory and the +files are `$XDG_STATE_HOME/oxinit/log/.log`, where `oxctl --user logs` +reads them. State rather than cache, because logs are worth keeping across a +reboot. + +**`oxctl --user`** talks to the calling user's manager. The socket is `0600` +in a `0700` directory owned by the user — the same authorization story as +PID 1's, one level down: access to it is control of that user's services. + +`oxinit-paths` resolves where all of this is, for either scope, from an +environment it is handed; `oxinit-unit` holds the user unit directories and +the user-scope rules. Both are host-tested. + +**Open, and not built.** Each of these is a decision, not a gap in the code +above: + +- **Proposed:** *Several sessions of one user.* The first session's manager + serves the second, but the first session ending stops it. Counting sessions + is a login manager's knowledge, not a service manager's — the shape is a + small launcher that a PAM session hook talks to, as Chimera Linux's + turnstile does — and it belongs outside oxinit. +- **Proposed:** *Delegation.* PID 1 could chown a cgroup to the user for a + system unit that asks for it, which would give the "user manager as a + system unit" form above full cgroup support. It needs a unit key, which is + a unit-format change with its own design. +- *Environment set after start.* A variable the session creates after the + manager started — `WAYLAND_DISPLAY` — is not seen by its services. systemd's + answer is an `import-environment` request; oxinit has no such request yet, + and adding one is a protocol change. + ## Workspace layout ``` @@ -917,7 +1167,9 @@ oxinit/ │ ├─ oxinit-user/ # /etc/passwd and /etc/group │ ├─ oxinit-ipc/ # control protocol types │ ├─ oxinit-log/ # record format, rotation policy, paths +│ ├─ oxinit-paths/ # where the sockets and logs are, system and user │ ├─ oxinit-timer/ # schedules and calendar arithmetic +│ ├─ oxinit-watchdog/ # hardware watchdog configuration and policy │ ├─ notify-probe/ # test fixture: a service that speaks sd_notify │ ├─ listen-probe/ # test fixture: a socket-activated service │ └─ xtask/ # build and boot automation @@ -950,6 +1202,13 @@ tested without a kernel should not be in it. read into records, bounding one that never ends, and choosing when to rotate. All three programs agree on it because all three depend on it, and none of it needs a descriptor to test. +- `oxinit-paths` is where every socket and log directory is, for PID 1 and for + a user manager. All three programs depend on it, and so do the crates whose + constants used to be the only definition. +- `oxinit-watchdog` decides when the hardware watchdog is written to, when it + stops being written to, and what happens to it at shutdown. A mistake there + resets a working machine or fails to reset a hung one, and neither is + something to find out on hardware. ## Development loop @@ -999,6 +1258,13 @@ the terminal, which is why the boot sequence wires stdio to `/dev/console`. The exit code is the assertion that matters. `SIGKILL` after a grace period is 137, and every `docker stop` produced it until PID 1 both handled `SIGTERM` and exited afterwards. +- **A hardware watchdog.** `cargo xtask test-watchdog` boots with QEMU's + i6300esb twice. Once fed for several timeouts and then halted, and still + running long after the halt — which only a magic close explains, because + `-no-reboot` turns the watchdog's reset into QEMU exiting. Once with a boot + unit that fails, a deadline shortened from the kernel command line, and a + machine that is reset. The driver is a module in the kernel it boots, loaded + by a unit, so the device appearing late is exercised too. - **A real userspace.** `cargo xtask test-distro` runs the same units against a distribution's own root filesystem rather than a hand-assembled one, which is the only test in which the services oxinit execs are dynamically linked diff --git a/CLAUDE.md b/CLAUDE.md index 506ed90..b4e79c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,8 @@ crates/oxinit-graph/ dependency resolution crates/oxinit-service/ service state machine and restart policy crates/oxinit-cgroup/ cgroup v2 crates/oxinit-ipc/ control protocol types +crates/oxinit-paths/ where the sockets and logs are, system and user +crates/oxinit-watchdog/ hardware watchdog configuration and policy crates/xtask/ build and boot automation docs/ specifications units/ the test image's unit files @@ -64,6 +66,11 @@ only exists for as long as its PID 1 does. Nowhere else, and never on a failure. If the loop cannot continue — in a container too — spawn `/bin/sh` on `/dev/console` and keep reaping. +A user manager (`oxinit --user`, `user.rs`) is not PID 1, and is the one +place this bends: it exits at the end of its shutdown, in the same +`shutdown::exit`, and when its loop cannot continue, in `user::give_up`. Both +are reachable only with `--user`. Do not add a third. + **No async.** No `tokio`, no `async-std`, no `futures`, no `async fn`. One thread, one `epoll` loop. This is not negotiable for `crates/oxinit`; other crates may do what they like. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04e3d88..0a3c0b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ rustup target add aarch64-unknown-linux-musl MSRV is stable minus two releases. No nightly features are used. **Tools.** `qemu-system-x86_64`, `qemu-system-aarch64` and `cpio`. Docker or -podman as well, for `cargo xtask container`, `test-distro` and `test-demo`. +podman as well, for `cargo xtask container`, `test-distro`, `test-demo` and +`test-watchdog`. ```bash # Debian/Ubuntu @@ -123,6 +124,9 @@ cargo xtask test-boot --arch all # M9: x86_64 and aarch64, in turn cargo xtask container # M6: runs it in Docker, same cargo xtask test-demo # M16: every command the README gives cargo xtask test-distro # M10: a real Alpine userspace +cargo test -p oxinit-watchdog # M17: when to feed, when to starve +cargo xtask test-watchdog # M17: QEMU's i6300esb, fed and starved +cargo test -p oxinit-paths # M18: where a user manager's things are ``` The library crates have no Linux dependencies and run anywhere. Parser, graph, diff --git a/Cargo.lock b/Cargo.lock index 339a04b..359c410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,7 @@ version = "0.1.0" dependencies = [ "oxinit-ipc", "oxinit-log", + "oxinit-paths", "rustix", ] @@ -80,10 +81,12 @@ dependencies = [ "oxinit-graph", "oxinit-ipc", "oxinit-log", + "oxinit-paths", "oxinit-service", "oxinit-timer", "oxinit-unit", "oxinit-user", + "oxinit-watchdog", "rustix", "thiserror", ] @@ -108,6 +111,7 @@ dependencies = [ name = "oxinit-ipc" version = "0.1.0" dependencies = [ + "oxinit-paths", "serde", "serde_json", ] @@ -115,6 +119,16 @@ dependencies = [ [[package]] name = "oxinit-log" version = "0.1.0" +dependencies = [ + "oxinit-paths", +] + +[[package]] +name = "oxinit-paths" +version = "0.1.0" +dependencies = [ + "thiserror", +] [[package]] name = "oxinit-service" @@ -144,11 +158,22 @@ dependencies = [ "thiserror", ] +[[package]] +name = "oxinit-watchdog" +version = "0.1.0" +dependencies = [ + "basic-toml", + "oxinit-unit", + "serde", + "thiserror", +] + [[package]] name = "oxlogd" version = "0.1.0" dependencies = [ "oxinit-log", + "oxinit-paths", "rustix", ] diff --git a/Cargo.toml b/Cargo.toml index 224b7ea..b75af42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,12 @@ members = [ "crates/oxinit-graph", "crates/oxinit-ipc", "crates/oxinit-log", + "crates/oxinit-paths", "crates/oxinit-service", "crates/oxinit-timer", "crates/oxinit-unit", "crates/oxinit-user", + "crates/oxinit-watchdog", "crates/oxlogd", "crates/xtask", ] diff --git a/README.md b/README.md index cf77244..824cb05 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,11 @@ immediate and says what was *asked for*, not what has finished: a stop ends when the unit's cgroup empties, and the one socket that has to stay responsive does not wait on the slowest thing on the machine. +`oxctl --user` asks the same things of your own user manager — `oxinit +--user`, the same supervisor run by a user for their own services, with units +in `~/.config/oxinit/user-units` and sockets under `$XDG_RUNTIME_DIR`. Whatever +starts your session starts it; oxinit does not manage logins. + ## Logs A service declaring `output = "log"` writes into a pipe rather than onto the @@ -325,7 +330,7 @@ getting it wrong strands the machine. ## Current state -Milestones 0 through 14 are done, and the roadmap is closed. oxinit boots under QEMU and runs as a +Milestones 0 through 18 are done, and the roadmap is closed. oxinit boots under QEMU and runs as a container's PID 1. | | | @@ -347,12 +352,14 @@ container's PID 1. | **M14** | Calendar schedules on the wall clock; datagram sockets; the list closed. | | **M15** | The documents' invariants enforced by the build, not by discipline. | | **M16** | A demo, a released binary, and a README that starts at the beginning. | +| **M17** | A hardware watchdog fed from the loop, with a boot deadline. | +| **M18** | User managers: `oxinit --user`, `oxctl --user`, `oxlogd --user`. | One `epoll` loop multiplexes the signalfd, the timerfd, the notify socket, the control socket, every socket unit's listening descriptor and every service cgroup's `cgroup.events`. One thread. No async runtime. -Nothing is scheduled after M16. [ROADMAP.md](ROADMAP.md) has the breakdown — +Nothing is scheduled after M18. [ROADMAP.md](ROADMAP.md) has the breakdown — what each milestone was verified against, what was deferred out of it, and which remaining ideas are deliberately not being taken. diff --git a/ROADMAP.md b/ROADMAP.md index ccdd6ce..3f398b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1025,6 +1025,108 @@ Third time in this project that the failure was *editing text without looking at the output*, and the second time it reached somebody. It is worth naming as a pattern rather than as three separate slips. +## M17 — A hardware watchdog + +**Done.** + +Asked for by hideOS, whose failure table has a row reading "PID 1 hangs — +hardware watchdog reboots", and nothing behind it once the boot was over. +Nothing on a machine recovers a hung PID 1, because nothing is above it; a +timer in the chipset is. + +- [x] `/usr/lib/oxinit/watchdog.toml`, replaced by `/etc/oxinit/watchdog.toml`, + overridden key by key by `oxinit.watchdog.*` on the kernel command line. +- [x] Fed from the loop body, not from a handler or a deadline alarm, so what + keeps the machine up is the loop coming round. +- [x] Half the timeout the driver settled on; a device that is not there yet + looked for again. +- [x] A boot deadline: `boot-unit` has to come up within `boot-sec`, or the + watchdog stops being fed and the hardware resets the machine. +- [x] Disarmed with the magic close before a power off or a halt, left + running into a reboot. Never opened in a container. + +**Fed from the loop, and that is the design.** The tempting version is an +alarm on the deadline heap, next to restart backoff and start timeouts. It +would keep the machine alive through exactly the failures the watchdog is +for: a heap that still fires while a handler elsewhere is wedged, or a panic +that drops the alarm and resets a machine that had survived it. The write is +in the loop body instead, after every handler, and `epoll_wait` gets a +timeout for it — the only thing in oxinit that wakes without a descriptor. + +**The boot deadline is what makes it worth having on a machine that updates +itself.** A boot manager that counts attempts — hideOS's does — can only +count a hang if something resets it. Before this, hideOS armed the watchdog +in its initramfs and stopped it from a unit at the end of boot; a hang +anywhere before that unit was a reset, and a hang after it was a machine +that sat there. Now one mechanism covers both: fed until the boot unit comes +up or the deadline passes, and fed for ever after that. + +**The driver is a module, which the test had to face.** The netboot kernel +`fetch` downloads has every watchdog driver as a module, and nothing in a +hand-built initramfs can load one it does not carry. `test-watchdog` takes +the kernel and its i6300esb module from the same `linux-virt` package, so +they always match, and loads the module from a unit — which is how the +device turning up after oxinit has started got a test rather than an +assumption. + +Verified: `cargo xtask test-watchdog`. Fed for fourteen seconds on a four +second timeout, then halted, and still running twelve seconds after the halt +— under `-no-reboot`, where a reset would have ended QEMU. Then a boot unit +that fails, a deadline cut from five minutes to eight seconds on the command +line, and QEMU ending without a line of shutdown. 23 host tests in +`oxinit-watchdog`, four more for `reached` in `oxinit-service`. + +## M18 — User managers + +**Done,** for what a service manager should do. What a session manager would +do is listed below, open. + +Also asked for by hideOS, whose desktop starts PipeWire and WirePlumber from +XDG autostart because oxinit had nowhere else to put a service that belongs +to a user. + +- [x] `oxinit --user`: the same supervisor, run by a user, with its sockets + under `$XDG_RUNTIME_DIR/oxinit` and its units in + `/usr/lib/oxinit/user-units`, `/etc/oxinit/user-units` and + `~/.config/oxinit/user-units`. +- [x] `user` and `tty` refused in a user unit; `%u` is the user. +- [x] One per user: a second one finds the first answering and exits 0. +- [x] A child subreaper, its own cgroup subtree when one is delegated, and + none — PID 1's degraded mode — when not. +- [x] Every stop signal, and `SIGHUP`, stops its units and exits. +- [x] `oxctl --user` and `oxlogd --user`. +- [x] `oxinit-paths`: one definition of where every socket and log is, for + both scopes, which `oxinit-ipc` and `oxinit-log` now take theirs from. + +**The same binary, not a second program.** A user unit has to mean what the +same file means to PID 1, and the way to guarantee that is to run it through +the same code. What differs is a table in ARCHITECTURE, and every row in it +is something the user manager *skips* — mounts, console, hostname, identity +changes, `reboot(2)`, the watchdog — plus the one thing it does differently +when its loop breaks: it exits, because it is not PID 1. That is the second +`exit()` in the crate, and CLAUDE.md and ARCHITECTURE now say so. + +**Starting it is not oxinit's job.** "Manage logins or sessions" is a +non-goal, and this does not change it: whatever starts a session starts the +user manager, with the session's environment, and signals it at the end. The +test image does it with a system unit running as `nobody`, which is also the +answer for services that should outlive a login. + +Open, each a decision rather than missing code — see ARCHITECTURE: + +- Several sessions of one user share one manager, and the first to end stops + it. Counting sessions is a login manager's knowledge. +- No cgroup delegation from PID 1, which would need a unit key. +- No way to give running services a variable the session set after the + manager started. + +Verified: `cargo xtask test-boot`, 46 checks — a user manager for `nobody` +started from a system unit, finding its cgroup not delegated and saying so; +a user unit printing `oxinit-user: nobody is nobody`, which is `%u` and +`id -un` agreeing; a `notify` user unit going ready on the user's own socket; +and, at shutdown, the user manager stopping it and exiting before the machine +syncs. The same units pass `test-distro` (48 checks) and `container` (10). + ## Not doing, and why These were on the list. They are coming off it with a reason rather than diff --git a/crates/oxctl/Cargo.toml b/crates/oxctl/Cargo.toml index c0f392e..91c4bcc 100644 --- a/crates/oxctl/Cargo.toml +++ b/crates/oxctl/Cargo.toml @@ -16,4 +16,5 @@ publish.workspace = true [dependencies] oxinit-ipc = { path = "../oxinit-ipc" } oxinit-log = { path = "../oxinit-log" } +oxinit-paths = { path = "../oxinit-paths" } rustix = { workspace = true, features = ["net"] } diff --git a/crates/oxctl/src/main.rs b/crates/oxctl/src/main.rs index 1b65588..925b6be 100644 --- a/crates/oxctl/src/main.rs +++ b/crates/oxctl/src/main.rs @@ -1,6 +1,7 @@ //! `oxctl` — the command-line client for oxinit. //! -//! Connects to `/run/oxinit/control.sock`, sends one request, prints one +//! Connects to `/run/oxinit/control.sock` — or, with `--user`, to the calling +//! user's own manager under `$XDG_RUNTIME_DIR` — sends one request, prints one //! reply, exits. There is no daemon here and no state: the socket is //! `SOCK_SEQPACKET`, so a request is one `send` and a reply is one `recv`. //! @@ -9,15 +10,15 @@ #![forbid(unsafe_code)] -use std::path::Path; use std::process::ExitCode; use rustix::net::{AddressFamily, SendFlags, SocketAddrUnix, SocketFlags, SocketType}; -use oxinit_ipc::{Request, Response, UnitStatus, CONTROL_PATH, MAX_MESSAGE}; +use oxinit_ipc::{Request, Response, UnitStatus, MAX_MESSAGE}; +use oxinit_paths::Paths; const USAGE: &str = "\ -usage: oxctl [unit] +usage: oxctl [--user] [unit] commands: list every unit oxinit knows about @@ -29,6 +30,9 @@ commands: reload re-read the unit directories --version which build this is +--user talks to your own user manager (oxinit --user) instead of PID 1, and +`logs` reads from ~/.local/state/oxinit/log. + `logs` reads the file directly. It does not go through oxinit: the control socket has to stay responsive, and bulk data is what would stop it being. @@ -36,9 +40,24 @@ oxinit answers immediately. `stop` means the stop was asked for, not that it has finished — a unit is not stopped until its cgroup is empty."; fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + // `--user` anywhere: it says which manager, not what to ask it. + let mut args: Vec = std::env::args().skip(1).collect(); + let user = args.iter().any(|arg| arg == "--user"); + args.retain(|arg| arg != "--user"); + + let paths = if user { + match Paths::user_from_env() { + Ok(paths) => paths, + Err(e) => { + eprintln!("oxctl: --user: {e}"); + return ExitCode::FAILURE; + } + } + } else { + Paths::system() + }; - match run(&args) { + match run(&args, &paths) { Ok(()) => ExitCode::SUCCESS, Err(message) => { eprintln!("oxctl: {message}"); @@ -47,7 +66,7 @@ fn main() -> ExitCode { } } -fn run(args: &[String]) -> Result<(), String> { +fn run(args: &[String], paths: &Paths) -> Result<(), String> { let command = args.first().map(String::as_str).unwrap_or(""); let unit = args.get(1).cloned(); @@ -60,7 +79,7 @@ fn run(args: &[String]) -> Result<(), String> { // Not a request at all. `oxlogd` writes files and this reads them, // because putting a service's whole output through the one socket // that has to stay responsive is exactly what would stop it being. - ("logs", Some(unit)) => return logs(&unit, tail(args)), + ("logs", Some(unit)) => return logs(paths, &unit, tail(args)), ("logs", None) => return Err(format!("logs needs a unit name\n\n{USAGE}")), ("start", Some(unit)) => Request::Start { unit }, @@ -84,7 +103,7 @@ fn run(args: &[String]) -> Result<(), String> { (other, _) => return Err(format!("unknown command `{other}`\n\n{USAGE}")), }; - match exchange(&request)? { + match exchange(paths, &request)? { Response::Units(units) => { print_units(&units, matches!(request, Request::List)); Ok(()) @@ -110,8 +129,8 @@ fn tail(args: &[String]) -> Option { /// Only the live file. The rotated generations are `.log.1` and up in /// the same directory, and reading them is `cat` — there is no index and no /// merge to do, which is most of the reason the format is plain text. -fn logs(unit: &str, tail: Option) -> Result<(), String> { - let path = oxinit_log::path(Path::new(oxinit_log::LOG_DIR), unit); +fn logs(paths: &Paths, unit: &str, tail: Option) -> Result<(), String> { + let path = oxinit_log::path(&paths.log_dir, unit); let text = std::fs::read_to_string(&path).map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => format!( @@ -133,7 +152,7 @@ fn logs(unit: &str, tail: Option) -> Result<(), String> { } /// One request, one reply. -fn exchange(request: &Request) -> Result { +fn exchange(paths: &Paths, request: &Request) -> Result { let body = oxinit_ipc::encode(request).map_err(|e| e.to_string())?; let socket = rustix::net::socket_with( @@ -144,13 +163,24 @@ fn exchange(request: &Request) -> Result { ) .map_err(|e| format!("socket: {e}"))?; - let addr = SocketAddrUnix::new(CONTROL_PATH).map_err(|e| format!("{CONTROL_PATH}: {e}"))?; + let control = paths.control(); + let addr = SocketAddrUnix::new(&control).map_err(|e| format!("{}: {e}", control.display()))?; rustix::net::connect(&socket, &addr).map_err(|e| { - format!( - "connect {CONTROL_PATH}: {e}\n\ - is oxinit running as PID 1, and are you root?" - ) + if paths.is_user() { + format!( + "connect {}: {e}\n\ + is your user manager running? It is `oxinit --user`, started \ + with your session", + control.display() + ) + } else { + format!( + "connect {}: {e}\n\ + is oxinit running as PID 1, and are you root?", + control.display() + ) + } })?; rustix::net::send(&socket, &body, SendFlags::empty()).map_err(|e| format!("send: {e}"))?; diff --git a/crates/oxinit-ipc/Cargo.toml b/crates/oxinit-ipc/Cargo.toml index bdf91df..628a427 100644 --- a/crates/oxinit-ipc/Cargo.toml +++ b/crates/oxinit-ipc/Cargo.toml @@ -14,5 +14,6 @@ publish.workspace = true # No Linux dependencies. The encoding is where a protocol bug would hide, and # it is tested on the host. [dependencies] +oxinit-paths = { path = "../oxinit-paths" } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true diff --git a/crates/oxinit-ipc/src/lib.rs b/crates/oxinit-ipc/src/lib.rs index 0291409..d0b9761 100644 --- a/crates/oxinit-ipc/src/lib.rs +++ b/crates/oxinit-ipc/src/lib.rs @@ -28,7 +28,10 @@ use serde::{Deserialize, Serialize}; /// Where oxinit listens. Mode `0600`, owned by root, in a root-owned /// directory: access to this socket is full control of the machine, and the /// mode is the whole authorization story. -pub const CONTROL_PATH: &str = "/run/oxinit/control.sock"; +/// +/// The system manager's. A user manager's is under that user's +/// `$XDG_RUNTIME_DIR`; `oxinit-paths` has both. +pub const CONTROL_PATH: &str = oxinit_paths::system::CONTROL; /// The largest message either side will send or accept. /// diff --git a/crates/oxinit-log/Cargo.toml b/crates/oxinit-log/Cargo.toml index a6b73f6..0e9abba 100644 --- a/crates/oxinit-log/Cargo.toml +++ b/crates/oxinit-log/Cargo.toml @@ -11,7 +11,9 @@ publish.workspace = true # Shared by oxinit, oxlogd and oxctl, so all three agree on where a log is and # what a line in it looks like. # -# No dependencies at all, and no syscalls. Splitting a read into records, -# bounding a line that never ends, and deciding when to rotate are where the -# bugs would be, and none of it needs a descriptor to test. +# No syscalls, and no dependency but the one that says where things are. +# Splitting a read into records, bounding a line that never ends, and deciding +# when to rotate are where the bugs would be, and none of it needs a +# descriptor to test. [dependencies] +oxinit-paths = { path = "../oxinit-paths" } diff --git a/crates/oxinit-log/src/lib.rs b/crates/oxinit-log/src/lib.rs index c9e7160..162814d 100644 --- a/crates/oxinit-log/src/lib.rs +++ b/crates/oxinit-log/src/lib.rs @@ -22,15 +22,17 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -/// Where `oxlogd` writes, and where `oxctl logs` reads. -pub const LOG_DIR: &str = "/var/log/oxinit"; +/// Where `oxlogd` writes, and where `oxctl logs` reads — for the system. A +/// user manager's logs are under that user's state directory; `oxinit-paths` +/// has both. +pub const LOG_DIR: &str = oxinit_paths::system::LOG_DIR; /// Where `oxinit` listens for `oxlogd`. /// /// PID 1 is the server. It already owns a listening socket and an event loop; /// the other direction would have PID 1 connecting to a service it supervises, /// at every service start, and deciding what to do when that connect fails. -pub const SOCKET_PATH: &str = "/run/oxinit/log.sock"; +pub const SOCKET_PATH: &str = oxinit_paths::system::LOG_SOCKET; /// The largest message on that socket. /// diff --git a/crates/oxinit-paths/Cargo.toml b/crates/oxinit-paths/Cargo.toml new file mode 100644 index 0000000..a991042 --- /dev/null +++ b/crates/oxinit-paths/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "oxinit-paths" +version.workspace = true +description = "Where oxinit's sockets and logs are, for the system and for a user" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +# No dependencies. `oxinit`, `oxctl` and `oxlogd` have to agree on where the +# sockets are, for the system manager and for each user's, and the way to make +# them agree is one definition that all three — and the crates whose +# constants used to be that definition — depend on. +[dependencies] +thiserror.workspace = true diff --git a/crates/oxinit-paths/src/lib.rs b/crates/oxinit-paths/src/lib.rs new file mode 100644 index 0000000..b05fc84 --- /dev/null +++ b/crates/oxinit-paths/src/lib.rs @@ -0,0 +1,303 @@ +//! Where oxinit's sockets and logs are. +//! +//! Two scopes. The **system** manager is PID 1 and its paths are fixed: +//! `/run/oxinit` and `/var/log/oxinit`. A **user** manager — `oxinit --user`, +//! one per logged-in user — keeps the same layout under that user's own +//! directories, found the way every other per-user program finds them: the +//! XDG base directory variables its session was started with. +//! +//! Shared by `oxinit`, `oxctl` and `oxlogd`. A client that looked for the +//! control socket somewhere the manager did not put it would fail with a +//! message about a missing file, which says nothing about why. +//! +//! Nothing here touches the filesystem. Resolving a user's paths takes the +//! environment as a function, so every rule below is a host test. +//! +//! `oxinit` depends on this crate, so a panic here is a panic in PID 1. + +#![forbid(unsafe_code)] +#![deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +/// The system manager's paths. Fixed: PID 1 has no environment worth +/// consulting, and every client has to find them without asking. +pub mod system { + /// Every socket PID 1 owns. + pub const RUNTIME_DIR: &str = "/run/oxinit"; + /// `oxctl` connects here. Mode `0600`, owned by root. + pub const CONTROL: &str = "/run/oxinit/control.sock"; + /// Services send `sd_notify` datagrams here; the value of + /// `NOTIFY_SOCKET`. + pub const NOTIFY: &str = "/run/oxinit/notify"; + /// `oxlogd` connects here for the read ends of the service pipes. + pub const LOG_SOCKET: &str = "/run/oxinit/log.sock"; + /// Where `oxlogd` writes, and where `oxctl logs` reads. + pub const LOG_DIR: &str = "/var/log/oxinit"; +} + +/// The socket file names, the same in every scope. +pub const CONTROL_NAME: &str = "control.sock"; +pub const NOTIFY_NAME: &str = "notify"; +pub const LOG_SOCKET_NAME: &str = "log.sock"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + /// PID 1. + System, + /// `oxinit --user`. + User, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PathsError { + #[error( + "XDG_RUNTIME_DIR is not set; a user manager keeps its sockets there, \ + and a login session sets it (pam_elogind, pam_rundir, pam_systemd)" + )] + NoRuntimeDir, + + #[error("XDG_RUNTIME_DIR is `{0}`, which is not an absolute path")] + RelativeRuntimeDir(String), + + #[error("HOME is not set or not absolute, and {0} is not set either")] + NoHome(&'static str), +} + +/// Where everything is, for one scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Paths { + pub scope: Scope, + /// The directory holding the control, notify and log sockets. + pub runtime_dir: PathBuf, + /// Where `oxlogd` writes `.log`. + pub log_dir: PathBuf, + /// `$XDG_CONFIG_HOME`, for a user: the base the user's own unit + /// directory is under. `None` for the system. + pub config_home: Option, +} + +impl Paths { + pub fn system() -> Self { + Self { + scope: Scope::System, + runtime_dir: PathBuf::from(system::RUNTIME_DIR), + log_dir: PathBuf::from(system::LOG_DIR), + config_home: None, + } + } + + /// A user manager's paths, from the process's own environment. + pub fn user_from_env() -> Result { + Self::user(|name| std::env::var_os(name)) + } + + /// A user manager's paths, from an environment given as a lookup. + /// + /// - Sockets: `$XDG_RUNTIME_DIR/oxinit/`. Required, and absolute: it is + /// the per-user, per-boot, `0700` directory the session was given, and + /// there is nowhere else a user's sockets can go that is both private + /// and gone at logout. + /// - Logs: `$XDG_STATE_HOME/oxinit/log/`, defaulting to + /// `~/.local/state/oxinit/log/`. State, not cache: logs are worth + /// keeping across a reboot, and not configuration either. + /// - Units: under `$XDG_CONFIG_HOME`, defaulting to `~/.config`. + /// + /// A relative `XDG_*_HOME` is ignored, as the XDG specification says: + /// relative to what would depend on the directory oxinit was started in. + pub fn user(env: impl Fn(&str) -> Option) -> Result { + let runtime = match env("XDG_RUNTIME_DIR") { + None => return Err(PathsError::NoRuntimeDir), + Some(value) if value.is_empty() => return Err(PathsError::NoRuntimeDir), + Some(value) => PathBuf::from(value), + }; + if !runtime.is_absolute() { + return Err(PathsError::RelativeRuntimeDir( + runtime.display().to_string(), + )); + } + + let home = env("HOME") + .map(PathBuf::from) + .filter(|home| home.is_absolute()); + let base = |name: &'static str, fallback: &str| -> Result { + match env(name).map(PathBuf::from).filter(|dir| dir.is_absolute()) { + Some(dir) => Ok(dir), + None => home + .as_deref() + .map(|home| home.join(fallback)) + .ok_or(PathsError::NoHome(name)), + } + }; + + let config_home = base("XDG_CONFIG_HOME", ".config")?; + let state_home = base("XDG_STATE_HOME", ".local/state")?; + + Ok(Self { + scope: Scope::User, + runtime_dir: runtime.join("oxinit"), + log_dir: state_home.join("oxinit").join("log"), + config_home: Some(config_home), + }) + } + + pub fn control(&self) -> PathBuf { + self.runtime_dir.join(CONTROL_NAME) + } + + pub fn notify(&self) -> PathBuf { + self.runtime_dir.join(NOTIFY_NAME) + } + + pub fn log_socket(&self) -> PathBuf { + self.runtime_dir.join(LOG_SOCKET_NAME) + } + + pub fn log(&self, unit: &str) -> PathBuf { + self.log_dir.join(format!("{unit}.log")) + } + + pub fn is_user(&self) -> bool { + self.scope == Scope::User + } + + pub fn runtime_dir(&self) -> &Path { + &self.runtime_dir + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| { + vars.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| OsString::from(value)) + } + } + + #[test] + fn the_system_paths_are_the_fixed_ones() { + let paths = Paths::system(); + assert_eq!(paths.control(), Path::new(system::CONTROL)); + assert_eq!(paths.notify(), Path::new(system::NOTIFY)); + assert_eq!(paths.log_socket(), Path::new(system::LOG_SOCKET)); + assert_eq!(paths.log_dir, Path::new(system::LOG_DIR)); + assert_eq!(paths.config_home, None); + assert!(!paths.is_user()); + } + + #[test] + fn a_user_keeps_the_same_layout_under_its_own_directories() { + let paths = Paths::user(env(&[ + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("HOME", "/home/ana"), + ])) + .unwrap(); + + assert_eq!( + paths.control(), + Path::new("/run/user/1000/oxinit/control.sock") + ); + assert_eq!(paths.notify(), Path::new("/run/user/1000/oxinit/notify")); + assert_eq!( + paths.log_socket(), + Path::new("/run/user/1000/oxinit/log.sock") + ); + assert_eq!( + paths.log_dir, + Path::new("/home/ana/.local/state/oxinit/log") + ); + assert_eq!( + paths.log("pipewire"), + Path::new("/home/ana/.local/state/oxinit/log/pipewire.log") + ); + assert_eq!( + paths.config_home.as_deref(), + Some(Path::new("/home/ana/.config")) + ); + assert!(paths.is_user()); + } + + #[test] + fn the_xdg_homes_win_over_home() { + let paths = Paths::user(env(&[ + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("HOME", "/home/ana"), + ("XDG_CONFIG_HOME", "/cfg"), + ("XDG_STATE_HOME", "/state"), + ])) + .unwrap(); + + assert_eq!(paths.config_home.as_deref(), Some(Path::new("/cfg"))); + assert_eq!(paths.log_dir, Path::new("/state/oxinit/log")); + } + + #[test] + fn a_relative_xdg_home_is_ignored() { + let paths = Paths::user(env(&[ + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("HOME", "/home/ana"), + ("XDG_CONFIG_HOME", "cfg"), + ])) + .unwrap(); + assert_eq!( + paths.config_home.as_deref(), + Some(Path::new("/home/ana/.config")) + ); + } + + #[test] + fn no_home_is_fine_when_the_xdg_homes_are_set() { + let paths = Paths::user(env(&[ + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("XDG_CONFIG_HOME", "/cfg"), + ("XDG_STATE_HOME", "/state"), + ])); + assert!(paths.is_ok()); + } + + #[test] + fn a_user_manager_needs_a_runtime_dir() { + assert_eq!( + Paths::user(env(&[("HOME", "/home/ana")])), + Err(PathsError::NoRuntimeDir) + ); + assert_eq!( + Paths::user(env(&[("XDG_RUNTIME_DIR", ""), ("HOME", "/home/ana")])), + Err(PathsError::NoRuntimeDir) + ); + assert_eq!( + Paths::user(env(&[ + ("XDG_RUNTIME_DIR", "run/user/1000"), + ("HOME", "/home/ana") + ])), + Err(PathsError::RelativeRuntimeDir("run/user/1000".to_owned())) + ); + } + + #[test] + fn and_a_home_when_the_xdg_homes_are_unset() { + assert_eq!( + Paths::user(env(&[("XDG_RUNTIME_DIR", "/run/user/1000")])), + Err(PathsError::NoHome("XDG_CONFIG_HOME")) + ); + assert_eq!( + Paths::user(env(&[ + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("HOME", "relative") + ])), + Err(PathsError::NoHome("XDG_CONFIG_HOME")) + ); + } +} diff --git a/crates/oxinit-service/src/lib.rs b/crates/oxinit-service/src/lib.rs index a4d4df7..3a81589 100644 --- a/crates/oxinit-service/src/lib.rs +++ b/crates/oxinit-service/src/lib.rs @@ -25,7 +25,7 @@ use std::fmt; use std::process::Child; use std::time::{Duration, Instant}; -use oxinit_unit::{Restart, Unit}; +use oxinit_unit::{Restart, ServiceType, Unit}; /// Backoff never grows past this, or a service that fails once a week ends up /// taking hours to come back. @@ -120,6 +120,14 @@ pub struct Instance { stop_cause: Option, /// Whether the stop timeout expired and `cgroup.kill` was used. killed: bool, + /// Whether the unit has ever come up: become `Active`, or — a `oneshot`, + /// which never does — run to a clean exit. Never cleared. + /// + /// What the hardware watchdog's boot deadline waits for. A boot is + /// confirmed by a unit having *got there*, so a unit that came up and + /// later stopped still confirmed it, and a `oneshot` that has finished + /// its job is the commonest way to say so. + pub reached: bool, } impl Instance { @@ -139,6 +147,7 @@ impl Instance { last_ping: None, stop_cause: None, killed: false, + reached: false, } } @@ -170,6 +179,7 @@ impl Instance { } pub fn entered_active(&mut self) { + self.reached = true; self.state = State::Active; self.active_since = Some(Instant::now()); self.last_ping = Some(Instant::now()); @@ -263,6 +273,15 @@ impl Instance { return None; } + // A `oneshot`'s readiness condition: it never becomes `Active`. + let oneshot = self + .unit + .service() + .is_some_and(|s| s.ty == ServiceType::Oneshot); + if oneshot && self.state == State::Activating && exit == Exit::Clean { + self.reached = true; + } + self.schedule_restart(exit) } @@ -503,6 +522,59 @@ mod tests { assert_eq!(instance.state, State::Inactive); } + fn oneshot() -> Unit { + oxinit_unit::parse( + "job", + "[service]\ntype = \"oneshot\"\nexec = \"/bin/true\"\n", + "host", + ) + .unwrap() + } + + #[test] + fn a_unit_that_became_active_has_reached_its_goal_for_good() { + let mut instance = Instance::new(unit("no", "1s")); + assert!(!instance.reached); + + instance.entered_active(); + assert!(instance.reached); + + instance.on_exit(Exit::Code(1)); + assert_eq!(instance.state, State::Failed); + assert!(instance.reached, "came up, then failed: it still came up"); + } + + #[test] + fn a_oneshot_reaches_its_goal_by_exiting_cleanly() { + let mut instance = Instance::new(oneshot()); + instance.state = State::Activating; + instance.on_exit(Exit::Clean); + assert_eq!(instance.state, State::Inactive); + assert!(instance.reached); + } + + #[test] + fn a_oneshot_that_fails_has_not_reached_it() { + let mut instance = Instance::new(oneshot()); + instance.state = State::Activating; + instance.on_exit(Exit::Code(1)); + assert!(!instance.reached); + } + + #[test] + fn a_service_exiting_cleanly_before_it_was_ready_has_not_reached_it() { + let notify = oxinit_unit::parse( + "n", + "[service]\ntype = \"notify\"\nexec = \"/bin/true\"\n", + "host", + ) + .unwrap(); + let mut instance = Instance::new(notify); + instance.state = State::Activating; + instance.on_exit(Exit::Clean); + assert!(!instance.reached); + } + #[test] fn exit_classification() { assert_eq!(Exit::from_status(Some(0), None), Exit::Clean); diff --git a/crates/oxinit-unit/src/error.rs b/crates/oxinit-unit/src/error.rs index 2069305..1271d19 100644 --- a/crates/oxinit-unit/src/error.rs +++ b/crates/oxinit-unit/src/error.rs @@ -62,6 +62,18 @@ pub enum UnitError { #[error("invalid unit name `{unit}`: expected only letters, digits, `_`, `.`, and `-`")] Name { unit: String }, + #[error( + "user unit `{unit}`: `user` is for system units; a user manager runs \ + every service as its own user" + )] + UserInUserUnit { unit: String }, + + #[error( + "user unit `{unit}`: `tty` is for system units; the terminal belongs to \ + the user's session" + )] + TtyInUserUnit { unit: String }, + #[error("read {path}: {message}")] Read { path: String, message: String }, diff --git a/crates/oxinit-unit/src/lib.rs b/crates/oxinit-unit/src/lib.rs index 78a2baa..8502453 100644 --- a/crates/oxinit-unit/src/lib.rs +++ b/crates/oxinit-unit/src/lib.rs @@ -24,9 +24,12 @@ pub mod unit; pub mod value; pub use error::{ExecError, UnitError, ValueError}; -pub use load::{load_default, load_dirs, Loaded, ETC_DIR, VENDOR_DIR}; +pub use load::{ + load_default, load_dirs, load_dirs_in, user_dirs, Loaded, ETC_DIR, USER_CONFIG_SUBDIR, + USER_ETC_DIR, USER_VENDOR_DIR, VENDOR_DIR, +}; pub use unit::{ - parse, Deps, Kind, Listen, Output, Resources, Restart, Service, ServiceType, Socket, - SocketType, Timer, Unit, + parse, parse_in, Deps, Kind, Listen, Output, Resources, Restart, Scope, Service, ServiceType, + Socket, SocketType, Timer, Unit, }; pub use value::{parse_duration, parse_size, DurationValue, SizeValue}; diff --git a/crates/oxinit-unit/src/load.rs b/crates/oxinit-unit/src/load.rs index fe7ccfc..45bdd37 100644 --- a/crates/oxinit-unit/src/load.rs +++ b/crates/oxinit-unit/src/load.rs @@ -9,7 +9,7 @@ use std::io; use std::path::{Path, PathBuf}; use crate::error::UnitError; -use crate::unit::{self, Unit}; +use crate::unit::{self, Scope, Unit}; /// Packaged units. Lowest precedence. pub const VENDOR_DIR: &str = "/usr/lib/oxinit/units"; @@ -17,6 +17,29 @@ pub const VENDOR_DIR: &str = "/usr/lib/oxinit/units"; /// Operator units. Replaces a vendor file of the same name. pub const ETC_DIR: &str = "/etc/oxinit/units"; +/// Packaged units for every user's manager. Lowest precedence. +pub const USER_VENDOR_DIR: &str = "/usr/lib/oxinit/user-units"; + +/// The operator's units for every user's manager. +pub const USER_ETC_DIR: &str = "/etc/oxinit/user-units"; + +/// A user's own units, under their `$XDG_CONFIG_HOME`. Highest precedence: +/// the user is the operator of their own manager. +pub const USER_CONFIG_SUBDIR: &str = "oxinit/user-units"; + +/// A user manager's unit directories, lowest precedence first. +/// +/// Three rather than two because a user manager has two operators: the +/// machine's, in `/etc`, and the user, in their own configuration. Each +/// replaces a file of the same name wholly, as `/etc` does for the system. +pub fn user_dirs(config_home: &Path) -> Vec { + vec![ + PathBuf::from(USER_VENDOR_DIR), + PathBuf::from(USER_ETC_DIR), + config_home.join(USER_CONFIG_SUBDIR), + ] +} + /// Units found on disk, plus whatever went wrong finding them. #[derive(Debug, Default)] pub struct Loaded { @@ -36,13 +59,18 @@ pub fn load_default(hostname: &str) -> Loaded { /// A missing directory is not an error: a machine with no `/etc/oxinit/units` /// is a machine with no operator overrides. pub fn load_dirs(dirs: &[&Path], hostname: &str) -> Loaded { + load_dirs_in(dirs, hostname, &Scope::System) +} + +/// As [`load_dirs`], for the manager `scope` says. +pub fn load_dirs_in(dirs: &[&Path], hostname: &str, scope: &Scope) -> Loaded { let mut loaded = Loaded::default(); for dir in dirs { match unit_files(dir) { Ok(files) => { for (name, path) in files { - match read_and_parse(&name, &path, hostname) { + match read_and_parse(&name, &path, hostname, scope) { // Replaces any unit of the same name from an earlier // directory, wholly. Ok(unit) => { @@ -81,13 +109,18 @@ fn unit_files(dir: &Path) -> io::Result> { Ok(found) } -fn read_and_parse(name: &str, path: &Path, hostname: &str) -> Result { +fn read_and_parse( + name: &str, + path: &Path, + hostname: &str, + scope: &Scope, +) -> Result { let text = std::fs::read_to_string(path).map_err(|source| UnitError::Read { path: path.display().to_string(), message: source.to_string(), })?; - unit::parse(name, &text, hostname) + unit::parse_in(name, &text, hostname, scope) } #[cfg(test)] @@ -184,6 +217,59 @@ mod tests { assert_eq!(loaded.errors.len(), 1); } + #[test] + fn a_user_manager_reads_three_directories_in_order() { + let dirs = user_dirs(Path::new("/home/ana/.config")); + assert_eq!( + dirs, + [ + PathBuf::from("/usr/lib/oxinit/user-units"), + PathBuf::from("/etc/oxinit/user-units"), + PathBuf::from("/home/ana/.config/oxinit/user-units"), + ] + ); + } + + #[test] + fn the_users_own_unit_replaces_the_machines() { + let vendor = TempDir::new("uvendor"); + let user = TempDir::new("uconfig"); + vendor.write("pipewire.toml", "[service]\nexec = \"/usr/bin/pipewire\"\n"); + user.write( + "pipewire.toml", + "[service]\nexec = \"/home/ana/bin/pipewire\"\n", + ); + + let scope = Scope::User { + name: "ana".to_owned(), + }; + let loaded = load_dirs_in(&[&vendor.0, &user.0], "h", &scope); + let unit = loaded.units.get("pipewire").unwrap(); + assert_eq!(unit.service().unwrap().exec, ["/home/ana/bin/pipewire"]); + assert_eq!(unit.service().unwrap().user, "ana"); + } + + #[test] + fn a_system_unit_in_a_user_directory_is_reported() { + let dir = TempDir::new("usys"); + dir.write( + "sshd.toml", + "[service]\nexec = \"/usr/bin/sshd\"\nuser = \"root\"\n", + ); + + let scope = Scope::User { + name: "ana".to_owned(), + }; + let loaded = load_dirs_in(&[&dir.0], "h", &scope); + assert!(loaded.units.is_empty()); + assert_eq!( + loaded.errors, + [UnitError::UserInUserUnit { + unit: "sshd".to_owned() + }] + ); + } + #[test] fn empty_file_has_no_kind_and_fails() { let dir = TempDir::new("empty"); diff --git a/crates/oxinit-unit/src/unit.rs b/crates/oxinit-unit/src/unit.rs index d5d9ca3..167228d 100644 --- a/crates/oxinit-unit/src/unit.rs +++ b/crates/oxinit-unit/src/unit.rs @@ -296,11 +296,33 @@ struct RawResources { tasks_max: Option, } -/// Parse one unit file. +/// Which manager a unit is loaded by. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Scope { + /// PID 1. + #[default] + System, + /// `oxinit --user`, running as the user named here. + /// + /// Every service runs as that user, because a user manager has nobody + /// else it could run one as. So `user` is refused rather than ignored — + /// a unit that says `user = "root"` and gets the user instead is a unit + /// that does not do what it says — and `%u` is the manager's user. + /// `tty` is refused too: the terminal belongs to the session, and a + /// service claiming it would take it from the user's own shell. + User { name: String }, +} + +/// Parse one unit file, for the system manager. /// /// `name` is the filename without `.toml`. `hostname` feeds the `%H` /// specifier; this crate does not read it from the system itself. pub fn parse(name: &str, text: &str, hostname: &str) -> Result { + parse_in(name, text, hostname, &Scope::System) +} + +/// Parse one unit file, for the manager `scope` says. +pub fn parse_in(name: &str, text: &str, hostname: &str, scope: &Scope) -> Result { validate_name(name)?; let raw: RawFile = basic_toml::from_str(text).map_err(|source| UnitError::Toml { @@ -352,7 +374,22 @@ pub fn parse(name: &str, text: &str, hostname: &str) -> Result let kind = match (raw.service, raw.socket, raw.timer) { (Some(service), None, None) => { - let user = service.user.unwrap_or_else(|| "root".to_owned()); + let user = match scope { + Scope::System => service.user.unwrap_or_else(|| "root".to_owned()), + Scope::User { name: owner } => { + if service.user.is_some() { + return Err(UnitError::UserInUserUnit { + unit: name.to_owned(), + }); + } + if service.tty == Some(true) { + return Err(UnitError::TtyInUserUnit { + unit: name.to_owned(), + }); + } + owner.clone() + } + }; let ty = service.ty.unwrap_or_default(); let specifiers = Specifiers { @@ -863,4 +900,62 @@ backlog = 64 ["/bin/x", "web", "web.service", "myhost"] ); } + + fn ana() -> Scope { + Scope::User { + name: "ana".to_owned(), + } + } + + #[test] + fn a_user_unit_runs_as_the_managers_user() { + let unit = parse_in( + "pipewire", + "[service]\nexec = \"/usr/bin/pipewire --who %u\"\n", + "h", + &ana(), + ) + .unwrap(); + let service = unit.service().unwrap(); + assert_eq!(service.user, "ana"); + assert_eq!(service.exec, ["/usr/bin/pipewire", "--who", "ana"]); + } + + #[test] + fn a_user_unit_cannot_name_a_user_even_its_own() { + for user in ["root", "ana"] { + let text = format!("[service]\nexec = \"/bin/true\"\nuser = \"{user}\"\n"); + assert_eq!( + parse_in("x", &text, "h", &ana()), + Err(UnitError::UserInUserUnit { + unit: "x".to_owned() + }) + ); + } + } + + #[test] + fn a_user_unit_cannot_claim_the_terminal() { + let text = "[service]\nexec = \"/bin/sh\"\ntty = true\n"; + assert_eq!( + parse_in("x", text, "h", &ana()), + Err(UnitError::TtyInUserUnit { + unit: "x".to_owned() + }) + ); + // Saying no is saying nothing. + let text = "[service]\nexec = \"/bin/sh\"\ntty = false\n"; + assert!(parse_in("x", text, "h", &ana()).is_ok()); + } + + #[test] + fn the_same_file_parses_the_same_in_both_scopes_but_for_who_runs_it() { + let text = "[unit]\nafter = [\"dbus\"]\n[service]\ntype = \"notify\"\nexec = \"/usr/bin/wireplumber\"\n"; + let system = parse("wp", text, "h").unwrap(); + let user = parse_in("wp", text, "h", &ana()).unwrap(); + assert_eq!(system.deps, user.deps); + assert_eq!(system.service().unwrap().ty, user.service().unwrap().ty); + assert_eq!(system.service().unwrap().user, "root"); + assert_eq!(user.service().unwrap().user, "ana"); + } } diff --git a/crates/oxinit-user/src/lib.rs b/crates/oxinit-user/src/lib.rs index c4ccb3f..1135bdf 100644 --- a/crates/oxinit-user/src/lib.rs +++ b/crates/oxinit-user/src/lib.rs @@ -127,6 +127,26 @@ pub fn resolve(name: &str, passwd: &str, group: &str) -> Result String { + let passwd = std::fs::read_to_string(PASSWD_PATH).unwrap_or_default(); + name_of(uid, &passwd).unwrap_or_else(|| uid.to_string()) +} + +/// The first account in a passwd file with this uid. +pub fn name_of(uid: u32, passwd: &str) -> Option { + passwd + .lines() + .filter_map(fields) + .find(|fields| fields.get(2).and_then(|v| v.parse::().ok()) == Some(uid)) + .and_then(|fields| fields.first().map(|name| (*name).to_owned())) +} + /// The colon-separated fields of one line, or `None` if the line is not an /// entry. /// @@ -174,6 +194,14 @@ nobody:x:65534: resolve(name, PASSWD, GROUP).unwrap() } + #[test] + fn a_uid_has_a_name() { + assert_eq!(name_of(1000, PASSWD).as_deref(), Some("oxinit")); + assert_eq!(name_of(0, PASSWD).as_deref(), Some("root")); + assert_eq!(name_of(4242, PASSWD), None); + assert_eq!(name_of(1000, "# nothing\nbroken\n"), None); + } + #[test] fn resolves_the_documented_fields() { let identity = resolved("nobody"); diff --git a/crates/oxinit-watchdog/Cargo.toml b/crates/oxinit-watchdog/Cargo.toml new file mode 100644 index 0000000..7b15a8f --- /dev/null +++ b/crates/oxinit-watchdog/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "oxinit-watchdog" +version.workspace = true +description = "Hardware watchdog configuration and the policy for feeding it" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +# No Linux dependencies and no syscalls. When to pet, when to stop petting and +# what to do with the device on the way down are the decisions that reset a +# machine or fail to, and they are tested on the host. Opening the device and +# the ioctl stay in the `oxinit` crate. +[dependencies] +oxinit-unit = { path = "../oxinit-unit" } +basic-toml.workspace = true +serde = { workspace = true, features = ["derive"] } +thiserror.workspace = true diff --git a/crates/oxinit-watchdog/src/lib.rs b/crates/oxinit-watchdog/src/lib.rs new file mode 100644 index 0000000..630d6d3 --- /dev/null +++ b/crates/oxinit-watchdog/src/lib.rs @@ -0,0 +1,756 @@ +//! The hardware watchdog: what is configured, and when to pet it. +//! +//! A hardware watchdog resets the machine unless something writes to it +//! within its timeout. oxinit is that something, from its event loop — so a +//! PID 1 that stops turning its loop is a machine that resets, which is the +//! only recovery there is from a hung PID 1. See ARCHITECTURE.md. +//! +//! On top of that, a **boot deadline**: until a named unit has come up, oxinit +//! pets only for so long. A boot that never gets there stops being fed and is +//! reset, which is what lets a boot manager count a hang as a failed attempt. +//! +//! Nothing here opens a device or reads a clock. Every decision takes the time +//! since oxinit started as an argument, so "what happens if the boot unit comes +//! up one second after the deadline" is a host test. +//! +//! `oxinit` depends on this crate, so a panic here is a panic in PID 1. + +#![forbid(unsafe_code)] +#![deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + +use std::time::Duration; + +use oxinit_unit::DurationValue; +use serde::Deserialize; +use thiserror::Error; + +/// Packaged configuration. Lowest precedence. +pub const VENDOR_FILE: &str = "/usr/lib/oxinit/watchdog.toml"; + +/// Operator configuration. Replaces the vendor file wholly, as a unit file in +/// `/etc` replaces one in `/usr/lib`: the effective configuration is always a +/// file you can `cat`, plus whatever the kernel command line says. +pub const ETC_FILE: &str = "/etc/oxinit/watchdog.toml"; + +/// The device when the configuration names none. +/// +/// `watchdog0` rather than the legacy `/dev/watchdog`: both are the first +/// watchdog, but only the numbered node exists for every one of them. +pub const DEFAULT_DEVICE: &str = "/dev/watchdog0"; + +/// Kernel command line keys are this, followed by a key from the file. +pub const CMDLINE_PREFIX: &str = "oxinit.watchdog."; + +/// Pet interval when the driver would neither take a timeout nor say what its +/// own is. Short enough for any hardware worth having. +const UNKNOWN_TIMEOUT_INTERVAL: Duration = Duration::from_secs(1); + +/// The largest timeout the ioctl can carry: it is a C `int`. +const MAX_TIMEOUT_SECS: u64 = i32::MAX as u64; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum WatchdogError { + #[error("{origin}: {message}")] + Toml { origin: String, message: String }, + + #[error("{origin}: `{key}` is not a key oxinit knows")] + UnknownKey { origin: String, key: String }, + + #[error("{origin}: {key}: {message}")] + Value { + origin: String, + key: String, + message: String, + }, + + #[error( + "timeout-sec must be whole seconds, between 1s and {MAX_TIMEOUT_SECS}s, \ + or 0s for no watchdog; the hardware counts seconds" + )] + Timeout, + + #[error( + "boot-sec needs boot-unit: a boot deadline is a deadline for something, \ + and boot-unit names what" + )] + BootWithoutUnit, + + #[error( + "boot-unit needs boot-sec: naming the unit that confirms a boot means \ + nothing without a deadline for it" + )] + UnitWithoutBoot, +} + +/// What a file or the command line said, key by key. +/// +/// Every key optional, so the two sources can be layered: the command line +/// wins key by key over the file. That differs from how the files themselves +/// combine, and on purpose — a file is an installation's configuration and is +/// replaced whole; a command line entry is one boot's exception to it, which +/// is what a boot manager entry or a test image wants to say. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Settings { + pub device: Option, + pub timeout: Option, + pub boot_unit: Option, + pub boot: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +struct RawFile { + device: Option, + timeout_sec: Option, + boot_unit: Option, + boot_sec: Option, +} + +impl Settings { + /// A `watchdog.toml`. `origin` is what to call it in an error. + pub fn parse(origin: &str, text: &str) -> Result { + let raw: RawFile = basic_toml::from_str(text).map_err(|e| WatchdogError::Toml { + origin: origin.to_owned(), + message: e.to_string(), + })?; + + Ok(Self { + device: raw.device, + timeout: raw.timeout_sec.map(|value| value.0), + boot_unit: raw.boot_unit, + boot: raw.boot_sec.map(|value| value.0), + }) + } + + /// The `oxinit.watchdog.*` entries of a kernel command line. + /// + /// Everything else on the line is somebody else's and is ignored. An + /// unknown key under the prefix is an error rather than ignored, for the + /// reason an unknown key in a unit is: a typo must not quietly mean the + /// default. + pub fn from_cmdline(cmdline: &str) -> Result { + let origin = "kernel command line"; + let mut settings = Self::default(); + + for word in cmdline.split_whitespace() { + let Some(rest) = word.strip_prefix(CMDLINE_PREFIX) else { + continue; + }; + let (key, value) = rest.split_once('=').unwrap_or((rest, "")); + + let duration = |value: &str| { + oxinit_unit::parse_duration(value).map_err(|e| WatchdogError::Value { + origin: origin.to_owned(), + key: format!("{CMDLINE_PREFIX}{key}"), + message: e.to_string(), + }) + }; + + match key { + "device" => settings.device = Some(value.to_owned()), + "timeout-sec" => settings.timeout = Some(duration(value)?), + "boot-unit" => settings.boot_unit = Some(value.to_owned()), + "boot-sec" => settings.boot = Some(duration(value)?), + _ => { + return Err(WatchdogError::UnknownKey { + origin: origin.to_owned(), + key: format!("{CMDLINE_PREFIX}{key}"), + }) + } + } + } + + Ok(settings) + } + + /// `self`, with anything it leaves unsaid taken from `base`. + pub fn over(self, base: Settings) -> Settings { + Settings { + device: self.device.or(base.device), + timeout: self.timeout.or(base.timeout), + boot_unit: self.boot_unit.or(base.boot_unit), + boot: self.boot.or(base.boot), + } + } + + /// What to run with. `None` is no watchdog: no timeout configured, or a + /// timeout of zero. + /// + /// Zero rather than a separate switch, so that the one key a command line + /// needs to turn the watchdog off for a boot is the key that turns it on. + pub fn resolve(self) -> Result, WatchdogError> { + let timeout = match self.timeout { + None => return Ok(None), + Some(timeout) if timeout.is_zero() => return Ok(None), + Some(timeout) => timeout, + }; + + if timeout.subsec_nanos() != 0 || timeout.as_secs() > MAX_TIMEOUT_SECS { + return Err(WatchdogError::Timeout); + } + let timeout = u32::try_from(timeout.as_secs()).map_err(|_| WatchdogError::Timeout)?; + + // A zero deadline is no deadline, for the same reason a zero timeout + // is no watchdog: it is how one boot turns it off. + let boot = match (self.boot.filter(|boot| !boot.is_zero()), self.boot_unit) { + (None, None) => None, + (None, Some(_)) if self.boot.is_some() => None, + (None, Some(_)) => return Err(WatchdogError::UnitWithoutBoot), + (Some(_), None) => return Err(WatchdogError::BootWithoutUnit), + (Some(within), Some(unit)) => Some(Boot { unit, within }), + }; + + Ok(Some(Config { + device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()), + timeout, + boot, + })) + } +} + +/// A watchdog to feed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub device: String, + /// Seconds, as the driver is asked for them. It may round. + pub timeout: u32, + pub boot: Option, +} + +/// How long a boot has to confirm itself, and what confirms it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Boot { + /// The boot is good once this unit has come up: become `Active`, or — a + /// `oneshot` — run to a clean exit. + pub unit: String, + /// Measured from oxinit's own start. What came before it — firmware, + /// kernel, initramfs — is whoever armed the watchdog first's to bound. + pub within: Duration, +} + +/// How often to pet a watchdog with this timeout: twice per timeout. +/// +/// Half rather than something tighter because the loop is woken for exactly +/// this, so the margin is scheduling latency, not the length of a handler. +pub fn pet_interval(timeout_secs: u32) -> Duration { + Duration::from_secs(u64::from(timeout_secs)) / 2 +} + +/// Where the boot deadline stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootState { + /// No deadline configured, or the unit came up in time. + Confirmed, + /// Waiting for the unit, inside the deadline. + Pending, + /// The deadline passed first. The watchdog is no longer fed, and the + /// machine resets one timeout from now. Final. + Overdue, +} + +/// What the loop should do with the device, now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Nothing, + /// Try to open it. It was not there, or never tried. + Open, + /// Write to it. + Pet, +} + +/// Something worth a line on the console. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Event { + BootConfirmed, + BootOverdue, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Tick { + pub action: Action, + pub event: Option, +} + +/// What to do with the device at the end of a shutdown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Release { + /// Write the magic `V` and close: the driver stops the timer. A machine + /// told to power off or halt must not be reset afterwards. + Disarm, + /// Pet it one last time and keep it running into `reboot(2)`. If the + /// kernel hangs on the way down, the watchdog finishes the reboot it was + /// asked for. A driver that stops itself at reboot does so after the + /// point where that hang could happen. + KeepRunning, +} + +/// The decision at the end of a shutdown, by whether it ends in a reboot. +pub fn release(reboot: bool) -> Release { + if reboot { + Release::KeepRunning + } else { + Release::Disarm + } +} + +/// When to pet, and when to stop. +/// +/// Times are durations since oxinit started, on the monotonic clock. +#[derive(Debug, Clone)] +pub struct Policy { + config: Config, + boot: BootState, + /// Whether the device is held. + open: bool, + /// How often to pet, from the timeout the driver settled on. + interval: Duration, + last_pet: Option, + last_try: Option, + shutting_down: bool, +} + +impl Policy { + pub fn new(config: Config) -> Self { + let boot = match config.boot { + Some(_) => BootState::Pending, + None => BootState::Confirmed, + }; + let interval = pet_interval(config.timeout); + + Self { + config, + boot, + open: false, + interval, + last_pet: None, + last_try: None, + shutting_down: false, + } + } + + pub fn config(&self) -> &Config { + &self.config + } + + pub fn boot_state(&self) -> BootState { + self.boot + } + + /// The unit whose coming up confirms the boot, while that is still being + /// waited for. + pub fn awaiting(&self) -> Option<&str> { + match (self.boot, self.config.boot.as_ref()) { + (BootState::Pending, Some(boot)) => Some(&boot.unit), + _ => None, + } + } + + /// The device is open. Opening it pings it, so that counts as a pet. + /// + /// `applied` is the timeout the driver settled on, which may differ from + /// the one asked for; `None` when it would neither take one nor report + /// its own, and then the interval is short enough for any hardware. + pub fn opened(&mut self, now: Duration, applied: Option) { + self.open = true; + self.last_pet = Some(now); + self.interval = match applied { + Some(secs) if secs > 0 => pet_interval(secs), + _ => UNKNOWN_TIMEOUT_INTERVAL, + }; + } + + /// Opening failed — most likely the device does not exist yet, because + /// its driver is a module nothing has loaded. Tried again one pet + /// interval later. + pub fn open_failed(&mut self, now: Duration) { + self.last_try = Some(now); + } + + /// The machine is going down. The boot deadline stops applying: a boot + /// interrupted by a shutdown is not a boot that hung, and the shutdown has + /// a deadline of its own. Petting continues until the end, because a + /// shutdown that hangs is exactly a hung PID 1. + pub fn begin_shutdown(&mut self) { + self.shutting_down = true; + } + + /// Decide, and record the decision as done. + /// + /// `booted` is whether the boot unit has come up. Called after every + /// wake-up of the loop, including the ones [`Policy::next_wake`] asked + /// for. + pub fn tick(&mut self, now: Duration, booted: bool) -> Tick { + let event = self.update_boot(now, booted); + + let action = if !self.open { + if self.due(self.last_try, now) { + self.last_try = Some(now); + Action::Open + } else { + Action::Nothing + } + } else if self.boot == BootState::Overdue { + // Starved on purpose. Nothing is ever written again, and the + // device stays open so that nothing else can feed it either. + Action::Nothing + } else if self.due(self.last_pet, now) { + self.last_pet = Some(now); + Action::Pet + } else { + Action::Nothing + }; + + Tick { action, event } + } + + /// How long the loop may sleep before [`Policy::tick`] has something to + /// do. `None` when nothing will ever be due again. + pub fn next_wake(&self, now: Duration) -> Option { + let mut soonest: Option = None; + let mut consider = |at: Duration| { + let wait = at.saturating_sub(now); + soonest = Some(soonest.map_or(wait, |s| s.min(wait))); + }; + + if !self.open { + consider(self.last_try.map_or(now, |last| last + self.interval)); + } else if self.boot != BootState::Overdue { + consider(self.last_pet.map_or(now, |last| last + self.interval)); + } + + if let (BootState::Pending, false, Some(boot)) = + (self.boot, self.shutting_down, self.config.boot.as_ref()) + { + consider(boot.within); + } + + soonest + } + + fn due(&self, last: Option, now: Duration) -> bool { + last.is_none_or(|last| now.saturating_sub(last) >= self.interval) + } + + fn update_boot(&mut self, now: Duration, booted: bool) -> Option { + if self.boot != BootState::Pending { + return None; + } + + // Asked first: a unit that came up in the same wake-up as the + // deadline came up in time. + if booted { + self.boot = BootState::Confirmed; + return Some(Event::BootConfirmed); + } + + let within = self.config.boot.as_ref().map(|boot| boot.within)?; + if !self.shutting_down && now >= within { + self.boot = BootState::Overdue; + return Some(Event::BootOverdue); + } + + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn secs(n: u64) -> Duration { + Duration::from_secs(n) + } + + fn config(timeout: u32, boot: Option<(&str, u64)>) -> Config { + Config { + device: DEFAULT_DEVICE.to_owned(), + timeout, + boot: boot.map(|(unit, within)| Boot { + unit: unit.to_owned(), + within: secs(within), + }), + } + } + + fn resolve(text: &str) -> Result, WatchdogError> { + Settings::parse("test", text).and_then(Settings::resolve) + } + + #[test] + fn no_timeout_is_no_watchdog() { + assert_eq!(resolve(""), Ok(None)); + assert_eq!(resolve("device = \"/dev/watchdog1\"\n"), Ok(None)); + } + + #[test] + fn a_zero_timeout_is_no_watchdog() { + assert_eq!(resolve("timeout-sec = \"0s\"\n"), Ok(None)); + } + + #[test] + fn a_timeout_alone_feeds_the_default_device() { + let config = resolve("timeout-sec = \"30s\"\n").unwrap().unwrap(); + assert_eq!(config.device, DEFAULT_DEVICE); + assert_eq!(config.timeout, 30); + assert_eq!(config.boot, None); + } + + #[test] + fn every_key() { + let config = resolve( + "device = \"/dev/watchdog1\"\ntimeout-sec = \"1min\"\n\ + boot-unit = \"boot-ok\"\nboot-sec = \"3min\"\n", + ) + .unwrap() + .unwrap(); + assert_eq!(config.device, "/dev/watchdog1"); + assert_eq!(config.timeout, 60); + assert_eq!( + config.boot, + Some(Boot { + unit: "boot-ok".to_owned(), + within: secs(180) + }) + ); + } + + #[test] + fn the_hardware_counts_whole_seconds() { + assert_eq!( + resolve("timeout-sec = \"1500ms\"\n"), + Err(WatchdogError::Timeout) + ); + assert_eq!( + resolve("timeout-sec = \"100000days\"\n"), + Err(WatchdogError::Timeout) + ); + } + + #[test] + fn a_deadline_and_its_unit_come_together() { + assert_eq!( + resolve("timeout-sec = \"30s\"\nboot-sec = \"3min\"\n"), + Err(WatchdogError::BootWithoutUnit) + ); + assert_eq!( + resolve("timeout-sec = \"30s\"\nboot-unit = \"boot-ok\"\n"), + Err(WatchdogError::UnitWithoutBoot) + ); + } + + #[test] + fn a_zero_deadline_is_no_deadline_even_with_a_unit() { + let config = resolve("timeout-sec = \"30s\"\nboot-unit = \"x\"\nboot-sec = \"0s\"\n") + .unwrap() + .unwrap(); + assert_eq!(config.boot, None); + } + + #[test] + fn unknown_keys_and_bare_numbers_are_refused() { + assert!(matches!( + resolve("timeout = \"30s\"\n"), + Err(WatchdogError::Toml { .. }) + )); + assert!(matches!( + resolve("timeout-sec = 30\n"), + Err(WatchdogError::Toml { .. }) + )); + } + + #[test] + fn the_command_line_wins_key_by_key() { + let file = Settings::parse( + "file", + "timeout-sec = \"30s\"\nboot-unit = \"boot-ok\"\nboot-sec = \"3min\"\n", + ) + .unwrap(); + let cmdline = Settings::from_cmdline( + "root=/dev/vda quiet oxinit.watchdog.boot-sec=45s console=ttyS0", + ) + .unwrap(); + + let config = cmdline.over(file).resolve().unwrap().unwrap(); + assert_eq!(config.timeout, 30, "from the file"); + assert_eq!( + config.boot.unwrap().within, + secs(45), + "from the command line" + ); + } + + #[test] + fn the_command_line_can_turn_it_off() { + let file = Settings::parse("file", "timeout-sec = \"30s\"\n").unwrap(); + let cmdline = Settings::from_cmdline("oxinit.watchdog.timeout-sec=0s").unwrap(); + assert_eq!(cmdline.over(file).resolve(), Ok(None)); + } + + #[test] + fn the_command_line_alone_can_turn_it_on() { + let cmdline = Settings::from_cmdline( + "oxinit.watchdog.timeout-sec=10s oxinit.watchdog.device=/dev/watchdog1", + ) + .unwrap(); + let config = cmdline + .over(Settings::default()) + .resolve() + .unwrap() + .unwrap(); + assert_eq!(config.device, "/dev/watchdog1"); + assert_eq!(config.timeout, 10); + } + + #[test] + fn the_command_line_refuses_typos_under_its_prefix() { + assert!(matches!( + Settings::from_cmdline("oxinit.watchdog.timout-sec=10s"), + Err(WatchdogError::UnknownKey { .. }) + )); + assert!(matches!( + Settings::from_cmdline("oxinit.watchdog.timeout-sec=10"), + Err(WatchdogError::Value { .. }) + )); + // Somebody else's prefix is somebody else's business. + assert_eq!( + Settings::from_cmdline("hideos.watchdog=45 oxinit.debug"), + Ok(Settings::default()) + ); + } + + #[test] + fn pets_twice_per_timeout() { + assert_eq!(pet_interval(30), secs(15)); + assert_eq!(pet_interval(1), Duration::from_millis(500)); + } + + #[test] + fn opens_first_then_pets_on_the_interval() { + let mut policy = Policy::new(config(10, None)); + + assert_eq!(policy.tick(secs(0), false).action, Action::Open); + policy.opened(secs(0), Some(10)); + + assert_eq!(policy.tick(secs(1), false).action, Action::Nothing); + assert_eq!(policy.next_wake(secs(1)), Some(secs(4))); + assert_eq!(policy.tick(secs(5), false).action, Action::Pet); + assert_eq!(policy.tick(secs(6), false).action, Action::Nothing); + assert_eq!(policy.tick(secs(10), false).action, Action::Pet); + } + + #[test] + fn the_interval_follows_the_timeout_the_driver_settled_on() { + let mut policy = Policy::new(config(60, None)); + policy.tick(secs(0), false); + // Asked for 60, the hardware could only do 20. + policy.opened(secs(0), Some(20)); + assert_eq!(policy.next_wake(secs(0)), Some(secs(10))); + } + + #[test] + fn an_unknown_timeout_pets_every_second() { + let mut policy = Policy::new(config(60, None)); + policy.tick(secs(0), false); + policy.opened(secs(0), None); + assert_eq!(policy.next_wake(secs(0)), Some(secs(1))); + } + + #[test] + fn a_missing_device_is_tried_again_an_interval_later() { + let mut policy = Policy::new(config(10, None)); + assert_eq!(policy.tick(secs(0), false).action, Action::Open); + policy.open_failed(secs(0)); + + assert_eq!(policy.tick(secs(2), false).action, Action::Nothing); + assert_eq!(policy.next_wake(secs(2)), Some(secs(3))); + assert_eq!(policy.tick(secs(5), false).action, Action::Open); + } + + #[test] + fn a_boot_that_confirms_in_time_is_fed_for_ever() { + let mut policy = Policy::new(config(10, Some(("boot-ok", 60)))); + policy.tick(secs(0), false); + policy.opened(secs(0), Some(10)); + assert_eq!(policy.awaiting(), Some("boot-ok")); + + let tick = policy.tick(secs(30), true); + assert_eq!(tick.event, Some(Event::BootConfirmed)); + assert_eq!(policy.boot_state(), BootState::Confirmed); + assert_eq!(policy.awaiting(), None); + + // Long after the deadline, still fed. + assert_eq!(policy.tick(secs(3600), false).action, Action::Pet); + } + + #[test] + fn a_boot_that_misses_its_deadline_stops_being_fed() { + let mut policy = Policy::new(config(10, Some(("boot-ok", 60)))); + policy.tick(secs(0), false); + policy.opened(secs(0), Some(10)); + + assert_eq!(policy.tick(secs(55), false).action, Action::Pet); + // The loop is told to wake at the deadline, not a pet later. + assert_eq!(policy.next_wake(secs(58)), Some(secs(2))); + + let tick = policy.tick(secs(60), false); + assert_eq!(tick.event, Some(Event::BootOverdue)); + assert_eq!(tick.action, Action::Nothing); + + // Final: the unit turning up afterwards does not rescue it. + let tick = policy.tick(secs(61), true); + assert_eq!(tick.event, None); + assert_eq!(tick.action, Action::Nothing); + assert_eq!(policy.tick(secs(70), true).action, Action::Nothing); + assert_eq!(policy.next_wake(secs(70)), None); + } + + #[test] + fn coming_up_at_the_deadline_is_coming_up_in_time() { + let mut policy = Policy::new(config(10, Some(("boot-ok", 60)))); + policy.tick(secs(0), false); + policy.opened(secs(0), Some(10)); + assert_eq!( + policy.tick(secs(60), true).event, + Some(Event::BootConfirmed) + ); + } + + #[test] + fn a_shutdown_suspends_the_boot_deadline_but_not_the_feeding() { + let mut policy = Policy::new(config(10, Some(("boot-ok", 60)))); + policy.tick(secs(0), false); + policy.opened(secs(0), Some(10)); + + policy.begin_shutdown(); + let tick = policy.tick(secs(65), false); + assert_eq!(tick.event, None); + assert_eq!(tick.action, Action::Pet); + assert_eq!(policy.next_wake(secs(65)), Some(secs(5))); + } + + #[test] + fn the_deadline_counts_while_the_device_is_missing() { + let mut policy = Policy::new(config(10, Some(("boot-ok", 20)))); + policy.tick(secs(0), false); + policy.open_failed(secs(0)); + + assert_eq!(policy.tick(secs(20), false).event, Some(Event::BootOverdue)); + + // A device that turns up afterwards is opened — which starts it — + // and then never fed. + assert_eq!(policy.tick(secs(25), false).action, Action::Open); + policy.opened(secs(25), Some(10)); + assert_eq!(policy.tick(secs(40), false).action, Action::Nothing); + } + + #[test] + fn power_off_and_halt_disarm_and_a_reboot_keeps_it_running() { + assert_eq!(release(false), Release::Disarm); + assert_eq!(release(true), Release::KeepRunning); + } +} diff --git a/crates/oxinit/Cargo.toml b/crates/oxinit/Cargo.toml index 0cd386b..395a43f 100644 --- a/crates/oxinit/Cargo.toml +++ b/crates/oxinit/Cargo.toml @@ -16,9 +16,11 @@ oxinit-cgroup = { path = "../oxinit-cgroup" } oxinit-graph = { path = "../oxinit-graph" } oxinit-ipc = { path = "../oxinit-ipc" } oxinit-log = { path = "../oxinit-log" } +oxinit-paths = { path = "../oxinit-paths" } oxinit-service = { path = "../oxinit-service" } oxinit-timer = { path = "../oxinit-timer" } oxinit-unit = { path = "../oxinit-unit" } oxinit-user = { path = "../oxinit-user" } +oxinit-watchdog = { path = "../oxinit-watchdog" } rustix = { workspace = true, features = ["event", "fs", "mount", "net", "pipe", "process", "stdio", "system", "termios", "time"] } thiserror.workspace = true diff --git a/crates/oxinit/src/cgroup.rs b/crates/oxinit/src/cgroup.rs index 9d478ab..a335f47 100644 --- a/crates/oxinit/src/cgroup.rs +++ b/crates/oxinit/src/cgroup.rs @@ -45,7 +45,7 @@ impl Cgroups { // decides if delegation could work at all — so it is worth one line at // boot, from the kernel's answer rather than oxinit's intention. if let Ok(placement) = std::fs::read_to_string("/proc/self/cgroup") { - println!("oxinit: pid 1 is in {}", placement.trim()); + println!("oxinit: pid {init} is in {}", placement.trim()); } Ok(Self { diff --git a/crates/oxinit/src/container.rs b/crates/oxinit/src/container.rs index a628792..3e36630 100644 --- a/crates/oxinit/src/container.rs +++ b/crates/oxinit/src/container.rs @@ -1,8 +1,10 @@ -//! Whether oxinit booted a machine or was handed a container. +//! Whether oxinit booted a machine, was handed a container, or is a user's +//! manager. //! //! One thing changes as a direct consequence: on a machine the end of a //! shutdown is `reboot(2)`, and in a container it is exiting, because the -//! machine is not oxinit's to reboot. See [`crate::shutdown`]. +//! machine is not oxinit's to reboot. A user manager is not PID 1 of anything +//! and exits the same way. See [`crate::shutdown`]. //! //! Nothing else keys off this. The console and the mounts each decide on their //! own local evidence — whether stdio is already usable, whether a filesystem @@ -20,6 +22,9 @@ pub enum Environment { Machine, /// PID 1 of a container, as named by whatever evidence found it. Container(Runtime), + /// `oxinit --user`, for the user named here. Not detected: asked for on + /// the command line, by whatever started it. + User(String), } impl Environment { @@ -37,6 +42,15 @@ impl Environment { pub fn is_container(&self) -> bool { matches!(self, Environment::Container(_)) } + + pub fn is_user(&self) -> bool { + matches!(self, Environment::User(_)) + } + + /// Whether a shutdown ends by exiting rather than by `reboot(2)`. + pub fn exits(&self) -> bool { + !matches!(self, Environment::Machine) + } } impl fmt::Display for Environment { @@ -44,6 +58,7 @@ impl fmt::Display for Environment { match self { Environment::Machine => f.write_str("a machine"), Environment::Container(runtime) => write!(f, "a container ({runtime})"), + Environment::User(name) => write!(f, "the user manager for {name}"), } } } diff --git a/crates/oxinit/src/control.rs b/crates/oxinit/src/control.rs index bc5eb04..64d5da8 100644 --- a/crates/oxinit/src/control.rs +++ b/crates/oxinit/src/control.rs @@ -8,18 +8,22 @@ //! //! Authorization is the socket's mode. `0600`, owned by root, in a root-owned //! directory. There is no in-protocol authentication and no per-command -//! permissions: access to this socket is full control of the machine. +//! permissions: access to this socket is full control of the machine. A user +//! manager's is the same thing one level down — `0600`, owned by the user, in +//! their `0700` runtime directory — and is full control of that user's +//! services. //! //! Nothing here blocks. A client connection is registered with epoll like //! every other descriptor, so a client that connects and says nothing costs a //! slot and no time at all. use std::os::fd::{AsFd, BorrowedFd, OwnedFd}; +use std::path::Path; use rustix::fs::Mode; use rustix::net::{AddressFamily, SendFlags, SocketAddrUnix, SocketFlags, SocketType}; -use oxinit_ipc::{CONTROL_PATH, MAX_MESSAGE}; +use oxinit_ipc::MAX_MESSAGE; use crate::error::{Error, Result}; @@ -48,14 +52,15 @@ pub struct Control { } impl Control { - pub fn bind() -> Result { - if let Some(parent) = std::path::Path::new(CONTROL_PATH).parent() { + pub fn bind(path: &Path) -> Result { + if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } // /run is a fresh tmpfs, so a leftover should be impossible. Removed // anyway: the alternative failure is EADDRINUSE on a socket nothing is - // listening to, at boot, which is a miserable thing to debug. - let _ = std::fs::remove_file(CONTROL_PATH); + // listening to, at boot, which is a miserable thing to debug. A user + // manager asks whether one is still answering before it gets here. + let _ = std::fs::remove_file(path); let listener = rustix::net::socket_with( AddressFamily::UNIX, @@ -65,12 +70,12 @@ impl Control { ) .map_err(Error::Control)?; - let addr = SocketAddrUnix::new(CONTROL_PATH).map_err(Error::Control)?; + let addr = SocketAddrUnix::new(path).map_err(Error::Control)?; rustix::net::bind(&listener, &addr).map_err(Error::Control)?; // After bind, because the file does not exist until then. This is the // whole of the authorization story for the socket. - rustix::fs::chmod(CONTROL_PATH, Mode::from_raw_mode(0o600)).map_err(Error::Control)?; + rustix::fs::chmod(path, Mode::from_raw_mode(0o600)).map_err(Error::Control)?; rustix::net::listen(&listener, BACKLOG).map_err(Error::Control)?; diff --git a/crates/oxinit/src/event.rs b/crates/oxinit/src/event.rs index ef79249..688b54f 100644 --- a/crates/oxinit/src/event.rs +++ b/crates/oxinit/src/event.rs @@ -4,8 +4,10 @@ //! is where multiplexing starts. Still one thread, still no async runtime. use std::os::fd::{AsFd, OwnedFd}; +use std::time::Duration; use rustix::event::epoll; +use rustix::time::Timespec; use crate::error::{Error, Result}; @@ -146,18 +148,28 @@ impl EventLoop { .map_err(Error::Epoll) } - /// Block until something is ready, then report what. + /// Block until something is ready, or until `timeout` passes, then + /// report what. A timeout that passes reports nothing. + /// + /// The timeout exists for the hardware watchdog, which is fed from the + /// loop itself rather than from an alarm on the deadline heap. Everything + /// else that is time-based arrives as a timerfd event and needs none. /// /// `EINTR` cannot happen here — every signal is blocked — but it is /// handled rather than assumed away. - pub fn wait(&mut self, out: &mut Vec) -> Result<()> { + pub fn wait(&mut self, out: &mut Vec, timeout: Option) -> Result<()> { out.clear(); self.events.clear(); + let timeout = timeout.map(|timeout| Timespec { + tv_sec: i64::try_from(timeout.as_secs()).unwrap_or(i64::MAX), + tv_nsec: i64::from(timeout.subsec_nanos()), + }); + match epoll::wait( &self.epoll, rustix::buffer::spare_capacity(&mut self.events), - None, + timeout.as_ref(), ) { Ok(_) => {} Err(rustix::io::Errno::INTR) => return Ok(()), diff --git a/crates/oxinit/src/logs.rs b/crates/oxinit/src/logs.rs index f843d9d..6738c88 100644 --- a/crates/oxinit/src/logs.rs +++ b/crates/oxinit/src/logs.rs @@ -20,6 +20,7 @@ use std::io::IoSlice; use std::mem::MaybeUninit; use std::os::fd::{AsFd, BorrowedFd, OwnedFd}; +use std::path::Path; use rustix::fs::Mode; use rustix::net::{ @@ -27,8 +28,6 @@ use rustix::net::{ SocketFlags, SocketType, }; -use oxinit_log::SOCKET_PATH; - use crate::error::{Error, Result}; /// How much output may sit in a pipe before the service writing to it blocks. @@ -61,11 +60,13 @@ pub struct Logs { } impl Logs { - pub fn bind() -> Result { - if let Some(parent) = std::path::Path::new(SOCKET_PATH).parent() { + /// Bind the socket `oxlogd` connects to: `/run/oxinit/log.sock` for the + /// system, under the user's runtime directory for a user manager. + pub fn bind(path: &Path) -> Result { + if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let _ = std::fs::remove_file(SOCKET_PATH); + let _ = std::fs::remove_file(path); let listener = rustix::net::socket_with( AddressFamily::UNIX, @@ -75,13 +76,13 @@ impl Logs { ) .map_err(Error::Logs)?; - let addr = SocketAddrUnix::new(SOCKET_PATH).map_err(Error::Logs)?; + let addr = SocketAddrUnix::new(path).map_err(Error::Logs)?; rustix::net::bind(&listener, &addr).map_err(Error::Logs)?; // Same story as the control socket: whoever can open this receives // every service's output, and the mode is the whole of what stops // them. - rustix::fs::chmod(SOCKET_PATH, Mode::from_raw_mode(0o600)).map_err(Error::Logs)?; + rustix::fs::chmod(path, Mode::from_raw_mode(0o600)).map_err(Error::Logs)?; rustix::net::listen(&listener, BACKLOG).map_err(Error::Logs)?; Ok(Self { diff --git a/crates/oxinit/src/main.rs b/crates/oxinit/src/main.rs index d6037ab..2098e01 100644 --- a/crates/oxinit/src/main.rs +++ b/crates/oxinit/src/main.rs @@ -46,11 +46,15 @@ mod shutdown; mod supervisor; mod sys; mod timer; +mod user; +mod watchdog; use std::os::fd::OwnedFd; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::process::{Child, ExitCode}; +use oxinit_paths::Paths; + use crate::error::{Error, Result}; /// The signals oxinit acts on. Everything else is read and discarded so it @@ -83,16 +87,33 @@ const SIGUSR2: u32 = libc::SIGUSR2 as u32; /// it with. const SIGPWR: u32 = libc::SIGPWR as u32; +/// Nothing, to PID 1. To a user manager, the session it belonged to has gone: +/// stop everything and exit. +const SIGHUP: u32 = libc::SIGHUP as u32; + fn main() -> ExitCode { - // Before the pid check, deliberately. `oxinit --version` has to work for - // somebody holding a binary and asking what it is, and that person is - // exactly the one who cannot run it as PID 1 to find out. - if std::env::args().any(|arg| arg == "--version" || arg == "-V") { + let init = rustix::process::getpid().is_init(); + + // `oxinit --version` has to work for somebody holding a binary and asking + // what it is, who is exactly the one who cannot run it as PID 1 to find + // out. Never as PID 1, though: the kernel hands init every command line + // word it does not recognise, and a stray `--version` there would return + // from `main` — which panics the kernel. + if !init && std::env::args().any(|arg| arg == "--version" || arg == "-V") { println!("oxinit {}", env!("CARGO_PKG_VERSION")); return ExitCode::SUCCESS; } - if !rustix::process::getpid().is_init() { + // A user's manager: the same supervisor, run by a user for their own + // services. See `user`. Never as PID 1, whatever argv says: the kernel + // hands init every command line word it does not recognise, and a stray + // `--user` there must not turn the machine's init into something that + // returns from `main`. + if !init && std::env::args().skip(1).any(|arg| arg == "--user") { + return user::boot(); + } + + if !init { eprintln!( "oxinit: running as pid {}, not 1. oxinit is an init system; \ it is not meant to be run from a shell.", @@ -159,7 +180,9 @@ fn boot() -> ! { } }; - let notify = match notify::Notify::bind() { + let paths = Paths::system(); + + let notify = match notify::Notify::bind(&paths.notify()) { Ok(notify) => notify, Err(e) => { eprintln!("oxinit: {e}"); @@ -170,7 +193,7 @@ fn boot() -> ! { // Not fatal. A machine with no control socket still boots and still // supervises; it just cannot be asked anything until the next boot, which // beats not booting. - let mut control = match control::Control::bind() { + let mut control = match control::Control::bind(&paths.control()) { Ok(control) => Some(control), Err(e) => { eprintln!("oxinit: {e}"); @@ -194,7 +217,7 @@ fn boot() -> ! { // Without a log socket a machine still boots and still supervises. What // stops working is `output = "log"`, and it says so against the unit that // asked for it rather than as one fatal error here. - let logs = match logs::Logs::bind() { + let logs = match logs::Logs::bind(&paths.log_socket()) { Ok(logs) => logs, Err(e) => { eprintln!("oxinit: {e}"); @@ -203,40 +226,20 @@ fn boot() -> ! { } }; - let mut supervisor = supervisor::Supervisor::load(&hostname, timers, notify, cgroups, logs); - - let registered = events - .register(&signals, event::Source::Signals) - .and_then(|()| events.register(supervisor.timers.as_fd(), event::Source::Timer)) - .and_then(|()| events.register(supervisor.timers.wall_fd(), event::Source::WallTimer)) - .and_then(|()| events.register(supervisor.notify.as_fd(), event::Source::Notify)); - - if let Some(control) = control.as_ref() { - if let Err(e) = events.register(control.as_fd(), event::Source::Control) { - eprintln!("oxinit: {e}"); - } - } + let mut supervisor = supervisor::Supervisor::load( + &hostname, + supervisor::UnitSource::system(), + timers, + notify, + cgroups, + logs, + ); - if let Some(fd) = supervisor.logs.listener() { - if let Err(e) = events.register(fd, event::Source::LogSocket) { - eprintln!("oxinit: {e}"); - } - } - - if let Err(e) = registered { + if let Err(e) = wire(&events, &signals, &supervisor, control.as_ref()) { eprintln!("oxinit: {e}"); fallback(Some(&signals)); } - // Every service cgroup, registered once and never touched again. The - // hierarchy is built before anything starts precisely so this is one pass - // rather than an epoll call on every start and stop. - for (id, fd) in supervisor.cgroups.events() { - if let Err(e) = events.register_pri(fd, event::Source::Cgroup(id)) { - eprintln!("oxinit: {e}"); - } - } - // With nothing to supervise, keep the machine usable the way M0 did. let mut shell = if supervisor.is_empty() { eprintln!("oxinit: nothing to start; falling back to a console shell"); @@ -251,6 +254,17 @@ fn boot() -> ! { // eagerly is never activated a second time by a connection. supervisor.sync_sockets(&events); + // Last, so the first write to the device is the loop's own first turn. + // A watchdog an initramfs left running keeps counting until then, which + // is why nothing slow sits between here and `run`. + let mut watchdog = watchdog::Watchdog::configure(&environment); + if let Some(watchdog) = watchdog.as_ref() { + let known = watchdog + .awaiting() + .is_none_or(|unit| supervisor.knows(unit)); + watchdog.check_boot_unit(known); + } + run( &mut events, &signals, @@ -258,9 +272,52 @@ fn boot() -> ! { &mut shell, &mut control, &environment, + &mut watchdog, ) } +/// Register everything the loop watches. +/// +/// The signalfd, the timers and the notify socket are the loop: failing to +/// register any of them is returned, and the caller cannot go on. The control +/// socket, the log socket and the cgroups each cost one feature when they +/// fail, and say so. +fn wire( + events: &event::EventLoop, + signals: &OwnedFd, + supervisor: &supervisor::Supervisor, + control: Option<&control::Control>, +) -> Result<()> { + events + .register(signals, event::Source::Signals) + .and_then(|()| events.register(supervisor.timers.as_fd(), event::Source::Timer)) + .and_then(|()| events.register(supervisor.timers.wall_fd(), event::Source::WallTimer)) + .and_then(|()| events.register(supervisor.notify.as_fd(), event::Source::Notify))?; + + if let Some(control) = control { + if let Err(e) = events.register(control.as_fd(), event::Source::Control) { + eprintln!("oxinit: {e}"); + } + } + + if let Some(fd) = supervisor.logs.listener() { + if let Err(e) = events.register(fd, event::Source::LogSocket) { + eprintln!("oxinit: {e}"); + } + } + + // Every service cgroup, registered once and never touched again. The + // hierarchy is built before anything starts precisely so this is one pass + // rather than an epoll call on every start and stop. + for (id, fd) in supervisor.cgroups.events() { + if let Err(e) = events.register_pri(fd, event::Source::Cgroup(id)) { + eprintln!("oxinit: {e}"); + } + } + + Ok(()) +} + /// One line for the mounts oxinit did not have to do, one for the ones it was /// not allowed to do, and one per genuine failure. /// @@ -301,15 +358,31 @@ fn run( shell: &mut Option, control: &mut Option, environment: &container::Environment, + watchdog: &mut Option, ) -> ! { let mut sources = Vec::new(); let mut pending = Vec::new(); loop { - if let Err(e) = events.wait(&mut sources) { + // Woken for the watchdog even when nothing else happens. With none + // configured this sleeps until an event, as it always did. + // And for a shutdown's deadline, which no event marks. + let timeout = [ + watchdog.as_ref().and_then(watchdog::Watchdog::wait_bound), + supervisor.wait_bound(), + ] + .into_iter() + .flatten() + .min(); + + // Falling back stops the watchdog being fed, and that is deliberate: + // a PID 1 that cannot run its loop is the hang the watchdog exists + // for. A machine that has one configured resets, rather than waiting + // on the console for someone who may never come. + if let Err(e) = events.wait(&mut sources, timeout) { eprintln!("oxinit: {e}"); // A failing epoll would spin this loop at full speed. - fallback(Some(signals)); + cannot_continue(signals, environment); } for source in &sources { @@ -319,7 +392,9 @@ fn run( // can leave that state stale, which is survivable — the next event // corrects it — where letting the panic escape is not. let result = catch_unwind(AssertUnwindSafe(|| match source { - event::Source::Signals => on_signals(signals, supervisor, shell, &mut pending), + event::Source::Signals => { + on_signals(signals, supervisor, shell, &mut pending, environment) + } event::Source::Timer => supervisor.on_timer(), event::Source::WallTimer => supervisor.on_wall_timer(), event::Source::Notify => supervisor.on_notify(), @@ -353,18 +428,34 @@ fn run( eprintln!("oxinit: socket reconciliation panicked; continuing"); } + // After everything else, so the boot unit's state is this wake-up's. + // Here rather than in a handler or as an alarm on the deadline heap: + // what feeds the watchdog is the loop coming round, and nothing a + // handler does — including panicking — can stand in for that. + if let Some(watchdog) = watchdog.as_mut() { + let result = catch_unwind(AssertUnwindSafe(|| { + let booted = watchdog + .awaiting() + .is_some_and(|unit| supervisor.reached(unit)); + watchdog.tick(booted, supervisor.shutting_down()); + })); + if result.is_err() { + eprintln!("oxinit: watchdog panicked; continuing"); + } + } + // A shutdown finishes on the same events everything else does: a unit // stops, its cgroup empties, and this is where that turns out to have // been the last one. if let Some(action) = supervisor.settled() { - if let Err(e) = shutdown::finalize(action, environment) { + if let Err(e) = shutdown::finalize(action, environment, watchdog.as_mut()) { // The kernel refused, and this is a machine — a container // exits inside `finalize` and never reaches here. PID 1 // exiting is a kernel panic, so the only option left is to // stay up and say so. eprintln!("oxinit: {action}: {e}"); eprintln!("oxinit: cannot go down; staying up with everything stopped"); - fallback(Some(signals)); + cannot_continue(signals, environment); } } } @@ -375,6 +466,7 @@ fn on_signals( supervisor: &mut supervisor::Supervisor, shell: &mut Option, pending: &mut Vec, + environment: &container::Environment, ) { if let Err(e) = sys::raw::read_signals(signals, pending).map_err(Error::ReadSignalFd) { eprintln!("oxinit: {e}"); @@ -403,6 +495,14 @@ fn on_signals( supervisor.on_sigchld(); } + // A user manager has nothing to power off, reboot or halt. Every + // signal that would ask a machine to go down asks it to stop its + // units and exit, and so does the hangup of the session it was + // started from. + SIGTERM | SIGINT | SIGPWR | SIGUSR1 | SIGHUP if environment.is_user() => { + supervisor.begin_shutdown(shutdown::Action::PowerOff) + } + SIGTERM | SIGPWR => supervisor.begin_shutdown(shutdown::Action::PowerOff), SIGINT => supervisor.begin_shutdown(shutdown::Action::Reboot), SIGUSR1 => supervisor.begin_shutdown(shutdown::Action::Halt), @@ -512,6 +612,15 @@ fn open_signalfd() -> Result { sys::raw::signalfd_all().map_err(Error::SignalFd) } +/// The loop cannot go on. PID 1 falls back to a console shell; a user manager +/// is not PID 1, and exits. +fn cannot_continue(signals: &OwnedFd, environment: &container::Environment) -> ! { + if environment.is_user() { + user::give_up() + } + fallback(Some(signals)) +} + /// Last resort. The loop cannot continue, so give an operator a shell and keep /// reaping. PID 1 exiting is a kernel panic, so this never returns. fn fallback(signals: Option<&OwnedFd>) -> ! { diff --git a/crates/oxinit/src/notify.rs b/crates/oxinit/src/notify.rs index 7d3af7b..2a2d706 100644 --- a/crates/oxinit/src/notify.rs +++ b/crates/oxinit/src/notify.rs @@ -20,26 +20,26 @@ use oxinit_service::notify::{parse, Message}; use crate::error::{Error, Result}; -/// Where services send readiness. Also the value of `NOTIFY_SOCKET`. -pub const NOTIFY_PATH: &str = "/run/oxinit/notify"; - pub struct Notify { socket: OwnedFd, } impl Notify { - pub fn bind() -> Result { - if let Some(parent) = Path::new(NOTIFY_PATH).parent() { + /// Bind the socket services send readiness to. `path` is also the value + /// of `NOTIFY_SOCKET`: `/run/oxinit/notify` for the system, under the + /// user's runtime directory for a user manager. + pub fn bind(path: &Path) -> Result { + if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } // A stale socket from a previous boot would make bind fail with // EADDRINUSE. /run is a fresh tmpfs so this is belt and braces. - let _ = std::fs::remove_file(NOTIFY_PATH); + let _ = std::fs::remove_file(path); let socket = rustix::net::socket(AddressFamily::UNIX, SocketType::DGRAM, None) .map_err(Error::Notify)?; - let addr = SocketAddrUnix::new(NOTIFY_PATH).map_err(Error::Notify)?; + let addr = SocketAddrUnix::new(path).map_err(Error::Notify)?; rustix::net::bind(&socket, &addr).map_err(Error::Notify)?; // Without SO_PASSCRED the kernel attaches no credentials and every diff --git a/crates/oxinit/src/shutdown.rs b/crates/oxinit/src/shutdown.rs index fdc4cee..6462a8f 100644 --- a/crates/oxinit/src/shutdown.rs +++ b/crates/oxinit/src/shutdown.rs @@ -17,6 +17,7 @@ use std::time::{Duration, Instant}; use rustix::system::RebootCommand; use crate::container::Environment; +use crate::watchdog::Watchdog; /// How long the whole shutdown may take before oxinit stops waiting for units /// and goes down anyway. @@ -92,6 +93,12 @@ impl Shutdown { pub fn overdue(&self) -> bool { self.started.elapsed() > DEADLINE } + + /// Until [`Shutdown::overdue`] turns true, and a moment past it: woken + /// exactly at the deadline, `overdue` is still false. + pub fn remaining(&self) -> Duration { + DEADLINE.saturating_sub(self.started.elapsed()) + Duration::from_millis(10) + } } /// Flush, seal, and go down. Does not return unless the kernel refuses. @@ -99,9 +106,17 @@ impl Shutdown { /// The order matters and is the whole of it: `sync` first, because a /// filesystem that has not been flushed is what makes a reboot lose data; /// then remount read-only, so anything still holding a write cannot make more. -pub fn finalize(action: Action, environment: &Environment) -> rustix::io::Result<()> { - if environment.is_container() { - exit(action) +/// +/// The hardware watchdog is let go last, after the filesystems are sealed: +/// disarmed for a power off or a halt, left running into a reboot. Anything +/// that hangs before that point is still a hang the watchdog ends. +pub fn finalize( + action: Action, + environment: &Environment, + watchdog: Option<&mut Watchdog>, +) -> rustix::io::Result<()> { + if environment.exits() { + exit(action, environment) } println!("oxinit: syncing"); @@ -114,18 +129,24 @@ pub fn finalize(action: Action, environment: &Environment) -> rustix::io::Result eprintln!("oxinit: remount / read-only: {e}"); } + if let Some(watchdog) = watchdog { + watchdog.release(action); + } + println!("oxinit: {action}"); rustix::system::reboot(action.command()) } -/// The end of a shutdown in a container. +/// The end of a shutdown in a container, or of a user manager. /// -/// **The one place oxinit exits, and the reason the rule is written as an -/// absolute everywhere else.** PID 1 exiting is a kernel panic — on a machine. -/// In a container it is the entire contract: the container is up for exactly as -/// long as its PID 1 is, so a supervisor that has stopped every unit and then -/// stays running is a container that will not stop, and `docker stop` ends it -/// with `SIGKILL` ten seconds later. +/// **The one place oxinit exits on purpose, and the reason the rule is +/// written as an absolute everywhere else.** PID 1 exiting is a kernel panic +/// — on a machine. In a container it is the entire contract: the container is +/// up for exactly as long as its PID 1 is, so a supervisor that has stopped +/// every unit and then stays running is a container that will not stop, and +/// `docker stop` ends it with `SIGKILL` ten seconds later. A user manager is +/// not PID 1 of anything, and a stopped one that stayed running would be a +/// process its session has to kill. /// /// Reached only from the end of an ordered shutdown, with every unit already /// stopped. No error path leads here; a container whose event loop has failed @@ -133,11 +154,18 @@ pub fn finalize(action: Action, environment: &Environment) -> rustix::io::Result /// the whole thing down. /// /// There is no `sync` and no remount. The filesystems belong to the runtime, -/// oxinit is not permitted to seal them, and flushing the host's disks from -/// inside a container is not its call to make. -fn exit(action: Action) -> ! { - let status = action.status(); - println!("oxinit: {action}: exiting with status {status}"); +/// or to the machine, and neither is a container's or a user's to seal. +fn exit(action: Action, environment: &Environment) -> ! { + // A user manager has no power off or reboot to report, only that it is + // done; every signal that stops it asks for the same thing. + let status = if environment.is_user() { + println!("oxinit: {environment}: everything stopped; exiting"); + 0 + } else { + let status = action.status(); + println!("oxinit: {action}: exiting with status {status}"); + status + }; std::process::exit(status) } diff --git a/crates/oxinit/src/supervisor.rs b/crates/oxinit/src/supervisor.rs index 7093559..b5055d9 100644 --- a/crates/oxinit/src/supervisor.rs +++ b/crates/oxinit/src/supervisor.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::os::fd::{BorrowedFd, OwnedFd}; +use std::path::{Path, PathBuf}; use std::process::{Child, Command}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -11,14 +12,14 @@ use rustix::process::{Pid, Signal}; use oxinit_cgroup::Cgroup; use oxinit_ipc::{Request, Response, UnitStatus}; use oxinit_service::{Exit, Instance, State, StopCause}; -use oxinit_unit::{Kind, Output, Resources, ServiceType, Unit}; +use oxinit_unit::{Kind, Output, Resources, Scope, ServiceType, Unit}; use crate::cgroup::{self, Cgroups}; use crate::error::Error; use crate::event::{EventLoop, Source}; use crate::listen::Sockets; use crate::logs::Logs; -use crate::notify::{self, Notify}; +use crate::notify::Notify; use crate::reap; use crate::shutdown::{Action, Shutdown}; use crate::sys::raw::{setup_child, ChildSetup, Image, FIRST_LISTEN_FD}; @@ -30,6 +31,10 @@ pub struct Supervisor { /// Kept for `reload`, which has to expand `%H` the same way the first /// load did or the same file would parse into a different unit. hostname: String, + /// Where the units are, which manager reads them, and where the sockets + /// its services are told about live. Kept for `reload` and for every + /// start, for the same reason. + source: UnitSource, /// Resolved start order. Also the order things are reported in. order: Vec, pub timers: Timers, @@ -58,17 +63,51 @@ pub struct Supervisor { shutdown: Option, } +/// Which units, read how, for which manager. +pub struct UnitSource { + /// Lowest precedence first. Each replaces a file of the same name in the + /// ones before it, wholly. + pub dirs: Vec, + pub scope: Scope, + /// The value of `NOTIFY_SOCKET` for a `notify` service. + pub notify: PathBuf, +} + +impl UnitSource { + /// PID 1's: `/usr/lib/oxinit/units`, then `/etc/oxinit/units`. + pub fn system() -> Self { + Self { + dirs: vec![ + PathBuf::from(oxinit_unit::VENDOR_DIR), + PathBuf::from(oxinit_unit::ETC_DIR), + ], + scope: Scope::System, + notify: PathBuf::from(oxinit_paths::system::NOTIFY), + } + } + + fn load(&self, hostname: &str) -> oxinit_unit::Loaded { + let dirs: Vec<&Path> = self.dirs.iter().map(PathBuf::as_path).collect(); + oxinit_unit::load_dirs_in(&dirs, hostname, &self.scope) + } + + fn is_user(&self) -> bool { + matches!(self.scope, Scope::User { .. }) + } +} + impl Supervisor { /// Load and resolve. Starting is a separate step so the caller can /// register the timerfd with epoll first. pub fn load( hostname: &str, + source: UnitSource, timers: Timers, notify: Notify, cgroups: Cgroups, logs: Logs, ) -> Self { - let loaded = oxinit_unit::load_default(hostname); + let loaded = source.load(hostname); for error in &loaded.errors { eprintln!("oxinit: {error}"); } @@ -91,6 +130,7 @@ impl Supervisor { next_elapse: BTreeMap::new(), shutdown: None, hostname: hostname.to_owned(), + source, }; } @@ -122,6 +162,7 @@ impl Supervisor { next_elapse: BTreeMap::new(), shutdown: None, hostname: hostname.to_owned(), + source, }; // An instance for every unit that loaded, not just the ones in the @@ -164,6 +205,18 @@ impl Supervisor { self.order.is_empty() } + /// Whether a unit by this name was loaded. + pub fn knows(&self, name: &str) -> bool { + self.instances.contains_key(name) + } + + /// Whether a unit has ever come up. See `Instance::reached`. + pub fn reached(&self, name: &str) -> bool { + self.instances + .get(name) + .is_some_and(|instance| instance.reached) + } + /// Queue every unit in the resolved order, then start whatever is ready. /// /// Not a loop over `order` calling `start`. `start` returns as soon as the @@ -331,6 +384,14 @@ impl Supervisor { self.shutdown.is_some() } + /// How long the loop may sleep before [`Supervisor::settled`] has to be + /// asked again: the shutdown's deadline. `settled` is otherwise asked + /// only when an event arrives, and a unit that never stops sends none — + /// the machine then waited for its backstop forever. + pub fn wait_bound(&self) -> Option { + self.shutdown.as_ref().map(Shutdown::remaining) + } + /// What to do now, if anything: `Some(action)` once every unit has /// stopped, or once waiting for the stragglers has gone on long enough. pub fn settled(&self) -> Option { @@ -448,10 +509,11 @@ impl Supervisor { let cgroup = self.cgroups.get(name); let listen = self.sockets.for_service(name); + let source = &self.source; let spawned = self .instances .get(name) - .map(|instance| spawn(&instance.unit, cgroup, &listen, output)); + .map(|instance| spawn(&instance.unit, source, cgroup, &listen, output)); let Some(spawned) = spawned else { return; @@ -574,15 +636,17 @@ impl Supervisor { return; }; + // Nothing fires once the machine is going down: `start` would refuse + // the service anyway, and saying it was being started — or announcing + // the next firing — would be a log line describing something that + // does not happen. + if self.shutting_down() { + return; + } + let service = timer.service.clone(); let schedule = timer.schedule.clone(); - - // Not re-armed once the machine is going down. The firing itself would - // be refused by `start`, so this is only about not announcing a - // schedule that will never be kept. - if !self.shutting_down() { - self.arm(name, &service, schedule.next(unix_now())); - } + self.arm(name, &service, schedule.next(unix_now())); if !self.instances.contains_key(&service) { eprintln!("oxinit: {name}: no unit named `{service}` to start"); @@ -686,8 +750,15 @@ impl Supervisor { } None if deactivating => { // Mid-stop the cgroup emptying is what ends the unit, not - // this exit — unless there is no cgroup to wait on. - if self.cgroups.get(&name).is_none() { + // this exit — unless there is no cgroup to wait on, or it + // emptied long ago: a daemon that left it took the + // `populated` event with it, and this exit is the last + // word there will be. + let waiting = self + .cgroups + .get(&name) + .is_some_and(|cgroup| cgroup.populated().unwrap_or(false)); + if !waiting { self.finish_deactivation(&name); } } @@ -907,7 +978,20 @@ impl Supervisor { instance.entered_deactivating(cause); let signalled = match self.cgroups.get(name) { - Some(cgroup) => terminate_cgroup(cgroup), + Some(cgroup) => { + let mut signalled = terminate_cgroup(cgroup); + // A daemon can move itself out of the cgroup it was started + // in — elogind does, into the hierarchy it manages. The + // cgroup is then empty and the process oxinit forked is + // elsewhere, still running; signalling the cgroup alone + // reached nothing and the stop waited for an exit that was + // never asked for, holding the shutdown open. + if let Some(pid) = child.filter(|pid| left_its_cgroup(cgroup, *pid)) { + eprintln!("oxinit: {name}: pid {pid} is no longer in its cgroup"); + signalled += usize::from(terminate(pid)); + } + signalled + } // No cgroup to walk. The one pid oxinit knows about is all there // is to work with. None => child.map_or(0, |pid| usize::from(terminate(pid))), @@ -961,9 +1045,21 @@ impl Supervisor { match self.cgroups.get(name) { Some(cgroup) => { - if let Err(e) = cgroup.kill() { + let killed = cgroup.kill(); + if let Err(e) = &killed { eprintln!("oxinit: {name}: {e}"); } + // The process oxinit forked, if `cgroup.kill` cannot have + // reached it: it moved out of the cgroup, or the cgroup is + // gone. Still oxinit's child, not yet reaped, so the pid + // cannot have been reused. + if let Some(pid) = + child.filter(|pid| killed.is_err() || left_its_cgroup(cgroup, *pid)) + { + if let Some(pid) = Pid::from_raw(pid as i32) { + let _ = rustix::process::kill_process(pid, Signal::KILL); + } + } } None => { // Without a cgroup this can only reach the one process oxinit @@ -1150,7 +1246,7 @@ impl Supervisor { /// re-bound: its descriptors were bound and registered with epoll once, at /// boot, and changing that safely is not something a reload can do. fn reload(&mut self) -> Response { - let loaded = oxinit_unit::load_default(&self.hostname); + let loaded = self.source.load(&self.hostname); if !loaded.errors.is_empty() { let problems: Vec = loaded.errors.iter().map(ToString::to_string).collect(); @@ -1421,6 +1517,28 @@ fn terminate_cgroup(cgroup: &Cgroup) -> usize { .count() } +/// Whether `pid` is outside `cgroup` and everything below it, from the +/// process's own `/proc/PID/cgroup` — `cgroup.procs` lists only the cgroup +/// itself, and a delegated unit's processes live in cgroups beneath it. +/// `false` when that cannot be read: the process is gone, and there is +/// nothing to signal. +fn left_its_cgroup(cgroup: &Cgroup, pid: u32) -> bool { + let Ok(text) = std::fs::read_to_string(format!("/proc/{pid}/cgroup")) else { + return false; + }; + let Some(path) = text.lines().find_map(|line| line.strip_prefix("0::")) else { + return false; + }; + // The hierarchy's mount point is not part of what /proc reports, so the + // comparison is of the tail: the unit's cgroup is an ancestor of the + // process's, or the process's own. + !std::path::Path::new(path) + .ancestors() + .filter_map(|ancestor| ancestor.strip_prefix("/").ok()) + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + .any(|ancestor| cgroup.path().ends_with(ancestor)) +} + fn terminate(pid: u32) -> bool { i32::try_from(pid) .ok() @@ -1458,6 +1576,7 @@ fn is_idle(state: State) -> bool { /// one came from, in the order it receives them. fn spawn( unit: &Unit, + source: &UnitSource, cgroup: Option<&Cgroup>, listen: &[(&str, BorrowedFd<'_>)], output: Option, @@ -1468,7 +1587,10 @@ fn spawn( // and exec there is no reading /etc/passwd and no allocating. Everything // that can fail fails now, where it can be reported against the unit that // asked for it. - let identity = if service.user == oxinit_user::ROOT { + // + // A user manager drops nothing: it already is the user, and has no + // privilege to change to anyone else. Its units cannot name a `user`. + let identity = if service.user == oxinit_user::ROOT || source.is_user() { None } else { Some(oxinit_user::resolve_system(&service.user).map_err(|e| e.to_string())?) @@ -1489,7 +1611,10 @@ fn spawn( // The protocol is opt-in by type: a service that did not ask for it should // not find NOTIFY_SOCKET in its environment. if service.ty == ServiceType::Notify { - env.push(("NOTIFY_SOCKET".to_owned(), notify::NOTIFY_PATH.to_owned())); + env.push(( + "NOTIFY_SOCKET".to_owned(), + source.notify.to_string_lossy().into_owned(), + )); // Services are expected to ping at roughly half this, so the interval // is passed through unchanged and the halving is their business. diff --git a/crates/oxinit/src/sys/raw.rs b/crates/oxinit/src/sys/raw.rs index 3da9ef7..43910b6 100644 --- a/crates/oxinit/src/sys/raw.rs +++ b/crates/oxinit/src/sys/raw.rs @@ -1,18 +1,19 @@ //! The only `unsafe` in the crate. //! -//! rustix covers every syscall oxinit needs except three: `sigprocmask` +//! rustix covers every syscall oxinit needs except four: `sigprocmask` //! (exposed only under rustix's unstable `runtime` feature), `signalfd` -//! (listed in rustix's own `not_implemented.rs`), and what a child does -//! between `fork` and `exec`, which is not a syscall at all but a closure the +//! (listed in rustix's own `not_implemented.rs`), the two watchdog `ioctl`s, +//! which rustix can issue but cannot type, and what a child does between +//! `fork` and `exec`, which is not a syscall at all but a closure the //! standard library runs in a process that may only call async-signal-safe -//! functions. All three are here, wrapped, and nothing outside this module -//! touches libc. +//! functions. All of them are here, wrapped, and nothing outside this module +//! touches libc or an `ioctl`. //! //! Every `unsafe` block below states its invariant. use std::io; use std::mem::MaybeUninit; -use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; +use std::os::fd::{AsRawFd as _, BorrowedFd, FromRawFd as _, OwnedFd}; use std::os::unix::process::CommandExt as _; use std::process::Command; use std::ptr; @@ -99,6 +100,42 @@ pub fn read_signals(fd: &OwnedFd, out: &mut Vec) -> Result<(), Errno> { Ok(()) } +/// `WDIOC_SETTIMEOUT` from ``: `_IOWR('W', 6, int)`. +const WDIOC_SETTIMEOUT: rustix::ioctl::Opcode = + rustix::ioctl::opcode::read_write::(b'W', 6); + +/// `WDIOC_GETTIMEOUT`: `_IOR('W', 7, int)`. +const WDIOC_GETTIMEOUT: rustix::ioctl::Opcode = rustix::ioctl::opcode::read::(b'W', 7); + +/// Ask a watchdog for a timeout, in seconds. Returns the one the driver +/// settled on, which it may have rounded to what the hardware can count. +pub fn watchdog_set_timeout(device: BorrowedFd<'_>, seconds: i32) -> Result { + let mut value: libc::c_int = seconds; + + // SAFETY: WDIOC_SETTIMEOUT is the opcode the kernel defines for this + // request, and its argument is a pointer to an `int`, which `value` is. + // The kernel writes the timeout it applied back through the same pointer, + // and `value` lives on this frame until the call has returned. + unsafe { + let updater = rustix::ioctl::Updater::::new(&mut value); + rustix::ioctl::ioctl(device, updater)?; + } + + Ok(value) +} + +/// The timeout a watchdog is running with, in seconds. +pub fn watchdog_get_timeout(device: BorrowedFd<'_>) -> Result { + // SAFETY: WDIOC_GETTIMEOUT is the opcode the kernel defines for this + // request, and what it writes through its argument is one `int`, which is + // the output type given here; the getter owns that storage for the + // duration of the call. + unsafe { + let getter = rustix::ioctl::Getter::::new(); + rustix::ioctl::ioctl(device, getter) + } +} + /// What a service's process does between `fork` and `exec`. /// /// Both steps have to happen here, in that order, and neither can be left to diff --git a/crates/oxinit/src/user.rs b/crates/oxinit/src/user.rs new file mode 100644 index 0000000..d13c783 --- /dev/null +++ b/crates/oxinit/src/user.rs @@ -0,0 +1,235 @@ +//! `oxinit --user`: a user's own service manager. +//! +//! The same supervisor as PID 1 — the same units, graph, state machine, +//! timers, sockets, `sd_notify`, control protocol and log pipes — run by a +//! user for their own services: PipeWire, WirePlumber, anything a desktop +//! session expects to find running. One per logged-in user. +//! +//! What it is not is the rest of PID 1. It mounts nothing, owns no console, +//! sets no hostname, feeds no watchdog and reboots nothing; it changes no +//! one's identity, because it has no one else to become; and its shutdown +//! ends by exiting, like a container's. Starting it is not its business +//! either: oxinit does not manage logins or sessions, so whatever starts a +//! session starts this — and keeps it to one per user, which the control +//! socket enforces. See ARCHITECTURE.md. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use rustix::fs::Mode; +use rustix::net::{AddressFamily, SocketAddrUnix, SocketFlags, SocketType}; + +use oxinit_paths::Paths; +use oxinit_unit::Scope; + +use crate::container::Environment; +use crate::supervisor::{Supervisor, UnitSource}; +use crate::{cgroup, control, event, logs, notify, timer}; + +/// Start a user manager. Returns only if it cannot start, or if one is +/// already running for this user. +/// +/// Until the loop starts, every failure is an exit with a message: this is +/// not PID 1, and a manager that could not be set up is better reported to +/// whatever started it than left running half-built. +/// +/// Never called as PID 1; `main` sees to that. +pub fn boot() -> ExitCode { + let uid = rustix::process::getuid(); + if uid.is_root() { + // Root's services are system units. A root user manager would be a + // second system manager with none of PID 1's guarantees. + eprintln!("oxinit: --user is for users; root's services are system units"); + return ExitCode::FAILURE; + } + + let paths = match Paths::user_from_env() { + Ok(paths) => paths, + Err(e) => { + eprintln!("oxinit: {e}"); + return ExitCode::FAILURE; + } + }; + let name = oxinit_user::name_of_system(uid.as_raw()); + + if let Err(e) = private_dir(&paths.runtime_dir) { + eprintln!("oxinit: {}: {e}", paths.runtime_dir.display()); + return ExitCode::FAILURE; + } + + // One per user. A second session for the same user starts this again, + // and finding the first one answering is success, not an error: the + // services that session wants are already running. + if answering(&paths.control()) { + println!("oxinit: the user manager for {name} is already running"); + return ExitCode::SUCCESS; + } + + // Orphans of this user's services come here rather than to PID 1. A + // `forking` daemon's real process is one, and it has to be reaped by the + // manager that is supervising it. + if let Err(e) = rustix::process::set_child_subreaper(Some(rustix::process::getpid())) { + eprintln!("oxinit: become a subreaper: {e}; orphans will go to pid 1"); + } + + let signals = match crate::open_signalfd() { + Ok(fd) => fd, + Err(e) => { + eprintln!("oxinit: {e}"); + return ExitCode::FAILURE; + } + }; + + let (timers, mut events, notify) = match ( + timer::Timers::new(), + event::EventLoop::new(), + notify::Notify::bind(&paths.notify()), + ) { + (Ok(timers), Ok(events), Ok(notify)) => (timers, events, notify), + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => { + eprintln!("oxinit: {e}"); + return ExitCode::FAILURE; + } + }; + + // Required here, where PID 1 can do without it: it is how the user + // reaches their manager at all, and what tells the next session one is + // already running. + let control = match control::Control::bind(&paths.control()) { + Ok(control) => control, + Err(e) => { + eprintln!("oxinit: {e}"); + return ExitCode::FAILURE; + } + }; + + let logs = match logs::Logs::bind(&paths.log_socket()) { + Ok(logs) => logs, + Err(e) => { + eprintln!("oxinit: {e}; continuing without log shipping"); + logs::Logs::unavailable() + } + }; + + let cgroups = own_cgroups(); + + // `%H` in a user unit means what it means in a system one. + let hostname = rustix::system::uname() + .nodename() + .to_string_lossy() + .into_owned(); + + let config_home = paths.config_home.clone().unwrap_or_default(); + let source = UnitSource { + dirs: oxinit_unit::user_dirs(&config_home), + scope: Scope::User { name: name.clone() }, + notify: paths.notify(), + }; + + let environment = Environment::User(name); + println!( + "oxinit: {environment}, units from {}", + source + .dirs + .iter() + .map(|dir| dir.display().to_string()) + .collect::>() + .join(", ") + ); + + let mut supervisor = Supervisor::load(&hostname, source, timers, notify, cgroups, logs); + + if let Err(e) = crate::wire(&events, &signals, &supervisor, Some(&control)) { + eprintln!("oxinit: {e}"); + return ExitCode::FAILURE; + } + + // Nothing to start is not a reason to leave. The user may add a unit and + // `oxctl --user reload`, and the next session would only start this + // again. + supervisor.start_all(); + supervisor.sync_sockets(&events); + + crate::run( + &mut events, + &signals, + &mut supervisor, + &mut None, + &mut Some(control), + &environment, + &mut None, + ) +} + +/// The loop cannot go on. A user manager is not PID 1: there is no kernel +/// panic to avoid and no console to fall back to, so it exits, and whatever +/// started it — the session — sees that it did. +/// +/// Its services are left running, reparented to PID 1. Stopping them here +/// would mean running the shutdown this loop can no longer run. +pub fn give_up() -> ! { + eprintln!("oxinit: the user manager cannot continue; exiting"); + std::process::exit(1) +} + +/// The runtime directory, private to the user. `$XDG_RUNTIME_DIR` is `0700` +/// already, but this one is the user manager's, and saying so costs nothing. +fn private_dir(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + rustix::fs::chmod(dir, Mode::from_raw_mode(0o700))?; + Ok(()) +} + +/// Whether a manager is already listening on this control socket. +/// +/// A socket file nobody answers on is a manager that died without cleaning +/// up — the next bind removes it. +fn answering(control: &Path) -> bool { + let Ok(socket) = rustix::net::socket_with( + AddressFamily::UNIX, + SocketType::SEQPACKET, + SocketFlags::CLOEXEC, + None, + ) else { + return false; + }; + let Ok(addr) = SocketAddrUnix::new(control) else { + return false; + }; + rustix::net::connect(&socket, &addr).is_ok() +} + +/// cgroups, if the user was given a subtree of their own to manage. +/// +/// A user manager can only create cgroups under one it may write to, which +/// means something above it delegated one — chowned the directory to the +/// user. Without that it runs as PID 1 does without a hierarchy: every +/// service still runs, and what needs a cgroup — `[resources]`, `forking` +/// readiness, `cgroup.kill` — fails on the unit that asked for it, saying +/// why. +fn own_cgroups() -> cgroup::Cgroups { + let own = std::fs::read_to_string("/proc/self/cgroup") + .ok() + .and_then(|text| { + text.lines() + .find_map(|line| line.strip_prefix("0::")) + .map(|path| path.trim().trim_start_matches('/').to_owned()) + }) + .map(|path| PathBuf::from(oxinit_cgroup::CGROUP_ROOT).join(path)); + + let Some(own) = own else { + println!("oxinit: no cgroup v2 placement; running without cgroups"); + return cgroup::Cgroups::unavailable(); + }; + + match cgroup::Cgroups::open(&own.to_string_lossy()) { + Ok(cgroups) => cgroups, + Err(e) => { + println!( + "oxinit: {} is not delegated to this user ({e}); running without cgroups", + own.display() + ); + cgroup::Cgroups::unavailable() + } + } +} diff --git a/crates/oxinit/src/watchdog.rs b/crates/oxinit/src/watchdog.rs new file mode 100644 index 0000000..c45ce7b --- /dev/null +++ b/crates/oxinit/src/watchdog.rs @@ -0,0 +1,264 @@ +//! The hardware watchdog, fed from the event loop. +//! +//! A hung PID 1 is the one failure nothing on the machine can recover from: +//! there is no supervisor above it. A hardware watchdog is outside it, so it +//! can — it resets the machine unless it is written to within its timeout. +//! oxinit writes to it from the loop itself rather than from a handler or a +//! timer alarm, so what keeps the machine alive is exactly the loop turning: +//! a handler that never returns, a loop that has fallen back to the console +//! shell, or a deadline heap that has stopped firing all end in a reset. +//! +//! When to write, when to stop, and what to do on the way down are decided in +//! `oxinit-watchdog`, which is tested on the host. This module is the device. + +use std::io; +use std::time::{Duration, Instant}; + +use std::os::fd::{AsFd, OwnedFd}; + +use rustix::fs::{Mode, OFlags}; + +use oxinit_watchdog::{Action, Event, Policy, Release, Settings, WatchdogError}; + +use crate::container::Environment; +use crate::shutdown; +use crate::sys::raw; + +pub struct Watchdog { + policy: Policy, + device: Option, + /// What every decision is measured from. Also the start of the boot + /// deadline. + started: Instant, + /// The device was missing and that has been said. Said once rather than + /// at every retry: a driver that is a module may take a while to load. + reported_missing: bool, +} + +impl Watchdog { + /// The watchdog the configuration asks for, if any. + /// + /// `None` when none is configured, in a container, or when the + /// configuration is broken — which is said, and is not fatal. A machine + /// whose watchdog was already running before oxinit, as an initramfs may + /// leave it, is then reset when nothing takes it over; one where nothing + /// started it boots without that protection. Either way the console says + /// why. + pub fn configure(environment: &Environment) -> Option { + let config = match load().and_then(Settings::resolve) { + Ok(config) => config?, + Err(e) => { + eprintln!("oxinit: watchdog: {e}; not opening the watchdog"); + return None; + } + }; + + // The hardware is the host's. A privileged container can open the + // host's /dev/watchdog, and feeding — or failing to feed — the host's + // reset line is not a container's call. + if environment.is_container() { + println!( + "oxinit: watchdog: {} is configured, but this is {environment}; \ + leaving the host's hardware alone", + config.device + ); + return None; + } + + match config.boot.as_ref() { + Some(boot) => println!( + "oxinit: watchdog: {}, {} s timeout; the boot has {} s to reach {}", + config.device, + config.timeout, + boot.within.as_secs(), + boot.unit + ), + None => println!( + "oxinit: watchdog: {}, {} s timeout", + config.device, config.timeout + ), + } + + Some(Self { + policy: Policy::new(config), + device: None, + started: Instant::now(), + reported_missing: false, + }) + } + + /// The unit the boot deadline is waiting for, while it is. + pub fn awaiting(&self) -> Option<&str> { + self.policy.awaiting() + } + + /// Said once, after the units have loaded: a boot unit that does not + /// exist is a boot nothing can confirm. The deadline still applies — the + /// configuration asked for a confirmed boot, and running without one is a + /// different configuration, not a smaller version of it. + pub fn check_boot_unit(&self, known: bool) { + if let (Some(unit), false) = (self.awaiting(), known) { + eprintln!( + "oxinit: watchdog: boot-unit `{unit}` is not a loaded unit; \ + nothing can confirm this boot, and the watchdog will reset it" + ); + } + } + + /// How long the loop may sleep before there is something to do here. + pub fn wait_bound(&self) -> Option { + self.policy.next_wake(self.now()) + } + + /// Called once per wake-up of the loop, after everything else. + /// + /// `booted` is whether the unit [`Watchdog::awaiting`] names has come up. + /// `shutting_down` suspends the boot deadline; asked every time rather + /// than told once, so no path into a shutdown can forget to say so. + pub fn tick(&mut self, booted: bool, shutting_down: bool) { + if shutting_down { + self.policy.begin_shutdown(); + } + + let now = self.now(); + let tick = self.policy.tick(now, booted); + + match tick.event { + Some(Event::BootConfirmed) => { + println!("oxinit: watchdog: the boot is confirmed"); + } + Some(Event::BootOverdue) => { + let config = self.policy.config(); + eprintln!( + "oxinit: watchdog: the boot did not reach {} within {} s; \ + no longer feeding {}, which resets the machine when its \ + {} s timeout runs out", + config.boot.as_ref().map_or("?", |boot| boot.unit.as_str()), + config.boot.as_ref().map_or(0, |boot| boot.within.as_secs()), + config.device, + config.timeout + ); + } + None => {} + } + + match tick.action { + Action::Open => self.open(now), + Action::Pet => self.pet(), + Action::Nothing => {} + } + } + + /// The end of a shutdown, just before `reboot(2)`. + pub fn release(&mut self, action: shutdown::Action) { + match oxinit_watchdog::release(action == shutdown::Action::Reboot) { + Release::Disarm => { + let Some(device) = self.device.take() else { + return; + }; + // The magic close: `V` is the last thing written, and the + // close that follows stops the timer. A driver without magic + // close stops on any close; one built `nowayout` does not stop + // at all, and says so in the kernel log. + match rustix::io::write(&device, b"V") { + Ok(_) => println!("oxinit: watchdog: stopped"), + Err(e) => eprintln!("oxinit: watchdog: stop: {e}"), + } + drop(device); + } + Release::KeepRunning => { + if self.device.is_some() { + self.pet(); + println!("oxinit: watchdog: left running through the reboot"); + } + } + } + } + + fn now(&self) -> Duration { + self.started.elapsed() + } + + fn open(&mut self, now: Duration) { + let path = self.policy.config().device.clone(); + + // CLOEXEC above all: a service that inherited this descriptor could + // keep the machine alive after PID 1 had hung, or reset it by + // closing it. + let device = match rustix::fs::open(&path, OFlags::WRONLY | OFlags::CLOEXEC, Mode::empty()) + { + Ok(device) => device, + Err(e) => { + if !self.reported_missing { + eprintln!( + "oxinit: watchdog: open {path}: {e}; looking again as it may yet appear" + ); + self.reported_missing = true; + } + self.policy.open_failed(now); + return; + } + }; + + let wanted = i32::try_from(self.policy.config().timeout).unwrap_or(i32::MAX); + let applied = match raw::watchdog_set_timeout(device.as_fd(), wanted) { + Ok(applied) => Some(applied), + Err(e) => { + eprintln!("oxinit: watchdog: {path} would not take a {wanted} s timeout: {e}"); + raw::watchdog_get_timeout(device.as_fd()).ok() + } + }; + let applied = applied.and_then(|secs| u32::try_from(secs).ok()); + + match applied { + Some(secs) => println!("oxinit: watchdog: feeding {path}, {secs} s timeout"), + None => println!( + "oxinit: watchdog: feeding {path} every second; it will not say its timeout" + ), + } + + self.policy.opened(now, applied); + self.device = Some(device); + } + + fn pet(&self) { + let Some(device) = self.device.as_ref() else { + return; + }; + + // Any byte but `V` is a keepalive. Not the ioctl: a write is what + // every driver has understood since before the ioctl existed. + if let Err(e) = rustix::io::write(device, b"\0") { + eprintln!("oxinit: watchdog: {e}"); + } + } +} + +/// The configuration, from the files and the kernel command line. +/// +/// `/etc` replaces `/usr/lib` wholly, as it does for units. The command line +/// then wins key by key, because it is one boot's exception to whichever +/// file was in force. +fn load() -> Result { + let file = match read(oxinit_watchdog::ETC_FILE)? { + Some(settings) => settings, + None => read(oxinit_watchdog::VENDOR_FILE)?.unwrap_or_default(), + }; + + // No /proc means no command line to read, which is not an error here: + // the mounts have already said so if it matters. + let cmdline = std::fs::read_to_string("/proc/cmdline").unwrap_or_default(); + + Ok(Settings::from_cmdline(&cmdline)?.over(file)) +} + +fn read(path: &str) -> Result, WatchdogError> { + match std::fs::read_to_string(path) { + Ok(text) => Settings::parse(path, &text).map(Some), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(WatchdogError::Toml { + origin: path.to_owned(), + message: e.to_string(), + }), + } +} diff --git a/crates/oxlogd/Cargo.toml b/crates/oxlogd/Cargo.toml index 6bee6ec..bce3a00 100644 --- a/crates/oxlogd/Cargo.toml +++ b/crates/oxlogd/Cargo.toml @@ -18,4 +18,5 @@ publish.workspace = true # place. [dependencies] oxinit-log = { path = "../oxinit-log" } +oxinit-paths = { path = "../oxinit-paths" } rustix = { workspace = true, features = ["event", "fs", "net"] } diff --git a/crates/oxlogd/src/main.rs b/crates/oxlogd/src/main.rs index dfa37b5..5627e0b 100644 --- a/crates/oxlogd/src/main.rs +++ b/crates/oxlogd/src/main.rs @@ -6,6 +6,9 @@ //! that descriptor is that unit's output, and it goes to //! `/var/log/oxinit/.log` a line at a time, timestamped. //! +//! With `--user` it is the same thing for a user manager: the socket under the +//! user's `$XDG_RUNTIME_DIR`, the files under `~/.local/state/oxinit/log`. +//! //! **PID 1 does not do this.** A service writing a megabyte a second must not //! be able to make PID 1 do work, and a bug in a log writer must kill a log //! writer. That is the same argument that put `oxctl` out of process, and it @@ -36,7 +39,8 @@ use rustix::net::{ SocketFlags, SocketType, }; -use oxinit_log::{Move, Rotation, Splitter, Timestamp, LOG_DIR, MAX_MESSAGE, SOCKET_PATH}; +use oxinit_log::{Move, Rotation, Splitter, Timestamp, MAX_MESSAGE}; +use oxinit_paths::Paths; /// How much to take from a pipe at once. /// @@ -60,14 +64,23 @@ fn run() -> Result<(), String> { return Ok(()); } + // `--user`: the shipper for a user manager, which is a unit of that + // manager's like this is a unit of PID 1's. Its socket and its files are + // under the user's own directories. + let paths = if std::env::args().any(|arg| arg == "--user") { + Paths::user_from_env().map_err(|e| format!("--user: {e}"))? + } else { + Paths::system() + }; + let dir = std::env::args() .skip_while(|arg| arg != "--dir") .nth(1) - .map_or_else(|| PathBuf::from(LOG_DIR), PathBuf::from); + .map_or_else(|| paths.log_dir.clone(), PathBuf::from); std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; - let socket = connect()?; + let socket = connect(&paths)?; println!("oxlogd: connected, writing to {}", dir.display()); // Only once the socket is up. A `type = "notify"` unit that said it was @@ -82,7 +95,7 @@ fn run() -> Result<(), String> { /// oxinit is the server. If it is not listening, there is nothing to wait for /// — the restart policy is what retries, at a backoff oxinit already knows how /// to compute. -fn connect() -> Result { +fn connect(paths: &Paths) -> Result { let socket = rustix::net::socket_with( AddressFamily::UNIX, SocketType::SEQPACKET, @@ -91,12 +104,19 @@ fn connect() -> Result { ) .map_err(|e| format!("socket: {e}"))?; - let addr = SocketAddrUnix::new(SOCKET_PATH).map_err(|e| format!("{SOCKET_PATH}: {e}"))?; + let path = paths.log_socket(); + let addr = SocketAddrUnix::new(&path).map_err(|e| format!("{}: {e}", path.display()))?; rustix::net::connect(&socket, &addr).map_err(|e| { format!( - "connect {SOCKET_PATH}: {e}\n\ - is oxinit running as PID 1, and are you root?" + "connect {}: {e}\n\ + is oxinit running{}?", + path.display(), + if paths.is_user() { + " for this user" + } else { + " as PID 1, and are you root" + } ) })?; diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index bc5f629..4b0a3b1 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -102,6 +102,7 @@ fn main() { "demo" => demo(&rest), "test-demo" => test_demo(&rest), "test-distro" => test_distro(&rest), + "test-watchdog" => test_watchdog(&rest), "fetch" => fetch(&rest), "" | "help" | "--help" | "-h" => { usage(); @@ -129,6 +130,9 @@ commands: test-demo build the demo image and check every claim the README makes test-distro boot a real distribution userspace and assert on the serial log + test-watchdog + boot with QEMU's i6300esb: fed until a halt that disarms it, + then a boot that never confirms itself and is reset fetch download the kernel and busybox a boot needs, into target/ options: @@ -339,6 +343,22 @@ const EXPECTED: &[(&str, &str)] = &[ "M13: %N and %u expand against a running machine", ), ("reloaded", "M5: oxctl reload, which no boot had ever run"), + ( + "oxinit: the user manager for nobody, units from", + "M18: a user manager started, for the user it runs as", + ), + ( + "oxinit-user: nobody is nobody", + "M18: a user unit ran as the manager's user, and %u is that user", + ), + ( + "oxinit: user-probe is ready", + "M18: sd_notify reached the user manager's own socket", + ), + ( + "oxinit: the user manager for nobody: everything stopped; exiting", + "M18: a user manager stops its units and exits when asked to", + ), ( "lingers stopped; not restarting, the machine is going down", "M16: a stop that completes mid-shutdown does not take the restart \ @@ -364,11 +384,18 @@ const EXPECTED: &[(&str, &str)] = &[ /// only thing that distinguishes `after` gating a start from `after` merely /// deciding what order starts were issued in. Both lines are present either /// way; only the sequence says the waiting happened. -const SEQUENCE: &[(&str, &str, &str)] = &[( - "oxinit: slow did not start in time", - "oxinit-m11: patient started", - "M11: `after` waits for activation to finish, not just for a start to be issued", -)]; +const SEQUENCE: &[(&str, &str, &str)] = &[ + ( + "oxinit: slow did not start in time", + "oxinit-m11: patient started", + "M11: `after` waits for activation to finish, not just for a start to be issued", + ), + ( + "oxinit: the user manager for nobody: everything stopped; exiting", + "oxinit: syncing", + "M18: the user manager is gone before the machine goes down", + ), +]; /// Lines that must not appear. A boot that logs one of these has failed even /// if everything else is present. @@ -455,7 +482,10 @@ fn test_boot(arch: Arch, args: &[String]) -> Result<(), String> { "-serial".as_ref(), format!("file:{}", log.display()).as_ref(), "-append".as_ref(), - format!("console={}", arch.console).as_ref(), + // `--version` and `-V`: words the kernel does not know, so it + // hands them to init, which must boot rather than print its + // version and return — a regression test, by being there. + format!("console={} --version -V", arch.console).as_ref(), "-m".as_ref(), "512".as_ref(), ]) @@ -801,6 +831,7 @@ cp -a /overlay/init /rootfs/init cp -a /overlay/bin/. /rootfs/bin/ mkdir -p /rootfs/etc/oxinit cp -a /overlay/etc/oxinit/units /rootfs/etc/oxinit/ +cp -a /overlay/etc/oxinit/user-units /rootfs/etc/oxinit/ # The one group the privilege-drop unit needs, added rather than substituted. echo 'oxinit:x:900:nobody' >> /rootfs/etc/group @@ -828,6 +859,331 @@ find . | cpio --quiet -o -H newc | gzip -9 > "/out/$1" echo "build.sh: packed $(find . | wc -l) paths from $(cat /etc/alpine-release)" "#; +/// A kernel with a hardware watchdog driver, and the driver. +/// +/// The netboot kernel `fetch` downloads has the watchdog core built in and +/// every driver as a module, and nothing in a hand-assembled initramfs can +/// load a module it does not have. Alpine's `linux-virt` package carries a +/// kernel and its modules together, so the two always match — which a module +/// from the package next to the netboot kernel would not, because the +/// package moves with point releases and the netboot image does not. +/// +/// Extracted inside the distribution's own image, like `test-distro`'s root, +/// and cached under `target/`. +const WATCHDOG_KERNEL_SCRIPT: &str = r#"#!/bin/sh +# Written by `cargo xtask test-watchdog`. Runs inside the distribution's image. +set -e +apk add --no-cache linux-virt >/dev/null +cp /boot/vmlinuz-virt /out/vmlinuz-virt-x86_64 +module=$(find /lib/modules -name 'i6300esb.ko*' | head -n 1) +case "$module" in + *.gz) gunzip -c "$module" > /out/i6300esb-x86_64.ko ;; + *) cp "$module" /out/i6300esb-x86_64.ko ;; +esac +echo "build.sh: $(ls /lib/modules) with $(basename "$module")" +"#; + +fn watchdog_kernel(engine: &str) -> Result<(PathBuf, PathBuf), String> { + let target = root().join("target"); + let kernel = target.join("vmlinuz-virt-x86_64"); + let module = target.join("i6300esb-x86_64.ko"); + if kernel.exists() && module.exists() { + return Ok((kernel, module)); + } + + let script = target.join("oxinit-watchdog-kernel.sh"); + fs::write(&script, WATCHDOG_KERNEL_SCRIPT) + .map_err(|e| format!("write {}: {e}", script.display()))?; + + println!("xtask: fetching linux-virt from {DISTRO_IMAGE} with {engine}"); + // The platform is named because the tag alone resolves to whatever was + // pulled last, and an aarch64 kernel boots nothing here. + run(Command::new(engine).args([ + "run".as_ref(), + "--rm".as_ref(), + "--platform".as_ref(), + "linux/amd64".as_ref(), + "-v".as_ref(), + format!("{}:/build.sh:ro", script.display()).as_ref(), + "-v".as_ref(), + format!("{}:/out", target.display()).as_ref(), + std::ffi::OsStr::new(DISTRO_IMAGE), + "/bin/sh".as_ref(), + "/build.sh".as_ref(), + ]))?; + + Ok((kernel, module)) +} + +/// One boot of the watchdog test. +struct WatchdogCase { + name: &'static str, + /// `/etc/oxinit/watchdog.toml`. + config: &'static str, + /// Appended to the kernel command line. + cmdline: &'static str, + /// What the boot unit runs. `/bin/true` confirms the boot; `/bin/false` + /// is a boot unit that fails, so the boot is never confirmed. + confirm: &'static str, + /// The signal that ends the boot, `seconds` after the boot unit. + end: (&'static str, u32), + expected: &'static [(&'static str, &'static str)], + forbidden: &'static [&'static str], + /// What QEMU has to do for the case to pass. + outcome: Outcome, +} + +#[derive(Clone, Copy)] +enum Outcome { + /// Still running this many seconds after this line reached the log. A + /// watchdog left armed would have reset it — and `-no-reboot` turns a + /// reset into QEMU exiting. + StaysUpAfter(&'static str, u64), + /// Exits on its own: the watchdog reset it. + Resets, +} + +/// Every case uses a 4 s hardware timeout: short, so a reset that should not +/// happen happens inside the test, and one that should does not take long. +const WATCHDOG_CASES: &[WatchdogCase] = &[ + WatchdogCase { + name: "fed", + config: "timeout-sec = \"4s\"\nboot-unit = \"confirm\"\nboot-sec = \"1min\"\n", + cmdline: "", + confirm: "/bin/true", + // Three timeouts and more of being fed, then a halt. + end: ("USR1", 14), + expected: &[ + ( + "oxinit: watchdog: /dev/watchdog0, 4 s timeout; the boot has 60 s to reach confirm", + "the configuration was read from /etc", + ), + ( + "looking again as it may yet appear", + "a device whose driver is a module is waited for, not given up on", + ), + ( + "oxinit: watchdog: feeding /dev/watchdog0, 4 s timeout", + "the device opened once its driver loaded, with the timeout asked for", + ), + ( + "oxinit: watchdog: the boot is confirmed", + "a oneshot that ran to a clean exit confirms the boot", + ), + ( + "oxinit: watchdog: stopped", + "a halt disarms the watchdog with the magic close", + ), + ( + "oxinit: halt", + "the halt itself, after fourteen fed seconds", + ), + ], + forbidden: &["did not reach", "panicked", "Kernel panic"], + outcome: Outcome::StaysUpAfter("oxinit: halt", 12), + }, + WatchdogCase { + name: "overdue", + config: "timeout-sec = \"4s\"\nboot-unit = \"confirm\"\nboot-sec = \"5min\"\n", + cmdline: "oxinit.watchdog.boot-sec=8s", + confirm: "/bin/false", + end: ("TERM", 60), + expected: &[ + ( + "the boot has 8 s to reach confirm", + "the kernel command line overrode the file's boot-sec", + ), + ( + "oxinit: watchdog: feeding /dev/watchdog0, 4 s timeout", + "the device was fed while the deadline ran", + ), + ( + "oxinit: watchdog: the boot did not reach confirm within 8 s", + "a boot unit that failed never confirms the boot", + ), + ], + forbidden: &[ + "the boot is confirmed", + "oxinit: syncing", + "oxinit: power off", + "panicked", + "Kernel panic", + ], + outcome: Outcome::Resets, + }, +]; + +/// Boot oxinit with a hardware watchdog under QEMU, twice: once fed until a +/// halt that has to disarm it, once with a boot that never confirms itself +/// and has to be reset. +/// +/// x86_64 only. The watchdog is QEMU's i6300esb, a PC chipset device; the +/// aarch64 `virt` board has no watchdog the kernel drives this way. +fn test_watchdog(args: &[String]) -> Result<(), String> { + let arch = find_arch(args)?; + if arch.name != "x86_64" { + return Err("test-watchdog is x86_64 only: its watchdog is QEMU's i6300esb".to_owned()); + } + let engine = flag(args, "--engine")?.unwrap_or("docker").to_owned(); + let shell = find_shell(arch, args)? + .ok_or("test-watchdog needs busybox: run `cargo xtask fetch` or pass --shell PATH")?; + + let (kernel, module) = watchdog_kernel(&engine)?; + let binary = build(arch)?; + + let mut failures = Vec::new(); + for case in WATCHDOG_CASES { + println!("\nxtask: === watchdog: {} ===", case.name); + if let Err(e) = watchdog_case(arch, case, &kernel, &module, &binary, &shell) { + failures.push(format!("{}: {e}", case.name)); + } + } + + if failures.is_empty() { + println!("\nxtask: the watchdog was fed, released, and starved as configured"); + return Ok(()); + } + Err(failures.join("\n")) +} + +fn watchdog_case( + arch: Arch, + case: &WatchdogCase, + kernel: &Path, + module: &Path, + binary: &Path, + shell: &Path, +) -> Result<(), String> { + // No unit directory of its own: every unit is written below, because + // these four are the whole of what the case is about. + let staging = stage_units(arch, binary, Some(shell), false, "watchdog")?; + + let link = staging.join("bin/insmod"); + let _ = fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink("busybox", &link) + .map_err(|e| format!("symlink {}: {e}", link.display()))?; + fs::copy(module, staging.join("i6300esb.ko")).map_err(|e| format!("copy module: {e}"))?; + + let etc = staging.join("etc/oxinit"); + let units = etc.join("units"); + fs::create_dir_all(&units).map_err(|e| format!("create {}: {e}", units.display()))?; + fs::write(etc.join("watchdog.toml"), case.config) + .map_err(|e| format!("write watchdog.toml: {e}"))?; + + let (signal, after) = case.end; + let files = [ + ( + "default.toml", + "[unit]\nwants = [\"load-watchdog\", \"confirm\", \"end\"]\n\n[target]\n".to_owned(), + ), + // Loaded by a unit rather than present at boot, which is what a + // module from a distribution's udev looks like to oxinit: a device + // that turns up a few seconds in. + ( + "load-watchdog.toml", + "[service]\ntype = \"oneshot\"\nexec = \"/bin/insmod /i6300esb.ko\"\n".to_owned(), + ), + ( + "confirm.toml", + format!( + "[unit]\nafter = [\"load-watchdog\"]\n\n\ + [service]\ntype = \"oneshot\"\nexec = \"{}\"\n", + case.confirm + ), + ), + ( + "end.toml", + format!( + "[unit]\nafter = [\"confirm\"]\n\n\ + [service]\nexec = '/bin/sh -c \"/bin/sleep {after}; /bin/kill -{signal} 1\"'\n" + ), + ), + ]; + for (name, text) in files { + fs::write(units.join(name), text).map_err(|e| format!("write {name}: {e}"))?; + } + + let image = root().join(format!("target/oxinit-watchdog-{}.cpio.gz", case.name)); + let script = format!( + "cd {} && find . | cpio --quiet -o -H newc | gzip -9 > {}", + shell_quote(&staging), + shell_quote(&image), + ); + run(Command::new("sh").arg("-c").arg(script))?; + + let log = root().join(format!("target/test-watchdog-{}.log", case.name)); + let _ = fs::remove_file(&log); + + // `-no-reboot` turns the watchdog's reset into QEMU exiting, which is + // what makes a reset observable at all. + let mut child = Command::new(arch.qemu) + .args(arch.machine) + .args([ + "-kernel".as_ref(), + kernel.as_os_str(), + "-initrd".as_ref(), + image.as_os_str(), + "-device".as_ref(), + "i6300esb".as_ref(), + "-no-reboot".as_ref(), + "-display".as_ref(), + "none".as_ref(), + "-serial".as_ref(), + format!("file:{}", log.display()).as_ref(), + "-append".as_ref(), + format!("console={} {}", arch.console, case.cmdline).as_ref(), + "-m".as_ref(), + "512".as_ref(), + ]) + .spawn() + .map_err(|e| format!("run {}: {e}", arch.qemu))?; + + let read_log = || fs::read_to_string(&log).unwrap_or_default(); + let mut failures = Vec::new(); + + match case.outcome { + Outcome::Resets => { + if !wait_for(&mut child, arch.timeout)? { + failures.push(format!( + "qemu was still running after {}s: nothing reset it", + arch.timeout + )); + } + } + Outcome::StaysUpAfter(line, seconds) => { + let mut seen = false; + for _ in 0..arch.timeout * 10 { + if read_log().contains(line) { + seen = true; + break; + } + if matches!(child.try_wait(), Ok(Some(_))) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + if seen { + std::thread::sleep(std::time::Duration::from_secs(seconds)); + match child.try_wait() { + Ok(None) => println!(" ok still up {seconds}s after `{line}`"), + _ => failures.push(format!( + "qemu exited within {seconds}s of `{line}`: the watchdog was still armed" + )), + } + } else { + failures.push(format!("`{line}` never appeared, or qemu exited first")); + } + + let _ = child.kill(); + let _ = child.wait(); + } + } + + check(&read_log(), case.expected, case.forbidden, &[], failures) +} + /// What a container log must contain, and what each line proves. /// /// The first three are M6 and nothing else tests them. The middle four are the @@ -1420,6 +1776,29 @@ fn stage_units( } println!("xtask: installed {count} units from {units}/"); + // A user manager's units, from `/user/`, where the user + // manager in the same set looks for them. + let user_src = units_src.join("user"); + if user_src.is_dir() { + let user_dst = staging.join("etc/oxinit/user-units"); + fs::create_dir_all(&user_dst) + .map_err(|e| format!("create {}: {e}", user_dst.display()))?; + let mut count = 0; + for entry in + fs::read_dir(&user_src).map_err(|e| format!("read {}: {e}", user_src.display()))? + { + let path = entry.map_err(|e| format!("read entry: {e}"))?.path(); + if path.extension().is_some_and(|ext| ext == "toml") { + if let Some(name) = path.file_name() { + fs::copy(&path, user_dst.join(name)) + .map_err(|e| format!("copy {}: {e}", path.display()))?; + count += 1; + } + } + } + println!("xtask: installed {count} user units from {units}/user/"); + } + if test { install_test_shutdown(&units_dst)?; } @@ -1433,7 +1812,7 @@ fn stage_units( /// not exist and the shell can only run its own builtins. const APPLETS: &[&str] = &[ "cat", "ls", "ps", "sleep", "mount", "umount", "hostname", "grep", "poweroff", "reboot", - "dmesg", "kill", "mkdir", "echo", "true", "false", "date", "id", "sed", "wc", + "dmesg", "kill", "mkdir", "echo", "true", "false", "date", "id", "sed", "wc", "chown", "chmod", ]; /// Whether to treat this binary as busybox, by filename. diff --git a/docs/UNIT_FORMAT.md b/docs/UNIT_FORMAT.md index f4a2225..e47eabc 100644 --- a/docs/UNIT_FORMAT.md +++ b/docs/UNIT_FORMAT.md @@ -85,6 +85,35 @@ An empty file in `/etc` does not mask the packaged unit — it is a unit with no kind section, which is a load error. To disable a unit, remove it from the dependencies of the target that pulls it in. +### User units + +A user manager — `oxinit --user`, see ARCHITECTURE.md — reads the same format +from three directories, each replacing a file of the same name in the ones +before it, wholly: + +| Directory | Purpose | +|------------------------------------------|------------------------------------------| +| `/usr/lib/oxinit/user-units/` | Units shipped by packages, for every user. | +| `/etc/oxinit/user-units/` | The operator's, for every user. | +| `$XDG_CONFIG_HOME/oxinit/user-units/` | The user's own. `~/.config` when unset. | + +The user is the operator of their own manager, so their directory comes last. +A user manager starts its own `default`, as PID 1 does. + +Everything in this document applies to a user unit, except: + +- `user` is a load error. Every service runs as the user the manager runs as; + there is no one else it could run one as, and a unit asking for someone + else and silently getting the user would not be doing what it says. +- `tty = true` is a load error. The terminal belongs to the user's session. +- `%u` is the user the manager runs as. +- `output = "log"` goes to `oxlogd --user`, which writes under + `$XDG_STATE_HOME/oxinit/log/` (`~/.local/state` when unset). +- `[resources]`, `type = "forking"` and the `cgroup.kill` escalation need a + cgroup the user may write to. Without one, which is the usual case, a unit + using the first two fails to start, saying why, and a stop that times out + can kill only the main process. + ## Example ```toml @@ -381,6 +410,8 @@ has. An unresolvable username at start time fails the unit. +Not allowed in a user unit. See [User units](#user-units). + ### `tty` - **Type:** boolean @@ -406,6 +437,8 @@ Failing to get the terminal does not fail the unit. A service that cannot be given job control is a nuisance; a console that will not start is a machine with no way in. +`tty = true` is not allowed in a user unit. See [User units](#user-units). + ### `output` - **Type:** string — `"console"`, `"log"`, or `"null"` @@ -786,7 +819,7 @@ is split into argv. This is the only runtime interpolation in the format. | `%n` | The unit name, e.g. `sshd`. | | `%N` | The fully-qualified name, e.g. `sshd.service`. | | `%H` | The hostname at the time of expansion. | -| `%u` | The value of `user`. | +| `%u` | The value of `user`; in a user unit, the user the manager runs as. | | `%%` | A literal `%`. | An unrecognised specifier is a load error. The set is closed and will grow only @@ -816,6 +849,7 @@ The parser rejects, at load time: - A unit named in both `requires` and `conflicts`. - A cycle in the `after`/`before` edges, reported with the cycle path. - A `exec` whose first element is not an absolute path. +- In a user unit, `user`, or `tty = true`. A load error means oxinit does not start with that unit set. It does not skip the bad unit and continue, because a partially loaded configuration produces a diff --git a/units/default.toml b/units/default.toml index c6a81d5..b45a86a 100644 --- a/units/default.toml +++ b/units/default.toml @@ -40,6 +40,7 @@ wants = [ "poke", "knock-socket", "knocker", + "user-manager", "absent", ] after = ["console", "banner", "logged"] diff --git a/units/user-manager.toml b/units/user-manager.toml new file mode 100644 index 0000000..1e3b2af --- /dev/null +++ b/units/user-manager.toml @@ -0,0 +1,11 @@ +# nobody's own oxinit. A session would start it; a test image has no session, +# so a system unit stands in, with the environment a session would have set. +# Its units are in /etc/oxinit/user-units — units/user/ in this tree. +[unit] +description = "The user manager for nobody" +requires = ["user-runtime"] +after = ["user-runtime"] + +[service] +user = "nobody" +exec = '/bin/sh -c "XDG_RUNTIME_DIR=/run/user/65534 HOME=/run/user/65534 exec /init --user"' diff --git a/units/user-runtime.toml b/units/user-runtime.toml new file mode 100644 index 0000000..e60f2bd --- /dev/null +++ b/units/user-runtime.toml @@ -0,0 +1,8 @@ +# The runtime directory a login session would have given `nobody`. A test +# image has no PAM and no elogind to make it, so this does. +[unit] +description = "A runtime directory for nobody" + +[service] +type = "oneshot" +exec = '/bin/sh -c "/bin/mkdir -p /run/user/65534 && /bin/chown 65534:65534 /run/user/65534 && /bin/chmod 0700 /run/user/65534"' diff --git a/units/user/default.toml b/units/user/default.toml new file mode 100644 index 0000000..ce148ad --- /dev/null +++ b/units/user/default.toml @@ -0,0 +1,6 @@ +# A user manager starts its `default`, as PID 1 does. +[target] + +[unit] +description = "nobody's default" +wants = ["greet", "user-probe"] diff --git a/units/user/greet.toml b/units/user/greet.toml new file mode 100644 index 0000000..5f1683a --- /dev/null +++ b/units/user/greet.toml @@ -0,0 +1,8 @@ +# A user unit runs as the manager's user, and `%u` is that user. Both halves +# of the line come out as `nobody` or neither does. +[unit] +description = "Says who it runs as" + +[service] +type = "oneshot" +exec = '/bin/sh -c "/bin/echo oxinit-user: %u is $(/usr/bin/id -un)"' diff --git a/units/user/user-probe.toml b/units/user/user-probe.toml new file mode 100644 index 0000000..145bb09 --- /dev/null +++ b/units/user/user-probe.toml @@ -0,0 +1,9 @@ +# sd_notify to a user manager: NOTIFY_SOCKET under the user's runtime +# directory, and the sender's credentials checked by a manager that is not +# PID 1. Stays up until the shutdown stops it. +[unit] +description = "sd_notify probe, for a user" + +[service] +type = "notify" +exec = "/bin/notify-probe" From 1cae7d62326154f888a128f4268266fb228b60e5 Mon Sep 17 00:00:00 2001 From: "Youri T. K. K. Mattar" Date: Sun, 4 Oct 2026 09:54:55 -0300 Subject: [PATCH 2/3] fix(lint): signalfd records through as_chunks, as clippy now asks --- crates/oxinit/src/sys/raw.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/oxinit/src/sys/raw.rs b/crates/oxinit/src/sys/raw.rs index 43910b6..1954da3 100644 --- a/crates/oxinit/src/sys/raw.rs +++ b/crates/oxinit/src/sys/raw.rs @@ -92,7 +92,7 @@ pub fn read_signals(fd: &OwnedFd, out: &mut Vec) -> Result<(), Errno> { let n = rustix::io::read(fd, buf.as_mut_slice())?; out.clear(); - for record in buf.get(..n).unwrap_or(&[]).chunks_exact(SIGINFO_SIZE) { + for record in buf.get(..n).unwrap_or(&[]).as_chunks::().0 { if let Some(signo) = record.get(..4).and_then(|b| b.try_into().ok()) { out.push(u32::from_ne_bytes(signo)); } From 8e78626a1c12d38f137b1d714d990b811a795b14 Mon Sep 17 00:00:00 2001 From: "Youri T. K. K. Mattar" Date: Sun, 4 Oct 2026 10:01:41 -0300 Subject: [PATCH 3/3] fix(console): a line to stderr in one write, so the console cannot splice another process's output into it --- crates/oxinit/src/control.rs | 6 +-- crates/oxinit/src/logs.rs | 8 +-- crates/oxinit/src/main.rs | 89 ++++++++++++++++++++------------- crates/oxinit/src/shutdown.rs | 2 +- crates/oxinit/src/supervisor.rs | 68 ++++++++++++------------- crates/oxinit/src/user.rs | 20 ++++---- crates/oxinit/src/watchdog.rs | 14 +++--- 7 files changed, 112 insertions(+), 95 deletions(-) diff --git a/crates/oxinit/src/control.rs b/crates/oxinit/src/control.rs index 64d5da8..c604a17 100644 --- a/crates/oxinit/src/control.rs +++ b/crates/oxinit/src/control.rs @@ -99,7 +99,7 @@ impl Control { let fd = rustix::net::accept_with(&self.listener, SocketFlags::CLOEXEC).ok()?; if self.clients.len() >= MAX_CLIENTS { - eprintln!("oxinit: control: {MAX_CLIENTS} clients already connected; refusing"); + report!("oxinit: control: {MAX_CLIENTS} clients already connected; refusing"); return None; } @@ -135,7 +135,7 @@ impl Control { return None; } if sent > buf.len() { - eprintln!("oxinit: control: {sent} byte request over the {MAX_MESSAGE} byte limit"); + report!("oxinit: control: {sent} byte request over the {MAX_MESSAGE} byte limit"); return None; } @@ -152,7 +152,7 @@ impl Control { // socket: it either fits or it is refused, and the size was checked // when the message was encoded. if let Err(e) = rustix::net::send(&client.fd, message, SendFlags::empty()) { - eprintln!("oxinit: control: reply: {e}"); + report!("oxinit: control: reply: {e}"); } } diff --git a/crates/oxinit/src/logs.rs b/crates/oxinit/src/logs.rs index 6738c88..9a790fb 100644 --- a/crates/oxinit/src/logs.rs +++ b/crates/oxinit/src/logs.rs @@ -123,7 +123,7 @@ impl Logs { }; if self.shipper.is_some() { - eprintln!("oxinit: logs: a shipper is already connected; refusing"); + report!("oxinit: logs: a shipper is already connected; refusing"); return false; } @@ -147,7 +147,7 @@ impl Logs { /// a replacement is a restart away. pub fn disconnect(&mut self) { if self.shipper.take().is_some() { - eprintln!("oxinit: logs: shipper gone; output is buffering in the pipes"); + report!("oxinit: logs: shipper gone; output is buffering in the pipes"); } } @@ -228,7 +228,7 @@ fn offer(shipper: Option<&OwnedFd>, unit: &str, fd: BorrowedFd<'_>) { let rights = [fd]; if !ancillary.push(SendAncillaryMessage::ScmRights(&rights)) { - eprintln!("oxinit: logs: {unit}: no room for the descriptor"); + report!("oxinit: logs: {unit}: no room for the descriptor"); return; } @@ -238,6 +238,6 @@ fn offer(shipper: Option<&OwnedFd>, unit: &str, fd: BorrowedFd<'_>) { &mut ancillary, SendFlags::DONTWAIT, ) { - eprintln!("oxinit: logs: {unit}: {e}"); + report!("oxinit: logs: {unit}: {e}"); } } diff --git a/crates/oxinit/src/main.rs b/crates/oxinit/src/main.rs index 2098e01..95b7cdb 100644 --- a/crates/oxinit/src/main.rs +++ b/crates/oxinit/src/main.rs @@ -30,6 +30,23 @@ compile_error!( or use `cargo xtask boot`. See CONTRIBUTING.md." ); +/// One line to stderr, in one write(2). +/// +/// `eprintln!` formats straight into stderr, which is unbuffered: a write +/// for each piece of the format string. On the console, where every +/// service writes too, another process's output then lands between the +/// pieces — CI caught a shell's cursor query inside `requires \`broken\``. +/// Formatted first, the line goes out whole. stdout needs nothing: it is +/// line-buffered, so `println!` already writes a line at a time. +macro_rules! report { + ($($arg:tt)*) => {{ + use std::io::Write as _; + let mut line = format!($($arg)*); + line.push('\n'); + let _ = std::io::stderr().write_all(line.as_bytes()); + }}; +} + mod cgroup; mod console; mod container; @@ -114,7 +131,7 @@ fn main() -> ExitCode { } if !init { - eprintln!( + report!( "oxinit: running as pid {}, not 1. oxinit is an init system; \ it is not meant to be run from a shell.", std::process::id() @@ -132,7 +149,7 @@ fn boot() -> ! { let mounted = mounts::mount_all(); if let Err(e) = console::attach() { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } // After the mounts, because the last of the checks reads `/proc`; before @@ -150,7 +167,7 @@ fn boot() -> ! { let (hostname, failure) = mounts::set_hostname(); if let Some(e) = failure { - eprintln!("oxinit: {e}; using the name already set, {hostname}"); + report!("oxinit: {e}; using the name already set, {hostname}"); } // Signals become event loop input rather than interruptions. Blocking must @@ -159,7 +176,7 @@ fn boot() -> ! { let signals = match open_signalfd() { Ok(fd) => fd, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); fallback(None) } }; @@ -167,7 +184,7 @@ fn boot() -> ! { let timers = match timer::Timers::new() { Ok(timers) => timers, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); fallback(Some(&signals)) } }; @@ -175,7 +192,7 @@ fn boot() -> ! { let mut events = match event::EventLoop::new() { Ok(events) => events, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); fallback(Some(&signals)) } }; @@ -185,7 +202,7 @@ fn boot() -> ! { let notify = match notify::Notify::bind(&paths.notify()) { Ok(notify) => notify, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); fallback(Some(&signals)) } }; @@ -196,8 +213,8 @@ fn boot() -> ! { let mut control = match control::Control::bind(&paths.control()) { Ok(control) => Some(control), Err(e) => { - eprintln!("oxinit: {e}"); - eprintln!("oxinit: continuing without a control socket"); + report!("oxinit: {e}"); + report!("oxinit: continuing without a control socket"); None } }; @@ -208,8 +225,8 @@ fn boot() -> ! { let cgroups = match cgroup::Cgroups::open(oxinit_cgroup::CGROUP_ROOT) { Ok(cgroups) => cgroups, Err(e) => { - eprintln!("oxinit: {e}"); - eprintln!("oxinit: continuing without cgroups"); + report!("oxinit: {e}"); + report!("oxinit: continuing without cgroups"); cgroup::Cgroups::unavailable() } }; @@ -220,8 +237,8 @@ fn boot() -> ! { let logs = match logs::Logs::bind(&paths.log_socket()) { Ok(logs) => logs, Err(e) => { - eprintln!("oxinit: {e}"); - eprintln!("oxinit: continuing without log shipping"); + report!("oxinit: {e}"); + report!("oxinit: continuing without log shipping"); logs::Logs::unavailable() } }; @@ -236,14 +253,14 @@ fn boot() -> ! { ); if let Err(e) = wire(&events, &signals, &supervisor, control.as_ref()) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); fallback(Some(&signals)); } // With nothing to supervise, keep the machine usable the way M0 did. let mut shell = if supervisor.is_empty() { - eprintln!("oxinit: nothing to start; falling back to a console shell"); - shell::spawn().map_err(|e| eprintln!("oxinit: {e}")).ok() + report!("oxinit: nothing to start; falling back to a console shell"); + shell::spawn().map_err(|e| report!("oxinit: {e}")).ok() } else { supervisor.start_all(); None @@ -296,13 +313,13 @@ fn wire( if let Some(control) = control { if let Err(e) = events.register(control.as_fd(), event::Source::Control) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } if let Some(fd) = supervisor.logs.listener() { if let Err(e) = events.register(fd, event::Source::LogSocket) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } @@ -311,7 +328,7 @@ fn wire( // rather than an epoll call on every start and stop. for (id, fd) in supervisor.cgroups.events() { if let Err(e) = events.register_pri(fd, event::Source::Cgroup(id)) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } @@ -339,14 +356,14 @@ fn report_mounts(mounted: &mounts::Mounts) { } else { "them" }; - eprintln!( + report!( "oxinit: not permitted to mount {}; continuing without {them}", mounted.refused.join(", ") ); } for failure in &mounted.failures { - eprintln!("oxinit: {failure}"); + report!("oxinit: {failure}"); } } @@ -380,7 +397,7 @@ fn run( // for. A machine that has one configured resets, rather than waiting // on the console for someone who may never come. if let Err(e) = events.wait(&mut sources, timeout) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); // A failing epoll would spin this loop at full speed. cannot_continue(signals, environment); } @@ -407,7 +424,7 @@ fn run( })); if result.is_err() { - eprintln!("oxinit: handler panicked; continuing"); + report!("oxinit: handler panicked; continuing"); } } @@ -417,7 +434,7 @@ fn run( // idempotent, and it cannot be forgotten in a path added later. let result = catch_unwind(AssertUnwindSafe(|| supervisor.pump())); if result.is_err() { - eprintln!("oxinit: start queue panicked; continuing"); + report!("oxinit: start queue panicked; continuing"); } // One reconciliation per wake-up rather than an arm/disarm call inside @@ -425,7 +442,7 @@ fn run( // cannot be forgotten in a path added later. let result = catch_unwind(AssertUnwindSafe(|| supervisor.sync_sockets(events))); if result.is_err() { - eprintln!("oxinit: socket reconciliation panicked; continuing"); + report!("oxinit: socket reconciliation panicked; continuing"); } // After everything else, so the boot unit's state is this wake-up's. @@ -440,7 +457,7 @@ fn run( watchdog.tick(booted, supervisor.shutting_down()); })); if result.is_err() { - eprintln!("oxinit: watchdog panicked; continuing"); + report!("oxinit: watchdog panicked; continuing"); } } @@ -453,8 +470,8 @@ fn run( // exits inside `finalize` and never reaches here. PID 1 // exiting is a kernel panic, so the only option left is to // stay up and say so. - eprintln!("oxinit: {action}: {e}"); - eprintln!("oxinit: cannot go down; staying up with everything stopped"); + report!("oxinit: {action}: {e}"); + report!("oxinit: cannot go down; staying up with everything stopped"); cannot_continue(signals, environment); } } @@ -469,7 +486,7 @@ fn on_signals( environment: &container::Environment, ) { if let Err(e) = sys::raw::read_signals(signals, pending).map_err(Error::ReadSignalFd) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return; } @@ -486,8 +503,8 @@ fn on_signals( if supervisor.shutting_down() { *shell = None; } else { - eprintln!("oxinit: {} exited; respawning", shell::SHELL); - *shell = shell::spawn().map_err(|e| eprintln!("oxinit: {e}")).ok(); + report!("oxinit: {} exited; respawning", shell::SHELL); + *shell = shell::spawn().map_err(|e| report!("oxinit: {e}")).ok(); } } } @@ -561,7 +578,7 @@ fn on_request( match oxinit_ipc::encode(&response) { Ok(bytes) => control.reply(id, &bytes), - Err(e) => eprintln!("oxinit: control: {e}"), + Err(e) => report!("oxinit: control: {e}"), } } @@ -586,7 +603,7 @@ fn on_shipper(supervisor: &mut supervisor::Supervisor, events: &event::EventLoop }; if let Err(e) = events.register(fd, event::Source::LogShipper) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); supervisor.logs.disconnect(); } } @@ -624,8 +641,8 @@ fn cannot_continue(signals: &OwnedFd, environment: &container::Environment) -> ! /// Last resort. The loop cannot continue, so give an operator a shell and keep /// reaping. PID 1 exiting is a kernel panic, so this never returns. fn fallback(signals: Option<&OwnedFd>) -> ! { - eprintln!("oxinit: event loop unavailable; falling back to a console shell"); - let mut child = shell::spawn().map_err(|e| eprintln!("oxinit: {e}")).ok(); + report!("oxinit: event loop unavailable; falling back to a console shell"); + let mut child = shell::spawn().map_err(|e| report!("oxinit: {e}")).ok(); let mut pending = Vec::new(); loop { @@ -645,7 +662,7 @@ fn fallback(signals: Option<&OwnedFd>) -> ! { }); if dead || child.is_none() { - child = shell::spawn().map_err(|e| eprintln!("oxinit: {e}")).ok(); + child = shell::spawn().map_err(|e| report!("oxinit: {e}")).ok(); } } } diff --git a/crates/oxinit/src/shutdown.rs b/crates/oxinit/src/shutdown.rs index 6462a8f..6f820bd 100644 --- a/crates/oxinit/src/shutdown.rs +++ b/crates/oxinit/src/shutdown.rs @@ -126,7 +126,7 @@ pub fn finalize( // remounted read-only, and failing here would strand a machine that was // about to shut down cleanly. if let Err(e) = rustix::mount::mount_remount("/", rustix::mount::MountFlags::RDONLY, "") { - eprintln!("oxinit: remount / read-only: {e}"); + report!("oxinit: remount / read-only: {e}"); } if let Some(watchdog) = watchdog { diff --git a/crates/oxinit/src/supervisor.rs b/crates/oxinit/src/supervisor.rs index b5055d9..db47fe7 100644 --- a/crates/oxinit/src/supervisor.rs +++ b/crates/oxinit/src/supervisor.rs @@ -109,14 +109,14 @@ impl Supervisor { ) -> Self { let loaded = source.load(hostname); for error in &loaded.errors { - eprintln!("oxinit: {error}"); + report!("oxinit: {error}"); } let instances = BTreeMap::new(); let mut order = Vec::new(); if loaded.units.is_empty() { - eprintln!("oxinit: no units found"); + report!("oxinit: no units found"); return Self { instances, order, @@ -139,13 +139,13 @@ impl Supervisor { match oxinit_graph::resolve(&loaded.units) { Ok(plan) => { for warning in &plan.warnings { - eprintln!("oxinit: {warning}"); + report!("oxinit: {warning}"); } order = plan.order; } Err(e) => { - eprintln!("oxinit: {e}"); - eprintln!("oxinit: refusing to start with an unresolvable unit set"); + report!("oxinit: {e}"); + report!("oxinit: refusing to start with an unresolvable unit set"); } } @@ -177,7 +177,7 @@ impl Supervisor { // cgroup rather than churning the hierarchy and the registration. if matches!(unit.kind, Kind::Service(_)) { if let Err(e) = supervisor.cgroups.add(name, &unit.full_name()) { - eprintln!("oxinit: {name}: {e}"); + report!("oxinit: {name}: {e}"); } } @@ -194,7 +194,7 @@ impl Supervisor { }; for failure in supervisor.sockets.bind(name, socket) { - eprintln!("oxinit: {name}: {failure}"); + report!("oxinit: {name}: {failure}"); } } @@ -256,7 +256,7 @@ impl Supervisor { // yet. Having already failed is the only thing that can be known, // and it is the thing the unit format promises to act on. if let Some(failed) = self.failed_requirement(&name) { - eprintln!("oxinit: {name}: requires `{failed}`, which failed; not starting"); + report!("oxinit: {name}: requires `{failed}`, which failed; not starting"); if let Some(instance) = self.instances.get_mut(&name) { instance.failed_to_start(); } @@ -409,7 +409,7 @@ impl Supervisor { } if shutdown.overdue() { - eprintln!( + report!( "oxinit: giving up waiting for {}; going down anyway", busy.join(", ") ); @@ -488,7 +488,7 @@ impl Supervisor { if let Some(instance) = self.instances.get_mut(name) { instance.failed_to_start(); } - eprintln!("oxinit: {name}: {e}"); + report!("oxinit: {name}: {e}"); return; } @@ -502,7 +502,7 @@ impl Supervisor { if let Some(instance) = self.instances.get_mut(name) { instance.failed_to_start(); } - eprintln!("oxinit: {name}: {e}"); + report!("oxinit: {name}: {e}"); return; } }; @@ -566,7 +566,7 @@ impl Supervisor { unit: name.to_owned(), }; if let Err(e) = self.timers.schedule(start_sec, alarm) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } @@ -576,7 +576,7 @@ impl Supervisor { } Err(e) => { instance.failed_to_start(); - eprintln!("oxinit: {name}: {e}"); + report!("oxinit: {name}: {e}"); } } } @@ -618,7 +618,7 @@ impl Supervisor { } } } - Err(e) => eprintln!("oxinit: {name}: {e}"), + Err(e) => report!("oxinit: {name}: {e}"), } } @@ -649,7 +649,7 @@ impl Supervisor { self.arm(name, &service, schedule.next(unix_now())); if !self.instances.contains_key(&service) { - eprintln!("oxinit: {name}: no unit named `{service}` to start"); + report!("oxinit: {name}: no unit named `{service}` to start"); return; } @@ -663,7 +663,7 @@ impl Supervisor { .is_some_and(|instance| !is_idle(instance.state)); if busy { - eprintln!("oxinit: {name}: {service} is still running; skipping this firing"); + report!("oxinit: {name}: {service} is still running; skipping this firing"); return; } @@ -745,7 +745,7 @@ impl Supervisor { delay, instance.restarts ); if let Err(e) = self.timers.schedule(delay, Alarm::Restart { unit: name }) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } None if deactivating => { @@ -786,7 +786,7 @@ impl Supervisor { if let Some(instance) = self.instances.get_mut(name) { instance.failed_to_start(); } - eprintln!( + report!( "oxinit: {name}: type = \"forking\" needs a cgroup to tell whether \ anything is left running" ); @@ -806,11 +806,11 @@ impl Supervisor { } (Exit::Clean, false) => { instance.failed_to_start(); - eprintln!("oxinit: {name}: the initial process left nothing running"); + report!("oxinit: {name}: the initial process left nothing running"); } (exit, _) => { instance.failed_to_start(); - eprintln!("oxinit: {name}: the initial process {exit}"); + report!("oxinit: {name}: the initial process {exit}"); } } } @@ -873,7 +873,7 @@ impl Supervisor { unit: name.to_owned(), }; if let Err(e) = self.timers.schedule(delay, alarm) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } None => println!("oxinit: {name} is {}", instance.state), @@ -911,7 +911,7 @@ impl Supervisor { unit: name.to_owned(), }; if let Err(e) = self.timers.schedule(delay, alarm) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } None => println!("oxinit: {name} is {}", instance.state), @@ -987,7 +987,7 @@ impl Supervisor { // reached nothing and the stop waited for an exit that was // never asked for, holding the shutdown open. if let Some(pid) = child.filter(|pid| left_its_cgroup(cgroup, *pid)) { - eprintln!("oxinit: {name}: pid {pid} is no longer in its cgroup"); + report!("oxinit: {name}: pid {pid} is no longer in its cgroup"); signalled += usize::from(terminate(pid)); } signalled @@ -1003,7 +1003,7 @@ impl Supervisor { unit: name.to_owned(), }; if let Err(e) = self.timers.schedule(stop_sec, alarm) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } @@ -1025,7 +1025,7 @@ impl Supervisor { return; } - eprintln!("oxinit: {name} did not start in time; stopping it"); + report!("oxinit: {name} did not start in time; stopping it"); self.stop(name, StopCause::Watchdog); } @@ -1039,7 +1039,7 @@ impl Supervisor { return; } - eprintln!("oxinit: {name} did not stop in time; killing its cgroup"); + report!("oxinit: {name} did not stop in time; killing its cgroup"); instance.stop_timed_out(); let child = instance.pid(); @@ -1047,7 +1047,7 @@ impl Supervisor { Some(cgroup) => { let killed = cgroup.kill(); if let Err(e) = &killed { - eprintln!("oxinit: {name}: {e}"); + report!("oxinit: {name}: {e}"); } // The process oxinit forked, if `cgroup.kill` cannot have // reached it: it moved out of the cgroup, or the cgroup is @@ -1065,7 +1065,7 @@ impl Supervisor { // Without a cgroup this can only reach the one process oxinit // forked; anything it forked in turn survives. Saying so // beats pretending the unit is gone. - eprintln!("oxinit: {name}: no cgroup; only its initial process can be killed"); + report!("oxinit: {name}: no cgroup; only its initial process can be killed"); if let Some(pid) = child { if let Some(pid) = Pid::from_raw(pid as i32) { let _ = rustix::process::kill_process(pid, Signal::KILL); @@ -1172,7 +1172,7 @@ impl Supervisor { match result { Ok(()) => self.sockets.set_armed(id, want), - Err(e) => eprintln!("oxinit: {service}: {e}"), + Err(e) => report!("oxinit: {service}: {e}"), } } } @@ -1265,7 +1265,7 @@ impl Supervisor { Some(_) | None => { if matches!(unit.kind, Kind::Service(_)) { if let Err(e) = self.cgroups.add(name, &unit.full_name()) { - eprintln!("oxinit: {name}: {e}"); + report!("oxinit: {name}: {e}"); } } self.instances @@ -1401,7 +1401,7 @@ impl Supervisor { unit: unit.to_owned(), }, ) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); } } @@ -1411,7 +1411,7 @@ impl Supervisor { let fired = match self.timers.wall_expired(unix_now()) { Ok(fired) => fired, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return; } }; @@ -1427,7 +1427,7 @@ impl Supervisor { let fired = match self.timers.expired() { Ok(fired) => fired, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return; } }; @@ -1473,7 +1473,7 @@ impl Supervisor { return; } - eprintln!("oxinit: {name} missed its watchdog deadline"); + report!("oxinit: {name} missed its watchdog deadline"); self.report_usage(name); // Through the ordinary stop path rather than a SIGKILL to the one pid diff --git a/crates/oxinit/src/user.rs b/crates/oxinit/src/user.rs index d13c783..6be06cf 100644 --- a/crates/oxinit/src/user.rs +++ b/crates/oxinit/src/user.rs @@ -39,21 +39,21 @@ pub fn boot() -> ExitCode { if uid.is_root() { // Root's services are system units. A root user manager would be a // second system manager with none of PID 1's guarantees. - eprintln!("oxinit: --user is for users; root's services are system units"); + report!("oxinit: --user is for users; root's services are system units"); return ExitCode::FAILURE; } let paths = match Paths::user_from_env() { Ok(paths) => paths, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return ExitCode::FAILURE; } }; let name = oxinit_user::name_of_system(uid.as_raw()); if let Err(e) = private_dir(&paths.runtime_dir) { - eprintln!("oxinit: {}: {e}", paths.runtime_dir.display()); + report!("oxinit: {}: {e}", paths.runtime_dir.display()); return ExitCode::FAILURE; } @@ -69,13 +69,13 @@ pub fn boot() -> ExitCode { // `forking` daemon's real process is one, and it has to be reaped by the // manager that is supervising it. if let Err(e) = rustix::process::set_child_subreaper(Some(rustix::process::getpid())) { - eprintln!("oxinit: become a subreaper: {e}; orphans will go to pid 1"); + report!("oxinit: become a subreaper: {e}; orphans will go to pid 1"); } let signals = match crate::open_signalfd() { Ok(fd) => fd, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return ExitCode::FAILURE; } }; @@ -87,7 +87,7 @@ pub fn boot() -> ExitCode { ) { (Ok(timers), Ok(events), Ok(notify)) => (timers, events, notify), (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return ExitCode::FAILURE; } }; @@ -98,7 +98,7 @@ pub fn boot() -> ExitCode { let control = match control::Control::bind(&paths.control()) { Ok(control) => control, Err(e) => { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return ExitCode::FAILURE; } }; @@ -106,7 +106,7 @@ pub fn boot() -> ExitCode { let logs = match logs::Logs::bind(&paths.log_socket()) { Ok(logs) => logs, Err(e) => { - eprintln!("oxinit: {e}; continuing without log shipping"); + report!("oxinit: {e}; continuing without log shipping"); logs::Logs::unavailable() } }; @@ -140,7 +140,7 @@ pub fn boot() -> ExitCode { let mut supervisor = Supervisor::load(&hostname, source, timers, notify, cgroups, logs); if let Err(e) = crate::wire(&events, &signals, &supervisor, Some(&control)) { - eprintln!("oxinit: {e}"); + report!("oxinit: {e}"); return ExitCode::FAILURE; } @@ -168,7 +168,7 @@ pub fn boot() -> ExitCode { /// Its services are left running, reparented to PID 1. Stopping them here /// would mean running the shutdown this loop can no longer run. pub fn give_up() -> ! { - eprintln!("oxinit: the user manager cannot continue; exiting"); + report!("oxinit: the user manager cannot continue; exiting"); std::process::exit(1) } diff --git a/crates/oxinit/src/watchdog.rs b/crates/oxinit/src/watchdog.rs index c45ce7b..4659c20 100644 --- a/crates/oxinit/src/watchdog.rs +++ b/crates/oxinit/src/watchdog.rs @@ -48,7 +48,7 @@ impl Watchdog { let config = match load().and_then(Settings::resolve) { Ok(config) => config?, Err(e) => { - eprintln!("oxinit: watchdog: {e}; not opening the watchdog"); + report!("oxinit: watchdog: {e}; not opening the watchdog"); return None; } }; @@ -98,7 +98,7 @@ impl Watchdog { /// different configuration, not a smaller version of it. pub fn check_boot_unit(&self, known: bool) { if let (Some(unit), false) = (self.awaiting(), known) { - eprintln!( + report!( "oxinit: watchdog: boot-unit `{unit}` is not a loaded unit; \ nothing can confirm this boot, and the watchdog will reset it" ); @@ -129,7 +129,7 @@ impl Watchdog { } Some(Event::BootOverdue) => { let config = self.policy.config(); - eprintln!( + report!( "oxinit: watchdog: the boot did not reach {} within {} s; \ no longer feeding {}, which resets the machine when its \ {} s timeout runs out", @@ -162,7 +162,7 @@ impl Watchdog { // at all, and says so in the kernel log. match rustix::io::write(&device, b"V") { Ok(_) => println!("oxinit: watchdog: stopped"), - Err(e) => eprintln!("oxinit: watchdog: stop: {e}"), + Err(e) => report!("oxinit: watchdog: stop: {e}"), } drop(device); } @@ -190,7 +190,7 @@ impl Watchdog { Ok(device) => device, Err(e) => { if !self.reported_missing { - eprintln!( + report!( "oxinit: watchdog: open {path}: {e}; looking again as it may yet appear" ); self.reported_missing = true; @@ -204,7 +204,7 @@ impl Watchdog { let applied = match raw::watchdog_set_timeout(device.as_fd(), wanted) { Ok(applied) => Some(applied), Err(e) => { - eprintln!("oxinit: watchdog: {path} would not take a {wanted} s timeout: {e}"); + report!("oxinit: watchdog: {path} would not take a {wanted} s timeout: {e}"); raw::watchdog_get_timeout(device.as_fd()).ok() } }; @@ -229,7 +229,7 @@ impl Watchdog { // Any byte but `V` is a keepalive. Not the ioctl: a write is what // every driver has understood since before the ioctl existed. if let Err(e) = rustix::io::write(device, b"\0") { - eprintln!("oxinit: watchdog: {e}"); + report!("oxinit: watchdog: {e}"); } } }