Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb96c7b1c | ||
|
|
0d18aff519 | ||
|
|
3b5e28e06e |
@@ -0,0 +1,77 @@
|
||||
# AGENTS.md
|
||||
|
||||
Rules for any AI agent (or human) changing this repository. The user's global rules
|
||||
also apply; this file adds the project-specific ones. Keep this file current: it is
|
||||
part of every release.
|
||||
|
||||
## Project in one paragraph
|
||||
|
||||
`doos` is a user-level container tool in C++20. `attach` builds a user + mount + pid
|
||||
+ uts + ipc (+ net) namespace set, overlays the host's `/usr`, `/etc`, `/opt` with a
|
||||
per-container upper dir, pivots into it and runs a command. Networking is slirp4netns.
|
||||
No root at runtime. Authoritative behavior: `SPEC.md`. Design notes: `docs/ARCHITECTURE.md`.
|
||||
Measurements and gaps: `docs/RESEARCH.md`.
|
||||
|
||||
## Mandatory release workflow (every change, same session)
|
||||
|
||||
1. Make the change. Smallest diff that does what was asked.
|
||||
2. Update docs in the same change (see the table below).
|
||||
3. Pick the bump:
|
||||
- `patch`: bug fix, docs, tests, tooling, no behavior change for users.
|
||||
- `minor`: new or changed user-visible behavior (flag, command, mount, limit).
|
||||
- `major`: breaking change to CLI, state layout, or container compatibility.
|
||||
4. Run `scripts/release.sh <patch|minor|major> "imperative commit subject"`.
|
||||
It bumps `VERSION`, builds, runs `tests/run.sh`, commits all changes, and creates
|
||||
the annotated tag `vX.Y.Z`. It aborts on any build or test failure.
|
||||
5. Verify live: run `build/doos` once against the real thing (the e2e suite does this).
|
||||
6. Report honestly what passed and what did not. Do not push unless the user asks.
|
||||
|
||||
Never hand-edit `VERSION` or create tags manually. Never skip the tests to release.
|
||||
|
||||
## Docs that must be updated with the code
|
||||
|
||||
| If you change | Update |
|
||||
|---|---|
|
||||
| CLI commands, flags, output | `README.md`, `SPEC.md` (CLI), `src/cli.cpp` usage text |
|
||||
| Limits or constants in `src/spec.hpp` | `SPEC.md` limits/layout tables (both must match) |
|
||||
| Mounts, namespaces, isolation | `SPEC.md`, `docs/ARCHITECTURE.md`, `docs/RESEARCH.md` host safety section |
|
||||
| Networking | `SPEC.md`, `docs/RESEARCH.md` networking table |
|
||||
| Source files added/removed/renamed | `docs/ARCHITECTURE.md` file map, `CMakeLists.txt`, this file |
|
||||
| A new measurement | `docs/RESEARCH.md` with machine, kernel, and what was run |
|
||||
| A new known gap or a closed gap | `docs/RESEARCH.md` gaps list and `README.md` |
|
||||
| These workflow rules | this file |
|
||||
| Anything above, in every release | `README.md` version line and `CHANGELOG.md` entry |
|
||||
|
||||
Docs describe what the system does today. No roadmap text. A gap is listed next to
|
||||
what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
|
||||
|
||||
## Changelog
|
||||
|
||||
Every release adds a `CHANGELOG.md` entry (newest first): version, date, bullets of
|
||||
what changed for users. The release script does not write it; write it before running
|
||||
the script.
|
||||
|
||||
## Code rules
|
||||
|
||||
- C++20, extremely object oriented: one class per responsibility (see file map).
|
||||
- Every limit is a named constant in `src/spec.hpp` and listed in `SPEC.md`.
|
||||
- No unbounded loops, sizes or recursion. Add a cap when you add a loop.
|
||||
- Destructive operations: dry-run by default, `--yes` to act, scoped to the store.
|
||||
- Fail loudly with a clear message. Never swallow an error that leaves state half-built.
|
||||
- Match surrounding style. No new dependencies without a stated reason.
|
||||
- Never use em dashes (U+2014) anywhere. The release script rejects them.
|
||||
- Output must stay readable on a narrow terminal.
|
||||
|
||||
## Testing
|
||||
|
||||
`tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces,
|
||||
slirp4netns, filesystem. Add a case for every behavior change. A green run is required
|
||||
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be
|
||||
installed once (needs sudo, ask the user, do not do it yourself).
|
||||
|
||||
## Environment notes
|
||||
|
||||
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`.
|
||||
Tests use a temp `DOOS_HOME`; never point tests at the user's real store.
|
||||
- Build: `cmake -S . -B build && cmake --build build`.
|
||||
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.3 - 2026-10-23
|
||||
- Docs: field trial of apt and common tools, new gap G7 (host-owned lower layer), README limits section. No code change.
|
||||
|
||||
## 0.1.2 - 2026-10-23
|
||||
- Docs: researched solutions for all known gaps (G1 to G6) in `docs/RESEARCH.md`, with measurements. Nothing implemented yet.
|
||||
|
||||
## 0.1.1 - 2026-10-23
|
||||
- Docs: AGENTS.md release workflow, ARCHITECTURE.md, expanded README, this changelog.
|
||||
- Tooling: `scripts/release.sh` (bump, build, test, commit, tag).
|
||||
|
||||
## 0.1.0 - 2026-10-23
|
||||
- First release: `list`, `get_or_create`, `attach`, `delete`.
|
||||
- overlayfs over host `/usr /etc /opt`, user/mount/pid/uts/ipc/net namespaces.
|
||||
- Networking: slirp4netns (default), `host`, `none`.
|
||||
- End-to-end test suite and AppArmor profile for Ubuntu 24.04+.
|
||||
@@ -1,5 +1,7 @@
|
||||
# doos
|
||||
|
||||
Version: see `VERSION` (currently 0.1.3). Release history: `CHANGELOG.md`.
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
@@ -18,3 +20,62 @@ sudo cp apparmor/doos /etc/apparmor.d/doos && sudo apparmor_parser -r /etc/appar
|
||||
```
|
||||
|
||||
See `SPEC.md` (behavior, limits) and `docs/RESEARCH.md` (what works, measurements, known gaps).
|
||||
|
||||
## Usage examples
|
||||
|
||||
```
|
||||
doos get_or_create dev # created dev
|
||||
doos attach dev # interactive shell as root inside
|
||||
doos attach dev -- apt --version # run one command
|
||||
doos attach --net none sandbox -- sh # no network at all
|
||||
doos list
|
||||
doos delete dev # dry run: shows what would go
|
||||
doos delete --yes dev # really deletes
|
||||
```
|
||||
|
||||
Container writes to `/usr`, `/etc`, `/opt` stay in the container (copy-on-write);
|
||||
`/root` and `/var` are private and persistent. `/tmp` is memory-backed and lost on exit.
|
||||
A container is `running` while its first `attach` command is alive; more `attach` calls
|
||||
join it. Data persists until `delete --yes`.
|
||||
|
||||
## Current limits you will hit
|
||||
|
||||
Tools run fine (curl, git, gcc, python3, pip, ping all verified). Package management does
|
||||
not: `apt-get update` needs a seeded `/var` and `-o APT::Sandbox::User=root`, and
|
||||
`apt-get install` cannot finish configuring packages because host-root-owned files are
|
||||
unwritable and unchownable inside. Details, measurements and the fix are in
|
||||
`docs/RESEARCH.md` (G7, "Field trial").
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `DOOS_HOME` | State directory (default `~/.local/share/doos`). No `,` or `:` allowed. |
|
||||
| `XDG_DATA_HOME` | Used as `$XDG_DATA_HOME/doos` when `DOOS_HOME` is unset. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Message | Cause and fix |
|
||||
|---|---|
|
||||
| `write /proc/<pid>/uid_map ... blocked` or `mount ...: Permission denied` | AppArmor restricts user namespaces. Install `apparmor/doos` (command above). |
|
||||
| `slirp4netns failed to start` | Install `slirp4netns`, or use `--net none` / `--net host`. |
|
||||
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. |
|
||||
| `container 'x' is running` on delete | Exit the attached command first. |
|
||||
| `container limit reached (64)` | Delete one first. |
|
||||
|
||||
## Safety summary
|
||||
|
||||
Host files are never written: system dirs are reached only through overlayfs. Host
|
||||
`/home`, `/sys` and host loopback are not reachable in the default mode. `--net host`
|
||||
removes network isolation and is opt-in. Known gaps (no cgroups, readable host `/etc`)
|
||||
are listed with researched solutions (G1 to G6, not implemented) in `docs/RESEARCH.md`.
|
||||
|
||||
## Project docs
|
||||
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `SPEC.md` | Behavior contract and limits |
|
||||
| `docs/ARCHITECTURE.md` | Code map and flows |
|
||||
| `docs/RESEARCH.md` | What works unprivileged, measurements, gaps |
|
||||
| `AGENTS.md` | Rules for AI agents, release workflow |
|
||||
| `CHANGELOG.md` | Release history |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Architecture
|
||||
|
||||
How doos works internally. Behavior contract is `SPEC.md`; this explains the code.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Class / role |
|
||||
|---|---|
|
||||
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
|
||||
| `src/util.{hpp,cpp}` | `SysError` (errno-carrying exception), `Fd` (RAII fd), `FileLock` (bounded flock), `fsutil` (mkdirs, read/write, bounded `removeTree`). |
|
||||
| `src/network.{hpp,cpp}` | `NetMode` + `Network` (parse, resolv.conf per mode), `SlirpSession` (owns the slirp4netns process). |
|
||||
| `src/container.{hpp,cpp}` | `Container` (one container's paths, meta, pid file, layout), `Store` (list, get_or_create, remove). |
|
||||
| `src/sandbox.{hpp,cpp}` | `Sandbox`: start a container (clone + mounts + pivot_root + init) or join a running one (setns). |
|
||||
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing and the four commands. |
|
||||
| `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. |
|
||||
| `tests/run.sh` | End-to-end suite. |
|
||||
| `scripts/release.sh` | Bump, build, test, commit, tag. |
|
||||
| `apparmor/doos` | Profile granting `userns` on Ubuntu 24.04+. |
|
||||
|
||||
## attach flow
|
||||
|
||||
```
|
||||
Cli::cmdAttach
|
||||
Store::getOrCreate lock, create layout + meta if missing
|
||||
Sandbox::run
|
||||
FileLock (start-or-join decision)
|
||||
runningPid()? yes -> join() no -> start()
|
||||
|
||||
start():
|
||||
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
|
||||
parent writes /proc/<child>/{setgroups,uid_map,gid_map} 0 -> invoking uid, size 1
|
||||
slirp mode: SlirpSession.start(child) waits for ready-fd
|
||||
recordPid(child) (pid + starttime, defeats pid reuse)
|
||||
lock.unlock() joiners must not wait for container lifetime
|
||||
write 'G' on sync pipe child continues
|
||||
parent: ignore SIGINT/SIGQUIT, forward SIGTERM/SIGHUP, waitpid(child)
|
||||
on exit: pid file cleared, slirp stopped (RAII)
|
||||
|
||||
child (containerInit, pid 1):
|
||||
PDEATHSIG=SIGKILL, close inherited lock fd, wait for 'G'
|
||||
setupRoot -> sethostname -> loopback up (isolated netns) -> rlimits/env/no_new_privs
|
||||
fork worker -> exec command
|
||||
pid 1 loop: forward signals, reap all, on worker exit kill(-1, SIGKILL), exit with its code
|
||||
```
|
||||
|
||||
## setupRoot order
|
||||
|
||||
1. `MS_REC|MS_PRIVATE` on `/` so nothing propagates to the host.
|
||||
2. Bind the container `rootfs/` onto itself (pivot_root needs a mountpoint).
|
||||
3. Write `resolv.conf` into the `/etc` upper dir (matches network mode every start).
|
||||
4. Overlay mounts (`lowerdir=<host dir>,upperdir,workdir,userxattr`, nosuid, nodev).
|
||||
5. Compat dirs: recreate host symlinks (usr-merge) or overlay real dirs.
|
||||
6. Bind private dirs (`/root`, `/var`), mount tmpfs (`/tmp`, `/run`).
|
||||
7. `/dev`: tmpfs, bind host device nodes, new devpts, `/dev/shm`, symlinks.
|
||||
8. `/proc`, then read-only remount and masks from `spec.hpp`.
|
||||
9. `chdir(rootfs)`, `pivot_root(".", ".")`, lazy unmount of old root.
|
||||
|
||||
## join flow
|
||||
|
||||
Open `/proc/<pid>/ns/{user,mnt,uts,ipc,net,pid}` and `/proc/<pid>/root` first, fork,
|
||||
`setns` user first (grants capability for the rest), `fchdir`+`chroot` to the container
|
||||
root, fork again (pid ns applies to children), apply env and limits, exec.
|
||||
In `host` network mode the net namespace is not joined.
|
||||
|
||||
## Lifetime model
|
||||
|
||||
The namespaces live as long as the first `attach` command. When it exits, pid 1 kills
|
||||
the rest and the container is `stopped`. State (upper dirs, `/root`, `/var`) persists on
|
||||
disk until `delete --yes`. There is no daemon.
|
||||
|
||||
## Error handling
|
||||
|
||||
All failures throw (`SysError` or `std::runtime_error`); `main` prints one line.
|
||||
Inside the child, errors print `doos: <message>` and exit 125. A parent-side failure
|
||||
after `clone` kills the child (`Reaper`), so no half-built container survives.
|
||||
+123
-1
@@ -29,7 +29,7 @@ Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1
|
||||
- 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.
|
||||
- Residual exposure (gaps G1 to G6 below): no resource limits, no disk cap, single uid, host `/etc` readable, abstract unix sockets shared in `--net host`, no seccomp filter.
|
||||
|
||||
## Measurements (this machine, 5 sequential `attach -- true`)
|
||||
|
||||
@@ -39,3 +39,125 @@ Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1
|
||||
| none | ~6 ms |
|
||||
|
||||
Single run set, one machine; no other hardware measured.
|
||||
|
||||
## Known gaps and researched solutions
|
||||
|
||||
Status of everything in this section: **researched, not implemented**. Probes ran on the
|
||||
machine above (cgroup v2, systemd user manager, ext4 root, uid 1000). Probe cgroups were
|
||||
removed afterwards. Rows marked "unmeasured" have no run behind them.
|
||||
|
||||
| Id | Gap | Proposed fix | Bump | Code that would change |
|
||||
|---|---|---|---|---|
|
||||
| G1 | No memory/cpu/pids limits | Per-container cgroup v2 leaf under the delegated user service | minor | `sandbox.cpp` start/join, new `Cgroup` class, `spec.hpp` |
|
||||
| G2 | No disk cap on upper dirs | Usage watchdog with `cgroup.freeze` at the hard limit; optional tmpfs upper | minor | `Sandbox::start` parent loop, `spec.hpp` |
|
||||
| G3 | Single uid mapping | `newuidmap`/`newgidmap` with `/etc/subuid`, fallback to single map | minor, delete path changes | `sandbox.cpp` writeMap, `Store::remove` |
|
||||
| G4 | Host `/etc` readable | Allowlist-only private `/etc` instead of an overlay of host `/etc` | minor | `spec.hpp` lists, `setupRoot` |
|
||||
| G5 | `--net host` shares abstract sockets | Keep opt-in; evaluate pasta as an isolated replacement | patch or minor | `network.cpp` |
|
||||
| G6 | No seccomp, nested userns allowed | Hand-built seccomp-bpf denylist after `no_new_privs` | minor | new `Seccomp` class, `sandbox.cpp` |
|
||||
| G7 | Host-owned lower dirs are not writable; `chown --reference` of host files fails; `/var` is empty so apt has no databases | Image-based lower layer (extracted rootfs, owned by mapped ids) plus G3; short term: pre-seeded directory skeleton | major (changes layout) | `Container::prepareLayout`, `setupRoot`, new image import |
|
||||
|
||||
### G1: memory, cpu and pids limits (cgroup v2)
|
||||
|
||||
Findings, measured:
|
||||
- `/sys/fs/cgroup` is cgroup2 with `nsdelegate`. `user@1000.service/cgroup.controllers` lists `cpuset cpu io memory pids`, and its `subtree_control` already enables all five. A plain user can `mkdir` below `/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/`.
|
||||
- A leaf with `pids.max=20`: the 21st fork failed with `Cannot fork`.
|
||||
- A leaf with `memory.max=64m`: touching 200 MiB ended in `Killed`, `memory.events` showed `oom_kill 1`. An untouched `bytearray(200MiB)` is not charged (lazy pages), so the first attempt proved nothing; only the touched run counts.
|
||||
- Gotcha, measured: enabling `+memory +pids` in `subtree_control` of a cgroup makes it a non-leaf; writing a pid into its `cgroup.procs` then fails with `I/O error`. The layout must be `.../doos/` (controllers enabled here, no processes) with one leaf per container `.../doos/<name>/` (limits set here, processes here).
|
||||
- `RLIMIT_NPROC` is not an option: it counts per real uid across the whole host user, not per container.
|
||||
|
||||
Design:
|
||||
1. Discover the base: parse `/proc/self/cgroup`, take the path up to and including `user@<uid>.service`.
|
||||
2. Parent creates `doos/<name>` after `clone`, writes `memory.max`, `memory.swap.max=0`, `pids.max`, `cpu.max`, then writes the child pid to `cgroup.procs` before the sync byte `G` (child never runs outside limits). `join()` children enter via the same `cgroup.procs` write before `setns`.
|
||||
3. On exit: write `1` to `cgroup.kill` (kernel >= 5.14), then `rmdir`.
|
||||
4. Defaults as constants in `spec.hpp` (static ceilings, no autotuning): for example memory 2g, pids 512, cpu unlimited. Flags `--memory`, `--pids`, `--cpus` may lower or raise within a hard cap.
|
||||
5. No delegation (no systemd user manager, cgroup v1): fail loudly with a counter and a clear reason; `--no-limits` is an explicit opt-out, never silent.
|
||||
|
||||
Rejected: `systemd-run --user --scope -p MemoryMax=...` (needs D-Bus, one more moving part). Unmeasured: behavior when doos itself runs inside a scope that is not under `user@.service` (the tmux scope here is, but other launchers may not be).
|
||||
|
||||
Sources: [rootlesscontaine.rs cgroup v2](https://rootlesscontaine.rs/getting-started/common/cgroup2/), [runc cgroup v2](https://github.com/thaJeztah/runc/blob/main/docs/cgroup-v2.md), [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/).
|
||||
|
||||
### G2: disk usage of upper dirs
|
||||
|
||||
Findings:
|
||||
- Project quotas (XFS `pquota`, ext4 project quota) need root to set. This machine's root is ext4 on `/dev/md2`. Not usable unprivileged. Docker's overlay2 size limit also only works on XFS with `pquota` ([source](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)).
|
||||
- Loop-mounted ext4 images need root; the kernel does not allow ext4 mounts from a user namespace. `fuse2fs` is not installed here. `fuse-overlayfs` is installed but has no quota either.
|
||||
- A `size=` tmpfs mounted inside the container userns is enforced, measured: `size=1m`, writing 5 MiB ended in `No space left on device`.
|
||||
|
||||
Design (follows the rule "machines hold, humans analyze"):
|
||||
1. Default: the parent process (already alive in `Sandbox::start`) measures upper-dir usage on a fixed interval with a floor (for example every 30 s, never faster). Soft limit: warning on stderr. Hard limit: write `1` to the container's `cgroup.freeze` (needs G1). Freeze stops processes but deletes nothing; the human frees space or raises the limit. Never auto-delete.
|
||||
2. Opt-in `--ephemeral`: upper dirs on a size-capped tmpfs. Hard cap by the kernel, but memory-backed, counted in the memory cgroup, and lost on exit.
|
||||
3. Constants (soft, hard, interval) in `spec.hpp`.
|
||||
|
||||
Unmeasured: cost of scanning a large upper dir (needs a run on a populated container); overshoot between two scans.
|
||||
|
||||
### G3: more than one uid inside
|
||||
|
||||
Findings, measured with `podman unshare` (map `0 -> 1000`, `1..65536 -> 100000..`): overlayfs with `userxattr` mounts fine under a multi-id map. `/etc/subuid` and `/etc/subgid` contain `retoor:100000:65536`, `newuidmap` and `newgidmap` are installed.
|
||||
- Host root-owned files in the lower dirs show as `65534 nobody`. That is already true with the single map, so nothing gets worse.
|
||||
- Gain: `chown`, `su`, `apt` sandbox users, daemons that drop privileges.
|
||||
- Cost, important: files created by container uids 1..65535 are owned by host uids 100000+. The host user cannot delete them. `Store::remove` as written would fail with `Permission denied`. Delete must run inside a user namespace with the same map (re-enter and `rm` there, or `podman unshare`-style helper) before removing the directory.
|
||||
|
||||
Design: after `clone`, parent execs `newuidmap <pid> 0 <uid> 1 1 <sub> 65536` and the gid equivalent, `setgroups` allowed. Fallback to the current single map when the user has no subuid range, with a printed notice. Store the chosen mode in `meta` (`idmap=single|subuid`) so delete knows which path to take.
|
||||
|
||||
Unmeasured: `apt install` end to end.
|
||||
|
||||
### G4: host `/etc` visible inside
|
||||
|
||||
Finding, measured: `find /etc -type f -perm -004` returns 1116 world-readable files on this host, including names such as `/etc/fail2ban/action.d/cloudflare-token.conf` (content not read). Anything world-readable in host `/etc` is readable in every container.
|
||||
|
||||
Design: replace the `/etc` overlay by a private `/etc`. At create time, populate `private/etc` from an allowlist in `spec.hpp` (`kEtcAllow`): `passwd group nsswitch.conf ld.so.cache ld.so.conf ld.so.conf.d ssl/certs ca-certificates alternatives bash.bashrc profile profile.d localtime timezone os-release protocols services hosts hostname`, plus a generated `resolv.conf`. Two ways: copy (stable, can go stale, for example CA certs) or read-only bind of each allowed path (always current, more mounts). Recommendation: read-only bind, since doos already carries the mount machinery.
|
||||
|
||||
Unmeasured: whether this allowlist is enough for `apt`, `curl`, `python3`, `git`; needs a run per tool before fixing the list.
|
||||
|
||||
### G5: `--net host` and abstract unix sockets
|
||||
|
||||
Fact: abstract `AF_UNIX` sockets belong to the network namespace, so `--net host` exposes host abstract sockets (some X11 and D-Bus setups use them) and host loopback services. This is inherent to sharing the namespace.
|
||||
|
||||
Researched alternative, pasta (installed, `pasta 0.0~git20260120`): it attaches to an existing netns by pid (`pasta --config-net <pid>`), gives outbound access, and mirrors host addresses into the namespace. Measured: outbound TCP worked, and an inside connection to `127.0.0.1:22` reached the host (default flags, once). A second run with `--no-splice --no-map-gw --map-host-loopback none` printed `Couldn't get any nameserver address`, and I could not confirm the loopback result from it. Conclusion: pasta is not proven to match slirp4netns' `--disable-host-loopback` guarantee. Slirp stays the default. Next step is a clean test matrix of pasta flags with a listener on a known host port.
|
||||
|
||||
Interim: `host` remains an explicit opt-in documented as unisolated.
|
||||
|
||||
### G6: kernel attack surface (seccomp, nested user namespaces)
|
||||
|
||||
Findings:
|
||||
- A seccomp-bpf filter installs unprivileged once `no_new_privs` is set (already done). Measured with a raw `prctl(PR_SET_SECCOMP)` program: a denied syscall (`keyctl`) returned `-1 EPERM`. No `libseccomp` headers on this machine, and "fewer dependencies" argues for hand-assembled BPF.
|
||||
- `/proc/sys` is read-only in the container, so `user.max_user_namespaces=0` cannot be set from inside; seccomp is the lever.
|
||||
|
||||
Design: denylist modelled on the Docker default profile, applied after `setupRoot` and before `exec`, and also in the `join()` path. Check `AUDIT_ARCH` first and kill on a foreign ABI (blocks the x32 bypass). Deny with `EPERM`: `kexec_load kexec_file_load bpf perf_event_open userfaultfd keyctl add_key request_key open_by_handle_at process_vm_readv process_vm_writev reboot swapon swapoff init_module finit_module delete_module`. Nested userns: deny `unshare` and `setns` with `CLONE_NEWUSER`, deny `clone` with `CLONE_NEWUSER`, and return `ENOSYS` for `clone3` (flags live in a struct, cannot be inspected; libc falls back to `clone`). Trade-off: breaks nested containers and Chromium-style sandboxes; make it a documented constant, not a switch that hides in code.
|
||||
|
||||
Unmeasured: compatibility impact on real workloads.
|
||||
|
||||
### Priority
|
||||
|
||||
1. G1 (limits): cheapest, measured to work, unlocks G2's freeze.
|
||||
2. G6 (seccomp): self-contained, no new dependency.
|
||||
3. G4 (private `/etc`): needs the per-tool allowlist runs first.
|
||||
4. G2, then G3 (G3 changes the delete path, do it deliberately).
|
||||
5. G5: needs a test matrix before any change.
|
||||
|
||||
## Field trial: apt and common tools (v0.1.2, measured)
|
||||
|
||||
Run in a fresh `--net slirp` container on the host above (Ubuntu with apt 3.2.0, dpkg 1.23.7).
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| curl https, git ls-remote, gcc compile+run, python3 (`ssl`, `sqlite3`), pip, ping 1.1.1.1 | all work |
|
||||
| `apt-get update` as shipped | fails: `/var/lib/apt/lists/partial is missing` (`/var` is an empty private dir) |
|
||||
| `apt-get install` as shipped | fails: `/var/lib/dpkg/lock-frontend` missing, no dpkg status database |
|
||||
| after seeding `/var` (dirs plus copy of host `dpkg/status`, `info`, `alternatives`, 40 MB) | apt starts, but the download method dies: `setegid 65534 failed`, `seteuid 42 failed` (sandbox user `_apt` cannot exist with a single id) |
|
||||
| with `-o APT::Sandbox::User=root` | `apt-get update` works: 58.9 MB fetched in 3 s |
|
||||
| `apt-get install cowsay` | download works; unpack fails: `unable to create '/usr/games/cowsay.dpkg-new': Permission denied` |
|
||||
| after pre-creating the host directory tree in the upper dirs (13164 dirs for `/usr`, done with a shell loop, 38 s; not yet implemented in C++) | unpack works, `/usr/games/cowsay hoi` runs; host `/usr/games/cowsay` still absent (isolation holds) |
|
||||
| configure step | fails: `chown: changing ownership of '/etc/shells.tmp': Invalid argument` (debianutils postinst copies owner of `/etc/shells`, which shows as `65534`), so dpkg reports 4 packages not fully installed |
|
||||
|
||||
Root causes:
|
||||
1. Host files owned by host root appear as `nobody` (uid 65534). Container root has `CAP_DAC_OVERRIDE` only over inodes whose owner is mapped, so it cannot write into host-root-owned directories of the lower layer, and cannot `chown` to or from an unmapped owner.
|
||||
2. `/var` has no package database.
|
||||
3. apt wants a second uid (G3).
|
||||
|
||||
Conclusion: the current design (host `/usr`, `/etc`, `/opt` as lower layer) is good for running tools and for isolated experiments, not for package management. Bare tools and compilers work. Honest status: **`apt-get install` does not complete today.**
|
||||
|
||||
Researched ways forward, in order of effort:
|
||||
- Short term, writable skeleton: at create time mirror every directory of the lower dirs into the upper dir with the same mode (directories owned by the user are writable; merged view is unchanged). Directories unreadable to the user (for example `/etc/ssl/private`, mode 700, root-owned) are skipped and stay as they are. Measured effect: writes into `/usr/bin` work, attach time unchanged (41 ms). Unmeasured: C++ implementation speed, and it does not fix the `chown` failure.
|
||||
- Proper fix, image layer: use an extracted distro rootfs (for example from `podman export` or `skopeo copy` plus unpack, both installed here; `mmdebstrap` and `debootstrap` are not) as the lower dir, with ownership shifted into the subuid range (G3). Then the tree is owned by mapped ids, `/var/lib/dpkg` exists, `chown` works, `_apt` exists, and host `/etc` stops leaking (G4). This also removes the dependence on the host distro. Cost: image import step, disk per image (shared lower, so once), and a layout change, hence a major bump.
|
||||
- Alternative to evaluate: `fuse-overlayfs` (installed) supports uid mappings between layers. Unmeasured; adds a FUSE process per container.
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bump VERSION, build, run the e2e suite, commit everything, tag.
|
||||
# Usage: scripts/release.sh patch|minor|major "commit subject"
|
||||
# Refuses to run when the build or tests fail. Does not push.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
kind=${1:?usage: release.sh patch|minor|major "subject"}
|
||||
subject=${2:?usage: release.sh patch|minor|major "subject"}
|
||||
|
||||
IFS=. read -r maj min pat < VERSION
|
||||
case "$kind" in
|
||||
patch) pat=$((pat+1)) ;;
|
||||
minor) min=$((min+1)); pat=0 ;;
|
||||
major) maj=$((maj+1)); min=0; pat=0 ;;
|
||||
*) echo "kind must be patch, minor or major" >&2; exit 2 ;;
|
||||
esac
|
||||
new="$maj.$min.$pat"
|
||||
|
||||
if git rev-parse "v$new" >/dev/null 2>&1; then echo "tag v$new already exists" >&2; exit 1; fi
|
||||
if grep -rl $'\xe2\x80\x94' --exclude-dir=build --exclude-dir=.git . | grep -q .; then
|
||||
echo "em dash found, remove it first" >&2; exit 1
|
||||
fi
|
||||
|
||||
echo "$new" > VERSION
|
||||
cmake -S . -B build >/dev/null
|
||||
cmake --build build
|
||||
tests/run.sh
|
||||
|
||||
git add -A
|
||||
git commit -m "$subject
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||||
git tag -a "v$new" -m "doos $new"
|
||||
echo "released v$new"
|
||||
Reference in New Issue
Block a user