From f904d33a11409d34d7c8d53aea349d6585c2fcc3 Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Sun, 23 Aug 2026 16:41:07 +0000 Subject: [PATCH] Add -D/--daemonize to run -r/--run sessions in the background Single-fork daemonize: the child calls setsid() itself rather than re-enabling bwrap's own --new-session, which was previously removed (and stays that way) because it only detaches the deeply-nested sandboxed command, leaving bwrap/nsenter/slocker-lite itself still attached to the original session -- not real daemonization. Calling setsid() in slocker-lite's own forked child, before it execs into nsenter/bwrap, detaches the whole chain at once, since exec() never changes session membership -- confirmed via ps -o sid,pgid,tty against a running daemonized session. The child also ignores SIGHUP (confirmed to survive exec() into bwrap, unlike a real handler, which exec() resets) and redirects stdin to /dev/null and stdout/stderr to a log file under $XDG_STATE_HOME/slocker-lite/logs/ (session_log_file_path(), new sibling to the existing session_pid_file_path() in pid_file.{h,cpp}). The original process blocks briefly on a pipe until the child reports the real bwrap pid (or exits without doing so), then prints it and exits -- keeping "pid" meaning the same thing everywhere in this codebase (the same one --list-processes/-e/--exec already use), rather than introducing a separate daemon-supervisor pid. run_bwrap() gained an on_bwrap_pid_known callback (bwrap.{h,cpp}) for this, invoked alongside the existing session-lock creation at the same instant. The daemonized child is what runs run_container()'s entire existing body afterward, including the unmount/cleanup that already ran once bwrap exits -- no separate watcher/reaper process. Testing caught a real bug before this was correct: the log file gets renamed from its initial (daemon-pid-named) filename to the final -.log once the real pid is known, but the parent had already been told the pre-rename path and was never updated -- fixed by re-reporting the path over the same pipe after the rename. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz --- CLAUDE.md | 80 +++++++++++++++++++++- README.md | 18 +++++ meson.build | 2 +- src/bwrap.cpp | 11 ++- src/bwrap.h | 13 +++- src/daemonize.cpp | 167 ++++++++++++++++++++++++++++++++++++++++++++++ src/daemonize.h | 61 +++++++++++++++++ src/main.cpp | 56 +++++++++++++--- src/pid_file.cpp | 12 +++- src/pid_file.h | 6 ++ 10 files changed, 407 insertions(+), 19 deletions(-) create mode 100644 src/daemonize.cpp create mode 100644 src/daemonize.h diff --git a/CLAUDE.md b/CLAUDE.md index 8194888..69a0469 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,20 @@ Source layout (all under `src/`): (`argv[optind:]`, required — errors out if empty) is collected the same way `-r`'s own command is, sharing that mode's exemption from the "no leftover positional args" check. + `-D/--daemonize` (has a short form; `'D'` was free) is a plain boolean flag + (`daemonize_flag`, set in its own `case 'D':`, same pattern as + `-n/--no-nsenter`) threaded through to `run_container()`, which computes + `container_name` *before* `mount_image()` now (moved up from right before + the `run_bwrap()` call — it only ever depended on `image_tar`, a parameter + available from the start, so this is a pure reordering) and, if daemonizing, + calls `daemonize(container_name)` (`daemonize.{h,cpp}`, see below) + immediately after: a returned value means this is the original (parent) + process (or a hard daemonize failure) — print it and `return` right away; + `nullopt` means this is the now-detached child, which falls through into the + rest of `run_container()`'s existing body completely unchanged, including + the unmount/cleanup that already runs after `run_bwrap()` returns (no + separate watcher/reaper — the daemonized child *is* what runs the whole + session, start to finish). `inspect_image_command()` implements `-i/--inspect `: prints every `OciImageConfig` field (user/group, exposed ports, env, volumes, default command) without mounting or running the image — extend it @@ -190,6 +204,12 @@ Source layout (all under `src/`): *sandboxed command's own* lookup is affected by a `--env PATH=...` override (as expected — same as overriding `PATH` in any real shell before running a bare command name), and `bwrap`/`nsenter` are always found regardless. + `run_bwrap()` also takes an optional `on_bwrap_pid_known` callback, invoked + alongside (not instead of) the session-lock-creation lambda, at the exact + same `on_start` timing — `-D/--daemonize` (`daemonize.{h,cpp}`, see below) + hooks in here via `report_daemon_started()` to learn the real pid at the + same instant everything else that needs it does, rather than needing its own + separate pid-discovery mechanism. - `priv_drop_helper.cpp` → the separate `slocker-lite-priv-drop` binary (its own `executable()` target in `meson.build`, **built with `-static`**). Deliberately has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is @@ -250,7 +270,11 @@ Source layout (all under `src/`): `$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same resolution pattern as `config_file_path()` below, for state instead of config), sanitizing `container_name` first (anything outside `[A-Za-z0-9._-]` - → `_`, since an image name/tag can contain `/` or `:`). `create_session_lock()` + → `_`, since an image name/tag can contain `/` or `:`). `session_log_file_path()` + is a sibling resolving to `$XDG_STATE_HOME/slocker-lite/logs/-.log` + instead — same sanitization, same `$XDG_STATE_HOME`/`$HOME` fallback, just a + different subdirectory and a `.log` extension — used by `daemonize.{h,cpp}` + (see below) for `-D/--daemonize`'s log file. `create_session_lock()` creates the file (`O_CREAT|O_WRONLY|O_TRUNC|O_CLOEXEC`, mode 0644 — `O_CLOEXEC` matters: this fd must never leak into the sandboxed command's own fd table), writes the pid as text, and takes an exclusive, non-blocking `flock()` on it — @@ -285,6 +309,56 @@ Source layout (all under `src/`): a check and a later removal. Only files it actually removes are reported back (as `SessionInfo`s with `running=false`); still-locked (running) files are left untouched and not reported. +- `daemonize.{h,cpp}` — implements `-D/--daemonize`'s fork/detach mechanics. + `daemonize(container_name)` sets up a `pipe2(..., O_CLOEXEC)` pair (so it + never leaks into `bwrap`/the sandboxed command, same reasoning as the pid + file's own `O_CLOEXEC`) and `fork()`s. The **child** calls `setsid()` — + deliberately here, not via re-adding bwrap's own `--new-session` (removed + earlier, see the `build_bwrap_args()` comment): `--new-session` only calls + `setsid()` for the deeply-nested sandboxed command *inside* bwrap's own + namespace setup, leaving the outer `bwrap`/`nsenter`/`slocker-lite` processes + still attached to the *original* session and still receiving its signals + (e.g. a `SIGHUP` when the controlling terminal closes) — not real + daemonization. Calling `setsid()` in our own forked child, *before* it execs + into `nsenter`/`bwrap`, detaches the entire chain at once, since `exec()` + never changes session membership — confirmed by direct testing + (`ps -o pid,sid,pgid,tty`): the daemon child becomes its own session leader + with no controlling tty, and `bwrap` (a later descendant) shares that same + session, also with no tty. The child also `sigaction()`s `SIGHUP` to + `SIG_IGN` (survives the later `exec()` into `nsenter`/`bwrap`, unlike a real + handler, which `exec()` resets to default — confirmed by sending `SIGHUP` + directly to a running daemonized `bwrap` pid and it staying alive), then + redirects stdin to `/dev/null` and stdout/stderr to a log file at + `session_log_file_path(container_name, getpid())` (`pid_file.h`) — named + after its *own* pid since the real session pid (`bwrap`'s) isn't known yet. + If the log directory/file can't be set up at all, that's a **hard** failure + here (`_exit(1)`), not best-effort — silently losing the very output + `--daemonize` was asked to capture would defeat the point of the flag. The + child reports `"LOG \n"` over the pipe immediately (so the parent can + show a useful location even on failure) and returns `nullopt` to its caller + (`run_container()`, `main.cpp`), which then falls through into the rest of + that function's existing body completely unchanged — **the daemonized child + is what runs the whole rest of `run_container()`, including the unmount/ + cleanup that already existed after `run_bwrap()` returns; no separate + watcher/reaper process exists**. The **parent** blocks reading the pipe until + EOF, returning the accumulated `"LOG "`/`"PID "` lines as a `DaemonizeResult` + — the caller then prints it and exits immediately without running any + session logic itself. `report_daemon_started(container_name, pid)` (called + from `run_bwrap()`'s new `on_bwrap_pid_known` callback — see `bwrap.{h,cpp}` + below — the instant the real `bwrap` pid is known) renames the pid-named log + file to `-.log`, re-reports the *updated* `"LOG "` line + (a real bug caught by testing: the parent's first `"LOG "` line names the + pre-rename, daemon-pid-named path — without a second one, the parent would + print a stale filename that doesn't match where the file actually ends up), + then `"PID \n"` and closes its own end of the pipe — must happen here, + explicitly, rather than waiting for the pipe to close naturally at the end of + the (potentially very long) daemon's lifetime, or the parent would block for + as long as the session runs instead of returning promptly. The pipe's write + fd and the current log path are tracked as private file-scope state in + `daemonize.cpp` (matching `process.cpp`'s own `g_foreground_child_pid` + pattern for "there's only ever one of these per process" runtime state), + since `report_daemon_started()` is called later, from a different function, + not threaded explicitly through every call in between. - `exec_session.{h,cpp}` — implements `-e/--exec `: joins an already-running `-r/--run` session's namespaces via `nsenter` and runs a command inside it in the foreground. `exec_in_session()` first confirms `pid` is a tracked, running @@ -398,8 +472,8 @@ Build directory is `buildDir/` (already configured). (see `priv_drop_helper.cpp` in "Project state") - Run the executable: `./buildDir/slocker-lite -m ` (see `--help` for the full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`, - `-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `--user`, `--group`, - `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`, + `-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `-D/--daemonize`, + `--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`, `--delete-volume-full`, `--list-processes`, `--clean-processes`, `-t/--test`, `--log-level`, `-h/--help`, `-V/--version`) - Run tests: `meson test -C buildDir` diff --git a/README.md b/README.md index e52bb48..480bf8c 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ slocker-lite -V|--version | `-u, --umount ` | Unmount a previously mounted layer (the ID printed by `--mount`/`--run`, or from `containers-storage layers`). | | `-c, --cleanup ` | Delete a layer and its ancestor chain from local storage (unmount it first). | | `-n, --no-nsenter` | With `--run`, bind the mount directly instead of `nsenter`-ing into `fuse-overlayfs`'s namespace. Automatic when running as root; use this to force it off otherwise. | +| `-D, --daemonize` | With `--run`, fork into the background: detaches from the controlling terminal (`setsid()`), ignores `SIGHUP`, and redirects stdin from `/dev/null` and stdout/stderr to a log file under `$XDG_STATE_HOME/slocker-lite/logs/`. Prints the session's pid and log path, then returns — the same pid `--list-processes`/`-e/--exec` use. | | `--user ` | With `--run`, run the command as this user (name or numeric uid) instead of the image's own declared user (or root, if it declares none). Resolved against the image's own `/etc/passwd`. Only takes effect when `--run` executes as root. | | `--group ` | With `--user`, use this group (name or numeric gid) instead of the user's primary group. | | `--hostname ` | With `--run`, set the sandbox's hostname. Only takes effect if the running kernel supports `--unshare-uts`; ignored with a warning otherwise. | @@ -117,6 +118,9 @@ sudo ./buildDir/slocker-lite -r myimage.tar --user git # Run with extra environment variables, from flags and/or a file ./buildDir/slocker-lite -r myimage.tar --env FOO=bar --env-file ./app.env +# Run in the background; prints its pid and log file, then returns +./buildDir/slocker-lite -r myimage.tar -D + # List every OCI image tar in a directory ./buildDir/slocker-lite -l ./images @@ -208,6 +212,20 @@ rather than joining the outer `bwrap` process's own namespaces, so the joined command sees the container's process tree and hostname too, not just its filesystem. +`-D/--daemonize` forks and detaches into the background by calling `setsid()` +itself, rather than re-enabling `bwrap`'s own `--new-session` — that flag only +detaches the deeply-nested sandboxed command, leaving `bwrap`/`nsenter` still +attached to the original session. Calling `setsid()` in `slocker-lite`'s own +forked child, before it execs into `nsenter`/`bwrap`, detaches the whole chain +at once (`exec()` never changes session membership), and correctly scopes +`bwrap`'s own `--die-with-parent` to that child. The child ignores `SIGHUP` and +redirects output to a log file before doing anything else; the original, +still-foreground process waits only long enough to learn the real session pid +(the same one `--list-processes`/`-e/--exec` use) before printing it and +returning — the detached child is what runs the entire session afterward, +including the same unmount/cleanup that always ran once the sandboxed command +exits. + See `CLAUDE.md` for the full architecture writeup (file-by-file breakdown, the reasoning behind each of the above, and known gaps). diff --git a/meson.build b/meson.build index 0d5409e..52ede57 100644 --- a/meson.build +++ b/meson.build @@ -19,7 +19,7 @@ configure_file(output : 'config.h', configuration : conf_data) slocker_lite = executable('slocker-lite', ['src/main.cpp', 'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp', 'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp', - 'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp'], + 'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp'], include_directories : include_directories('.'), dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep], install : true) diff --git a/src/bwrap.cpp b/src/bwrap.cpp index 86fa987..55cd7c0 100644 --- a/src/bwrap.cpp +++ b/src/bwrap.cpp @@ -333,7 +333,8 @@ std::optional> wrap_for_root_namespace(const std::strin int run_bwrap(const std::string& root, const std::vector& command, bool use_nsenter, const std::vector& volumes, std::optional user, const std::optional& hostname, const std::string& container_name, - const std::vector>& extra_env) { + const std::vector>& extra_env, + const std::function& on_bwrap_pid_known) { if (user && !find_priv_drop_helper()) { spdlog::error("could not find the {} helper next to this binary; --user/--group requires it", kPrivDropHelperName); @@ -348,7 +349,13 @@ int run_bwrap(const std::string& root, const std::vector& command, std::optional session_lock; int exit_code = run_process_foreground( - *argv, [&](pid_t pid) { session_lock = create_session_lock(container_name, pid); }, + *argv, + [&](pid_t pid) { + session_lock = create_session_lock(container_name, pid); + if (on_bwrap_pid_known) { + on_bwrap_pid_known(pid); + } + }, build_sandbox_env(user, extra_env)); if (session_lock) { diff --git a/src/bwrap.h b/src/bwrap.h index 0902651..2869b73 100644 --- a/src/bwrap.h +++ b/src/bwrap.h @@ -16,11 +16,14 @@ #pragma once +#include #include #include #include #include +#include + #include "volume_mount.h" // Probes the running kernel for which Linux namespace types can actually be @@ -83,9 +86,13 @@ std::vector build_bwrap_args(const std::string& root, // with `extra_env` (from --env/--env-file, see env_spec.h) appended after the // built-in PATH/HOME/PWD/TERM, and passed to run_process_foreground()'s own env // override, rather than relying on bwrap's own --clearenv/--setenv (which -// build_bwrap_args() no longer uses). Returns bwrap's exit code, or -1 on -// failure to launch. +// build_bwrap_args() no longer uses). If `on_bwrap_pid_known` is set, it's +// called (alongside, not instead of, the session-lock creation above) at the +// same instant the real pid becomes known -- e.g. -D/--daemonize uses this to +// report that pid back to the still-waiting original process (see +// daemonize.h). Returns bwrap's exit code, or -1 on failure to launch. int run_bwrap(const std::string& root, const std::vector& command, bool use_nsenter, const std::vector& volumes, std::optional user, const std::optional& hostname, const std::string& container_name, - const std::vector>& extra_env); + const std::vector>& extra_env, + const std::function& on_bwrap_pid_known = nullptr); diff --git a/src/daemonize.cpp b/src/daemonize.cpp new file mode 100644 index 0000000..12fcdca --- /dev/null +++ b/src/daemonize.cpp @@ -0,0 +1,167 @@ +// Copyright (C) 2026 Viorel Munteanu +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "daemonize.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "pid_file.h" + +namespace { + +// Only ever one daemonize() call per process, same "singleton runtime state" +// pattern as process.cpp's own g_foreground_child_pid. +int g_report_fd = -1; +std::filesystem::path g_log_path; + +// Reads whatever the child reports over the pipe until EOF, extracting the +// "LOG " and (if present) "PID " lines. Parent-side only. +DaemonizeResult read_daemonize_report(int fd) { + std::string buffer; + char chunk[256]; + ssize_t n; + while ((n = read(fd, chunk, sizeof(chunk))) > 0) { + buffer.append(chunk, static_cast(n)); + } + + DaemonizeResult result; + size_t pos = 0; + while (pos < buffer.size()) { + size_t eol = buffer.find('\n', pos); + if (eol == std::string::npos) { + break; + } + std::string line = buffer.substr(pos, eol - pos); + pos = eol + 1; + + if (line.rfind("LOG ", 0) == 0) { + result.log_path = line.substr(4); + } else if (line.rfind("PID ", 0) == 0) { + result.pid = static_cast(std::atoi(line.c_str() + 4)); + } + } + return result; +} + +void report_line(const std::string& line) { + if (g_report_fd >= 0) { + write(g_report_fd, line.data(), line.size()); + } +} + +} // namespace + +std::optional daemonize(const std::string& container_name) { + int fds[2]; + if (pipe2(fds, O_CLOEXEC) != 0) { + spdlog::error("failed to set up daemonize pipe: {}", strerror(errno)); + return DaemonizeResult{std::nullopt, ""}; + } + + pid_t pid = fork(); + if (pid < 0) { + spdlog::error("failed to fork for --daemonize: {}", strerror(errno)); + close(fds[0]); + close(fds[1]); + return DaemonizeResult{std::nullopt, ""}; + } + + if (pid > 0) { + // Parent: block until the child either reports the real pid or exits + // without ever doing so (a hard failure before bwrap ever started). + close(fds[1]); + auto result = read_daemonize_report(fds[0]); + close(fds[0]); + return result; + } + + // Child: detach from the controlling terminal and everything it implies + // (see daemonize.h for why this, not bwrap's own --new-session). + close(fds[0]); + g_report_fd = fds[1]; + + setsid(); + + struct sigaction ignore_sighup = {}; + ignore_sighup.sa_handler = SIG_IGN; + sigemptyset(&ignore_sighup.sa_mask); + sigaction(SIGHUP, &ignore_sighup, nullptr); + + g_log_path = session_log_file_path(container_name, getpid()); + + std::error_code ec; + std::filesystem::create_directories(g_log_path.parent_path(), ec); + if (ec) { + spdlog::error("failed to create log directory {}: {}", g_log_path.parent_path().string(), ec.message()); + _exit(1); + } + + int log_fd = open(g_log_path.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (log_fd < 0) { + spdlog::error("failed to open log file {}: {}", g_log_path.string(), strerror(errno)); + _exit(1); + } + + int null_fd = open("/dev/null", O_RDONLY); + if (null_fd >= 0) { + dup2(null_fd, STDIN_FILENO); + close(null_fd); + } + dup2(log_fd, STDOUT_FILENO); + dup2(log_fd, STDERR_FILENO); + close(log_fd); + + report_line(fmt::format("LOG {}\n", g_log_path.string())); + + return std::nullopt; +} + +void report_daemon_started(const std::string& container_name, pid_t pid) { + if (g_report_fd < 0) { + return; + } + + auto new_path = session_log_file_path(container_name, pid); + if (new_path != g_log_path) { + std::error_code ec; + std::filesystem::rename(g_log_path, new_path, ec); + if (ec) { + spdlog::warn("failed to rename log file {} to {}: {}", g_log_path.string(), new_path.string(), + ec.message()); + } else { + g_log_path = new_path; + // The parent already got an earlier "LOG" line naming the pre-rename + // path (sent before the real pid was known) -- report the updated one + // so it prints the path the file actually ends up at, not a stale one. + report_line(fmt::format("LOG {}\n", g_log_path.string())); + } + } + + report_line(fmt::format("PID {}\n", pid)); + close(g_report_fd); + g_report_fd = -1; +} diff --git a/src/daemonize.h b/src/daemonize.h new file mode 100644 index 0000000..5200da1 --- /dev/null +++ b/src/daemonize.h @@ -0,0 +1,61 @@ +// Copyright (C) 2026 Viorel Munteanu +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#pragma once + +#include +#include + +#include + +struct DaemonizeResult { + std::optional pid; // the real session pid, if the child got that far + std::string log_path; // where the daemon's output ended up +}; + +// Forks and detaches the calling process into a background daemon: the child +// calls setsid() -- becoming a new session leader, detaching the *entire* +// nsenter/bwrap/sandboxed-command chain from the controlling terminal at once, +// since exec() never changes session membership. This is deliberately done +// here rather than via bwrap's own --new-session, which only calls setsid() +// for the deeply-nested sandboxed command itself, leaving bwrap/nsenter/this +// process still attached to the original session (and still receiving its +// signals, e.g. a SIGHUP when the controlling terminal closes) -- not real +// daemonization. The child also ignores SIGHUP (inherited across the later +// exec() into nsenter/bwrap, since SIG_IGN -- unlike a real handler -- survives +// exec()) and redirects stdin to /dev/null and stdout/stderr to a log file +// under $XDG_STATE_HOME/slocker-lite/logs/ (session_log_file_path(), see +// pid_file.h), initially named after its own pid since the real session pid +// isn't known yet (see report_daemon_started() below). If the log +// file/directory can't be set up at all, that's a hard failure, not +// best-effort -- silently losing the very output --daemonize was asked to +// capture would defeat the point of the flag. +// +// Returns nullopt in the child -- the caller should continue on to do the +// real work. In the parent, blocks until the child either reports the real +// session pid (via report_daemon_started()) or exits without ever doing so, +// then returns a result describing what happened -- the caller should print +// it and exit immediately, without running any further session logic. +std::optional daemonize(const std::string& container_name); + +// Called from the daemonized child once the real session pid is known (from +// run_bwrap()'s on_bwrap_pid_known callback): renames the pid-named log file +// to -.log and reports success back to the parent still +// blocked in daemonize(), which then returns and can exit. A failed rename is +// only a warning -- the daemon keeps running under the old filename, matching +// this project's established best-effort-on-auxiliary-state-tracking +// convention (e.g. pid_file.cpp's own lock/removal warnings). +void report_daemon_started(const std::string& container_name, pid_t pid); diff --git a/src/main.cpp b/src/main.cpp index 1ff2293..385e5b6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ #include "config.h" #include "config_file.h" #include "containers_storage.h" +#include "daemonize.h" #include "env_spec.h" #include "exec_session.h" #include "oci_image.h" @@ -81,7 +83,7 @@ constexpr int kCleanProcessesOpt = 264; constexpr int kEnvOpt = 265; constexpr int kEnvFileOpt = 266; -constexpr std::array kLongOptions = {{ +constexpr std::array kLongOptions = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, @@ -91,6 +93,7 @@ constexpr std::array kLongOptions = {{ {"run", required_argument, nullptr, 'r'}, {"cleanup", required_argument, nullptr, 'c'}, {"no-nsenter", no_argument, nullptr, 'n'}, + {"daemonize", no_argument, nullptr, 'D'}, {"list-images", required_argument, nullptr, 'l'}, {"user", required_argument, nullptr, kUserOpt}, {"group", required_argument, nullptr, kGroupOpt}, @@ -144,6 +147,14 @@ void print_usage(const char* prog) { " (this is automatic when running as root, where\n" " the mount is already directly visible; pass\n" " this to force it off otherwise)\n" + " -D, --daemonize with --run, fork into the background: detaches\n" + " from the controlling terminal (setsid()) and\n" + " ignores SIGHUP, redirecting stdin from /dev/null\n" + " and stdout/stderr to a log file under\n" + " $XDG_STATE_HOME/slocker-lite/logs/. Prints the\n" + " session's pid and log path, then returns\n" + " immediately -- the same pid --list-processes/\n" + " -e/--exec use\n" " --user with --run, run the command as this user (name\n" " or numeric uid) instead of the image's own\n" " declared user (or root, if it declares none),\n" @@ -540,7 +551,31 @@ int run_container(const std::filesystem::path& image_tar, const std::optional& user, const std::optional& group, const std::optional& hostname, const std::vector>& volume_specs, - const std::vector& env_specs, const AppConfig& app_config) { + const std::vector& env_specs, bool daemonize_flag, const AppConfig& app_config) { + // Only depends on image_tar, so this can run before mount_image() -- moved + // up here (rather than right before the run_bwrap() call, as before) so + // daemonize() below can use the real container name for the log file from + // its very first line, not just after a later rename. + auto image_ref = read_image_ref(image_tar); + std::string container_name = + image_ref ? fmt::format("{}:{}", image_ref->name, image_ref->tag) : image_tar.stem().string(); + + if (daemonize_flag) { + auto result = daemonize(container_name); + if (result) { + // Parent (or a hard daemonize failure before ever forking) -- report + // and return immediately. The child falls through below instead. + if (result->pid) { + fmt::print("started in background: pid {}, log: {}\n", *result->pid, result->log_path); + return 0; + } + spdlog::error("failed to start in background{}", result->log_path.empty() + ? "" + : fmt::format(" (see log: {})", result->log_path)); + return 1; + } + } + auto mounted = mount_image(image_tar); if (!mounted) { return 1; @@ -606,14 +641,15 @@ int run_container(const std::filesystem::path& image_tar, : std::vector{"/bin/sh"}; } - auto image_ref = read_image_ref(image_tar); - std::string container_name = - image_ref ? fmt::format("{}:{}", image_ref->name, image_ref->tag) : image_tar.stem().string(); + std::function on_bwrap_pid_known; + if (daemonize_flag) { + on_bwrap_pid_known = [&](pid_t pid) { report_daemon_started(container_name, pid); }; + } int exit_code = -1; if (ok) { exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname, - container_name, *resolved_env); + container_name, *resolved_env, on_bwrap_pid_known); if (exit_code < 0) { spdlog::error("failed to run bwrap"); } @@ -644,6 +680,7 @@ int main(int argc, char* argv[]) { Mode mode = Mode::kNone; std::string mode_arg; bool disable_nsenter = false; + bool daemonize_flag = false; std::optional user_flag; std::optional group_flag; std::optional hostname_flag; @@ -652,7 +689,7 @@ int main(int argc, char* argv[]) { opterr = 0; int opt; - while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:", kLongOptions.data(), nullptr)) != -1) { + while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:D", kLongOptions.data(), nullptr)) != -1) { switch (opt) { case 'h': print_usage(argv[0]); @@ -744,6 +781,9 @@ int main(int argc, char* argv[]) { case 'n': disable_nsenter = true; break; + case 'D': + daemonize_flag = true; + break; case kLogLevelOpt: if (!apply_log_level(optarg)) { return 1; @@ -861,7 +901,7 @@ int main(int argc, char* argv[]) { spdlog::debug("running as root; skipping nsenter (the mount is already directly visible)"); } return run_container(mode_arg, command, use_nsenter, user_flag, group_flag, hostname_flag, volume_specs, - env_specs, *config); + env_specs, daemonize_flag, *config); } auto mounted = mount_image(mode_arg); diff --git a/src/pid_file.cpp b/src/pid_file.cpp index 8654d54..4b3c0f3 100644 --- a/src/pid_file.cpp +++ b/src/pid_file.cpp @@ -44,7 +44,7 @@ std::string sanitize_for_filename(std::string_view name) { return result.empty() ? "container" : result; } -std::filesystem::path session_run_dir() { +std::filesystem::path xdg_state_dir() { const char* xdg_state_home = std::getenv("XDG_STATE_HOME"); std::filesystem::path state_home; if (xdg_state_home && *xdg_state_home) { @@ -53,7 +53,11 @@ std::filesystem::path session_run_dir() { const char* home = std::getenv("HOME"); state_home = std::filesystem::path(home ? home : "") / ".local" / "state"; } - return state_home / "slocker-lite" / "run"; + return state_home / "slocker-lite"; +} + +std::filesystem::path session_run_dir() { + return xdg_state_dir() / "run"; } struct ParsedSessionFile { @@ -102,6 +106,10 @@ std::filesystem::path session_pid_file_path(std::string_view container_name, pid return session_run_dir() / fmt::format("{}-{}", sanitize_for_filename(container_name), pid); } +std::filesystem::path session_log_file_path(std::string_view container_name, pid_t pid) { + return xdg_state_dir() / "logs" / fmt::format("{}-{}.log", sanitize_for_filename(container_name), pid); +} + std::optional create_session_lock(std::string_view container_name, pid_t pid) { auto path = session_pid_file_path(container_name, pid); diff --git a/src/pid_file.h b/src/pid_file.h index 1073b01..6b5aa15 100644 --- a/src/pid_file.h +++ b/src/pid_file.h @@ -45,6 +45,12 @@ struct SessionLock { // names/tags can contain '/' (registry paths) or ':'. std::filesystem::path session_pid_file_path(std::string_view container_name, pid_t pid); +// $XDG_STATE_HOME/slocker-lite/logs/-.log (or the +// $HOME/.local/state/... fallback), sibling to session_pid_file_path() above -- +// same sanitization, same directory resolution, just a different subdirectory +// and a .log extension. Used by daemonize.{h,cpp} for -D/--daemonize's log file. +std::filesystem::path session_log_file_path(std::string_view container_name, pid_t pid); + // Creates the pid file for (container_name, pid) (creating its parent directory if // needed), writes `pid` as text, and takes an exclusive advisory flock() on it (see // SessionLock above). Returns nullopt (logging a warning, never fatal -- session