switch-root: return real errors instead of false success - #496
Conversation
troglobit
left a comment
There was a problem hiding this comment.
Sorry for the slow reply, been on vacation and stayed off the keyboard a bit more than usual.
Thanks for the writeup, and no need to apologize for the length, it helped. You are right about the bug: the ACK went out before anything was checked. Factoring out a precheck is a good way around that, and three small commits made it easy to read.
A few things before this can go in.
do_move_mount() cannot tell "not mounted" from "not a mount point":
if (stat(oldpath, &st))
return 0; /* Not mounted, skip */stat() succeeds on a plain directory, so we reach MS_MOVE and get EINVAL back. /run can be in that state: fs_finalize() only mounts a tmpfs there if !fistmpfs("/run"), and fistmpfs() uses statfs(), which reports the containing filesystem, so on a tmpfs rootfs /run stays a plain directory. A ramfs initramfs does get the tmpfs, so it depends on the layout. But where it happens, your patch turns a dbg() on a working system into a switch-root that dies after everything has been killed. The guard needs fismnt(), or an st_dev compare against the parent, before a -1 can be fatal.
Also, the || chain stops at the first failure, so a bad /dev means /proc, /sys and /run are never tried. Try all four and log each one.
Now, on the question you left open; I think rescue mode is a good idea here. We already do this for other boot failures we cannot come back from, see fsck() and fs_mount_all() in finit.c: log with LOG_CONSOLE | LOG_ALERT, then call sulogin(1), which gives you a maintenance shell and reboots when you exit it. A switch-root that dies mid-teardown is the same kind of failure, and this is complex enough to debug that I want a shell instead of a dead machine.
So for anything past the point of no return, the moves, the chdir, the mount, the chroot, and a failed execl, drop the return -1 and go to sulogin instead. Two things to watch if you take it on: sulogin() is static in finit.c, so it needs exporting, and sig_unblock() only runs just before the execl today, so it
has to happen first or the shell inherits a blocked signal mask. The shell will also be in better shape before the moves than after a partial one, since by then /dev has moved. Still much better than what we have.
Next, the runlevel guard still ACKs, which is the same bug you found:
case INIT_CMD_SWITCH_ROOT:
if (runlevel != INIT_LEVEL && runlevel != 1) {
warnx("switch-root only allowed in runlevel S or 1");
goto done;
}result is still 0, and done: sends ACK when result is 0. So switch-root in the wrong runlevel exits 0 and prints nothing. Needs result = 1; before the goto.
The NACK also loses the reason. The precheck logs what is actually wrong, but only errno reaches the client, so the user gets switch-root: Invalid argument while the useful text goes to the console. Pass the message out too. Some precheck paths also let close() and logit() run before errno is read, so you can end up with switch-root: Success.
Smaller things:
- Let
api_cb()send the reply. Setresultand fall through todone:instead of writing the NACK and closing sd yourself.leave:closessdas well, so it now gets closed twice on the common path - The precheck reuses
newroot_stfor the init binary. Correct, but a separatestruct stat stis easier to follow errno = EACCEScovers both "stat failed" and "not a regular file".EISDIRorENOEXECfits what we printlogit(LOG_ERR, "switch_root: cannot stat /")dropsstrerror(errno), unlike its neighbours- Add a comment on the
client_send() && rq.cmd == INIT_CMD_NACKtest saying why it differs fromdo_cmd(), that a lost connection is the expected success here. Otherwise it reads like a broken copy. It also still exits 0 when finit is not running, sinceclient_send()returns 255 for connect and write failures
too and leavesrq.cmdalone
On the commits themselves: we do not use the Conventional Commits style. So write initramfs: ... not fix(initramfs): ..., two subjects also wrap onto a second line with no blank line, so git reads that as the body. And all three are subject only, which is the main thing I want fixed. The reasoning in your PR text belongs in the commit bodies, commit messages are there to tell the story of why :-)
|
Fixed everything from the review, ready for another pass whenever you've got time Also hope the vacation actually recharged you and this PR isn't what welcomes you back lol |
|
Time to fix merge conflicts... Btw @troglobit u still alive? |
|
Yes, still alive @FixeQD :-) Been really busy lately, first evening I've had to sit down with this properly. The update addresses everything from the review, nice work! One thing left: the wrong-runlevel path now NACKs, but On the commits, I think you read more into my last comment than I intended. One commit per review point is more than the change needs, and the new ones are still subject-only, which was the main thing I wanted fixed. Folded together this is maybe four commits, each with the why in the body, and your PR text already has all of it. Rather than sending you around another lap: if it's OK with you I'll take the branch from here, sort out the conflicts, fold the commits into a final series with your authorship intact, fix the runlevel message, and merge. You've done the hard part. If you'd rather finish it yourself, that's fine too, just let me know. |
|
Alr, if u want u can take the branch, good luck 👍 |
initctl switch-root always exits 0, no matter what happens on the
finit side. do_switch_root_api() sends the ACK before switch_root()
has validated anything, so any failure after that point never reaches
the client -- the error only shows up in the log. The runlevel guard
in api_cb() has the same problem: it rejects the request but still
sends an ACK.
Split the validation out of switch_root() into switch_root_precheck()
and run it before the ACK. A failed check now sends a NACK with the
error message, which initctl prints before exiting 1:
initctl switch-root /mnt
switch-root: /mnt is not a mount point
The ACK is only sent once the precheck passes, since after that point
we are committed. On success finit execs the new init and the
connection dies with no reply at all, so initctl treats only an
explicit NACK as failure.
Signed-off-by: Paweł Sobczak <github@fixeq.qzz.io>
The precheck accepts a directory as new init: access(path, X_OK) only checks search permission, which directories almost always have, so the mistake is not caught until after teardown. The snprintf() building init_path also never checks for truncation, so an overlong newroot + newinit validates the wrong path. Require a regular file, with EISDIR or ENOEXEC to match what is printed, and fail with ENAMETOOLONG on a truncated init path. Signed-off-by: Paweł Sobczak <github@fixeq.qzz.io>
A failed move of /dev, /proc, /sys or /run is logged at dbg() level and ignored, so the new init boots without its virtual filesystems and falls over much later in some unrelated way. The steps after, chdir/mount/chroot and the final execl(), do return -1 on failure, but by then all services are dead and the API socket is gone, so there is nobody left to report to: the system hangs with a live but useless PID 1. Make a failed move fatal, and try all four moves even if one fails, so a bad /dev does not also skip /proc, /sys and /run. A plain directory is not an error though: /run stays a plain directory on a tmpfs rootfs, so only a path on a different device than / is treated as a mount point and moved. Any failure past the point of no return now drops to sulogin(1) for a maintenance shell that reboots on exit, same as a fatal fsck() at boot. Signals are unblocked before the moves so the shell does not inherit finit's blocked signal mask. Signed-off-by: Paweł Sobczak <github@fixeq.qzz.io>
There, just waiting for regression tests before merging. Thank you again for your contribution, very appreciated! 🙏 🙇♂️ |
|
Only one flaky test that failed in the gcc job, test fixed on |
So this started because I hit a (I would name it "a bug") in a downstream project (finix) where
initctl switch-rootalways exited 0 no matter what happened, even when the switch actually failed. Turned outdo_switch_root_api()was sending ACK to the client beforeswitch_root()had validated anything, so any failure after that point was just invisible, client already thought it worked3 commits, each one is a separate thing so they're easy to review/revert on their own if needed:
switch_root()intoswitch_root_precheck(), run it before ACK. If precheck fails (bad newroot, missing/non-executable init, whatever), the client now gets a real NACK with an actual error message instead of silence.initctl switch-rootfinally returns exit code 1 on failure instead of always 0. ACK only goes out once precheck passes, since after that we're committed anywayaccess(init_path, X_OK)happily passes for directories since X_OK is just "search permission" and dirs almost always have it, so pointingnewinitat a dir by mistake would only blow up at the very end, after everything's already torn down. Added astat()+S_ISREGcheck. Alsosnprintfbuildinginit_pathwasn't checking for truncation, so a long enough newroot+newinit would silently validate the wrong path. Both are cheap one-off checks, no behavior change for the normal casedo_move_mount()returns an int but nobody was checking it. If moving/proc,/sys,/devor/runto the new root failed, it only logged atdbg()level (so basically nowhere by default) and switch_root kept going straight into chroot+exec anyway. New init boots into an environment missing core virtual filesystems and fails in some completely unrelated, confusing way later. Now it aborts and logs atLOG_ERRif any of the 4 moves failDidn't touch the deeper issue that a failure after all this (mid-teardown, e.g.
execl()itself failing once everything's already killed and mounted still leaves the process alive but the system in a pretty rough state with nothing left to talk to it. That's a real design question (rescue shell as last resort? just die loud?) and I didn't want to sneak a behavior change like that into a validation PR without discussing it firstSorry if this description is longer than it needs to be, heard you're (Yes, I'm talking about u @troglobit :P) pretty serious about code quality on this project so figured I'd rather over-explain the reasoning than have you guess at it from the diff alone
Btw C is a dystopian hell 😭