#include "cli.hpp" #include #include #include #include #include #include "container.hpp" #include "sandbox.hpp" #include "spec.hpp" #include "util.hpp" 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; std::optional sha256; bool yes = false; bool iUnderstand = false; // --i-understand, required for relaxed / --net host std::vector positional; // up to maxPositional, the rest lands in tail std::vector tail; }; struct Allowed { bool container = false; // --net --image --size --ids bool yes = false; bool sha256 = false; }; Parsed parse(const std::vector& rest, Allowed allow, std::size_t wantPositional) { Parsed p; auto value = [&](std::size_t& i, const std::string& flag) -> std::string { const std::string& a = rest[i]; if (a.size() > flag.size() && a.compare(0, flag.size() + 1, flag + "=") == 0) return a.substr(flag.size() + 1); if (i + 1 >= rest.size()) throw std::runtime_error(flag + " needs a value"); return rest[++i]; }; auto is = [&](const std::string& a, const std::string& flag) { return a == flag || a.rfind(flag + "=", 0) == 0; }; std::size_t i = 0; for (; i < rest.size(); ++i) { const std::string& a = rest[i]; if (a == "--") { ++i; break; } if (allow.container && is(a, "--net")) p.opts.net = Network::parse(value(i, "--net")); 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 (a == "--i-understand") p.iUnderstand = 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); else if (p.positional.size() < wantPositional) p.positional.push_back(a); else break; } if (p.positional.size() < wantPositional) throw std::runtime_error("missing argument"); if (i < rest.size() && rest[i] == "--") ++i; p.tail.assign(rest.begin() + static_cast(i), rest.end()); return p; } void hardenNotice(const Container& c, const Parsed& p) { bool relaxed = p.opts.relaxed.value_or(c.relaxed()); bool hostNet = (p.opts.net.has_value() ? *p.opts.net : c.net()) == NetMode::Host; if ((relaxed || hostNet) && !p.iUnderstand) { throw std::runtime_error("--hardening relaxed and --net host drop capability drop, seccomp filtering and/or " "network isolation; pass --i-understand to confirm you accept the reduced sandboxing"); } if (relaxed || hostNet) { std::fprintf(stderr, "doos: warning: reduced sandboxing (%s%s%s)\n", relaxed ? "no capability drop, no seccomp, full /etc" : "", (relaxed && hostNet) ? "; " : "", hostNet ? "shares the host network namespace" : ""); } } 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::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::stoul(whole)) * 1000 + static_cast(std::stoul(frac)); } 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 [OPTIONS] NAME\n" " doos attach [OPTIONS] NAME [-- CMD [ARGS...]]\n" " doos delete [--yes] NAME\n" " doos image list\n" " doos image add [--sha256 HEX] SOURCE [NAME]\n" " doos image delete [--yes] NAME\n" " doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n" "\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" " --i-understand confirm you accept reduced sandboxing when using relaxed or --net host\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, 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); } int Cli::run() { if (args_.empty() || args_[0] == "-h" || args_[0] == "--help" || args_[0] == "help") { usage(args_.empty() ? stderr : stdout); return args_.empty() ? 2 : 0; } std::string cmd = args_[0]; for (auto& ch : cmd) if (ch == '-') ch = '_'; std::vector rest(args_.begin() + 1, args_.end()); if (cmd == "__version" || cmd == "version") { std::puts(DOOS_VERSION); return 0; } if (cmd == "list") return cmdList(); if (cmd == "get_or_create") return cmdGetOrCreate(rest); if (cmd == "attach") return cmdAttach(rest); if (cmd == "delete") return cmdDelete(rest); if (cmd == "image") return cmdImage(rest); if (cmd == "apparmor") return cmdApparmor(); 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 %-12s %-6s %s\n", 16, "NAME", "STATE", "NET", "IMAGE", "SIZE", "AGE"); for (const auto& c : all) { std::printf("%-*s %-8s %-6s %-12s %-6s %s\n", 16, c.name().c_str(), c.runningPid() ? "running" : "stopped", Network::name(c.net()).c_str(), c.imageMode() ? c.image().c_str() : "host", formatSize(c.sizeCap()).c_str(), age(c.created()).c_str()); } return 0; } int Cli::cmdGetOrCreate(std::vector rest) { Parsed p = parse(rest, {true, false, false}, 1); if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name"); Store store = Store::fromEnvironment(); auto [c, created] = store.getOrCreate(p.positional[0], p.opts); hardenNotice(c, p); std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str()); return 0; } int Cli::cmdAttach(std::vector rest) { Parsed p = parse(rest, {true, false, false}, 1); Store store = Store::fromEnvironment(); auto [c, created] = store.getOrCreate(p.positional[0], p.opts); (void)created; hardenNotice(c, p); return Sandbox(std::move(c), std::move(p.tail)).run(); } int Cli::cmdDelete(std::vector rest) { Parsed p = parse(rest, {false, true, false}, 1); if (!p.tail.empty()) throw std::runtime_error("delete takes only a name"); Store store = Store::fromEnvironment(); std::puts(store.remove(p.positional[0], p.yes).c_str()); return 0; } int Cli::cmdApparmor() { std::fputs(Sandbox::apparmorProfile().c_str(), stdout); return 0; } int Cli::cmdImage(std::vector rest) { if (rest.empty()) throw std::runtime_error("image needs a subcommand: list, add, delete"); std::string sub = rest[0]; std::vector tail(rest.begin() + 1, rest.end()); if (sub == "list") return cmdImageList(); if (sub == "add") return cmdImageAdd(tail); if (sub == "delete") return cmdImageDelete(tail); throw std::runtime_error("unknown image subcommand '" + sub + "' (list, add, delete)"); } int Cli::cmdImageList() { Store store = Store::fromEnvironment(); auto installed = store.images().list(); std::printf("%-*s %-6s %-7s %s\n", 16, "IMAGE", "SIZE", "IDS", "STATE"); for (const auto& i : installed) { std::printf("%-*s %-6s %-7s %s\n", 16, i.name.c_str(), formatSize(i.bytes).c_str(), IdMap::name(i.ids).c_str(), "installed"); } for (const auto& n : Catalog::names()) { bool have = false; for (const auto& i : installed) have = have || i.name == n; if (!have) std::printf("%-*s %-6s %-7s %s\n", 16, n.c_str(), "-", "-", "available"); } return 0; } int Cli::cmdImageAdd(std::vector rest) { Parsed p = parse(rest, {false, false, true}, 1); if (p.tail.size() > 1) throw std::runtime_error("image add takes SOURCE and an optional NAME"); const std::string& source = p.positional[0]; ImageSource src; bool isUrl = source.rfind("https://", 0) == 0; bool isFile = !isUrl && source.find('/') != std::string::npos; if (source.rfind("http://", 0) == 0) throw std::runtime_error("plain http is refused; use https"); if (!isUrl && !isFile && Catalog::knows(ImageStore::normalize(source))) { src = Catalog::resolve(ImageStore::normalize(source)); if (p.sha256) src.sha256 = *p.sha256; } else if (isUrl) { if (!p.sha256) throw std::runtime_error("a URL image needs --sha256 HEX (unverifiable input is rejected)"); src.location = source; 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, debian, debian-RELEASE), an https URL, nor a file"); } src.location = std::filesystem::absolute(source).string(); src.isFile = true; if (p.sha256) src.sha256 = *p.sha256; } if (!p.tail.empty()) { src.name = ImageStore::normalize(p.tail[0]); } else if (src.name.empty()) { std::string base = std::filesystem::path(src.location).filename().string(); for (const char* ext : {".tar.gz", ".tar.xz", ".tar.zst", ".tgz", ".tar"}) { std::string e = ext; if (base.size() > e.size() && base.compare(base.size() - e.size(), e.size(), e) == 0) base.erase(base.size() - e.size()); } for (auto& ch : base) ch = std::isalnum(static_cast(ch)) ? static_cast(std::tolower(ch)) : '-'; src.name = base.substr(0, spec::kMaxNameLen); } Store store = Store::fromEnvironment(); ImageInfo info = store.images().add(src, IdMap::best()); std::printf("added image %s (%s, ids %s)\n", info.name.c_str(), formatSize(info.bytes).c_str(), IdMap::name(info.ids).c_str()); return 0; } int Cli::cmdImageDelete(std::vector rest) { Parsed p = parse(rest, {false, true, false}, 1); if (!p.tail.empty()) throw std::runtime_error("image delete takes only a name"); Store store = Store::fromEnvironment(); std::string name = ImageStore::normalize(p.positional[0]); std::puts(store.images().remove(name, p.yes, store.containersUsing(name)).c_str()); return 0; } } // namespace doos