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:
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 ]
|
||||
Reference in New Issue
Block a user