commit 36f4f890c7d4c9e65dfb79056951d2b77ef45596 Author: retoor Date: Fri Oct 23 20:54:55 2026 +0200 doos 0.1.0: user-level overlayfs containers with slirp networking Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..63d1e7c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.16) +file(STRINGS ${CMAKE_SOURCE_DIR}/VERSION DOOS_VERSION LIMIT_COUNT 1) +project(doos VERSION ${DOOS_VERSION} LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +add_executable(doos + src/main.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp) +target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic) +target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}") +install(TARGETS doos RUNTIME DESTINATION bin) diff --git a/README.md b/README.md new file mode 100644 index 0000000..376b377 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# doos + +User-level containers in C++20: overlayfs over the host's `/usr`, `/etc`, `/opt`, user/mount/pid namespaces, networking through slirp4netns. No root at runtime. + +``` +doos list +doos get_or_create [--net slirp|host|none] NAME +doos attach [--net slirp|host|none] NAME [-- CMD...] +doos delete [--yes] NAME +``` + +Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`. + +Requirements: Linux >= 5.11, `slirp4netns` for the default network mode. On Ubuntu 24.04+ install the AppArmor profile once: + +``` +sudo cp apparmor/doos /etc/apparmor.d/doos && sudo apparmor_parser -r /etc/apparmor.d/doos +``` + +See `SPEC.md` (behavior, limits) and `docs/RESEARCH.md` (what works, measurements, known gaps). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..dde7c47 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,63 @@ +# doos specification + +Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. + +## CLI + +| Command | Behavior | +|---|---| +| `doos list` | Table of NAME, STATE (running/stopped), NET, AGE. | +| `doos get_or_create [--net M] NAME` | Creates the container if missing. Prints `created NAME` or `exists NAME`. | +| `doos attach [--net M] NAME [-- CMD...]` | get_or_create, then run CMD (default `/bin/bash`, else `/bin/sh`). Starts the container if stopped, joins it if running. Exit code is the command's. | +| `doos delete [--yes] NAME` | Dry run unless `--yes`. Refuses a running container. | + +`get-or-create` is accepted as an alias. Network mode `M` is fixed at creation: `slirp` (default), `host`, `none`. + +## Limits (hard caps) + +| Item | Cap | +|---|---| +| Containers | 64 | +| Name | `[a-z0-9][a-z0-9_-]{0,31}` | +| Command arguments | 256 | +| Open files inside | 4096 | +| `/tmp`, `/run`, `/dev/shm` | 256m, 64m, 64m (tmpfs) | +| slirp4netns ready wait | 5 s | +| Lock wait | 5 s | +| Delete recursion depth | 256 | + +## State layout + +`$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`. Path must not contain `,` or `:`. + +``` +containers/NAME/{meta,pid,lock,rootfs/,layers//{upper,work},private/{root,var}} +``` + +## Container filesystem + +| Path | Source | +|---|---| +| `/usr /etc /opt` (and `/bin /sbin /lib /lib32 /lib64` when real dirs) | overlayfs, lower = host dir, upper = `layers//upper` | +| `/bin /sbin /lib...` when host symlinks | same symlink recreated | +| `/root /var` | persistent private dirs, no host data | +| `/tmp /run /dev/shm` | tmpfs | +| `/dev` | tmpfs + bound null zero full random urandom tty, new devpts instance | +| `/proc` | own pid namespace; `sys sysrq-trigger irq bus fs` read-only; `kcore keys timer_list` masked | +| `/sys`, `/home`, everything else on host | absent | + +## Isolation + +User, mount, pid, uts, ipc namespaces always; network namespace unless `host`. Single-entry id map (container root = invoking user). `no_new_privs`, core dumps off. pid 1 is a reaping init that forwards SIGTERM/SIGINT/SIGHUP/SIGQUIT and kills the container when the command exits. Container lifetime = the first `attach` command; later attaches join via setns. + +## Networking + +- `slirp`: private netns, `slirp4netns --configure --disable-host-loopback`, DNS 10.0.2.3, outbound only, host 127.0.0.1 unreachable. +- `none`: private netns with loopback only. +- `host`: shares the host network namespace (no isolation, explicit opt-in). + +## Safety rules + +- `delete` is dry-run by default, scoped to `containers/NAME`, refuses running containers, never follows symlinks. +- Invalid names, bad state paths and missing slirp4netns fail loudly. +- A failed start kills the half-built container (no orphans). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/apparmor/doos b/apparmor/doos new file mode 100644 index 0000000..ae75a51 --- /dev/null +++ b/apparmor/doos @@ -0,0 +1,11 @@ +# Allows doos to use unprivileged user namespaces on Ubuntu 24.04+ where +# kernel.apparmor_restrict_unprivileged_userns=1 strips capabilities (mount, etc.) +# from unknown binaries inside a new userns. Same pattern as /etc/apparmor.d/lxc-unshare. +# The profile is unconfined apart from granting `userns`; confinement comes from doos itself. +abi , +include + +profile doos /{usr/local/bin,home/*/projects/doos/build}/doos flags=(unconfined) { + userns, + @{exec_path} mr, +} diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md new file mode 100644 index 0000000..4009e0c --- /dev/null +++ b/docs/RESEARCH.md @@ -0,0 +1,41 @@ +# Research: user-level containers with overlayfs + +Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime. + +## What works unprivileged + +1. `clone(CLONE_NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET])`, then the parent writes `setgroups=deny`, `uid_map`, `gid_map` (single entry, own uid). No setuid helpers (`newuidmap`) needed. +2. overlayfs mounts inside the userns (kernel >= 5.11) with the `userxattr` option. Upper and work dirs must be on a filesystem with user xattrs (ext4, tmpfs >= 6.6 verified for ours). +3. Mounts that work inside: tmpfs, proc (needs own pidns), devpts (new instance, no `gid=` since gid 5 is unmapped), bind mounts of host `/dev/{null,zero,full,random,urandom,tty}` (no `mknod` in a userns), read-only remount of `/proc` subpaths (must copy locked flags from `statvfs`). +4. `pivot_root(".", ".")` plus lazy unmount of the old root. + +## What does not + +- Writing mapped uid ranges beyond own uid needs `newuidmap` + `/etc/subuid`. Skipped: single mapping keeps the design dumb. Consequence: no chown/su to other uids inside, so `apt install` as non-root users does not work. +- cgroups: no memory/cpu/pid limits without systemd delegation. Not implemented (gap). +- Ubuntu 24.04+: `kernel.apparmor_restrict_unprivileged_userns=1` lets any binary create a userns but strips capabilities inside it. Measured: uid_map write succeeds, first `mount` fails with EPERM. Fix is an AppArmor profile granting `userns` to the binary (`apparmor/doos`, install once with sudo). Turning the sysctl off is global and weaker. + +## Networking options + +| Option | Result | +|---|---| +| slirp4netns (chosen default) | Works unprivileged. Needs only the netns of the child pid. `--disable-host-loopback` blocks the container from reaching host 127.0.0.1 (measured: blocked). DNS via 10.0.2.3 works (measured). | +| pasta | Installed here but not used: slirp4netns suffices, one dependency. | +| veth + bridge | Needs CAP_NET_ADMIN in the host netns. Not possible unprivileged. | +| host netns | Works, zero isolation. Offered as explicit `--net host`. | + +## Host safety design + +- No host paths writable: overlay lower dirs are only reachable through overlay (writes copy up to the container upper). Verified: write to `/usr` and `/etc` inside leaves host untouched. +- Only `/usr /etc /opt` and compat dirs are exposed read-through. `/home`, `/var`, `/sys`, `/root` of the host are not present. +- Old root detached after pivot, `no_new_privs`, `/proc` hardened, nosuid/nodev on tmpfs/devpts/overlay. +- Residual exposure (gaps): host `/etc` is readable inside (any world-readable secret in it); abstract unix sockets are shared in `--net host`; no resource cgroups, so a container can fill the disk through its upper dir or fork-bomb within the user's `RLIMIT_NPROC`; kernel attack surface of userns is that of the host kernel. + +## Measurements (this machine, 5 sequential `attach -- true`) + +| Mode | Per attach | +|---|---| +| slirp | ~26 ms | +| none | ~6 ms | + +Single run set, one machine; no other hardware measured. diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..acca69a --- /dev/null +++ b/prompt.md @@ -0,0 +1,9 @@ +DIt project moet doos heten. Het is geschreven in C++ en het bied een user-level container die gebruik maakt van een overlayfs. +Het moet cli commands hebben voor: + - list containers + - get_or_create container + - attach container (this does get_or_Create first) + - delete container +THe containers should have networking going on. + +Please deep resarch on how to achieve all this on user level and what mounts we need while keeping our host safe. diff --git a/src/cli.cpp b/src/cli.cpp new file mode 100644 index 0000000..4b92b59 --- /dev/null +++ b/src/cli.cpp @@ -0,0 +1,132 @@ +#include "cli.hpp" + +#include +#include +#include + +#include "container.hpp" +#include "sandbox.hpp" + +namespace doos { + +namespace { + +// Splits leading --options from the first positional. Everything after the positional is returned as-is. +struct Parsed { + std::optional net; + bool yes = false; + std::string name; + std::vector tail; +}; + +Parsed parse(const std::vector& rest, bool allowNet, bool allowYes) { + Parsed p; + std::size_t i = 0; + for (; i < rest.size(); ++i) { + const std::string& a = rest[i]; + if (a == "--") { ++i; break; } + if (allowNet && a == "--net") { + if (i + 1 >= rest.size()) throw std::runtime_error("--net needs a value (slirp, host, none)"); + p.net = rest[++i]; + } else if (allowNet && a.rfind("--net=", 0) == 0) { + p.net = a.substr(6); + } else if (allowYes && a == "--yes") { + p.yes = true; + } else if (a.size() > 1 && a[0] == '-') { + throw std::runtime_error("unknown option " + a); + } else { + break; + } + } + if (i >= rest.size()) throw std::runtime_error("missing container name"); + p.name = rest[i++]; + if (i < rest.size() && rest[i] == "--") ++i; + p.tail.assign(rest.begin() + static_cast(i), rest.end()); + return p; +} + +std::string age(long created) { + long d = std::time(nullptr) - created; + if (d < 120) return std::to_string(d) + "s"; + if (d < 7200) return std::to_string(d / 60) + "m"; + if (d < 172800) return std::to_string(d / 3600) + "h"; + return std::to_string(d / 86400) + "d"; +} + +} // namespace + +void Cli::usage(std::FILE* out) { + std::fputs( + "doos " DOOS_VERSION " - user-level containers (overlayfs + namespaces)\n" + "\n" + "usage:\n" + " doos list\n" + " doos get_or_create [--net slirp|host|none] NAME\n" + " doos attach [--net slirp|host|none] NAME [-- CMD [ARGS...]]\n" + " doos delete [--yes] NAME\n" + "\n" + "attach creates NAME when missing. delete only reports unless --yes is given.\n" + "State lives in $DOOS_HOME or ~/.local/share/doos.\n", + out); +} + +int Cli::run() { + if (args_.empty() || args_[0] == "-h" || args_[0] == "--help" || args_[0] == "help") { + usage(args_.empty() ? stderr : stdout); + return args_.empty() ? 2 : 0; + } + std::string cmd = args_[0]; + for (auto& ch : cmd) if (ch == '-') ch = '_'; + std::vector rest(args_.begin() + 1, args_.end()); + if (cmd == "--version" || cmd == "version") { std::puts(DOOS_VERSION); return 0; } + if (cmd == "list") return cmdList(); + if (cmd == "get_or_create") return cmdGetOrCreate(rest); + if (cmd == "attach") return cmdAttach(rest); + if (cmd == "delete") return cmdDelete(rest); + std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str()); + usage(stderr); + return 2; +} + +int Cli::cmdList() { + Store store = Store::fromEnvironment(); + auto all = store.list(); + if (all.empty()) { std::puts("no containers"); return 0; } + std::printf("%-*s %-8s %-6s %s\n", 20, "NAME", "STATE", "NET", "AGE"); + for (const auto& c : all) { + std::printf("%-*s %-8s %-6s %s\n", 20, c.name().c_str(), c.runningPid() ? "running" : "stopped", + Network::name(c.net()).c_str(), age(c.created()).c_str()); + } + return 0; +} + +int Cli::cmdGetOrCreate(std::vector rest) { + Parsed p = parse(rest, true, false); + if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name"); + std::optional net; + if (p.net) net = Network::parse(*p.net); + Store store = Store::fromEnvironment(); + auto [c, created] = store.getOrCreate(p.name, net); + std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str()); + return 0; +} + +int Cli::cmdAttach(std::vector rest) { + Parsed p = parse(rest, true, false); + std::optional net; + if (p.net) net = Network::parse(*p.net); + Store store = Store::fromEnvironment(); + auto [c, created] = store.getOrCreate(p.name, net); + (void)created; + return Sandbox(std::move(c), std::move(p.tail)).run(); +} + +int Cli::cmdDelete(std::vector rest) { + Parsed p = parse(rest, false, true); + if (!p.tail.empty()) throw std::runtime_error("delete takes only a name"); + Store store = Store::fromEnvironment(); + std::puts(store.remove(p.name, p.yes).c_str()); + return 0; +} + +} // namespace doos diff --git a/src/cli.hpp b/src/cli.hpp new file mode 100644 index 0000000..8a48ad0 --- /dev/null +++ b/src/cli.hpp @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include + +namespace doos { + +class Cli { +public: + explicit Cli(std::vector args) : args_(std::move(args)) {} + int run(); + +private: + int cmdList(); + int cmdGetOrCreate(std::vector rest); + int cmdAttach(std::vector rest); + int cmdDelete(std::vector rest); + static void usage(std::FILE* out); + + std::vector args_; +}; + +} // namespace doos diff --git a/src/container.cpp b/src/container.cpp new file mode 100644 index 0000000..2603afc --- /dev/null +++ b/src/container.cpp @@ -0,0 +1,214 @@ +#include "container.hpp" + +#include +#include +#include +#include +#include + +#include "spec.hpp" +#include "util.hpp" + +namespace fs = std::filesystem; + +namespace doos { + +namespace { + +std::string slug(std::string_view hostDir) { + std::string s(hostDir); + if (!s.empty() && s.front() == '/') s.erase(0, 1); + return s; +} + +std::map parseKv(const std::string& text) { + std::map kv; + std::istringstream in(text); + for (std::string line; std::getline(in, line);) { + auto eq = line.find('='); + if (eq != std::string::npos) kv[line.substr(0, eq)] = line.substr(eq + 1); + } + return kv; +} + +// starttime (field 22 of /proc/PID/stat), used to defeat pid reuse. +std::optional startTime(pid_t pid) { + try { + std::string st = fsutil::readFile("/proc/" + std::to_string(pid) + "/stat"); + auto rp = st.rfind(')'); + if (rp == std::string::npos) return std::nullopt; + std::istringstream in(st.substr(rp + 2)); + std::string tok; + for (int i = 3; i <= 22; ++i) { + if (!(in >> tok)) return std::nullopt; + } + return tok; + } catch (const std::exception&) { + return std::nullopt; + } +} + +} // namespace + +void Container::validateName(const std::string& name) { + bool ok = !name.empty() && name.size() <= spec::kMaxNameLen && + (std::islower(static_cast(name[0])) || std::isdigit(static_cast(name[0]))); + for (char c : name) { + ok = ok && (std::islower(static_cast(c)) || std::isdigit(static_cast(c)) || + c == '-' || c == '_'); + } + if (!ok) { + throw std::runtime_error("invalid container name '" + name + "': use [a-z0-9][a-z0-9_-]{0,31}"); + } +} + +fs::path Container::layer(const std::string& hostDir) const { return dir_ / "layers" / slug(hostDir); } +fs::path Container::privateDir(const std::string& hostDir) const { return dir_ / "private" / slug(hostDir); } + +void Container::prepareLayout() const { + auto addOverlay = [&](std::string_view d) { + std::string dir(d); + fsutil::mkdirs(rootfs() / slug(d)); + fsutil::mkdirs(layer(dir) / "upper"); + fsutil::mkdirs(layer(dir) / "work"); + }; + for (auto d : spec::kOverlayDirs) addOverlay(d); + for (auto d : spec::kCompatDirs) { + std::error_code ec; + if (fs::is_directory(fs::symlink_status(std::string(d), ec)) && !ec) addOverlay(d); + } + for (auto d : spec::kPrivateDirs) { + fsutil::mkdirs(rootfs() / slug(d)); + fsutil::mkdirs(privateDir(std::string(d)), 0755); + } + for (auto d : spec::kEmptyDirs) fsutil::mkdirs(rootfs() / slug(d)); +} + +void Container::create(NetMode net) { + net_ = net; + created_ = std::time(nullptr); + fsutil::mkdirs(dir_); + prepareLayout(); + fsutil::writeFile(metaFile(), "net=" + Network::name(net_) + "\ncreated=" + std::to_string(created_) + + "\nversion=" DOOS_VERSION "\n"); +} + +void Container::load() { + auto kv = parseKv(fsutil::readFile(metaFile())); + net_ = Network::parse(kv.count("net") ? kv["net"] : "slirp"); + created_ = kv.count("created") ? std::stol(kv["created"]) : 0; + prepareLayout(); // heals a container created by an older layout +} + +std::optional Container::runningPid() const { + try { + std::istringstream in(fsutil::readFile(pidFile())); + long pid = 0; + std::string start; + if (!(in >> pid >> start) || pid <= 1) return std::nullopt; + auto now = startTime(static_cast(pid)); + if (now && *now == start) return static_cast(pid); + } catch (const std::exception&) { + } + return std::nullopt; +} + +void Container::recordPid(pid_t pid) const { + auto st = startTime(pid); + if (!st) throw std::runtime_error("container init exited before it could be recorded"); + fsutil::writeFile(pidFile(), std::to_string(pid) + " " + *st + "\n"); +} + +void Container::clearPid() const { + std::error_code ec; + fs::remove(pidFile(), ec); +} + +Store::Store(fs::path root) : root_(std::move(root)) { + std::string r = root_.string(); + if (r.find_first_of(",:") != std::string::npos) { + throw std::runtime_error("state path must not contain ',' or ':' (overlayfs option syntax): " + r); + } + fsutil::mkdirs(containersDir(), 0700); +} + +Store Store::fromEnvironment() { + if (const char* h = std::getenv("DOOS_HOME"); h && *h) return Store(h); + if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) return Store(fs::path(x) / "doos"); + const char* home = std::getenv("HOME"); + if (!home || !*home) throw std::runtime_error("HOME is not set; set DOOS_HOME"); + return Store(fs::path(home) / ".local/share/doos"); +} + +std::vector Store::list() const { + std::vector out; + for (const auto& e : fs::directory_iterator(containersDir())) { + if (!e.is_directory() || e.is_symlink()) continue; + std::string name = e.path().filename().string(); + try { + Container::validateName(name); + Container c(e.path(), name); + c.load(); + out.push_back(std::move(c)); + } catch (const std::exception& ex) { + std::fprintf(stderr, "doos: skipping %s: %s\n", name.c_str(), ex.what()); + } + if (out.size() >= spec::kMaxContainers) break; + } + std::sort(out.begin(), out.end(), [](const Container& a, const Container& b) { return a.name() < b.name(); }); + return out; +} + +std::pair Store::getOrCreate(const std::string& name, std::optional net) { + Container::validateName(name); + fs::path dir = containersDir() / name; + Container c(dir, name); + std::error_code ec; + if (fs::exists(dir / "meta", ec)) { + FileLock lock(c.lockFile(), spec::kLockTimeoutMs); + c.load(); + if (net && *net != c.net()) { + throw std::runtime_error("container '" + name + "' exists with net=" + Network::name(c.net()) + + "; network mode is fixed at creation"); + } + return {c, false}; + } + std::size_t count = 0; + for (const auto& e : fs::directory_iterator(containersDir())) count += e.is_directory() ? 1 : 0; + if (count >= spec::kMaxContainers) { + throw std::runtime_error("container limit reached (" + std::to_string(spec::kMaxContainers) + + "); delete one first"); + } + fsutil::mkdirs(dir, 0700); + FileLock lock(c.lockFile(), spec::kLockTimeoutMs); + if (fs::exists(dir / "meta", ec)) { // lost a creation race + c.load(); + return {c, false}; + } + c.create(net.value_or(NetMode::Slirp)); + return {c, true}; +} + +std::string Store::remove(const std::string& name, bool confirmed) { + Container::validateName(name); + fs::path dir = containersDir() / name; + std::error_code ec; + auto st = fs::symlink_status(dir, ec); + if (ec || !fs::is_directory(st)) throw std::runtime_error("no such container '" + name + "'"); + if (fs::canonical(dir).parent_path() != fs::canonical(containersDir())) { + throw std::runtime_error("refusing to delete outside the container store: " + dir.string()); + } + Container c(dir, name); + FileLock lock(c.lockFile(), spec::kLockTimeoutMs); + if (auto pid = c.runningPid()) { + throw std::runtime_error("container '" + name + "' is running (pid " + std::to_string(*pid) + ")"); + } + if (!confirmed) { + std::size_t n = fsutil::countEntries(dir, 1000000); + return "dry run: would delete " + dir.string() + " (" + std::to_string(n) + " entries). Add --yes to confirm."; + } + fsutil::removeTree(dir); + return "deleted " + name; +} + +} // namespace doos diff --git a/src/container.hpp b/src/container.hpp new file mode 100644 index 0000000..ecf384a --- /dev/null +++ b/src/container.hpp @@ -0,0 +1,67 @@ +#pragma once +#include + +#include +#include +#include +#include + +#include "network.hpp" + +namespace doos { + +class Container { +public: + Container(std::filesystem::path dir, std::string name) : dir_(std::move(dir)), name_(std::move(name)) {} + + static void validateName(const std::string& name); + + const std::string& name() const { return name_; } + const std::filesystem::path& dir() const { return dir_; } + std::filesystem::path rootfs() const { return dir_ / "rootfs"; } + std::filesystem::path layer(const std::string& hostDir) const; // layers//{upper,work} + std::filesystem::path privateDir(const std::string& hostDir) const; + std::filesystem::path lockFile() const { return dir_ / "lock"; } + std::filesystem::path pidFile() const { return dir_ / "pid"; } + std::filesystem::path metaFile() const { return dir_ / "meta"; } + + NetMode net() const { return net_; } + long created() const { return created_; } + + void create(NetMode net); // writes layout and meta + void load(); // reads meta + + // Process id (outside view) of the container's init, if it is alive. + std::optional runningPid() const; + void recordPid(pid_t pid) const; + void clearPid() const; + + // Creates the (empty) mountpoint skeleton and layer dirs. Idempotent. + void prepareLayout() const; + +private: + std::filesystem::path dir_; + std::string name_; + NetMode net_ = NetMode::Slirp; + long created_ = 0; +}; + +class Store { +public: + explicit Store(std::filesystem::path root); + static Store fromEnvironment(); + + std::vector list() const; + // Returns the container and whether it was created by this call. + std::pair getOrCreate(const std::string& name, std::optional net); + // Dry-run unless confirmed. Returns a human-readable report line. + std::string remove(const std::string& name, bool confirmed); + + const std::filesystem::path& root() const { return root_; } + +private: + std::filesystem::path containersDir() const { return root_ / "containers"; } + std::filesystem::path root_; +}; + +} // namespace doos diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..c860b51 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,13 @@ +#include +#include + +#include "cli.hpp" + +int main(int argc, char** argv) { + try { + return doos::Cli(std::vector(argv + 1, argv + argc)).run(); + } catch (const std::exception& e) { + std::fprintf(stderr, "doos: %s\n", e.what()); + return 1; + } +} diff --git a/src/network.cpp b/src/network.cpp new file mode 100644 index 0000000..2846769 --- /dev/null +++ b/src/network.cpp @@ -0,0 +1,86 @@ +#include "network.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "spec.hpp" +#include "util.hpp" + +namespace doos { + +NetMode Network::parse(const std::string& s) { + if (s == "slirp") return NetMode::Slirp; + if (s == "host") return NetMode::Host; + if (s == "none") return NetMode::None; + throw std::runtime_error("unknown network mode '" + s + "' (use slirp, host or none)"); +} + +std::string Network::name(NetMode m) { + switch (m) { + case NetMode::Slirp: return "slirp"; + case NetMode::Host: return "host"; + case NetMode::None: return "none"; + } + return "?"; +} + +std::string Network::resolvConf(NetMode m) { + switch (m) { + case NetMode::Slirp: return "nameserver " + std::string(spec::kSlirpDns) + "\n"; + case NetMode::None: return ""; + case NetMode::Host: { + // Host netns is shared, so the host resolver config is valid. Stub on 127.0.0.53 works too. + std::ifstream in("/etc/resolv.conf"); + return std::string(std::istreambuf_iterator(in), {}); + } + } + return ""; +} + +void SlirpSession::start(pid_t targetPid) { + int ready[2]; + if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe"); + Fd readEnd(ready[0]), writeEnd(ready[1]); + + pid_ = ::fork(); + if (pid_ < 0) throw SysError("fork slirp4netns"); + if (pid_ == 0) { + ::prctl(PR_SET_PDEATHSIG, SIGKILL); + int nul = ::open("/dev/null", O_WRONLY); + if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); } + int wfd = ::dup(writeEnd.get()); // dup clears CLOEXEC + std::string readyArg = "--ready-fd=" + std::to_string(wfd); + std::string target = std::to_string(targetPid); + std::string bin(spec::kSlirpBinary), tap(spec::kSlirpTapName); + ::execlp(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback", + readyArg.c_str(), target.c_str(), tap.c_str(), static_cast(nullptr)); + ::dprintf(2, "doos: cannot exec %s: %s\n", bin.c_str(), std::strerror(errno)); + ::_exit(127); + } + writeEnd.reset(); + + pollfd p{readEnd.get(), POLLIN, 0}; + int rc = ::poll(&p, 1, spec::kNetReadyTimeoutMs); + char c = 0; + if (rc > 0 && ::read(readEnd.get(), &c, 1) == 1) return; + stop(); + throw std::runtime_error(rc == 0 ? "slirp4netns not ready in time" + : "slirp4netns failed to start (is it installed and permitted?)"); +} + +void SlirpSession::stop() { + if (pid_ <= 0) return; + ::kill(pid_, SIGTERM); + ::waitpid(pid_, nullptr, 0); + pid_ = -1; +} + +} // namespace doos diff --git a/src/network.hpp b/src/network.hpp new file mode 100644 index 0000000..c9d9312 --- /dev/null +++ b/src/network.hpp @@ -0,0 +1,35 @@ +#pragma once +#include + +#include + +namespace doos { + +enum class NetMode { Slirp, Host, None }; + +class Network { +public: + static NetMode parse(const std::string& s); + static std::string name(NetMode m); + // Does the mode need its own network namespace? + static bool isolated(NetMode m) { return m != NetMode::Host; } + // /etc/resolv.conf content matching the mode. + static std::string resolvConf(NetMode m); +}; + +// Owns a slirp4netns process attached to a container's network namespace. +class SlirpSession { +public: + SlirpSession() = default; + SlirpSession(const SlirpSession&) = delete; + SlirpSession& operator=(const SlirpSession&) = delete; + ~SlirpSession() { stop(); } + // Starts slirp4netns for the netns of `targetPid` and blocks until it is ready. + void start(pid_t targetPid); + void stop(); + +private: + pid_t pid_ = -1; +}; + +} // namespace doos diff --git a/src/sandbox.cpp b/src/sandbox.cpp new file mode 100644 index 0000000..09b41df --- /dev/null +++ b/src/sandbox.cpp @@ -0,0 +1,392 @@ +#include "sandbox.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "spec.hpp" +#include "util.hpp" + +namespace fs = std::filesystem; + +namespace doos { + +namespace { + +volatile sig_atomic_t g_forwardTo = 0; + +void forwardSignal(int sig) { + if (g_forwardTo > 0) ::kill(g_forwardTo, sig); +} + +void installForwarding(pid_t target, std::initializer_list sigs) { + g_forwardTo = target; + struct sigaction sa; + std::memset(&sa, 0, sizeof sa); + sa.sa_handler = forwardSignal; + sigemptyset(&sa.sa_mask); + for (int s : sigs) ::sigaction(s, &sa, nullptr); +} + +void doMount(const char* src, const std::string& target, const char* type, unsigned long flags, + const std::string& data = "") { + if (::mount(src, target.c_str(), type, flags, data.empty() ? nullptr : data.c_str()) != 0) { + throw SysError(std::string("mount ") + (type ? type : "bind") + " " + target); + } +} + +// Remount read-only while preserving the locked flags a userns cannot change. +void remountReadOnly(const std::string& path) { + doMount(path.c_str(), path, nullptr, MS_BIND | MS_REC); + struct statvfs sv; + if (::statvfs(path.c_str(), &sv) != 0) throw SysError("statvfs " + path); + unsigned long flags = MS_BIND | MS_REMOUNT | MS_RDONLY; + if (sv.f_flag & ST_NOSUID) flags |= MS_NOSUID; + if (sv.f_flag & ST_NODEV) flags |= MS_NODEV; + if (sv.f_flag & ST_NOEXEC) flags |= MS_NOEXEC; + if (sv.f_flag & ST_NOATIME) flags |= MS_NOATIME; + if (sv.f_flag & ST_NODIRATIME) flags |= MS_NODIRATIME; + if (sv.f_flag & ST_RELATIME) flags |= MS_RELATIME; + doMount(nullptr, path, nullptr, flags); +} + +void touch(const std::string& path) { + int fd = ::open(path.c_str(), O_CREAT | O_WRONLY | O_CLOEXEC, 0644); + if (fd < 0) throw SysError("create " + path); + ::close(fd); +} + +void bringLoopbackUp() { + int s = ::socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (s < 0) throw SysError("socket"); + Fd guard(s); + ifreq ifr; + std::memset(&ifr, 0, sizeof ifr); + std::strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1); + if (::ioctl(s, SIOCGIFFLAGS, &ifr) != 0) throw SysError("SIOCGIFFLAGS lo"); + ifr.ifr_flags |= IFF_UP; + if (::ioctl(s, SIOCSIFFLAGS, &ifr) != 0) throw SysError("SIOCSIFFLAGS lo"); +} + +std::string sub(const fs::path& root, std::string_view hostDir) { return (root / hostDir.substr(1)).string(); } + +void mountOverlay(const Container& c, const fs::path& root, std::string_view hostDir) { + std::string dir(hostDir); + std::string opts = "lowerdir=" + dir + ",upperdir=" + (c.layer(dir) / "upper").string() + + ",workdir=" + (c.layer(dir) / "work").string() + ",userxattr"; + doMount("overlay", sub(root, hostDir), "overlay", MS_NODEV | MS_NOSUID, opts); +} + +void bindHostDevice(const fs::path& root, const std::string& dev) { + std::string target = (root / "dev" / dev).string(); + touch(target); + doMount(("/dev/" + dev).c_str(), target, nullptr, MS_BIND); +} + +void setupRoot(const Container& c) { + fs::path root = c.rootfs(); + doMount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE); + doMount(root.c_str(), root.string(), nullptr, MS_BIND | MS_REC); + + // resolv.conf lives in the /etc upperdir so it matches the network mode on every start. + fs::path resolv = c.layer("/etc") / "upper" / "resolv.conf"; + std::error_code ec; + fs::remove(resolv, ec); + fsutil::writeFile(resolv, Network::resolvConf(c.net())); + + for (auto d : spec::kOverlayDirs) { + std::error_code e; + if (fs::is_directory(std::string(d), e)) mountOverlay(c, root, d); + } + for (auto d : spec::kCompatDirs) { + std::error_code e; + std::string host(d); + auto st = fs::symlink_status(host, e); + if (e) continue; + if (fs::is_symlink(st)) { + fs::path target = fs::read_symlink(host); + fs::path link = root / host.substr(1); + fs::remove(link, e); + fs::create_symlink(target, link); + } else if (fs::is_directory(st)) { + mountOverlay(c, root, d); + } + } + for (auto d : spec::kPrivateDirs) doMount(c.privateDir(std::string(d)).c_str(), sub(root, d), nullptr, MS_BIND); + + // tmpfs mounts + doMount("tmpfs", sub(root, "/tmp"), "tmpfs", MS_NOSUID | MS_NODEV, "mode=1777,size=" + std::string(spec::kTmpSize)); + doMount("tmpfs", sub(root, "/run"), "tmpfs", MS_NOSUID | MS_NODEV, "mode=755,size=" + std::string(spec::kRunSize)); + + // /dev + doMount("tmpfs", sub(root, "/dev"), "tmpfs", MS_NOSUID | MS_STRICTATIME, "mode=755,size=64k"); + for (auto d : spec::kDevices) bindHostDevice(root, std::string(d)); + fsutil::mkdirs(root / "dev/pts"); + fsutil::mkdirs(root / "dev/shm", 01777); + doMount("devpts", sub(root, "/dev/pts"), "devpts", MS_NOSUID | MS_NOEXEC, "newinstance,ptmxmode=0666,mode=0620"); + doMount("tmpfs", sub(root, "/dev/shm"), "tmpfs", MS_NOSUID | MS_NODEV | MS_NOEXEC, + "mode=1777,size=" + std::string(spec::kShmSize)); + fs::create_symlink("pts/ptmx", root / "dev/ptmx"); + fs::create_symlink("/proc/self/fd", root / "dev/fd"); + fs::create_symlink("/proc/self/fd/0", root / "dev/stdin"); + fs::create_symlink("/proc/self/fd/1", root / "dev/stdout"); + fs::create_symlink("/proc/self/fd/2", root / "dev/stderr"); + + // /proc (our own pid namespace), with the dangerous parts read-only or hidden. + doMount("proc", sub(root, "/proc"), "proc", MS_NOSUID | MS_NODEV | MS_NOEXEC); + for (auto p : spec::kProcReadOnly) remountReadOnly(sub(root, p)); + for (auto p : spec::kProcMasked) doMount((root / "dev/null").c_str(), sub(root, p), nullptr, MS_BIND); + + // Switch root, then drop the old one. + if (::chdir(root.c_str()) != 0) throw SysError("chdir newroot"); + if (::syscall(SYS_pivot_root, ".", ".") != 0) throw SysError("pivot_root"); + if (::umount2(".", MNT_DETACH) != 0) throw SysError("umount old root"); + if (::chdir("/") != 0) throw SysError("chdir /"); +} + +void applyLimitsAndEnv(const Container& c) { + if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) throw SysError("no_new_privs"); + rlimit core{0, 0}; + ::setrlimit(RLIMIT_CORE, &core); + rlimit nofile; + if (::getrlimit(RLIMIT_NOFILE, &nofile) == 0) { + if (nofile.rlim_max > spec::kMaxOpenFiles) nofile.rlim_max = spec::kMaxOpenFiles; + if (nofile.rlim_cur > nofile.rlim_max) nofile.rlim_cur = nofile.rlim_max; + ::setrlimit(RLIMIT_NOFILE, &nofile); + } + const char* term = std::getenv("TERM"); + std::string termv = term && *term ? term : "xterm"; + const char* lang = std::getenv("LANG"); + std::string langv = lang ? lang : ""; + ::clearenv(); + ::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1); + ::setenv("HOME", std::string(spec::kHome).c_str(), 1); + ::setenv("USER", "root", 1); + ::setenv("LOGNAME", "root", 1); + ::setenv("TERM", termv.c_str(), 1); + ::setenv("HOSTNAME", c.name().c_str(), 1); + ::setenv("container", "doos", 1); + ::setenv("PS1", ("(doos:" + c.name() + ") \\u@\\h:\\w\\$ ").c_str(), 1); + if (!langv.empty()) ::setenv("LANG", langv.c_str(), 1); +} + +[[noreturn]] void execCommand(std::vector cmd) { + if (cmd.empty()) cmd = {::access("/bin/bash", X_OK) == 0 ? "/bin/bash" : "/bin/sh"}; + std::vector argv; + for (auto& a : cmd) argv.push_back(a.data()); + argv.push_back(nullptr); + ::execvp(argv[0], argv.data()); + ::dprintf(2, "doos: exec %s: %s\n", argv[0], std::strerror(errno)); + ::_exit(127); +} + +int exitCodeOf(int status) { + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return 1; +} + +struct ChildCtx { + const Container* container; + const std::vector* command; + int syncFd; + int lockFd; // inherited copy must be closed or the flock outlives unlock() +}; + +// Runs as pid 1 of the new pid namespace. +int containerInit(void* arg) { + auto* ctx = static_cast(arg); + try { + ::prctl(PR_SET_PDEATHSIG, SIGKILL); + ::close(ctx->lockFd); + char go = 0; + if (::read(ctx->syncFd, &go, 1) != 1 || go != 'G') ::_exit(125); + ::close(ctx->syncFd); + + const Container& c = *ctx->container; + setupRoot(c); + if (::sethostname(c.name().c_str(), c.name().size()) != 0) throw SysError("sethostname"); + if (Network::isolated(c.net())) bringLoopbackUp(); + applyLimitsAndEnv(c); + + pid_t worker = ::fork(); + if (worker < 0) throw SysError("fork"); + if (worker == 0) execCommand(*ctx->command); + + // Minimal init: forward termination signals, reap everything, mirror the worker's status. + installForwarding(worker, {SIGTERM, SIGINT, SIGHUP, SIGQUIT}); + int code = 1; + for (;;) { + int status = 0; + pid_t p = ::waitpid(-1, &status, 0); + if (p < 0) { + if (errno == EINTR) continue; + break; + } + if (p == worker) { + code = exitCodeOf(status); + break; + } + } + ::kill(-1, SIGKILL); + ::_exit(code); + } catch (const std::exception& e) { + ::dprintf(2, "doos: %s\n", e.what()); + ::_exit(125); + } +} + +void writeMap(pid_t pid, const char* file, const std::string& content) { + std::string path = "/proc/" + std::to_string(pid) + "/" + file; + int fd = ::open(path.c_str(), O_WRONLY | O_CLOEXEC); + if (fd < 0) throw SysError("open " + path); + Fd guard(fd); + if (::write(fd, content.data(), content.size()) != static_cast(content.size())) { + throw SysError("write " + path + + " (user namespaces blocked? see apparmor/doos and docs/RESEARCH.md)"); + } +} + +} // namespace + +int Sandbox::run() { + if (command_.size() > spec::kMaxCommandArgs) throw std::runtime_error("too many command arguments"); + // Decide start-or-join under the lock so concurrent attaches agree. + FileLock lock(container_.lockFile(), spec::kLockTimeoutMs); + if (auto pid = container_.runningPid()) { + return join(*pid, lock); + } + return start(lock); +} + +int Sandbox::start(FileLock& lock) { + NetMode net = container_.net(); + int flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD; + if (Network::isolated(net)) flags |= CLONE_NEWNET; + + int sync[2]; + if (::pipe2(sync, O_CLOEXEC) != 0) throw SysError("pipe"); + Fd syncRead(sync[0]), syncWrite(sync[1]); + + std::vector stack(1 << 20); + ChildCtx ctx{&container_, &command_, syncRead.get(), lock.fd()}; + pid_t child = ::clone(containerInit, stack.data() + stack.size(), flags, &ctx); + if (child < 0) throw SysError("clone (user namespaces available?)"); + + // From here on the child is ours: make sure it never outlives a failed setup. + struct Reaper { + pid_t pid; + bool armed = true; + ~Reaper() { + if (armed) { + ::kill(pid, SIGKILL); + ::waitpid(pid, nullptr, 0); + } + } + } reaper{child}; + + writeMap(child, "setgroups", "deny"); + writeMap(child, "uid_map", "0 " + std::to_string(::geteuid()) + " 1\n"); + writeMap(child, "gid_map", "0 " + std::to_string(::getegid()) + " 1\n"); + + SlirpSession slirp; + if (net == NetMode::Slirp) slirp.start(child); + + container_.recordPid(child); + struct PidGuard { + const Container& c; + ~PidGuard() { c.clearPid(); } + } pidGuard{container_}; + + char go = 'G'; + if (::write(syncWrite.get(), &go, 1) != 1) throw SysError("sync child"); + syncWrite.reset(); + syncRead.reset(); + + // The lock only protects the start decision; joiners must not wait for the container's lifetime. + lock.unlock(); + struct sigaction ign; + std::memset(&ign, 0, sizeof ign); + ign.sa_handler = SIG_IGN; + ::sigaction(SIGINT, &ign, nullptr); + ::sigaction(SIGQUIT, &ign, nullptr); + installForwarding(child, {SIGTERM, SIGHUP}); + + int status = 0; + while (::waitpid(child, &status, 0) < 0) { + if (errno != EINTR) throw SysError("waitpid"); + } + reaper.armed = false; + return exitCodeOf(status); +} + +int Sandbox::join(pid_t initPid, FileLock& lock) { + std::string base = "/proc/" + std::to_string(initPid); + // Order matters: user first (grants the capabilities to join the rest). + const char* names[] = {"user", "mnt", "uts", "ipc", "net", "pid"}; + const int kinds[] = {CLONE_NEWUSER, CLONE_NEWNS, CLONE_NEWUTS, CLONE_NEWIPC, CLONE_NEWNET, CLONE_NEWPID}; + Fd fds[6]; + for (int i = 0; i < 6; ++i) { + if (container_.net() == NetMode::Host && kinds[i] == CLONE_NEWNET) continue; + fds[i].reset(::open((base + "/ns/" + names[i]).c_str(), O_RDONLY | O_CLOEXEC)); + if (!fds[i].valid()) throw SysError(std::string("open ns/") + names[i]); + } + Fd rootFd(::open((base + "/root").c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + if (!rootFd.valid()) throw SysError("open container root"); + + lock.unlock(); + pid_t child = ::fork(); + if (child < 0) throw SysError("fork"); + if (child == 0) { + try { + for (int i = 0; i < 6; ++i) { + if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) { + throw SysError(std::string("setns ") + names[i]); + } + } + if (::fchdir(rootFd.get()) != 0 || ::chroot(".") != 0) throw SysError("chroot into container"); + if (::chdir(std::string(spec::kHome).c_str()) != 0) throw SysError("chdir home"); + // setns(pid) only affects children, so fork once more. + pid_t inner = ::fork(); + if (inner < 0) throw SysError("fork"); + if (inner == 0) { + applyLimitsAndEnv(container_); + execCommand(command_); + } + int status = 0; + installForwarding(inner, {SIGTERM, SIGHUP}); + while (::waitpid(inner, &status, 0) < 0) { + if (errno != EINTR) break; + } + ::_exit(exitCodeOf(status)); + } catch (const std::exception& e) { + ::dprintf(2, "doos: %s\n", e.what()); + ::_exit(125); + } + } + struct sigaction ign; + std::memset(&ign, 0, sizeof ign); + ign.sa_handler = SIG_IGN; + ::sigaction(SIGINT, &ign, nullptr); + ::sigaction(SIGQUIT, &ign, nullptr); + installForwarding(child, {SIGTERM, SIGHUP}); + int status = 0; + while (::waitpid(child, &status, 0) < 0) { + if (errno != EINTR) throw SysError("waitpid"); + } + return exitCodeOf(status); +} + +} // namespace doos diff --git a/src/sandbox.hpp b/src/sandbox.hpp new file mode 100644 index 0000000..5791618 --- /dev/null +++ b/src/sandbox.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +#include "container.hpp" +#include "util.hpp" + +namespace doos { + +// Runs a command inside a container: starts the namespaces if the container is +// stopped, joins them if it is already running. Returns the command's exit code. +class Sandbox { +public: + Sandbox(Container container, std::vector command) + : container_(std::move(container)), command_(std::move(command)) {} + + int run(); + +private: + int start(FileLock& lock); + int join(pid_t initPid, FileLock& lock); + + Container container_; + std::vector command_; +}; + +} // namespace doos diff --git a/src/spec.hpp b/src/spec.hpp new file mode 100644 index 0000000..7818a33 --- /dev/null +++ b/src/spec.hpp @@ -0,0 +1,52 @@ +// Single source of truth for limits and layout. Mirrors SPEC.md; change both together. +#pragma once +#include +#include +#include + +namespace spec { + +// Hard caps +constexpr std::size_t kMaxContainers = 64; +constexpr std::size_t kMaxNameLen = 32; +constexpr std::size_t kMaxCommandArgs = 256; +constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap +constexpr unsigned long kMaxOpenFiles = 4096; // RLIMIT_NOFILE ceiling inside +constexpr int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then +constexpr int kLockTimeoutMs = 5000; // per-container lock wait +constexpr std::string_view kTmpSize = "256m"; +constexpr std::string_view kRunSize = "64m"; +constexpr std::string_view kShmSize = "64m"; + +// Networking +constexpr std::string_view kSlirpBinary = "slirp4netns"; +constexpr std::string_view kSlirpDns = "10.0.2.3"; +constexpr std::string_view kSlirpTapName = "tap0"; + +// Container identity +constexpr std::string_view kContainerPath = + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +constexpr std::string_view kHome = "/root"; + +// Host directories exposed as overlay lowerdirs. Writes land in the per-container upperdir. +constexpr std::array kOverlayDirs = {"/usr", "/etc", "/opt"}; + +// Host top-level entries: recreated as symlinks when the host has them as symlinks (usr-merge), +// overlaid like kOverlayDirs when they are real directories, skipped when absent. +constexpr std::array kCompatDirs = {"/bin", "/sbin", "/lib", "/lib32", "/lib64"}; + +// Persistent per-container private directories (no host lower layer). +constexpr std::array kPrivateDirs = {"/root", "/var"}; + +// Empty mountpoints / tmpfs +constexpr std::array kEmptyDirs = {"/mnt", "/srv", "/tmp", "/run", "/dev", "/proc"}; + +// Host device nodes bound into the container /dev (no mknod possible in a userns). +constexpr std::array kDevices = {"null", "zero", "full", "random", "urandom", "tty"}; + +// /proc paths remounted read-only, or hidden behind /dev/null. +constexpr std::array kProcReadOnly = {"/proc/sys", "/proc/sysrq-trigger", "/proc/irq", + "/proc/bus", "/proc/fs"}; +constexpr std::array kProcMasked = {"/proc/kcore", "/proc/keys", "/proc/timer_list"}; + +} // namespace spec diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 0000000..0506c31 --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,98 @@ +#include "util.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "spec.hpp" + +namespace fs = std::filesystem; + +namespace doos { + +SysError::SysError(const std::string& what, int err) + : std::runtime_error(what + ": " + std::strerror(err)), code_(err) {} + +Fd& Fd::operator=(Fd&& o) noexcept { + if (this != &o) reset(o.release()); + return *this; +} + +int Fd::release() { + int f = fd_; + fd_ = -1; + return f; +} + +void Fd::reset(int fd) { + if (fd_ >= 0) ::close(fd_); + fd_ = fd; +} + +FileLock::FileLock(const fs::path& file, int timeoutMs) { + fd_.reset(::open(file.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600)); + if (!fd_.valid()) throw SysError("open lock " + file.string()); + for (int waited = 0;; waited += 50) { + if (::flock(fd_.get(), LOCK_EX | LOCK_NB) == 0) return; + if (errno != EWOULDBLOCK) throw SysError("flock " + file.string()); + if (waited >= timeoutMs) throw std::runtime_error("timed out waiting for lock " + file.string()); + ::poll(nullptr, 0, 50); + } +} + +namespace fsutil { + +void mkdirs(const fs::path& p, mode_t mode) { + std::error_code ec; + fs::create_directories(p, ec); + if (ec) throw std::runtime_error("mkdir " + p.string() + ": " + ec.message()); + ::chmod(p.c_str(), mode); +} + +void writeFile(const fs::path& p, const std::string& content) { + std::ofstream out(p, std::ios::trunc); + if (!out) throw std::runtime_error("cannot write " + p.string()); + out << content; + out.flush(); + if (!out) throw std::runtime_error("write failed " + p.string()); +} + +std::string readFile(const fs::path& p) { + std::ifstream in(p); + if (!in) throw std::runtime_error("cannot read " + p.string()); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +void removeTree(const fs::path& p, std::size_t depth) { + if (depth > spec::kMaxTreeDepth) throw std::runtime_error("tree too deep, refusing: " + p.string()); + std::error_code ec; + auto st = fs::symlink_status(p, ec); + if (ec) return; + if (fs::is_directory(st)) { + // overlayfs workdirs are mode 000 + fs::permissions(p, fs::perms::owner_all, fs::perm_options::add, ec); + for (const auto& e : fs::directory_iterator(p)) removeTree(e.path(), depth + 1); + } + if (::remove(p.c_str()) != 0) throw SysError("remove " + p.string()); +} + +std::size_t countEntries(const fs::path& p, std::size_t cap) { + std::size_t n = 0; + std::error_code ec; + for (auto it = fs::recursive_directory_iterator(p, fs::directory_options::skip_permission_denied, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (++n >= cap) break; + } + return n; +} + +} // namespace fsutil +} // namespace doos diff --git a/src/util.hpp b/src/util.hpp new file mode 100644 index 0000000..a31144d --- /dev/null +++ b/src/util.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include + +namespace doos { + +class SysError : public std::runtime_error { +public: + explicit SysError(const std::string& what, int err = errno); + int code() const { return code_; } + +private: + int code_; +}; + +class Fd { +public: + Fd() = default; + explicit Fd(int fd) : fd_(fd) {} + Fd(Fd&& o) noexcept : fd_(o.release()) {} + Fd& operator=(Fd&& o) noexcept; + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + ~Fd() { reset(); } + int get() const { return fd_; } + int release(); + void reset(int fd = -1); + bool valid() const { return fd_ >= 0; } + +private: + int fd_ = -1; +}; + +// flock(2) on a file for the lifetime of the object, bounded wait. +class FileLock { +public: + FileLock(const std::filesystem::path& file, int timeoutMs); + ~FileLock() = default; + void unlock() { fd_.reset(); } + int fd() const { return fd_.get(); } + +private: + Fd fd_; +}; + +namespace fsutil { +void mkdirs(const std::filesystem::path& p, mode_t mode = 0755); +void writeFile(const std::filesystem::path& p, const std::string& content); +std::string readFile(const std::filesystem::path& p); +// Removes a tree without following symlinks; bounded depth. +void removeTree(const std::filesystem::path& p, std::size_t depth = 0); +std::size_t countEntries(const std::filesystem::path& p, std::size_t cap); +} // namespace fsutil + +} // namespace doos diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..e13a2f5 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# End-to-end test through the real path: real overlayfs, namespaces, slirp4netns, filesystem. +# Usage: tests/run.sh [path/to/doos] +set -u +DOOS=$(readlink -f "${1:-$(dirname "$0")/../build/doos}") +export DOOS_HOME=$(mktemp -d) +pass=0; fail=0 +ok() { pass=$((pass+1)); echo "ok $1"; } +bad() { fail=$((fail+1)); echo "FAIL $1"; } +check() { local d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; } +out() { "$DOOS" attach t -- sh -c "$1" 2>&1; } +trap '"$DOOS" delete --yes t >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT + +check "list empty" "$DOOS" list +check "get_or_create creates" "$DOOS" get_or_create t +[ "$("$DOOS" get_or_create t)" = "exists t" ] && ok "get_or_create idempotent" || bad "get_or_create idempotent" +check "invalid name rejected" bash -c "! '$DOOS' get_or_create 'Bad Name'" +check "net mode fixed at creation" bash -c "! '$DOOS' get_or_create --net none t" + +[ "$(out 'id -u')" = "0" ] && ok "root inside" || bad "root inside" +[ "$(out hostname)" = "t" ] && ok "own hostname" || bad "own hostname" +[ "$(out 'echo $$')" != "" ] && ok "attach runs command" || bad "attach runs command" +[ "$(out 'cat /proc/1/comm; ps -e 2>/dev/null | wc -l' | head -1)" != "" ] && ok "own pid ns" || bad "own pid ns" +[ "$(out 'ls /home 2>&1; echo $?' | tail -1)" != "0" ] && ok "host /home hidden" || bad "host /home hidden" +[ "$(out 'test -e /sys && echo yes || echo no')" = "no" ] && ok "no /sys" || bad "no /sys" + +out 'echo x > /usr/doos-test-file && echo x > /etc/doos-test-file && echo x > /root/persist' >/dev/null +[ ! -e /usr/doos-test-file ] && [ ! -e /etc/doos-test-file ] && ok "host /usr,/etc untouched" || bad "host /usr,/etc untouched" +[ "$(out 'cat /usr/doos-test-file /root/persist | tr -d "\n"')" = "xx" ] && ok "writes persist across attach" || bad "writes persist across attach" +[ "$(out 'echo 1 > /proc/sys/kernel/hostname 2>&1; echo $?' | tail -1)" != "0" ] && ok "/proc/sys read-only" || bad "/proc/sys read-only" + +# network: slirp +out 'getent hosts example.com' | grep -q . && ok "slirp: dns" || bad "slirp: dns" +out 'bash -c "exec 3<>/dev/tcp/1.1.1.1/443 && echo up"' | grep -q up && ok "slirp: outbound tcp" || bad "slirp: outbound tcp" +python3 -m http.server 0 --bind 127.0.0.1 >/tmp/doos-hs.$$ 2>&1 & hs=$!; sleep 1 +port=$(grep -o 'port [0-9]*' /tmp/doos-hs.$$ | grep -o '[0-9]*') +[ -n "$port" ] && out "bash -c \"exec 3<>/dev/tcp/127.0.0.1/$port && echo reach\"" | grep -q reach && bad "host loopback isolated" || ok "host loopback isolated" +kill $hs 2>/dev/null; rm -f /tmp/doos-hs.$$ + +# concurrent attach joins the running container +"$DOOS" attach t -- sh -c 'sleep 4' & sleep 1 +[ "$("$DOOS" list | awk '$1=="t"{print $2}')" = "running" ] && ok "list shows running" || bad "list shows running" +check "delete refuses running" bash -c "! '$DOOS' delete --yes t" +[ "$(out 'ls /tmp/ 2>&1; cat /proc/1/comm' | tail -1)" != "" ] && ok "second attach joins" || bad "second attach joins" +wait; sleep 0.5 +[ "$("$DOOS" list | awk '$1=="t"{print $2}')" = "stopped" ] && ok "stopped after exit" || bad "stopped after exit" + +# none / host modes +"$DOOS" get_or_create --net none n >/dev/null +"$DOOS" attach n -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && bad "net none blocks" || ok "net none blocks" +"$DOOS" delete --yes n >/dev/null +"$DOOS" get_or_create --net host h >/dev/null +"$DOOS" attach h -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && ok "net host works" || bad "net host works" +"$DOOS" delete --yes h >/dev/null + +# delete: dry-run by default +"$DOOS" delete t | grep -q "dry run" && [ -d "$DOOS_HOME/containers/t" ] && ok "delete is dry-run" || bad "delete is dry-run" +"$DOOS" delete --yes t | grep -q deleted && [ ! -e "$DOOS_HOME/containers/t" ] && ok "delete --yes removes" || bad "delete --yes removes" + +echo "passed=$pass failed=$fail" +[ "$fail" -eq 0 ]