diff --git a/CLAUDE.md b/CLAUDE.md index a39a890..f1029c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,13 +50,22 @@ Source layout (all under `src/`): path first, and passes the resolved list to `run_bwrap()`. `--hostname ` (long-option only, no short form) is likewise threaded straight through `run_container()` into `run_bwrap()`/`build_bwrap_args()` (`bwrap.{h,cpp}`) — - see there for how/when it actually takes effect. + see there for how/when it actually takes effect. `run_container()` also derives + a `container_name` for the session-tracking pid file (see `pid_file.{h,cpp}` + below): `read_image_ref()` (`oci_image.{h,cpp}`) applied to the single image + tar being run, formatted as `name:tag`, falling back to the tar's own filename + stem if `read_image_ref()` can't determine one — passed through to + `run_bwrap()` alongside everything else. - `oci_image.{h,cpp}` — validates/parses the OCI Image Layout tar (libarchive + nlohmann_json) and extracts layer blobs. `list_oci_images()` scans a directory (non-recursively) for `*.tar`/`*.tar.*` files and, for each valid OCI archive, - derives an image name/tag from its `index.json` manifest annotations - (`io.containerd.image.name` preferred, else `org.opencontainers.image.ref.name`), - falling back to the archive's filename and `"latest"` respectively. + derives an image name/tag via `read_image_ref()` from its `index.json` manifest + annotations (`io.containerd.image.name` preferred, else + `org.opencontainers.image.ref.name`), falling back to the archive's filename and + `"latest"` respectively. `read_image_ref()` is public (not just an internal + helper of `list_oci_images()`) precisely so `run_container()` (`main.cpp`) can + reuse the exact same logic to name a *single* image tar's session pid file (see + `pid_file.{h,cpp}` below) instead of duplicating it. `read_oci_image_config()` reads the image config blob referenced by the manifest and extracts `User` (split on `:` into `OciImageConfig::user`/`group`), `ExposedPorts`, `Env`, `Volumes`, and the effective default command @@ -104,7 +113,14 @@ Source layout (all under `src/`): one valid entry, so the helper's own `setuid()` fails cleanly there instead of silently doing nothing. `run_bwrap()` fails fast (returns -1) if the helper can't be found next to this binary when `--user` was requested, rather than silently - running the command as root. + running the command as root. `run_bwrap()` also takes a `container_name` and + tracks the running session with it: it passes a lambda as + `run_process_foreground()`'s new `on_start` callback (see `process.{h,cpp}` + below) that calls `create_session_lock(container_name, pid)` (`pid_file.{h,cpp}`, + see below) the instant the real `bwrap` pid is known, then calls + `release_session_lock()` once `run_process_foreground()` returns (covering + every exit path — normal, nonzero, or a forwarded-signal exit — since that call + always blocks until the child has actually exited). - `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 @@ -143,7 +159,36 @@ Source layout (all under `src/`): to the running child and keeps waiting instead of letting the default disposition kill `slocker-lite` itself — without this, Ctrl-C (or `kill`) during `-r`'s `bwrap` run would skip `run_container()`'s unmount/cleanup entirely, leaving the layer - imported and/or mounted. + imported and/or mounted. `run_process_foreground()` also takes an optional + `on_start` callback, invoked with the child's real pid right after `fork()` + succeeds (before the signal handlers go up and it blocks in `waitpid()`) — the + only point where that pid is knowable, and still accurate even when `argv` + itself execs into something else first (e.g. `nsenter` handing off to the final + command via its own in-place `execvp()` — a pid never changes across `exec()`). + `run_bwrap()` (`bwrap.cpp`) is the one caller that uses it, for session pid-file + tracking (see `pid_file.{h,cpp}` below). +- `pid_file.{h,cpp}` — tracks one running `-r/--run` session (a live `bwrap` + process) as a locked pid file, so an outside process (or a later + `slocker-lite` invocation) can tell whether it's still running. + `session_pid_file_path()` resolves + `$XDG_STATE_HOME/slocker-lite/run/-` (falling back to + `$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()` + 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 — + held only by that fd, so its lifetime tracks `slocker-lite`'s own process + lifetime (released automatically on any exit, including a crash), which lines + up with `bwrap` itself being invoked with `--die-with-parent`. Any external + tool can check liveness the same way: attempt the same exclusive non-blocking + `flock()` on the file — success means nothing holds it anymore (stale, safe to + remove), `EWOULDBLOCK` means a live process still does. `release_session_lock()` + closes the fd (releasing the flock immediately) and removes the file. Every + failure path here (can't create the directory/file, can't lock, can't remove) + is a `spdlog::warn`, never fatal — session tracking is best-effort and must + never block or fail `-r/--run` itself. - `config_file.{h,cpp}` — `load_config_file()` reads and parses (via libyaml's document API, ``) the `global` and `volumes` sections of the local YAML config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`, diff --git a/README.md b/README.md index 74bfdc8..23dfe00 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,13 @@ there, `slocker-lite` instead bind-mounts a separate, statically-linked helper (`slocker-lite-priv-drop`) into the sandbox and routes the command through it to drop privileges before exec. +While a `-r/--run` session is active, its `bwrap` process is tracked as a locked +PID file under `$XDG_STATE_HOME/slocker-lite/run/` (falling back to +`$HOME/.local/state/...`), named after the image and its PID so the same image can +be run concurrently without collisions. The file is removed automatically once the +run ends; any tool can check whether a session is still alive by attempting the +same exclusive, non-blocking `flock()` on its file. + 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 442d297..074fb15 100644 --- a/meson.build +++ b/meson.build @@ -18,7 +18,8 @@ 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/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp', + 'src/pid_file.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 792297b..0dd3a8f 100644 --- a/src/bwrap.cpp +++ b/src/bwrap.cpp @@ -32,6 +32,7 @@ #include #include +#include "pid_file.h" #include "process.h" namespace { @@ -305,7 +306,7 @@ 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::optional& hostname, const std::string& container_name) { if (user && !find_priv_drop_helper()) { spdlog::error("could not find the {} helper next to this binary; --user/--group requires it", kPrivDropHelperName); @@ -318,5 +319,13 @@ int run_bwrap(const std::string& root, const std::vector& command, return -1; } - return run_process_foreground(*argv); + std::optional session_lock; + int exit_code = run_process_foreground( + *argv, [&](pid_t pid) { session_lock = create_session_lock(container_name, pid); }); + + if (session_lock) { + release_session_lock(*session_lock); + } + + return exit_code; } diff --git a/src/bwrap.h b/src/bwrap.h index fed67ba..46264e8 100644 --- a/src/bwrap.h +++ b/src/bwrap.h @@ -75,8 +75,10 @@ std::vector build_bwrap_args(const std::string& root, // If `user` is set, this process's own binary is bind-mounted into the sandbox and // used to drop privileges to that uid/gid before running `command` -- see // build_bwrap_args(). `hostname`, if set, is forwarded to build_bwrap_args() -- -// see there for when it does/doesn't take effect. Returns bwrap's exit code, or -1 -// on failure to launch. +// see there for when it does/doesn't take effect. While bwrap is running, +// `container_name` (paired with its actual pid) is recorded as a locked session +// pid file under $XDG_STATE_HOME (see pid_file.h) -- removed again once it exits. +// 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::optional& hostname, const std::string& container_name); diff --git a/src/main.cpp b/src/main.cpp index c9ba5cb..e68d05c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -522,9 +522,14 @@ 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(); + int exit_code = -1; if (ok) { - exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname); + exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname, + container_name); if (exit_code < 0) { spdlog::error("failed to run bwrap"); } diff --git a/src/oci_image.cpp b/src/oci_image.cpp index 1afccd0..d36a73b 100644 --- a/src/oci_image.cpp +++ b/src/oci_image.cpp @@ -135,57 +135,6 @@ ParsedRef parse_image_ref(const std::string& ref) { return {"", ref}; } -std::optional read_image_ref(const std::filesystem::path& tar_path) { - auto layout = read_entry_to_string(tar_path, "oci-layout"); - if (!layout) { - spdlog::debug("{}: not an OCI image tar (missing oci-layout)", tar_path.string()); - return std::nullopt; - } - - auto index_content = read_entry_to_string(tar_path, "index.json"); - if (!index_content) { - spdlog::debug("{}: not an OCI image tar (missing index.json)", tar_path.string()); - return std::nullopt; - } - - json index; - try { - index = json::parse(*index_content); - } catch (const json::parse_error& e) { - spdlog::debug("{}: index.json is not valid JSON: {}", tar_path.string(), e.what()); - return std::nullopt; - } - - json manifest_entry; - bool found = false; - for (const auto& m : index.value("manifests", json::array())) { - if (m.value("mediaType", "") == "application/vnd.oci.image.manifest.v1+json") { - manifest_entry = m; - found = true; - break; - } - } - if (!found) { - spdlog::debug("{}: index.json has no OCI image manifest entry", tar_path.string()); - return std::nullopt; - } - - OciImageRef ref; - ref.path = tar_path; - - auto annotations = manifest_entry.value("annotations", json::object()); - std::string annotation_ref = annotations.value("io.containerd.image.name", ""); - if (annotation_ref.empty()) { - annotation_ref = annotations.value("org.opencontainers.image.ref.name", ""); - } - - ParsedRef parsed = annotation_ref.empty() ? ParsedRef{} : parse_image_ref(annotation_ref); - ref.name = parsed.name.empty() ? archive_basename(tar_path) : parsed.name; - ref.tag = parsed.tag.empty() ? "latest" : parsed.tag; - - return ref; -} - struct OciManifest { std::string digest; json data; @@ -284,6 +233,57 @@ std::optional parse_exposed_port(const std::string& key) { } // namespace +std::optional read_image_ref(const std::filesystem::path& tar_path) { + auto layout = read_entry_to_string(tar_path, "oci-layout"); + if (!layout) { + spdlog::debug("{}: not an OCI image tar (missing oci-layout)", tar_path.string()); + return std::nullopt; + } + + auto index_content = read_entry_to_string(tar_path, "index.json"); + if (!index_content) { + spdlog::debug("{}: not an OCI image tar (missing index.json)", tar_path.string()); + return std::nullopt; + } + + json index; + try { + index = json::parse(*index_content); + } catch (const json::parse_error& e) { + spdlog::debug("{}: index.json is not valid JSON: {}", tar_path.string(), e.what()); + return std::nullopt; + } + + json manifest_entry; + bool found = false; + for (const auto& m : index.value("manifests", json::array())) { + if (m.value("mediaType", "") == "application/vnd.oci.image.manifest.v1+json") { + manifest_entry = m; + found = true; + break; + } + } + if (!found) { + spdlog::debug("{}: index.json has no OCI image manifest entry", tar_path.string()); + return std::nullopt; + } + + OciImageRef ref; + ref.path = tar_path; + + auto annotations = manifest_entry.value("annotations", json::object()); + std::string annotation_ref = annotations.value("io.containerd.image.name", ""); + if (annotation_ref.empty()) { + annotation_ref = annotations.value("org.opencontainers.image.ref.name", ""); + } + + ParsedRef parsed = annotation_ref.empty() ? ParsedRef{} : parse_image_ref(annotation_ref); + ref.name = parsed.name.empty() ? archive_basename(tar_path) : parsed.name; + ref.tag = parsed.tag.empty() ? "latest" : parsed.tag; + + return ref; +} + std::string oci_digest_hex(std::string_view digest) { constexpr std::string_view prefix = "sha256:"; if (digest.substr(0, prefix.size()) == prefix) { diff --git a/src/oci_image.h b/src/oci_image.h index be5c6b9..52d2892 100644 --- a/src/oci_image.h +++ b/src/oci_image.h @@ -49,6 +49,14 @@ struct OciImageRef { std::filesystem::path path; // the archive file this was found in }; +// Determines a single image tar's own name/tag the same way list_oci_images() does +// for each file it scans: from its index.json manifest annotations +// (io.containerd.image.name preferred, else org.opencontainers.image.ref.name), +// falling back to the archive's filename (name) and "latest" (tag) when unset or +// unparseable. Returns nullopt if `tar_path` isn't a valid OCI Image Layout tar +// with at least one image manifest entry. +std::optional read_image_ref(const std::filesystem::path& tar_path); + // Scans `dir` (non-recursively) for files matching *.tar or *.tar.*, and for each one // that's a valid OCI Image Layout archive, determines an image name/tag from its // index.json manifest annotations (io.containerd.image.name or diff --git a/src/pid_file.cpp b/src/pid_file.cpp new file mode 100644 index 0000000..566e9bd --- /dev/null +++ b/src/pid_file.cpp @@ -0,0 +1,105 @@ +// 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 "pid_file.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +std::string sanitize_for_filename(std::string_view name) { + std::string result; + result.reserve(name.size()); + for (char c : name) { + if (std::isalnum(static_cast(c)) || c == '-' || c == '_' || c == '.') { + result += c; + } else { + result += '_'; + } + } + return result.empty() ? "container" : result; +} + +std::filesystem::path session_run_dir() { + const char* xdg_state_home = std::getenv("XDG_STATE_HOME"); + std::filesystem::path state_home; + if (xdg_state_home && *xdg_state_home) { + state_home = xdg_state_home; + } else { + const char* home = std::getenv("HOME"); + state_home = std::filesystem::path(home ? home : "") / ".local" / "state"; + } + return state_home / "slocker-lite" / "run"; +} + +} // namespace + +std::filesystem::path session_pid_file_path(std::string_view container_name, pid_t pid) { + return session_run_dir() / fmt::format("{}-{}", 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); + + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + spdlog::warn("failed to create session state directory {}: {}", path.parent_path().string(), + ec.message()); + return std::nullopt; + } + + int fd = open(path.c_str(), O_CREAT | O_WRONLY | O_TRUNC | O_CLOEXEC, 0644); + if (fd < 0) { + spdlog::warn("failed to create session pid file {}: {}", path.string(), strerror(errno)); + return std::nullopt; + } + + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + spdlog::warn("failed to lock session pid file {}: {}", path.string(), strerror(errno)); + close(fd); + return std::nullopt; + } + + std::string contents = fmt::format("{}\n", pid); + if (write(fd, contents.data(), contents.size()) < 0) { + spdlog::warn("failed to write session pid file {}: {}", path.string(), strerror(errno)); + } + + return SessionLock{path, fd}; +} + +void release_session_lock(const SessionLock& lock) { + if (lock.fd >= 0) { + close(lock.fd); + } + std::error_code ec; + std::filesystem::remove(lock.path, ec); + if (ec) { + spdlog::warn("failed to remove session pid file {}: {}", lock.path.string(), ec.message()); + } +} diff --git a/src/pid_file.h b/src/pid_file.h new file mode 100644 index 0000000..43875b0 --- /dev/null +++ b/src/pid_file.h @@ -0,0 +1,57 @@ +// 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 +#include + +#include + +// Tracks one running -r/--run session (a live bwrap process) as a PID file with an +// advisory flock() held for as long as this process is running it -- so a *different* +// process can tell a stale leftover file apart from a genuinely still-running +// session: attempt the same exclusive, non-blocking flock() on the same file -- +// succeeding means nothing holds it anymore (stale, safe to remove), failing +// (EWOULDBLOCK) means a live process still does. The lock is held only by `fd` +// (opened O_CLOEXEC), so it's never visible to the sandboxed command itself, and +// its lifetime tracks this process's own lifetime -- released automatically if +// this process exits for any reason, including a crash. +struct SessionLock { + std::filesystem::path path; + int fd = -1; +}; + +// $XDG_STATE_HOME/slocker-lite/run/-, or +// $HOME/.local/state/slocker-lite/run/- if XDG_STATE_HOME is +// unset/empty (mirrors config_file_path()'s XDG_CONFIG_HOME pattern). Characters in +// container_name outside [A-Za-z0-9._-] are replaced with '_', since image +// names/tags can contain '/' (registry paths) or ':'. +std::filesystem::path session_pid_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 +// tracking is best-effort and must never block or fail -r/--run itself) if the +// directory/file can't be created or the lock can't be taken. +std::optional create_session_lock(std::string_view container_name, pid_t pid); + +// Closes fd (releasing the flock immediately) and removes the file. Best-effort: +// logs a warning on failure, never treated as fatal, mirroring run_container()'s +// own unmount/cleanup-failure handling. +void release_session_lock(const SessionLock& lock); diff --git a/src/process.cpp b/src/process.cpp index 3146ef0..94ed150 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -108,7 +108,8 @@ ProcessResult run_process(const std::vector& argv) { return {exit_code, output}; } -int run_process_foreground(const std::vector& argv) { +int run_process_foreground(const std::vector& argv, + const std::function& on_start) { spdlog::debug("running external command: {}", fmt::join(argv, " ")); pid_t pid = fork(); @@ -125,6 +126,9 @@ int run_process_foreground(const std::vector& argv) { } g_foreground_child_pid = pid; + if (on_start) { + on_start(pid); + } struct sigaction action = {}; action.sa_handler = forward_signal_to_foreground_child; diff --git a/src/process.h b/src/process.h index a769383..05cf14a 100644 --- a/src/process.h +++ b/src/process.h @@ -17,11 +17,14 @@ #pragma once #include +#include #include #include #include #include +#include + struct ProcessResult { int exit_code; std::string stdout_output; @@ -33,8 +36,14 @@ ProcessResult run_process(const std::vector& argv); // Runs argv[0] with the given arguments via fork/execvp, with stdin/stdout/stderr // all inherited from the caller (no output capture) for interactive/foreground use. -// Returns the exit code, or -1 if fork or exec failed. -int run_process_foreground(const std::vector& argv); +// If `on_start` is set, it's called with the child's pid immediately after fork() +// succeeds and before this blocks in waitpid() -- e.g. so a caller can record the +// real pid of what it just launched. This stays accurate even when argv itself +// execs into something else before the real target (e.g. nsenter handing off to +// the final command), since exec() never changes the pid. Returns the exit code, +// or -1 if fork or exec failed (on_start is not called in that case). +int run_process_foreground(const std::vector& argv, + const std::function& on_start = nullptr); // Searches $PATH for an executable regular file named `name`, in PATH order. // Returns its full path, or nullopt if not found.