doos 1.1.0: limits, hardening, pasta, signatures, Debian image
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+108
-22
@@ -3,6 +3,7 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
|
||||
#include "spec.hpp"
|
||||
@@ -12,55 +13,140 @@ namespace fs = std::filesystem;
|
||||
|
||||
namespace doos {
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::create(const std::string& name, std::string& whyNot) {
|
||||
namespace {
|
||||
bool hasController(const fs::path& dir, const std::string& name) {
|
||||
try {
|
||||
std::string self = fsutil::readFile("/proc/self/cgroup");
|
||||
std::string marker = "user@" + std::to_string(::geteuid()) + ".service";
|
||||
auto at = self.find(marker);
|
||||
if (self.rfind("0::", 0) != 0 || at == std::string::npos) {
|
||||
whyNot = "no cgroup v2 delegated to the user manager (" + marker + " not found)";
|
||||
std::istringstream in(fsutil::readFile(dir / "cgroup.controllers"));
|
||||
for (std::string c; in >> c;) {
|
||||
if (c == name) return true;
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool tryWrite(const fs::path& file, const std::string& text) {
|
||||
try {
|
||||
fsutil::writeFile(file, text);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
fs::path Cgroup::base(std::string& whyNot) {
|
||||
std::string self;
|
||||
try {
|
||||
self = fsutil::readFile("/proc/self/cgroup");
|
||||
} catch (const std::exception& e) {
|
||||
whyNot = e.what();
|
||||
return {};
|
||||
}
|
||||
std::string marker = "user@" + std::to_string(::geteuid()) + ".service";
|
||||
auto at = self.find(marker);
|
||||
if (self.rfind("0::", 0) != 0 || at == std::string::npos) {
|
||||
whyNot = "no cgroup v2 delegated to the user manager (" + marker + " not found)";
|
||||
return {};
|
||||
}
|
||||
return fs::path("/sys/fs/cgroup") / self.substr(3, at + marker.size() - 3).substr(1);
|
||||
}
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::create(const std::string& name, const Limits& limits, std::string& whyNot,
|
||||
std::string& notEnforced) {
|
||||
try {
|
||||
fs::path b = base(whyNot);
|
||||
if (b.empty()) return nullptr;
|
||||
fs::path top = b / std::string(spec::kCgroupDir);
|
||||
std::error_code ec;
|
||||
fs::create_directory(top, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + top.string() + ": " + ec.message();
|
||||
return nullptr;
|
||||
}
|
||||
fs::path base = fs::path("/sys/fs/cgroup") / self.substr(3, at + marker.size() - 3).substr(1);
|
||||
fs::path dir = base / std::string(spec::kCgroupDir);
|
||||
std::error_code ec;
|
||||
// Controllers must be enabled for children of `doos/` for the limit files to exist in `doos/<name>`.
|
||||
std::string enable;
|
||||
for (const char* c : {"memory", "pids", "cpu"}) {
|
||||
if (hasController(b, c)) enable += std::string(" +") + c;
|
||||
}
|
||||
if (!enable.empty()) tryWrite(top / "cgroup.subtree_control", enable);
|
||||
|
||||
fs::path dir = top / name;
|
||||
fs::create_directory(dir, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + dir.string() + ": " + ec.message();
|
||||
return nullptr;
|
||||
}
|
||||
fs::path leaf = dir / name;
|
||||
fs::create_directory(leaf, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + leaf.string() + ": " + ec.message();
|
||||
for (const char* leaf : {"main", "shell"}) fs::create_directory(dir / leaf, ec);
|
||||
if (::access((dir / "main" / "cgroup.freeze").c_str(), W_OK) != 0) {
|
||||
whyNot = "cgroup.freeze not writable in " + dir.string();
|
||||
return nullptr;
|
||||
}
|
||||
if (::access((leaf / "cgroup.freeze").c_str(), W_OK) != 0) {
|
||||
whyNot = "cgroup.freeze not writable in " + leaf.string();
|
||||
fs::remove(leaf, ec);
|
||||
return nullptr;
|
||||
auto note = [&](const std::string& what) { notEnforced += (notEnforced.empty() ? "" : ", ") + what; };
|
||||
if (limits.memoryBytes) {
|
||||
if (!tryWrite(dir / "memory.max", std::to_string(limits.memoryBytes))) note("memory");
|
||||
else {
|
||||
tryWrite(dir / "memory.swap.max", "0"); // otherwise the cap can be dodged through swap
|
||||
tryWrite(dir / "memory.oom.group", "1"); // an OOM kill takes the whole container, not one process
|
||||
}
|
||||
}
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(leaf));
|
||||
if (limits.pids && !tryWrite(dir / "pids.max", std::to_string(limits.pids))) note("pids");
|
||||
if (limits.milliCpus) {
|
||||
long quota = static_cast<long>(limits.milliCpus) * spec::kCpuPeriodUs / 1000;
|
||||
if (!tryWrite(dir / "cpu.max", std::to_string(quota) + " " + std::to_string(spec::kCpuPeriodUs))) note("cpus");
|
||||
}
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(dir, true));
|
||||
} catch (const std::exception& e) {
|
||||
whyNot = e.what();
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::open(const std::string& name) {
|
||||
std::string why;
|
||||
fs::path b = base(why);
|
||||
if (b.empty()) return nullptr;
|
||||
fs::path dir = b / std::string(spec::kCgroupDir) / name;
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(dir / "shell", ec)) return nullptr;
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(dir, false));
|
||||
}
|
||||
|
||||
Cgroup::~Cgroup() {
|
||||
// Processes die with the container; give the kernel a moment to empty the group, then remove it.
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
if (!owner_) return;
|
||||
// Everything dies with the container's init; kill any straggler, then remove the leaves and the group.
|
||||
tryWrite(dir_ / "cgroup.kill", "1");
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
std::error_code ec;
|
||||
fs::remove(dir_ / "main", ec);
|
||||
fs::remove(dir_ / "shell", ec);
|
||||
if (fs::remove(dir_, ec)) return;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
}
|
||||
|
||||
void Cgroup::add(pid_t pid) const { fsutil::writeFile(dir_ / "cgroup.procs", std::to_string(pid)); }
|
||||
void Cgroup::addMain(pid_t pid) const { fsutil::writeFile(dir_ / "main" / "cgroup.procs", std::to_string(pid)); }
|
||||
void Cgroup::addShell(pid_t pid) const { fsutil::writeFile(dir_ / "shell" / "cgroup.procs", std::to_string(pid)); }
|
||||
|
||||
void Cgroup::freeze(bool on) const {
|
||||
fsutil::writeFile(dir_ / "cgroup.freeze", on ? "1" : "0");
|
||||
fsutil::writeFile(dir_ / "main" / "cgroup.freeze", on ? "1" : "0");
|
||||
frozen_ = on;
|
||||
}
|
||||
|
||||
std::uint64_t Cgroup::counter(const char* file, const char* key) const {
|
||||
try {
|
||||
std::istringstream in(fsutil::readFile(dir_ / file));
|
||||
std::string k;
|
||||
std::uint64_t v;
|
||||
while (in >> k >> v) {
|
||||
if (k == key) return v;
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::uint64_t Cgroup::oomKills() const { return counter("memory.events", "oom_kill"); }
|
||||
std::uint64_t Cgroup::pidFailures() const { return counter("pids.events", "max"); }
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+27
-6
@@ -1,29 +1,50 @@
|
||||
#pragma once
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace doos {
|
||||
|
||||
// One cgroup v2 leaf per running container, under the cgroup delegated to the user's systemd manager.
|
||||
// Used to freeze a container that hit its size cap (freezing needs no controller). Best effort:
|
||||
// create() returns null when delegation is absent, and the caller must say so.
|
||||
struct Limits {
|
||||
std::uint64_t memoryBytes = 0; // 0 = not limited
|
||||
std::uint32_t pids = 0;
|
||||
std::uint32_t milliCpus = 0; // 0 = all cores
|
||||
};
|
||||
|
||||
// cgroup v2 subtree of one running container under the cgroup delegated to the user's systemd manager:
|
||||
//
|
||||
// doos/<name>/ limits live here (memory.max, pids.max, cpu.max), no processes
|
||||
// main/ the container's init and everything it starts; frozen when the size cap is hit
|
||||
// shell/ processes started by later `doos attach` calls; never frozen (rescue path), same limits
|
||||
//
|
||||
// Best effort: create() returns null when delegation is absent, and the caller must say why.
|
||||
class Cgroup {
|
||||
public:
|
||||
static std::unique_ptr<Cgroup> create(const std::string& name, std::string& whyNot);
|
||||
static std::unique_ptr<Cgroup> create(const std::string& name, const Limits& limits, std::string& whyNot,
|
||||
std::string& notEnforced);
|
||||
// Existing group of a running container, for joiners. Null when none.
|
||||
static std::unique_ptr<Cgroup> open(const std::string& name);
|
||||
~Cgroup();
|
||||
Cgroup(const Cgroup&) = delete;
|
||||
Cgroup& operator=(const Cgroup&) = delete;
|
||||
|
||||
void add(pid_t pid) const;
|
||||
void addMain(pid_t pid) const;
|
||||
void addShell(pid_t pid) const;
|
||||
void freeze(bool on) const;
|
||||
bool frozen() const { return frozen_; }
|
||||
// Number of OOM kills and of fork failures at the pids limit since creation.
|
||||
std::uint64_t oomKills() const;
|
||||
std::uint64_t pidFailures() const;
|
||||
|
||||
private:
|
||||
explicit Cgroup(std::filesystem::path dir) : dir_(std::move(dir)) {}
|
||||
Cgroup(std::filesystem::path dir, bool owner) : dir_(std::move(dir)), owner_(owner) {}
|
||||
static std::filesystem::path base(std::string& whyNot);
|
||||
std::uint64_t counter(const char* file, const char* key) const;
|
||||
std::filesystem::path dir_;
|
||||
bool owner_;
|
||||
mutable bool frozen_ = false;
|
||||
};
|
||||
|
||||
|
||||
+43
-7
@@ -15,6 +15,9 @@ namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
std::uint32_t parseCount(const std::string& text);
|
||||
std::uint32_t parseCpus(const std::string& text);
|
||||
|
||||
// Leading --options, then the first positional, then everything else untouched.
|
||||
struct Parsed {
|
||||
Options opts;
|
||||
@@ -47,6 +50,14 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
else if (allow.container && is(a, "--image")) p.opts.image = value(i, "--image");
|
||||
else if (allow.container && is(a, "--size")) p.opts.size = parseSize(value(i, "--size"));
|
||||
else if (allow.container && is(a, "--ids")) p.opts.ids = IdMap::parse(value(i, "--ids"));
|
||||
else if (allow.container && is(a, "--memory")) p.opts.memory = parseSize(value(i, "--memory"));
|
||||
else if (allow.container && is(a, "--pids")) p.opts.pids = parseCount(value(i, "--pids"));
|
||||
else if (allow.container && is(a, "--cpus")) p.opts.milliCpus = parseCpus(value(i, "--cpus"));
|
||||
else if (allow.container && is(a, "--hardening")) {
|
||||
std::string v = value(i, "--hardening");
|
||||
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
|
||||
p.opts.relaxed = (v == "relaxed");
|
||||
}
|
||||
else if (allow.yes && a == "--yes") p.yes = true;
|
||||
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
|
||||
else if (a.size() > 1 && a[0] == '-') throw std::runtime_error("unknown option " + a);
|
||||
@@ -59,6 +70,26 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
return p;
|
||||
}
|
||||
|
||||
std::uint32_t parseCount(const std::string& text) {
|
||||
if (text.empty() || text.size() > 9 || text.find_first_not_of("0123456789") != std::string::npos) {
|
||||
throw std::runtime_error("invalid number '" + text + "'");
|
||||
}
|
||||
return static_cast<std::uint32_t>(std::stoul(text));
|
||||
}
|
||||
|
||||
// "2", "0.5", "1.25" cpus -> thousandths; "0" means all cores.
|
||||
std::uint32_t parseCpus(const std::string& text) {
|
||||
std::size_t dot = text.find('.');
|
||||
std::string whole = text.substr(0, dot), frac = dot == std::string::npos ? "" : text.substr(dot + 1);
|
||||
if (whole.empty() || whole.size() > 5 || frac.size() > 3 ||
|
||||
whole.find_first_not_of("0123456789") != std::string::npos ||
|
||||
frac.find_first_not_of("0123456789") != std::string::npos) {
|
||||
throw std::runtime_error("invalid cpus '" + text + "' (examples: 2, 0.5, 1.25, 0 for all cores)");
|
||||
}
|
||||
while (frac.size() < 3) frac += '0';
|
||||
return static_cast<std::uint32_t>(std::stoul(whole)) * 1000 + static_cast<std::uint32_t>(std::stoul(frac));
|
||||
}
|
||||
|
||||
std::string age(long created) {
|
||||
long d = std::time(nullptr) - created;
|
||||
if (d < 120) return std::to_string(d) + "s";
|
||||
@@ -82,13 +113,18 @@ void Cli::usage(std::FILE* out) {
|
||||
" doos image add [--sha256 HEX] SOURCE [NAME]\n"
|
||||
" doos image delete [--yes] NAME\n"
|
||||
"\n"
|
||||
"options (fixed at creation, except --size):\n"
|
||||
" --image NAME start from an image instead of the host system (fetched on demand)\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g (e.g. 512m, 8g)\n"
|
||||
" --net slirp|host|none network mode, default slirp\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available\n"
|
||||
"options:\n"
|
||||
" --image NAME start from an image instead of the host system (fetched on demand) [fixed]\n"
|
||||
" --net slirp|pasta|host|none network, default slirp (pasta: host-like, private sockets) [fixed]\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available [fixed]\n"
|
||||
" --hardening default|relaxed relaxed = no capability drop, no seccomp, full /etc [fixed]\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g\n"
|
||||
" --memory SIZE memory cap, 64m..1t, default 8g\n"
|
||||
" --pids N process cap, 16..4194304, default 4096\n"
|
||||
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\n"
|
||||
" size, memory, pids and cpus can be changed on any later call; they apply at the next start.\n"
|
||||
"\n"
|
||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, an https URL (needs --sha256), or a tar file.\n"
|
||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\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);
|
||||
@@ -194,7 +230,7 @@ int Cli::cmdImageAdd(std::vector<std::string> rest) {
|
||||
src.sha256 = *p.sha256;
|
||||
} else {
|
||||
if (!std::filesystem::is_regular_file(source)) {
|
||||
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION), an https URL, nor a file");
|
||||
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE), an https URL, nor a file");
|
||||
}
|
||||
src.location = std::filesystem::absolute(source).string();
|
||||
src.isFile = true;
|
||||
|
||||
+115
-10
@@ -1,6 +1,8 @@
|
||||
#include "container.hpp"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@@ -82,6 +84,23 @@ void Container::seed(const std::string& marker, const std::string& label,
|
||||
fsutil::writeFile(m, "ok\n");
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Names that may hold credentials are never copied, even inside allowed directories.
|
||||
bool etcDenied(const fs::path& p) {
|
||||
std::string n = p.filename().string();
|
||||
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".key") == 0) return true;
|
||||
for (auto d : spec::kEtcDenyNames) {
|
||||
std::string pat(d);
|
||||
if (!pat.empty() && pat.back() == '*') {
|
||||
if (n.rfind(pat.substr(0, pat.size() - 1), 0) == 0) return true;
|
||||
} else if (n == pat) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void Container::prepareLayout() const {
|
||||
fsutil::mkdirs(rootfs());
|
||||
if (imageMode()) {
|
||||
@@ -154,8 +173,17 @@ void Container::prepareLayout() const {
|
||||
for (auto d : spec::kCopiedDirs) {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(std::string(d), ec)) continue;
|
||||
seed("copy-" + std::string(slug(d)), "copy of " + std::string(d),
|
||||
[&] { return fsutil::copyTree(std::string(d), privateDir(std::string(d))); });
|
||||
std::string key = slug(d);
|
||||
std::replace(key.begin(), key.end(), '/', '_');
|
||||
if (relaxed_) {
|
||||
seed("copy-" + key, "copy of " + std::string(d) + " (relaxed)",
|
||||
[&] { return fsutil::copyTree(std::string(d), privateDir(std::string(d))); });
|
||||
} else {
|
||||
seed("copy-" + key, "allowlisted " + std::string(d), [&] {
|
||||
std::vector<std::string> names(spec::kEtcAllow.begin(), spec::kEtcAllow.end());
|
||||
return fsutil::copyEntries(std::string(d), privateDir(std::string(d)), names, etcDenied);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,15 +205,40 @@ std::uint64_t Container::usedBytes(std::size_t& skipped) const {
|
||||
std::error_code ec;
|
||||
if (fs::exists(dir_ / "layers", ec)) findUppers(dir_ / "layers", 0, dirs);
|
||||
if (fs::exists(dir_ / "private", ec)) dirs.push_back(dir_ / "private");
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& d : dirs) total += fsutil::diskUsage(d, skipped);
|
||||
return total;
|
||||
auto scan = [&](std::size_t& sk) {
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& d : dirs) total += fsutil::diskUsage(d, sk);
|
||||
return total;
|
||||
};
|
||||
if (ids_ == IdMode::Single) return scan(skipped);
|
||||
// Files of other container ids (host uids 100000+) and their mode 700 directories are invisible to the host
|
||||
// user; measuring as root of the id map sees them.
|
||||
int p[2];
|
||||
if (::pipe2(p, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
Fd rd(p[0]), wr(p[1]);
|
||||
int rc = UserNsJob::run(ids_, [&] {
|
||||
std::size_t sk = 0;
|
||||
std::uint64_t t = scan(sk);
|
||||
::dprintf(wr.get(), "%llu %zu\n", static_cast<unsigned long long>(t), sk);
|
||||
return 0;
|
||||
});
|
||||
wr.reset();
|
||||
if (rc != 0) throw std::runtime_error("size scan failed");
|
||||
char buf[64] = {0};
|
||||
if (::read(rd.get(), buf, sizeof buf - 1) <= 0) throw std::runtime_error("size scan returned nothing");
|
||||
unsigned long long t = 0;
|
||||
std::size_t sk = 0;
|
||||
std::sscanf(buf, "%llu %zu", &t, &sk);
|
||||
skipped += sk;
|
||||
return t;
|
||||
}
|
||||
|
||||
void Container::writeMeta() const {
|
||||
fsutil::writeFile(metaFile(), "net=" + Network::name(net_) + "\ncreated=" + std::to_string(created_) +
|
||||
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
|
||||
"\nsize=" + std::to_string(size_) + "\n");
|
||||
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
|
||||
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
|
||||
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n");
|
||||
}
|
||||
|
||||
void Container::setSizeCap(std::uint64_t bytes) {
|
||||
@@ -197,11 +250,41 @@ void Container::setSizeCap(std::uint64_t bytes) {
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::create(const NetMode& net, const std::string& image, IdMode ids, std::uint64_t size) {
|
||||
void Container::setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids,
|
||||
std::optional<std::uint32_t> milliCpus) {
|
||||
if (memory) {
|
||||
if (*memory < spec::kMinMemoryBytes || *memory > spec::kMaxMemoryBytes) {
|
||||
throw std::runtime_error("memory must be between " + formatSize(spec::kMinMemoryBytes) + " and " +
|
||||
formatSize(spec::kMaxMemoryBytes));
|
||||
}
|
||||
memory_ = *memory;
|
||||
}
|
||||
if (pids) {
|
||||
if (*pids < spec::kMinPids || *pids > spec::kMaxPids) {
|
||||
throw std::runtime_error("pids must be between " + std::to_string(spec::kMinPids) + " and " +
|
||||
std::to_string(spec::kMaxPids));
|
||||
}
|
||||
pids_ = *pids;
|
||||
}
|
||||
if (milliCpus) {
|
||||
if (*milliCpus != 0 && (*milliCpus < spec::kMinMilliCpus || *milliCpus > spec::kMaxMilliCpus)) {
|
||||
throw std::runtime_error("cpus must be between 0.1 and 1024 (0 = all cores)");
|
||||
}
|
||||
milliCpus_ = *milliCpus;
|
||||
}
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::create(const NetMode& net, const std::string& image, IdMode ids, const Options& o) {
|
||||
net_ = net;
|
||||
image_ = image;
|
||||
ids_ = ids;
|
||||
size_ = size;
|
||||
size_ = o.size.value_or(spec::kDefaultSizeBytes);
|
||||
relaxed_ = o.relaxed.value_or(false);
|
||||
memory_ = spec::kDefaultMemoryBytes;
|
||||
pids_ = spec::kDefaultPids;
|
||||
milliCpus_ = 0;
|
||||
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus);
|
||||
created_ = std::time(nullptr);
|
||||
fsutil::mkdirs(dir_);
|
||||
prepareLayout();
|
||||
@@ -220,6 +303,10 @@ void Container::readMeta() {
|
||||
image_ = kv.count("image") ? kv["image"] : "";
|
||||
ids_ = IdMap::parse(kv.count("ids") ? kv["ids"] : "single");
|
||||
size_ = kv.count("size") ? std::stoull(kv["size"]) : spec::kDefaultSizeBytes;
|
||||
memory_ = kv.count("memory") ? std::stoull(kv["memory"]) : spec::kDefaultMemoryBytes;
|
||||
pids_ = kv.count("pids") ? static_cast<std::uint32_t>(std::stoul(kv["pids"])) : spec::kDefaultPids;
|
||||
milliCpus_ = kv.count("cpus") ? static_cast<std::uint32_t>(std::stoul(kv["cpus"])) : 0;
|
||||
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
|
||||
}
|
||||
|
||||
std::optional<pid_t> Container::runningPid() const {
|
||||
@@ -307,7 +394,9 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
if (opts.net) fixed("net", Network::name(c.net()), Network::name(*opts.net));
|
||||
if (image) fixed("image", c.image().empty() ? "none" : c.image(), *image);
|
||||
if (opts.ids) fixed("ids", IdMap::name(c.ids()), IdMap::name(*opts.ids));
|
||||
if (opts.relaxed) fixed("hardening", c.relaxed() ? "relaxed" : "default", *opts.relaxed ? "relaxed" : "default");
|
||||
if (opts.size && *opts.size != c.sizeCap()) c.setSizeCap(*opts.size);
|
||||
if (opts.memory || opts.pids || opts.milliCpus) c.setLimits(opts.memory, opts.pids, opts.milliCpus);
|
||||
};
|
||||
|
||||
if (fs::exists(dir / "meta", ec)) {
|
||||
@@ -329,7 +418,7 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
ImageStore store = images();
|
||||
if (!store.has(*image)) {
|
||||
if (!Catalog::knows(*image)) {
|
||||
throw std::runtime_error("unknown image '" + *image + "'. Add it: doos image add SOURCE [NAME] (catalog: alpine, ubuntu)");
|
||||
throw std::runtime_error("unknown image '" + *image + "'. Add it: doos image add SOURCE [NAME] (catalog: alpine, ubuntu, debian)");
|
||||
}
|
||||
std::fprintf(stderr, "doos: image '%s' not present, fetching it\n", image->c_str());
|
||||
store.add(Catalog::resolve(*image), ids);
|
||||
@@ -353,7 +442,23 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
verifyExisting();
|
||||
return {c, false};
|
||||
}
|
||||
c.create(opts.net.value_or(NetMode::Slirp), image.value_or(""), ids, size);
|
||||
Options resolved = opts;
|
||||
resolved.size = size;
|
||||
try {
|
||||
c.create(opts.net.value_or(NetMode::Slirp), image.value_or(""), ids, resolved);
|
||||
} catch (...) {
|
||||
// a container that failed halfway must not stay behind as an unreadable directory
|
||||
try {
|
||||
if (ids == IdMode::Subuid) UserNsJob::run(ids, [&] { fsutil::removeTree(dir); return 0; });
|
||||
else fsutil::removeTree(dir);
|
||||
} catch (...) {
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (c.net() == NetMode::Host) {
|
||||
std::fprintf(stderr, "doos: note: --net host shares the host's abstract unix sockets and loopback; "
|
||||
"--net pasta gives the same reachability in a private namespace\n");
|
||||
}
|
||||
return {c, true};
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -21,8 +21,13 @@ struct Options {
|
||||
std::optional<std::string> image;
|
||||
std::optional<IdMode> ids;
|
||||
std::optional<std::uint64_t> size;
|
||||
std::optional<std::uint64_t> memory;
|
||||
std::optional<std::uint32_t> pids;
|
||||
std::optional<std::uint32_t> milliCpus; // 0 = all cores
|
||||
std::optional<bool> relaxed; // --hardening relaxed
|
||||
};
|
||||
|
||||
|
||||
class Container {
|
||||
public:
|
||||
Container(std::filesystem::path dir, std::string name) : dir_(std::move(dir)), name_(std::move(name)) {}
|
||||
@@ -47,8 +52,14 @@ public:
|
||||
IdMode ids() const { return ids_; }
|
||||
std::uint64_t sizeCap() const { return size_; }
|
||||
void setSizeCap(std::uint64_t bytes);
|
||||
std::uint64_t memoryCap() const { return memory_; }
|
||||
std::uint32_t pidsCap() const { return pids_; }
|
||||
std::uint32_t milliCpus() const { return milliCpus_; }
|
||||
bool relaxed() const { return relaxed_; }
|
||||
// Limits take effect the next time the container starts.
|
||||
void setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids, std::optional<std::uint32_t> milliCpus);
|
||||
|
||||
void create(const NetMode& net, const std::string& image, IdMode ids, std::uint64_t size);
|
||||
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
|
||||
void readMeta(); // meta only, no side effects
|
||||
void load(); // readMeta + heal the layout
|
||||
|
||||
@@ -60,6 +71,7 @@ public:
|
||||
// Mountpoints, layer dirs, and one-time seeding (skeleton, /etc copy). Idempotent.
|
||||
void prepareLayout() const;
|
||||
// Bytes the container wrote (writable layers and private dirs), as the host user can see them.
|
||||
// Measured from inside the id map, so directories of other container ids are counted too.
|
||||
std::uint64_t usedBytes(std::size_t& skipped) const;
|
||||
|
||||
private:
|
||||
@@ -73,6 +85,10 @@ private:
|
||||
std::string image_;
|
||||
IdMode ids_ = IdMode::Single;
|
||||
std::uint64_t size_ = 0;
|
||||
std::uint64_t memory_ = 0;
|
||||
std::uint32_t pids_ = 0;
|
||||
std::uint32_t milliCpus_ = 0;
|
||||
bool relaxed_ = false;
|
||||
};
|
||||
|
||||
class Store {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "hardening.hpp"
|
||||
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <sched.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstddef>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
#ifndef SECCOMP_RET_KILL_PROCESS
|
||||
#define SECCOMP_RET_KILL_PROCESS 0x80000000U
|
||||
#endif
|
||||
#ifndef CAP_LAST_CAP_FALLBACK
|
||||
#define CAP_LAST_CAP_FALLBACK 40
|
||||
#endif
|
||||
|
||||
namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(__x86_64__)
|
||||
constexpr unsigned kArch = AUDIT_ARCH_X86_64;
|
||||
constexpr bool kHasX32 = true;
|
||||
#elif defined(__aarch64__)
|
||||
constexpr unsigned kArch = AUDIT_ARCH_AARCH64;
|
||||
constexpr bool kHasX32 = false;
|
||||
#else
|
||||
constexpr unsigned kArch = 0;
|
||||
constexpr bool kHasX32 = false;
|
||||
#endif
|
||||
|
||||
// Syscalls answered with EPERM. Entries the architecture does not have are skipped at compile time.
|
||||
std::vector<unsigned> deniedSyscalls() {
|
||||
std::vector<unsigned> v;
|
||||
#define DENY(name) v.push_back(static_cast<unsigned>(name))
|
||||
#ifdef SYS_acct
|
||||
DENY(SYS_acct);
|
||||
#endif
|
||||
#ifdef SYS_add_key
|
||||
DENY(SYS_add_key);
|
||||
#endif
|
||||
#ifdef SYS_bpf
|
||||
DENY(SYS_bpf);
|
||||
#endif
|
||||
#ifdef SYS_clock_adjtime
|
||||
DENY(SYS_clock_adjtime);
|
||||
#endif
|
||||
#ifdef SYS_clock_settime
|
||||
DENY(SYS_clock_settime);
|
||||
#endif
|
||||
#ifdef SYS_create_module
|
||||
DENY(SYS_create_module);
|
||||
#endif
|
||||
#ifdef SYS_delete_module
|
||||
DENY(SYS_delete_module);
|
||||
#endif
|
||||
#ifdef SYS_finit_module
|
||||
DENY(SYS_finit_module);
|
||||
#endif
|
||||
#ifdef SYS_init_module
|
||||
DENY(SYS_init_module);
|
||||
#endif
|
||||
#ifdef SYS_get_kernel_syms
|
||||
DENY(SYS_get_kernel_syms);
|
||||
#endif
|
||||
#ifdef SYS_keyctl
|
||||
DENY(SYS_keyctl);
|
||||
#endif
|
||||
#ifdef SYS_kexec_file_load
|
||||
DENY(SYS_kexec_file_load);
|
||||
#endif
|
||||
#ifdef SYS_kexec_load
|
||||
DENY(SYS_kexec_load);
|
||||
#endif
|
||||
#ifdef SYS_lookup_dcookie
|
||||
DENY(SYS_lookup_dcookie);
|
||||
#endif
|
||||
#ifdef SYS_nfsservctl
|
||||
DENY(SYS_nfsservctl);
|
||||
#endif
|
||||
#ifdef SYS_open_by_handle_at
|
||||
DENY(SYS_open_by_handle_at);
|
||||
#endif
|
||||
#ifdef SYS_perf_event_open
|
||||
DENY(SYS_perf_event_open);
|
||||
#endif
|
||||
#ifdef SYS_pivot_root
|
||||
DENY(SYS_pivot_root);
|
||||
#endif
|
||||
#ifdef SYS_process_vm_readv
|
||||
DENY(SYS_process_vm_readv);
|
||||
#endif
|
||||
#ifdef SYS_process_vm_writev
|
||||
DENY(SYS_process_vm_writev);
|
||||
#endif
|
||||
#ifdef SYS_query_module
|
||||
DENY(SYS_query_module);
|
||||
#endif
|
||||
#ifdef SYS_quotactl
|
||||
DENY(SYS_quotactl);
|
||||
#endif
|
||||
#ifdef SYS_reboot
|
||||
DENY(SYS_reboot);
|
||||
#endif
|
||||
#ifdef SYS_request_key
|
||||
DENY(SYS_request_key);
|
||||
#endif
|
||||
#ifdef SYS_settimeofday
|
||||
DENY(SYS_settimeofday);
|
||||
#endif
|
||||
#ifdef SYS_stime
|
||||
DENY(SYS_stime);
|
||||
#endif
|
||||
#ifdef SYS_swapon
|
||||
DENY(SYS_swapon);
|
||||
#endif
|
||||
#ifdef SYS_swapoff
|
||||
DENY(SYS_swapoff);
|
||||
#endif
|
||||
#ifdef SYS_sysfs
|
||||
DENY(SYS_sysfs);
|
||||
#endif
|
||||
#ifdef SYS__sysctl
|
||||
DENY(SYS__sysctl);
|
||||
#endif
|
||||
#ifdef SYS_uselib
|
||||
DENY(SYS_uselib);
|
||||
#endif
|
||||
#ifdef SYS_userfaultfd
|
||||
DENY(SYS_userfaultfd);
|
||||
#endif
|
||||
#ifdef SYS_ustat
|
||||
DENY(SYS_ustat);
|
||||
#endif
|
||||
#ifdef SYS_mount
|
||||
DENY(SYS_mount);
|
||||
#endif
|
||||
#ifdef SYS_umount2
|
||||
DENY(SYS_umount2);
|
||||
#endif
|
||||
#ifdef SYS_umount
|
||||
DENY(SYS_umount);
|
||||
#endif
|
||||
#ifdef SYS_mount_setattr
|
||||
DENY(SYS_mount_setattr);
|
||||
#endif
|
||||
#ifdef SYS_move_mount
|
||||
DENY(SYS_move_mount);
|
||||
#endif
|
||||
#ifdef SYS_open_tree
|
||||
DENY(SYS_open_tree);
|
||||
#endif
|
||||
#ifdef SYS_fsopen
|
||||
DENY(SYS_fsopen);
|
||||
#endif
|
||||
#ifdef SYS_fsconfig
|
||||
DENY(SYS_fsconfig);
|
||||
#endif
|
||||
#ifdef SYS_fsmount
|
||||
DENY(SYS_fsmount);
|
||||
#endif
|
||||
#ifdef SYS_fspick
|
||||
DENY(SYS_fspick);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_setup
|
||||
DENY(SYS_io_uring_setup);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_enter
|
||||
DENY(SYS_io_uring_enter);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_register
|
||||
DENY(SYS_io_uring_register);
|
||||
#endif
|
||||
#ifdef SYS_setns
|
||||
DENY(SYS_setns);
|
||||
#endif
|
||||
#ifdef SYS_kcmp
|
||||
DENY(SYS_kcmp);
|
||||
#endif
|
||||
#undef DENY
|
||||
return v;
|
||||
}
|
||||
|
||||
sock_filter stmt(unsigned short code, unsigned k) { return BPF_STMT(code, k); }
|
||||
sock_filter jump(unsigned short code, unsigned k, unsigned char jt, unsigned char jf) { return BPF_JUMP(code, k, jt, jf); }
|
||||
|
||||
constexpr unsigned kOffNr = offsetof(seccomp_data, nr);
|
||||
constexpr unsigned kOffArch = offsetof(seccomp_data, arch);
|
||||
constexpr unsigned kOffArg0 = offsetof(seccomp_data, args); // low 32 bits on little-endian
|
||||
|
||||
std::vector<sock_filter> buildFilter() {
|
||||
const unsigned eperm = SECCOMP_RET_ERRNO | EPERM;
|
||||
const unsigned enosys = SECCOMP_RET_ERRNO | ENOSYS;
|
||||
std::vector<sock_filter> f;
|
||||
// wrong architecture: kill, never guess
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffArch));
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, kArch, 1, 0));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffNr));
|
||||
if (kHasX32) { // x32 syscall numbers must not dodge the numbers below
|
||||
f.push_back(jump(BPF_JMP | BPF_JSET | BPF_K, 0x40000000U, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, enosys));
|
||||
}
|
||||
// clone3 cannot be inspected (flags live in a struct): ENOSYS makes libc fall back to clone()
|
||||
#ifdef SYS_clone3
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, static_cast<unsigned>(SYS_clone3), 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, enosys));
|
||||
#endif
|
||||
// clone/unshare with CLONE_NEWUSER: EPERM (nested user namespaces are the usual first step of kernel exploits)
|
||||
for (unsigned nr : {static_cast<unsigned>(SYS_clone), static_cast<unsigned>(SYS_unshare)}) {
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 4));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffArg0));
|
||||
f.push_back(jump(BPF_JMP | BPF_JSET | BPF_K, CLONE_NEWUSER, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, eperm));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffNr)); // restore the accumulator for the next compare
|
||||
}
|
||||
for (unsigned nr : deniedSyscalls()) {
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, eperm));
|
||||
}
|
||||
f.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
if (f.size() > 4096) throw std::runtime_error("seccomp program too large");
|
||||
return f;
|
||||
}
|
||||
|
||||
void dropCapabilities() {
|
||||
int last = CAP_LAST_CAP_FALLBACK;
|
||||
std::ifstream in("/proc/sys/kernel/cap_last_cap");
|
||||
if (in) in >> last;
|
||||
for (int cap = 0; cap <= last; ++cap) {
|
||||
if (std::find(spec::kKeptCaps.begin(), spec::kKeptCaps.end(), cap) != spec::kKeptCaps.end()) continue;
|
||||
if (::prctl(PR_CAPBSET_DROP, cap, 0, 0, 0) != 0 && errno != EINVAL) {
|
||||
throw SysError("drop capability " + std::to_string(cap));
|
||||
}
|
||||
}
|
||||
::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Hardening::apply() {
|
||||
dropCapabilities();
|
||||
if (kArch == 0) {
|
||||
std::fprintf(stderr, "doos: no seccomp filter for this CPU architecture, capabilities were still reduced\n");
|
||||
return;
|
||||
}
|
||||
std::vector<sock_filter> prog = buildFilter();
|
||||
sock_fprog fp{static_cast<unsigned short>(prog.size()), prog.data()};
|
||||
if (::prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &fp) != 0) throw SysError("install seccomp filter");
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
namespace doos {
|
||||
|
||||
// Confinement applied to every process that runs a container command, right before exec:
|
||||
// 1. capability bounding set reduced to the Docker default set (spec::kKeptCaps),
|
||||
// 2. a seccomp-bpf denylist (keyring, bpf, perf, userfaultfd, io_uring, mount family, kexec, module loading,
|
||||
// nested user namespaces and the like), with an architecture check and x32 guard.
|
||||
// Both need no_new_privs, which the caller sets. Throws when the filter cannot be installed.
|
||||
class Hardening {
|
||||
public:
|
||||
static void apply();
|
||||
};
|
||||
|
||||
} // namespace doos
|
||||
+119
-4
@@ -1,5 +1,8 @@
|
||||
#include "image.hpp"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include <cstdio>
|
||||
#include <sys/utsname.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -35,6 +38,81 @@ std::string fetchText(const std::string& url) {
|
||||
return r.out;
|
||||
}
|
||||
|
||||
void fetchFile(const std::string& url, const fs::path& out) {
|
||||
ProcResult r = runProcess({"curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
|
||||
"--max-time", "60", "--max-filesize", std::to_string(spec::kMaxCaptureBytes),
|
||||
"--output", out.string(), url});
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
}
|
||||
|
||||
// Private scratch directory, removed on scope exit.
|
||||
class TempDir {
|
||||
public:
|
||||
TempDir() {
|
||||
std::string tmpl = (fs::temp_directory_path() / "doos-XXXXXX").string();
|
||||
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
|
||||
path_ = tmpl;
|
||||
}
|
||||
~TempDir() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(path_, ec);
|
||||
}
|
||||
TempDir(const TempDir&) = delete;
|
||||
TempDir& operator=(const TempDir&) = delete;
|
||||
const fs::path& path() const { return path_; }
|
||||
|
||||
private:
|
||||
fs::path path_;
|
||||
};
|
||||
|
||||
std::string upper(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// Verifies a detached signature against a publisher key that is fetched over https and accepted only when its
|
||||
// fingerprint equals the pinned one. Returns false (with a loud note) when gpg is not installed; throws when the
|
||||
// signature is bad, missing, or made by another key.
|
||||
bool verifySignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
|
||||
if (runProcess({"sh", "-c", "command -v gpg"}, true, true).code != 0) {
|
||||
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
|
||||
return false;
|
||||
}
|
||||
TempDir home;
|
||||
::chmod(home.path().c_str(), 0700);
|
||||
fs::path keyFile = home.path() / "key.asc";
|
||||
fetchFile(keyUrl, keyFile);
|
||||
std::string h = home.path().string();
|
||||
if (runProcess({"gpg", "--batch", "--quiet", "--homedir", h, "--import", keyFile.string()}, false, true).code != 0) {
|
||||
throw std::runtime_error("cannot import the publisher key from " + keyUrl);
|
||||
}
|
||||
ProcResult fp = runProcess({"gpg", "--batch", "--homedir", h, "--with-colons", "--fingerprint"}, true, true);
|
||||
if (fp.out.find(":::::::::" + upper(fpr) + ":") == std::string::npos) {
|
||||
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
|
||||
}
|
||||
ProcResult v = runProcess({"gpg", "--batch", "--homedir", h, "--status-fd", "1", "--verify", sig.string(), data.string()}, true, true);
|
||||
bool good = false, valid = false;
|
||||
std::istringstream in(v.out);
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
if (line.find("BADSIG") != std::string::npos || line.find("ERRSIG") != std::string::npos ||
|
||||
line.find("EXPKEYSIG") != std::string::npos || line.find("REVKEYSIG") != std::string::npos) {
|
||||
throw std::runtime_error("bad signature on " + data.filename().string());
|
||||
}
|
||||
if (line.find("[GNUPG:] GOODSIG") == 0) good = true;
|
||||
if (line.find("[GNUPG:] VALIDSIG") == 0) {
|
||||
std::istringstream ls(line);
|
||||
std::vector<std::string> tok;
|
||||
for (std::string t; ls >> t;) tok.push_back(upper(t));
|
||||
if (tok.size() > 2 && (tok[2] == upper(fpr) || tok.back() == upper(fpr))) valid = true;
|
||||
}
|
||||
}
|
||||
if (v.code != 0 || !good || !valid) {
|
||||
throw std::runtime_error("signature check failed for " + data.filename().string() + " (not signed by " + fpr + ")");
|
||||
}
|
||||
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isHex64(const std::string& s) {
|
||||
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
|
||||
}
|
||||
@@ -53,10 +131,11 @@ std::string lowerCase(std::string s) {
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> Catalog::names() { return {"alpine", "ubuntu", "ubuntu-24.04"}; }
|
||||
std::vector<std::string> Catalog::names() { return {"alpine", "ubuntu", "ubuntu-24.04", "debian"}; }
|
||||
|
||||
bool Catalog::knows(const std::string& n) {
|
||||
return n == "alpine" || n == "ubuntu" || (n.rfind("ubuntu-", 0) == 0 && n.size() > 7);
|
||||
return n == "alpine" || n == "ubuntu" || (n.rfind("ubuntu-", 0) == 0 && n.size() > 7) || n == "debian" ||
|
||||
(n.rfind("debian-", 0) == 0 && n.size() > 7);
|
||||
}
|
||||
|
||||
ImageSource Catalog::resolve(const std::string& n) {
|
||||
@@ -84,6 +163,32 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
}
|
||||
src.location = base + file;
|
||||
src.sha256 = sha;
|
||||
src.sigUrl = src.location + ".asc";
|
||||
src.keyUrl = std::string(spec::kAlpineKeyUrl);
|
||||
src.keyFpr = std::string(spec::kAlpineKeyFpr);
|
||||
return src;
|
||||
}
|
||||
if (n == "debian" || n.rfind("debian-", 0) == 0) {
|
||||
std::string rel = n == "debian" ? std::string(spec::kDebianDefaultRelease) : n.substr(7);
|
||||
if (rel.empty() || !std::all_of(rel.begin(), rel.end(), [](unsigned char c) { return std::islower(c); })) {
|
||||
throw std::runtime_error("invalid debian release '" + rel + "' (example: debian-bookworm)");
|
||||
}
|
||||
std::string arch = cpuArch(true);
|
||||
std::string dist = arch == "amd64" ? "dist-amd64" : "dist-arm64v8";
|
||||
std::string base = std::string(spec::kDebianBase) + dist + "/" + rel + "/slim/oci/";
|
||||
std::string index = fetchText(base + "index.json");
|
||||
// The manifest is embedded (base64) in the index; its single layer digest is the sha256 of the rootfs tarball.
|
||||
auto d = index.find("\"data\":\"");
|
||||
if (d == std::string::npos) throw std::runtime_error("unexpected debian index.json (no embedded manifest)");
|
||||
d += 8;
|
||||
std::string manifest = base64Decode(index.substr(d, index.find('"', d) - d));
|
||||
auto l = manifest.find("\"layers\"");
|
||||
auto dg = l == std::string::npos ? l : manifest.find("sha256:", l);
|
||||
if (dg == std::string::npos) throw std::runtime_error("unexpected debian manifest (no layer digest)");
|
||||
std::string sha = manifest.substr(dg + 7, 64);
|
||||
if (!isHex64(sha)) throw std::runtime_error("unexpected debian layer digest");
|
||||
src.location = base + "blobs/rootfs.tar.gz";
|
||||
src.sha256 = sha; // content-addressed: the digest is the integrity check; there is no publisher signature
|
||||
return src;
|
||||
}
|
||||
if (n == "ubuntu" || n.rfind("ubuntu-", 0) == 0) {
|
||||
@@ -93,7 +198,12 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
}
|
||||
std::string arch = cpuArch(true);
|
||||
std::string base = std::string(spec::kUbuntuBase) + ver + "/release/";
|
||||
std::string sums = fetchText(base + "SHA256SUMS");
|
||||
TempDir tmp;
|
||||
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
||||
fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg");
|
||||
verifySignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
|
||||
std::string(spec::kUbuntuKeyFpr));
|
||||
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
||||
std::string suffix = "-base-" + arch + ".tar.gz";
|
||||
std::string bestFile, bestSha;
|
||||
std::vector<unsigned long> bestVer;
|
||||
@@ -114,7 +224,7 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
src.sha256 = bestSha;
|
||||
return src;
|
||||
}
|
||||
throw std::runtime_error("unknown catalog image '" + n + "' (known: alpine, ubuntu, ubuntu-VERSION)");
|
||||
throw std::runtime_error("unknown catalog image '" + n + "' (known: alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE)");
|
||||
}
|
||||
|
||||
std::string ImageStore::normalize(const std::string& name) {
|
||||
@@ -214,6 +324,11 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
||||
throw std::runtime_error("sha256 mismatch for " + src.location + ": expected " + src.sha256 + ", got " + got);
|
||||
}
|
||||
std::fprintf(stderr, "sha256 verified\n");
|
||||
if (!src.sigUrl.empty()) {
|
||||
TempDir sigDir;
|
||||
fetchFile(src.sigUrl, sigDir.path() / "file.asc");
|
||||
verifySignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
|
||||
}
|
||||
|
||||
+6
-1
@@ -15,6 +15,10 @@ struct ImageSource {
|
||||
std::string location; // https URL or local file path
|
||||
std::string sha256; // expected, empty only for local files
|
||||
bool isFile = false;
|
||||
// Optional detached signature over the downloaded file; the key is fetched and must match the pinned fingerprint.
|
||||
std::string sigUrl;
|
||||
std::string keyUrl;
|
||||
std::string keyFpr;
|
||||
};
|
||||
|
||||
struct ImageInfo {
|
||||
@@ -25,7 +29,8 @@ struct ImageInfo {
|
||||
std::uint64_t bytes = 0;
|
||||
};
|
||||
|
||||
// Built-in sources: alpine, ubuntu (latest LTS), ubuntu-VERSION. Resolved from the publishers over https.
|
||||
// Built-in sources: alpine, ubuntu (latest LTS), ubuntu-VERSION, debian, debian-RELEASE. Resolved from the
|
||||
// publishers over https; Alpine and Ubuntu are also checked against their pinned release signing keys.
|
||||
class Catalog {
|
||||
public:
|
||||
static std::vector<std::string> names();
|
||||
|
||||
+50
-2
@@ -18,14 +18,16 @@ namespace doos {
|
||||
|
||||
NetMode Network::parse(const std::string& s) {
|
||||
if (s == "slirp") return NetMode::Slirp;
|
||||
if (s == "pasta") return NetMode::Pasta;
|
||||
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)");
|
||||
throw std::runtime_error("unknown network mode '" + s + "' (use slirp, pasta, host or none)");
|
||||
}
|
||||
|
||||
std::string Network::name(NetMode m) {
|
||||
switch (m) {
|
||||
case NetMode::Slirp: return "slirp";
|
||||
case NetMode::Pasta: return "pasta";
|
||||
case NetMode::Host: return "host";
|
||||
case NetMode::None: return "none";
|
||||
}
|
||||
@@ -36,8 +38,10 @@ std::string Network::resolvConf(NetMode m) {
|
||||
switch (m) {
|
||||
case NetMode::Slirp: return "nameserver " + std::string(spec::kSlirpDns) + "\n";
|
||||
case NetMode::None: return "";
|
||||
case NetMode::Pasta:
|
||||
case NetMode::Host: {
|
||||
// Host netns is shared, so the host resolver config is valid. Stub on 127.0.0.53 works too.
|
||||
// pasta forwards the namespace's loopback to the host and host mode shares it, so the host resolver
|
||||
// config is valid as is, including the 127.0.0.53 stub.
|
||||
std::ifstream in("/etc/resolv.conf");
|
||||
return std::string(std::istreambuf_iterator<char>(in), {});
|
||||
}
|
||||
@@ -84,3 +88,47 @@ void SlirpSession::stop() {
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
namespace doos {
|
||||
|
||||
void PastaSession::start(pid_t targetPid) {
|
||||
pid_ = ::fork();
|
||||
if (pid_ < 0) throw SysError("fork pasta");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
std::string target = std::to_string(targetPid);
|
||||
::execlp("pasta", "pasta", "--foreground", "--quiet", "--config-net", target.c_str(), static_cast<char*>(nullptr));
|
||||
::_exit(127);
|
||||
}
|
||||
// pasta has no ready fd: wait until the target namespace has an interface besides lo.
|
||||
std::string dev = "/proc/" + std::to_string(targetPid) + "/net/dev";
|
||||
for (int waited = 0; waited < spec::kNetReadyTimeoutMs; waited += 20) {
|
||||
int st = 0;
|
||||
if (::waitpid(pid_, &st, WNOHANG) == pid_) {
|
||||
pid_ = -1;
|
||||
throw std::runtime_error("pasta exited early (is it installed and permitted?)");
|
||||
}
|
||||
std::ifstream in(dev);
|
||||
int lines = 0;
|
||||
for (std::string line; std::getline(in, line);) ++lines;
|
||||
if (lines > 3) return; // two header lines + lo + at least one more interface
|
||||
::poll(nullptr, 0, 20);
|
||||
}
|
||||
stop();
|
||||
throw std::runtime_error("pasta not ready in time");
|
||||
}
|
||||
|
||||
void PastaSession::stop() {
|
||||
if (pid_ <= 0) return;
|
||||
// pasta drops to another uid after start, so we cannot signal it, and it leaves by itself shortly after its
|
||||
// namespace is gone. Reap it if it is already done; otherwise let init adopt it instead of blocking the exit.
|
||||
for (int waited = 0; waited < 20; waited += 5) {
|
||||
if (::waitpid(pid_, nullptr, WNOHANG) == pid_) break;
|
||||
::poll(nullptr, 0, 5);
|
||||
}
|
||||
pid_ = -1;
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+20
-1
@@ -5,7 +5,12 @@
|
||||
|
||||
namespace doos {
|
||||
|
||||
enum class NetMode { Slirp, Host, None };
|
||||
// slirp: private netns, outbound only, host loopback blocked (default).
|
||||
// pasta: private netns that behaves like the host network (host loopback, bound ports forwarded), but with
|
||||
// its own abstract unix sockets.
|
||||
// host: the host's network namespace itself (shares abstract sockets), explicit opt-in.
|
||||
// none: loopback only.
|
||||
enum class NetMode { Slirp, Pasta, Host, None };
|
||||
|
||||
class Network {
|
||||
public:
|
||||
@@ -17,6 +22,20 @@ public:
|
||||
static std::string resolvConf(NetMode m);
|
||||
};
|
||||
|
||||
// Owns a pasta process attached to a container's network namespace.
|
||||
class PastaSession {
|
||||
public:
|
||||
PastaSession() = default;
|
||||
PastaSession(const PastaSession&) = delete;
|
||||
PastaSession& operator=(const PastaSession&) = delete;
|
||||
~PastaSession() { stop(); }
|
||||
void start(pid_t targetPid);
|
||||
void stop();
|
||||
|
||||
private:
|
||||
pid_t pid_ = -1;
|
||||
};
|
||||
|
||||
// Owns a slirp4netns process attached to a container's network namespace.
|
||||
class SlirpSession {
|
||||
public:
|
||||
|
||||
+45
-10
@@ -17,10 +17,12 @@
|
||||
#include <cstring>
|
||||
|
||||
#include <poll.h>
|
||||
#include <algorithm>
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include "cgroup.hpp"
|
||||
#include "hardening.hpp"
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
@@ -200,12 +202,16 @@ 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);
|
||||
// No single file can outgrow the size cap, which bounds the overshoot between two usage scans.
|
||||
rlimit fsize{c.sizeCap(), c.sizeCap()};
|
||||
::setrlimit(RLIMIT_FSIZE, &fsize);
|
||||
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);
|
||||
}
|
||||
if (!c.relaxed()) Hardening::apply();
|
||||
const char* term = std::getenv("TERM");
|
||||
std::string termv = term && *term ? term : "xterm";
|
||||
const char* lang = std::getenv("LANG");
|
||||
@@ -340,12 +346,20 @@ int Sandbox::start(FileLock& lock) {
|
||||
IdMap::apply(child, container_.ids());
|
||||
|
||||
SlirpSession slirp;
|
||||
PastaSession pasta;
|
||||
if (net == NetMode::Slirp) slirp.start(child);
|
||||
if (net == NetMode::Pasta) pasta.start(child);
|
||||
|
||||
std::string cgroupWhy;
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::create(container_.name(), cgroupWhy);
|
||||
if (cgroup) cgroup->add(child);
|
||||
else std::fprintf(stderr, "doos: size cap only warns, cannot freeze: %s\n", cgroupWhy.c_str());
|
||||
Limits limits{container_.memoryCap(), container_.pidsCap(), container_.milliCpus()};
|
||||
std::string cgroupWhy, notEnforced;
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::create(container_.name(), limits, cgroupWhy, notEnforced);
|
||||
if (cgroup) {
|
||||
cgroup->addMain(child);
|
||||
if (!notEnforced.empty()) std::fprintf(stderr, "doos: limits not enforced (controller missing): %s\n", notEnforced.c_str());
|
||||
} else {
|
||||
std::fprintf(stderr, "doos: memory, pids and cpu limits are not enforced and the size cap only warns: %s\n",
|
||||
cgroupWhy.c_str());
|
||||
}
|
||||
|
||||
container_.recordPid(child);
|
||||
struct PidGuard {
|
||||
@@ -369,34 +383,50 @@ int Sandbox::start(FileLock& lock) {
|
||||
|
||||
int status = watch(child, cgroup.get());
|
||||
reaper.armed = false;
|
||||
if (cgroup && cgroup->oomKills() > 0) {
|
||||
std::fprintf(stderr, "doos: '%s' was killed by its memory limit (%s); raise it with --memory\n",
|
||||
container_.name().c_str(), formatSize(container_.memoryCap()).c_str());
|
||||
}
|
||||
if (cgroup && cgroup->pidFailures() > 0) {
|
||||
std::fprintf(stderr, "doos: '%s' hit its process limit (%u) %llu times; raise it with --pids\n",
|
||||
container_.name().c_str(), container_.pidsCap(),
|
||||
static_cast<unsigned long long>(cgroup->pidFailures()));
|
||||
}
|
||||
return exitCodeOf(status);
|
||||
}
|
||||
|
||||
// Waits for the container's init while enforcing the size cap: warn at kSizeWarnPercent, freeze at the cap,
|
||||
// thaw again once usage drops below kSizeThawPercent. Nothing is ever deleted by this loop.
|
||||
int Sandbox::watch(pid_t child, Cgroup* cgroup) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
long fd = ::syscall(SYS_pidfd_open, child, 0);
|
||||
Fd pidfd(static_cast<int>(fd));
|
||||
const std::uint64_t cap = container_.sizeCap();
|
||||
bool warned = false;
|
||||
auto lastScan = std::chrono::steady_clock::now();
|
||||
auto nextScan = Clock::now() + std::chrono::milliseconds(spec::kSizeCheckMinIntervalMs);
|
||||
for (;;) {
|
||||
auto left = std::chrono::duration_cast<std::chrono::milliseconds>(nextScan - Clock::now()).count();
|
||||
int timeout = static_cast<int>(std::max<long>(left, 50));
|
||||
if (pidfd.valid()) {
|
||||
pollfd p{pidfd.get(), POLLIN, 0};
|
||||
int r = ::poll(&p, 1, spec::kSizeCheckIntervalMs);
|
||||
int r = ::poll(&p, 1, timeout);
|
||||
if (r > 0) break;
|
||||
if (r < 0 && errno != EINTR) break;
|
||||
} else {
|
||||
int st = 0;
|
||||
if (::waitpid(child, &st, WNOHANG) == child) return st;
|
||||
::poll(nullptr, 0, 1000);
|
||||
::poll(nullptr, 0, std::min(timeout, 1000));
|
||||
}
|
||||
if (g_terminating && cgroup && cgroup->frozen()) cgroup->freeze(false); // a frozen init cannot see SIGTERM
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (now - lastScan < std::chrono::milliseconds(spec::kSizeCheckIntervalMs)) continue;
|
||||
lastScan = now;
|
||||
if (Clock::now() < nextScan) continue;
|
||||
|
||||
auto began = Clock::now();
|
||||
std::size_t skipped = 0;
|
||||
std::uint64_t used = container_.usedBytes(skipped);
|
||||
// The scan may use at most 1/kSizeScanDutyFactor of the time: big trees are scanned less often.
|
||||
auto took = Clock::now() - began;
|
||||
nextScan = Clock::now() + std::max(std::chrono::milliseconds(spec::kSizeCheckMinIntervalMs),
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(took) * spec::kSizeScanDutyFactor);
|
||||
const char* name = container_.name().c_str();
|
||||
if (used >= cap) {
|
||||
if (cgroup && !cgroup->frozen()) {
|
||||
@@ -444,10 +474,15 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
|
||||
if (!rootFd.valid()) throw SysError("open container root");
|
||||
|
||||
lock.unlock();
|
||||
// Later attaches live in the `shell` leaf: same limits as the container, but never frozen.
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::open(container_.name());
|
||||
pid_t child = ::fork();
|
||||
if (child < 0) throw SysError("fork");
|
||||
if (child == 0) {
|
||||
try {
|
||||
if (cgroup) {
|
||||
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
|
||||
}
|
||||
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]);
|
||||
|
||||
+42
-1
@@ -25,10 +25,22 @@ constexpr std::uint64_t kMinSizeBytes = 64ULL << 20;
|
||||
constexpr std::uint64_t kMaxSizeBytes = 1ULL << 40;
|
||||
constexpr int kSizeWarnPercent = 80;
|
||||
constexpr int kSizeThawPercent = 95; // thaw again below this share of the cap
|
||||
constexpr int kSizeCheckIntervalMs = 10000; // floor between usage scans, never faster
|
||||
constexpr int kSizeCheckMinIntervalMs = 2000; // floor between usage scans, never faster
|
||||
constexpr int kSizeScanDutyFactor = 10; // next scan waits at least 10x the last scan's duration
|
||||
constexpr std::size_t kMaxScanEntries = 5000000; // usage scan stops (and reports) beyond this
|
||||
constexpr std::string_view kCgroupDir = "doos"; // under the delegated user@UID.service cgroup
|
||||
|
||||
// Resource limits per container (--memory, --pids, --cpus). Enforced by cgroup v2; cpus 0 means "all cores".
|
||||
constexpr std::uint64_t kDefaultMemoryBytes = 8ULL << 30;
|
||||
constexpr std::uint64_t kMinMemoryBytes = 64ULL << 20;
|
||||
constexpr std::uint64_t kMaxMemoryBytes = 1ULL << 40;
|
||||
constexpr std::uint32_t kDefaultPids = 4096;
|
||||
constexpr std::uint32_t kMinPids = 16;
|
||||
constexpr std::uint32_t kMaxPids = 4194304;
|
||||
constexpr std::uint32_t kMaxMilliCpus = 1024000; // 1024 cpus
|
||||
constexpr std::uint32_t kMinMilliCpus = 100; // 0.1 cpu
|
||||
constexpr int kCpuPeriodUs = 100000;
|
||||
|
||||
// Seeding limits
|
||||
constexpr std::size_t kMaxSkeletonDirs = 200000;
|
||||
constexpr std::size_t kMaxEtcCopyFiles = 50000;
|
||||
@@ -47,6 +59,16 @@ constexpr std::size_t kMaxCaptureBytes = 1 << 20; // listing / checksum fetches
|
||||
constexpr std::string_view kAlpineBase = "https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/";
|
||||
constexpr std::string_view kUbuntuBase = "https://cdimage.ubuntu.com/ubuntu-base/releases/";
|
||||
constexpr std::string_view kUbuntuDefaultVersion = "26.04";
|
||||
constexpr std::string_view kDebianBase = "https://raw.githubusercontent.com/debuerreotype/docker-debian-artifacts/";
|
||||
constexpr std::string_view kDebianDefaultRelease = "trixie";
|
||||
|
||||
// Release signing keys, pinned by fingerprint. Keys are fetched from the publisher and rejected unless the
|
||||
// fingerprint matches. Sources of the fingerprints: alpinelinux.org/downloads, ubuntu.com verify tutorial.
|
||||
constexpr std::string_view kAlpineKeyUrl = "https://alpinelinux.org/keys/ncopa.asc";
|
||||
constexpr std::string_view kAlpineKeyFpr = "0482D84022F52DF1C4E7CD43293ACD0907D9495A";
|
||||
constexpr std::string_view kUbuntuKeyUrl =
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
|
||||
// Networking
|
||||
constexpr std::string_view kSlirpBinary = "slirp4netns";
|
||||
@@ -65,6 +87,25 @@ constexpr std::array<std::string_view, 2> kOverlayDirs = {"/usr", "/opt"};
|
||||
// chown --reference and in-place edits work). Unreadable entries are skipped and counted.
|
||||
constexpr std::array<std::string_view, 1> kCopiedDirs = {"/etc"};
|
||||
|
||||
// Host mode /etc under --hardening default: only these entries are copied (relaxed copies everything readable).
|
||||
// Entries that may hold credentials are skipped even inside allowed directories.
|
||||
constexpr std::array<std::string_view, 53> kEtcAllow = {
|
||||
"passwd", "group", "nsswitch.conf", "hosts", "hostname", "host.conf", "localtime", "timezone", "machine-id",
|
||||
"ld.so.conf", "ld.so.conf.d", "ld.so.cache", "ssl", "ca-certificates", "ca-certificates.conf", "pki",
|
||||
"alternatives", "apt", "dpkg", "bash.bashrc", "bashrc", "profile", "profile.d", "environment", "inputrc",
|
||||
"os-release", "lsb-release", "issue", "issue.net", "debian_version", "shells", "protocols", "services",
|
||||
"mime.types", "magic", "gai.conf", "locale.gen", "locale.conf", "default", "gitconfig", "bash_completion",
|
||||
"bash_completion.d", "vim", "emacs", "xdg", "fonts", "mailcap", "wgetrc", "ssh/ssh_config",
|
||||
"ssh/ssh_config.d", "perl", "python3", "terminfo"};
|
||||
constexpr std::array<std::string_view, 8> kEtcDenyNames = {"private", "shadow", "gshadow", "sudoers", "sudoers.d",
|
||||
"auth.conf", "auth.conf.d", "ssh_host_*"};
|
||||
|
||||
// Capabilities kept in the bounding set under --hardening default (the Docker default set).
|
||||
constexpr std::array<int, 14> kKeptCaps = {0 /*CHOWN*/, 1 /*DAC_OVERRIDE*/, 3 /*FOWNER*/, 4 /*FSETID*/,
|
||||
5 /*KILL*/, 6 /*SETGID*/, 7 /*SETUID*/, 8 /*SETPCAP*/,
|
||||
10 /*NET_BIND_SERVICE*/, 13 /*NET_RAW*/, 18 /*SYS_CHROOT*/,
|
||||
27 /*MKNOD*/, 29 /*AUDIT_WRITE*/, 31 /*SETFCAP*/};
|
||||
|
||||
// Extra overlay mounted inside the private /var, only when the host has the directory (dpkg database).
|
||||
constexpr std::array<std::string_view, 1> kVarOverlayDirs = {"/var/lib/dpkg"};
|
||||
|
||||
|
||||
+61
-4
@@ -81,6 +81,29 @@ std::string formatSize(std::uint64_t b) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string base64Decode(const std::string& in) {
|
||||
std::string out;
|
||||
unsigned val = 0;
|
||||
int bits = -8;
|
||||
for (unsigned char c : in) {
|
||||
int d;
|
||||
if (c >= 'A' && c <= 'Z') d = c - 'A';
|
||||
else if (c >= 'a' && c <= 'z') d = c - 'a' + 26;
|
||||
else if (c >= '0' && c <= '9') d = c - '0' + 52;
|
||||
else if (c == '+' || c == '-') d = 62;
|
||||
else if (c == '/' || c == '_') d = 63;
|
||||
else if (c == '=' || std::isspace(c)) continue;
|
||||
else throw std::runtime_error("invalid base64 input");
|
||||
val = (val << 6) | static_cast<unsigned>(d);
|
||||
bits += 6;
|
||||
if (bits >= 0) {
|
||||
out.push_back(static_cast<char>((val >> bits) & 0xFF));
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool quiet) {
|
||||
int pipefd[2] = {-1, -1};
|
||||
if (capture && ::pipe2(pipefd, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
@@ -145,7 +168,7 @@ void mirrorRec(const fs::path& lower, const fs::path& upper, SeedStats& st, std:
|
||||
}
|
||||
}
|
||||
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth) {
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth, const SkipFn& skip) {
|
||||
if (depth > spec::kMaxTreeDepth) throw std::runtime_error("tree too deep: " + from.string());
|
||||
std::error_code ec;
|
||||
fs::directory_iterator it(from, ec), end;
|
||||
@@ -156,13 +179,14 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
||||
const fs::path& src = it->path();
|
||||
fs::path dst = to / src.filename();
|
||||
if (::lstat(src.c_str(), &sb) != 0) { ++st.skipped; continue; }
|
||||
if (skip && skip(src)) { ++st.skipped; continue; }
|
||||
if (st.made >= spec::kMaxEtcCopyFiles) throw std::runtime_error("copy file cap reached: " + from.string());
|
||||
if (S_ISDIR(sb.st_mode)) {
|
||||
if (::mkdir(dst.c_str(), (sb.st_mode & 07777) | 0700) != 0 && errno != EEXIST) {
|
||||
throw SysError("mkdir " + dst.string());
|
||||
}
|
||||
++st.made;
|
||||
copyRec(src, dst, st, depth + 1);
|
||||
copyRec(src, dst, st, depth + 1, skip);
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
std::error_code e2;
|
||||
fs::remove(dst, e2);
|
||||
@@ -217,13 +241,46 @@ SeedStats mirrorDirs(const fs::path& lower, const fs::path& upper) {
|
||||
return st;
|
||||
}
|
||||
|
||||
SeedStats copyTree(const fs::path& from, const fs::path& to) {
|
||||
SeedStats copyTree(const fs::path& from, const fs::path& to, const SkipFn& skip) {
|
||||
SeedStats st;
|
||||
mkdirs(to);
|
||||
copyRec(from, to, st, 0);
|
||||
copyRec(from, to, st, 0, skip);
|
||||
return st;
|
||||
}
|
||||
|
||||
SeedStats copyEntries(const fs::path& from, const fs::path& to, const std::vector<std::string>& entries,
|
||||
const SkipFn& skip) {
|
||||
SeedStats total;
|
||||
mkdirs(to);
|
||||
for (const auto& e : entries) {
|
||||
fs::path src = from / e, dst = to / e;
|
||||
struct stat sb;
|
||||
if (::lstat(src.c_str(), &sb) != 0) continue; // not present on this host: nothing to copy, nothing skipped
|
||||
if (skip && skip(src)) { ++total.skipped; continue; }
|
||||
mkdirs(dst.parent_path());
|
||||
if (S_ISDIR(sb.st_mode)) {
|
||||
SeedStats s = copyTree(src, dst, skip);
|
||||
total.made += s.made; total.skipped += s.skipped; total.bytes += s.bytes;
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
std::error_code ec;
|
||||
fs::remove(dst, ec);
|
||||
fs::create_symlink(fs::read_symlink(src), dst);
|
||||
++total.made;
|
||||
} else if (S_ISREG(sb.st_mode) && ::access(src.c_str(), R_OK) == 0) {
|
||||
std::error_code ec;
|
||||
fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
|
||||
if (ec) { ++total.skipped; continue; }
|
||||
::chmod(dst.c_str(), (sb.st_mode & 07777) | 0600);
|
||||
total.bytes += static_cast<std::uint64_t>(sb.st_size);
|
||||
++total.made;
|
||||
} else {
|
||||
++total.skipped;
|
||||
}
|
||||
if (total.bytes > spec::kMaxEtcCopyBytes) throw std::runtime_error("copy byte cap reached: " + from.string());
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
SeedStats copyFiles(const fs::path& from, const fs::path& to) {
|
||||
SeedStats st;
|
||||
std::error_code ec;
|
||||
|
||||
+9
-1
@@ -2,6 +2,7 @@
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -51,6 +52,8 @@ private:
|
||||
std::uint64_t parseSize(const std::string& text);
|
||||
std::string formatSize(std::uint64_t bytes);
|
||||
|
||||
std::string base64Decode(const std::string& in); // lenient on whitespace, throws on bad characters
|
||||
|
||||
struct ProcResult {
|
||||
int code = -1;
|
||||
std::string out; // stdout, only when captured
|
||||
@@ -67,7 +70,12 @@ struct SeedStats {
|
||||
// Creates every directory of `lower` in `upper` (owner rwx added) so a unmapped-owner lower stays writable.
|
||||
SeedStats mirrorDirs(const std::filesystem::path& lower, const std::filesystem::path& upper);
|
||||
// Copies dirs, regular files and symlinks readable by the caller. Capped by spec.hpp limits.
|
||||
SeedStats copyTree(const std::filesystem::path& from, const std::filesystem::path& to);
|
||||
// `skip` (optional) is asked for every entry and returns true to leave it out (counted as skipped).
|
||||
using SkipFn = std::function<bool(const std::filesystem::path&)>;
|
||||
SeedStats copyTree(const std::filesystem::path& from, const std::filesystem::path& to, const SkipFn& skip = nullptr);
|
||||
// Copies only the named entries (relative paths, files, links or dirs) of `from` into `to`.
|
||||
SeedStats copyEntries(const std::filesystem::path& from, const std::filesystem::path& to,
|
||||
const std::vector<std::string>& entries, const SkipFn& skip);
|
||||
// Copies only the regular files directly inside `from` (readable ones) into `to`; existing targets stay.
|
||||
SeedStats copyFiles(const std::filesystem::path& from, const std::filesystem::path& to);
|
||||
// Sum of allocated bytes (st_blocks) below p, lstat only. `skipped` counts unreadable dirs.
|
||||
|
||||
Reference in New Issue
Block a user