doos 0.1.0: user-level overlayfs containers with slirp networking

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 20:54:55 +02:00
co-authored by Claude Sonnet 5.5
commit 36f4f890c7
21 changed files with 1418 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
build/
+15
View File
@@ -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)
+20
View File
@@ -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).
+63
View File
@@ -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/<dir>/{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/<dir>/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).
+1
View File
@@ -0,0 +1 @@
0.1.0
+11
View File
@@ -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 <abi/5.0>,
include <tunables/global>
profile doos /{usr/local/bin,home/*/projects/doos/build}/doos flags=(unconfined) {
userns,
@{exec_path} mr,
}
+41
View File
@@ -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.
+9
View File
@@ -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.
+132
View File
@@ -0,0 +1,132 @@
#include "cli.hpp"
#include <ctime>
#include <iostream>
#include <optional>
#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<std::string> net;
bool yes = false;
std::string name;
std::vector<std::string> tail;
};
Parsed parse(const std::vector<std::string>& 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<long>(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<std::string> 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<std::string> rest) {
Parsed p = parse(rest, true, false);
if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name");
std::optional<NetMode> 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<std::string> rest) {
Parsed p = parse(rest, true, false);
std::optional<NetMode> 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<std::string> 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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <cstdio>
#include <string>
#include <vector>
namespace doos {
class Cli {
public:
explicit Cli(std::vector<std::string> args) : args_(std::move(args)) {}
int run();
private:
int cmdList();
int cmdGetOrCreate(std::vector<std::string> rest);
int cmdAttach(std::vector<std::string> rest);
int cmdDelete(std::vector<std::string> rest);
static void usage(std::FILE* out);
std::vector<std::string> args_;
};
} // namespace doos
+214
View File
@@ -0,0 +1,214 @@
#include "container.hpp"
#include <algorithm>
#include <cctype>
#include <ctime>
#include <map>
#include <sstream>
#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<std::string, std::string> parseKv(const std::string& text) {
std::map<std::string, std::string> 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<std::string> 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<unsigned char>(name[0])) || std::isdigit(static_cast<unsigned char>(name[0])));
for (char c : name) {
ok = ok && (std::islower(static_cast<unsigned char>(c)) || std::isdigit(static_cast<unsigned char>(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<pid_t> 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_t>(pid));
if (now && *now == start) return static_cast<pid_t>(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<Container> Store::list() const {
std::vector<Container> 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<Container, bool> Store::getOrCreate(const std::string& name, std::optional<NetMode> 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
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <sys/types.h>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#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/<name>/{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<pid_t> 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<Container> list() const;
// Returns the container and whether it was created by this call.
std::pair<Container, bool> getOrCreate(const std::string& name, std::optional<NetMode> 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
+13
View File
@@ -0,0 +1,13 @@
#include <cstdio>
#include <exception>
#include "cli.hpp"
int main(int argc, char** argv) {
try {
return doos::Cli(std::vector<std::string>(argv + 1, argv + argc)).run();
} catch (const std::exception& e) {
std::fprintf(stderr, "doos: %s\n", e.what());
return 1;
}
}
+86
View File
@@ -0,0 +1,86 @@
#include "network.hpp"
#include <fcntl.h>
#include <poll.h>
#include <sys/prctl.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cstring>
#include <fstream>
#include <stdexcept>
#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<char>(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<char*>(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
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <sys/types.h>
#include <string>
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
+392
View File
@@ -0,0 +1,392 @@
#include "sandbox.hpp"
#include <fcntl.h>
#include <net/if.h>
#include <sched.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <sys/mount.h>
#include <sys/prctl.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cstring>
#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<int> 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<std::string> cmd) {
if (cmd.empty()) cmd = {::access("/bin/bash", X_OK) == 0 ? "/bin/bash" : "/bin/sh"};
std::vector<char*> 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<std::string>* 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<ChildCtx*>(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<ssize_t>(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<char> 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
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <string>
#include <vector>
#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<std::string> 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<std::string> command_;
};
} // namespace doos
+52
View File
@@ -0,0 +1,52 @@
// Single source of truth for limits and layout. Mirrors SPEC.md; change both together.
#pragma once
#include <array>
#include <cstddef>
#include <string_view>
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<std::string_view, 3> 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<std::string_view, 5> kCompatDirs = {"/bin", "/sbin", "/lib", "/lib32", "/lib64"};
// Persistent per-container private directories (no host lower layer).
constexpr std::array<std::string_view, 2> kPrivateDirs = {"/root", "/var"};
// Empty mountpoints / tmpfs
constexpr std::array<std::string_view, 6> kEmptyDirs = {"/mnt", "/srv", "/tmp", "/run", "/dev", "/proc"};
// Host device nodes bound into the container /dev (no mknod possible in a userns).
constexpr std::array<std::string_view, 6> kDevices = {"null", "zero", "full", "random", "urandom", "tty"};
// /proc paths remounted read-only, or hidden behind /dev/null.
constexpr std::array<std::string_view, 5> kProcReadOnly = {"/proc/sys", "/proc/sysrq-trigger", "/proc/irq",
"/proc/bus", "/proc/fs"};
constexpr std::array<std::string_view, 3> kProcMasked = {"/proc/kcore", "/proc/keys", "/proc/timer_list"};
} // namespace spec
+98
View File
@@ -0,0 +1,98 @@
#include "util.hpp"
#include <fcntl.h>
#include <poll.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstring>
#include <fstream>
#include <sstream>
#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
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <cerrno>
#include <filesystem>
#include <stdexcept>
#include <string>
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
Executable
+61
View File
@@ -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 ]