Both fixes (is_network_in_use()'s ip-exit-code misread, and the kill_session()-vs-async-cleanup race) plus the INFO()-based diagnostics that made finding them possible are now documented in test_compose_orchestrator.cpp's own CLAUDE.md entry, alongside confirmation that the full [integration][root][net] suite (73 assertions, 14 cases) passes cleanly on the real Android target device. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
213 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project state
slocker-lite (C++20, built with Meson) mounts an OCI Image Layout tar (oci-layout +
index.json + blobs/sha256/*, as produced by skopeo/podman save --format oci-archive/modern docker save) using containers-storage and fuse-overlayfs, then
(via -r/--run) runs a sandboxed command against it with bwrap. The real deployment
target is Android with a stock kernel, where podman/docker don't run (missing
namespace support) and there's no kernel overlayfs (hence fuse-overlayfs); bwrap is
invoked in "degraded mode" using only whichever --unshare-xxx namespaces the running
kernel actually supports. See README.md for the human-facing overview (build/usage/
status); this file stays the dense, file-by-file reference. Still early-stage.
Source layout (all under src/):
-
main.cpp— the CLI entry point only, and deliberately tiny (~45 lines): callsparse_args()(cli_args.h) first and returns its exit code immediately if it gives one (covers-h/-Vand every parse error); otherwise callsmigrate_legacy_config_if_needed()(config_file.h, see below) unconditionally, resolves the effective global config path (args.config_file_flagif-c/--config-filewas given, elseconfig_file_path()— a-cpath that doesn't exist is a hard error here, unlike the default path's own missing-file leniency), loads that viaload_global_config()and the separate, always-fixedpersistent_file_path()viaload_persistent_config(), and merges both into oneAppConfig. Config loading had to move to afterparse_args()(it used to run first, specifically so an explicit--log-levelcould still override the config file's own value by being applied later) since which file even supplies the global section now depends on-c, itself a CLI flag — this only works without breaking that precedence becauseParsedArgs::log_level_flag_given(cli_args.h) tracks whether the CLI already gave--log-level(which still applies immediately, inparse_args(), unchanged);main()then only appliesconfig.log_levelviaapply_log_level()when that's false, preserving the exact same final SPDLOG_LEVEL-env → config-file →--log-levelprecedence as before, just with the config load itself now happening later. Finally callsdispatch_command()(commands.h) and returns its result. All of the actual option-parsing and command logic that used to live here moved out intocli_args.{h,cpp}/commands.{h,cpp}/self_test.{h,cpp}(see below) specifically to keep this file from re-growing into a dumping ground as more commands (docker-compose support, etc.) get added. -
cli_args.{h,cpp}— command-line parsing only, nothing else.Mode(the enum of every CLI action) andParsedArgs(everythingparse_args()extracts fromargv) live in the header sincecommands.h'sdispatch_command()consumes them;print_usage()/print_version(), theoptionsnamespace of getopt long-option codes, and thelong_optionsarray itself are.cpp-local.parse_args(argc, argv, out)runs thegetopt_longloop plus all of the post-loop validation that used to live at the top ofmain(): mode/--volumeinteraction (-valone vs. combined with-r, see below),--grouprequires--user, no leftover positional args outside-r/-e, and (moved here from what used to be inline in theMode::execdispatch arm)-x/--exec <pid>'s own pid parsing/validation (ParsedArgs::exec_pid, a positive integer or a hard error) and its trailing-command requirement (ParsedArgs::command, required non-empty).--kill <pid>(ParsedArgs::kill_pid) shares that same positive-integer parsing/validation via a small extractedparse_pid_arg()helper (.cpp-local) rather than duplicating thestrtoldance a second time — unlike-x/--exec, it takes no trailing command, so it's simply not added to the leftover-args exemption list (Mode::run/Mode::execonly). Returns an exit codemain()should return immediately (0for-h/-V,1for any parse error) when set;nulloptmeansoutis ready fordispatch_command().-D/--daemonize(has a short form;'D'was free) is a plain boolean flag (ParsedArgs::daemonize_flag, set in its owncase 'D':, same pattern as--no-nsenter).--hostname <name>/--env VAR=VALUE/--env-file <file>(all long-option only,--env/--env-fileboth repeatable) are collected here intoParsedArgs::hostname_flag/env_specs—--envpushes{false, optarg},--env-filepushes{true, optarg}into the same orderedstd::vector<EnvSpec>(not two separate lists), preserving their exact relative command-line order across both flags, sinceresolve_env_specs()(env_spec.{h,cpp}, see below) needs that order to let a later one override an earlier one for the same variable name — actually resolving them happens later, incommands.cpp'srun_container().-v/--volumeis dual-purpose: used alone it's a standaloneMode::volumerequest; combined with-r/--runit's repeatable and requests a volume mount instead (resolved later byresolve_volume_mount(),volume_mount.{h,cpp}, see below). Since-vmust be repeatable with-rbut each occurrence still takes two space-separated tokens, the getopt loop doesn't let'v'setModedirectly: it accumulates(spec, path)pairs intoParsedArgs::volume_specs(consuming the second token manually, with a guard against swallowing the next flag if there isn't one), and only after the loop decides whether that means one standaloneMode::volumecall or, together with-r, passesvolume_specsthrough unresolved fordispatch_command()/run_container()to handle.-n/--network(seedocs/networking-design.mdfor the full feature design) is dual-purpose the same way, but simpler: since a network join has no equivalent of a volume's container-mount-path second argument, each occurrence is a singlerequired_argumenttoken (just the name) accumulated intoParsedArgs::network_specs— no manual second-token consumption needed,'n''s owncasejust doesnetwork_specs.push_back(optarg). The same post-loop split as-vdecidesMode::network(standalone, exactly one occurrence) vs. join-with--r(repeatable, no limit).--extern/--intern/--subnet <cidr>/--with-ipv6/--subnet6 <cidr>/--with-veth(ParsedArgs::network_extern_flag/network_intern_flag/network_subnet_flag/network_with_ipv6_flag/network_subnet6_flag/network_with_veth_flag) only apply to the standalone (create) case and are rejected with a clear error if given any other way (e.g. alongside-r) —Mode::networkadditionally requires exactly one of--extern/--intern. Unlike the plain boolean flags elsewhere in this file,--with-ipv6/--with-vetharerequired_argument(e.g.--with-veth=false), parsed viaparse_bool_flag()(config_file.h— exported specifically so this file can reuse the exact same accepted forms,"1"/"on"/"yes"/"true"and"0"/"off"/"no"/"false", as the config file itself, rather than a second, drifting copy) intoParsedArgs::network_with_ipv6_flag/network_with_veth_flag(std::optional<bool>—nulloptmeans "not given on the CLI, use the config file's own default", not "false").--with-veth=falseforcesNetworkEntry::veth(config_file.h) tofalseat creation time; when neither is given,create_network_command()(commands.cpp) falls back toAppConfig::with_veth/with_ipv6(config_file.h's own two newglobal.with-veth/global.with-ipv6keys, same "unset means enabled" convention as the sixunshare-*keys), only defaulting totrueif that, too, is unset — seenetwork_bridge.h'sprobe_veth_support()/should_use_veth()for what a resolvedfalsecontrols: lets the tap+relay fallback (the real target device's kernel lacksCONFIG_VETH— seedocs/networking-design.md's addendum) be exercised on a veth-capable machine like this dev box, without needing the actual veth-less hardware, or be made this dev box's own default via the config file instead of passing--with-veth=falseon every-n --extern/--interninvocation.-nused to belong to--no-nsenter: reassigned here since--networkwill be far more heavily used;--no-nsentermoved to long-option-only (options::no_nsenter) rather than hunting for a new letter, matching--kill's own "rare/niche flag, long-only is no real loss" precedent.-p/--port-forward('p'was free) is repeatable the same accumulate-now, resolve-after-the-loop way as-n(ParsedArgs::port_forward_specs, raw"[<network>:]<host-port>: <container-port>"strings — actual parsing happens later, inport_forward.h, since resolving which network a spec refers to needs runtime join state that doesn't exist yet at parse time), but has no standalone use at all: rejected post-loop unless combined with-r.-c/--config-file <path>reuses'c'(freed up when--cleanupdropped its own short form) intoParsedArgs::config_file_flag— a plainrequired_argumentflag with no mode interaction at all, always available regardless of what command is being run, same as--hostname/--user. It only ever affects which filemain()(main.cpp, see below) loads for the global section (config_file.h'sload_global_config()); resolving it, and deciding whether the resultingAppConfig's ownlog_levelshould still be applied, both had to move out of this file and intomain(), since they need to know about the loaded config, whichcli_args.cppitself has no dependency on otherwise. Whatparse_args()does still do, unchanged, is apply--log-levelimmediately in its own case (apply_log_level()) — but now also sets a newParsedArgs::log_level_flag_givenbool alongside it, purely somain()can tell afterward whether the CLI already provided one before deciding whether to also apply the config's own (seemain.cpp's own entry for why this two-flag dance is needed at all). -
commands.{h,cpp}— every command's implementation, plus the dispatcher.dispatch_command(args, config_path, config)(the only externally-linked function; everything else in this file is.cpp-local) is aswitch (args.mode)with one explicitcaseperModeenumerator and nodefault:, so-Wswitch(this project builds atwarning_level=3) forces a compile warning/error if a futureModevalue is ever added without a matching dispatch case, instead of silently falling through to the wrong command — confirmed by testing (temporarily adding an unhandled enumerator triggered exactly the expected-Wswitchwarning).Mode::mounthas its own explicit case (mount_command()) for the same reason: it used to be handled only by falling off the end of a longif/elsechain inmain()with no explicit check at all — the very kind of implicit, easy-to-silently-break behavior this dispatcher redesign exists to close off, especially with moreModevalues (docker-compose support) expected soon.list_processes_command()implements--list-processes(long-option only): callslist_sessions()(pid_file.{h,cpp}, see below) and prints one tab-alignedpid,container name,running/exitedrow per entry (same two-column tab-alignment scheme aslist_images_command()/list_volumes_command(), extended to a third column), no header row, silent success on an empty list.clean_processes_command()implements--clean-processes(also long-option only): callsclean_stale_sessions()(pid_file.{h,cpp}) and prints oneremoved stale pid file for '<name>' (pid <pid>)line per file actually removed, then also callsclean_stale_port_forwards()(port_forward.h, see below — commit 6 ofdocs/networking-design.md's sequence) and prints oneremoved stale port-forward rules for '<name>-<pid>'line per record actually swept, thenclean_stale_tap_relays()(network_tap_relay.h, see below — the direct tap+relay analog of the port-forward sweep) and prints oneremoved stale tap-relay processes for '<name>-<pid>'line the same way — nothing is printed for sessions still running, and an empty result (nothing stale) is silent success, same convention as the rest of this file's list/delete commands.Mode::exec's dispatch case is a one-line call toexec_in_session(*args.exec_pid, args.command)(exec_session.{h,cpp}, see below) — the pid/command parsing and validation now happens incli_args.cpp'sparse_args()instead (see above).inspect_image_command()implements-i/--inspect <image.tar>: prints everyOciImageConfigfield (user/group, exposed ports, env, volumes, default command) without mounting or running the image — extend it wheneverOciImageConfiggains a new field (seeoci_image.{h,cpp}below).run_container()unconditionally callsread_oci_image_config()and reuses the result for two independent defaults: the command to run (Entrypoint ++ Cmd) when none is given on the command line, and, when--userwasn't given, the sandboxed process's user/group (config.User, split intoOciImageConfig::user/group) — an explicit--user/--groupon the command line always takes precedence.create_volume_command()implements-v/--volume <name> <directory>;list_volumes_command()implements--list-volumes(same tab-alignment scheme aslist_images_command(), reused as-is);delete_volume_command()implements both--delete-volume <name>(config entry only) and--delete-volume-full <name>(alsostd::filesystem::remove_all()s the host directory — errors out before touching the config if that fails, warns instead of failing if the directory was already gone) — seeconfig_file.{h,cpp}below for what a "volume" means here (a distinct concept fromOciImageConfig::volumes).dispatch_command()'sMode::volume/Mode::delete_volume/Mode::delete_volume_fullcases call these.create_network_command()/list_networks_command()/delete_network_command()are the direct network equivalents (Mode::network/Mode::list_networks/Mode::delete_network) — seedocs/networking-design.mdfor the full feature design andconfig_file.{h,cpp}below forNetworkEntry. Joining a network from-r/--run(repeatable-n <name>,ParsedArgs::network_specs, seecli_args.{h,cpp}above) is handled byrun_container(), further below, vianetwork_join.{h,cpp}(see below).create_network_command()rejects a name containing':'first (is_valid_network_name(),network_subnet.h— needed sinceport_forward.h's-psyntax splits a spec on':'; a network name containing one would make that parse ambiguous), then a duplicate name, then resolvessubnet/subnet6: an explicit--subnet/--subnet6is validated (is_valid_ipv4_cidr()/is_valid_ipv6_cidr()) and checked for overlap against every existing network's subnet (ipv4_cidrs_overlap()/ipv6_cidrs_overlap(),network_subnet.{h,cpp}, see below); otherwise the next free block is auto-allocated (allocate_ipv4_subnet()/allocate_ipv6_subnet()). Once asubnet/subnet6is resolved,create_network_command()callsensure_network_provisioned()(network_bridge.h, see below) to actually stand up the network's host-side state (bridge, sysctls, iptables rules forextern; a dedicated persistent namespace + bridge forintern) — only once that succeeds is the entry appended toconfig.networksand persisted; a network that fails to provision isn't saved.list_networks_command()reuses the same independently-per-column tab-alignment scheme aslist_processes_command()(name/kind/subnet/bridge each aligned — the bridge name recomputed viabridge_name(network.name)—network_bridge.h— rather than stored, since it's already a pure deterministic function of the name — then the IPv6 subnet — or"(no ipv6)"— appended unaligned as the trailing column, nothing follows it).delete_network_command()takes adelete_fullbool, same shape asdelete_volume_command()'s own:--delete-network(false) only removes the config entry, leaving the network's live host-side state untouched;--delete-network-full(true) additionally callsteardown_network_state()(network_bridge.h, see below) first. Unlikedelete_volume_command()'s-fullvariant (which bails out before touching the config if its singleremove_all()call fails),delete_network_command()doesn't gate the config removal onteardown_network_state()'s success at all — that function is deliberately best-effort/non-fatal per-step (see its own doc comment), so a step "failing" because that piece was already gone by hand (exactlyensure_network_provisioned()'s own existence-check caveat, above) is expected, not a reason to leave a network the user explicitly asked to delete sitting in the config.write_config_command()implements-w/--write-config: unlikecreate_volume_command()/delete_volume_command()'s own use ofwrite_persistent_config()(which only ever persistsAppConfigfields that are already set, into the separatepersistent.yaml), this fills in every global field before writing viawrite_global_config()— the sixunshare-*bools pluswith-veth/with-ipv6, all via.value_or(true), andlog_levelfrom the actually activespdlog::get_level()(not merely a default for when unset — this also captures an explicit--log-levelpassed alongside-won the same command line, overriding whatever the effective config's ownlog-levelalready was, sincemain()/parse_args()already applied it in that precedence order by the time this runs — seemain.cpp's own entry above for the full precedence chain, including how-c/--config-filefits in) — so a bare-wbootstraps a complete, fully-populated global config file for hand-editing, and-wcombined with other flags captures their effective values into it.volumes/networksaren't part ofAppConfig's global-only concern from this command's point of view at all anymore — it never reads or writespersistent.yaml.config_path(the parameter this function takes) ismain()'s already-resolved effective global path — the defaultconfig.yaml, or wherever-c/--config-filepointed instead — so-wcombined with-cbootstraps a global config file at that custom location rather than the default. Prints the file's full path (write_global_config()already creates the parent directory and the file itself if missing, so no separate existence check is needed here).run_container()(theMode::rundispatch case) resolves each-vspec (erroring out,ok = false, same as a failed--userresolution —bwrapis skipped but unmount/cleanup still runs) into aResolvedVolumeMount, rejecting a duplicate or non-absolute container path first, and passes the resolved list torun_bwrap().--hostname <name>is likewise threaded straight throughrun_container()intorun_bwrap()/build_bwrap_args()(bwrap.{h,cpp}) — see there for how/when it actually takes effect.run_container()also derives acontainer_namefor the session-tracking pid file (seepid_file.{h,cpp}below):read_image_ref()(oci_image.{h,cpp}) applied to the single image tar being run, formatted asname:tag, falling back to the tar's own filename stem ifread_image_ref()can't determine one — passed through torun_bwrap()alongside everything else.--env/--env-file's already-orderedenv_specs(seecli_args.{h,cpp}above) are resolved here, once, viaresolve_env_specs()(env_spec.{h,cpp}, see below) — sameok = false-on-failure pattern as volume/user resolution — and the resolved list is passed torun_bwrap()asextra_env.-D/--daemonize'sdaemonize_flagis also consumed here:run_container()computescontainer_namebeforemount_image()(needed sodaemonize()below can use the real container name for the log file from its very first line, not just after a later rename) and, if daemonizing, callsdaemonize(container_name)(daemonize.{h,cpp}, see below) immediately after: a returned value means this is the original (parent) process (or a hard daemonize failure) — print it andreturnright away;nulloptmeans this is the now-detached child, which falls through into the rest ofrun_container()'s existing body completely unchanged, including the unmount/cleanup that already runs afterrun_bwrap()returns (no separate watcher/reaper — the daemonized child is what runs the whole session, start to finish).network_specs(repeatable-n,cli_args.h) is validated up front, beforerun_bwrap()is ever called: joining a network needs a real, isolated network namespace to attach a veth into (unlike--hostname, this can't just be skipped/degraded when unavailable), so if any networks were requested,run_container()checks both thatnamespace_config.netis actually enabled (global.unshare-net,config_file.h) and thatdetect_bwrap_unshare_args()(bwrap.h) reports the running kernel actually supports--unshare-net— either failing setsok = falsewith a clear error, the same pattern as a failed--userresolution. If validation passed,on_bwrap_pid_known(already used for-D/--daemonize'sreport_daemon_started()) additionally callsjoin_networks(pid, network_specs, app_config)(network_join.h, see below) — networks are joined before the daemonize report is sent, so a-D-daemonized caller doesn't get control back until network setup has already had its chance to run.join_networks()itself early-returns (no namespace wait at all) whennetwork_specsis empty, so calling it unconditionally wheneveron_bwrap_pid_knownfires for any reason (e.g.-D/--daemonizealone, no-n) doesn't cost anything.-p'sport_forward_specs(cli_args.h) are syntax/range-parsed (parse_port_forward_spec(),port_forward.h) up front too —ok = falseon a bad spec, same as other validation failures — but resolving which network each targets can only happen afterjoin_networks()returns (it needs to know which networks actually joined, and their assigned IPs), so that happens in the sameon_bwrap_pid_knowncallback, right after thejoin_networks()call:add_port_forward()per spec, collecting the ones that actually landed into astd::vector<ActivePortForward>declared inrun_container()'s own scope (captured by reference) — read again afterrun_bwrap()returns toremove_port_forward()each one. This two-places split (add during the callback, remove afterrun_bwrap()returns) mirrors howjoin_networks()'s own veths don't need an explicit removal step (the kernel tears them down once the session's namespace goes away) while port-forward rules — host-global, named, persistent iptables state — very much do.on_bwrap_pid_knownitself is only set at all whendaemonize_flag || !network_specs.empty() || !parsed_port_forwards.empty()— a real gap caught while wiring this up: an earlier version only checked the first two, so-pgiven without-nor-Dwould silently never even attempt to run (no error, nothing logged) since the callback that resolves/applies it would never fire at all. At the end of the callback,record_port_forwards(container_name, pid, active_port_forwards)(port_forward.h, see below) persists whatever actually landed to a small state file — so that a later--clean-processesrun can find and remove these rules even if this process crashes before ever reaching its ownremove_port_forward()calls afterrun_bwrap()returns; those calls are paired with aremove_port_forward_record(container_name, bwrap_pid)(bwrap_pidcaptured from the same callback, in a variable declared inrun_container()'s own scope) so a cleanly-exiting session's own record doesn't linger for--clean-processesto find later. The same callback also collects eachJoinedNetwork::relay(network_join.h) returned byjoin_networks()into astd::vector<TapRelayHandle> active_relaysdeclared inrun_container()'s own scope — the direct tap+relay (network_tap_relay.h) analog ofactive_port_forwardsabove, since a relay process is likewise independent host-global state (unlike a veth pair) that needs an explicitstop_tap_relay()call for each, made right alongside theremove_port_forward()loop afterrun_bwrap()returns — paired the same two-places way withrecord_tap_relays(container_name, pid, active_relays)(called right afterrecord_port_forwards(), same callback) andremove_tap_relay_record(container_name, bwrap_pid)(called right after thestop_tap_relay()loop) for--clean-processes's own crash-orphan sweep (network_tap_relay.h'sclean_stale_tap_relays(), see below). -
self_test.{h,cpp}—run_self_tests(args, config)implements-t/--test(argsisParsedArgs::test_args,cli_args.h— everything on the command line after-t, captured the same trailing-argv way-r/-xcapture their own command;configis the same effective,-c/--config-file-resolvedAppConfigany other command gets, threaded through fromdispatch_command()'s ownMode::testcase). Mostly plumbing: builds a synthetic argv ({"slocker-lite -t"} + args) and hands it straight to Catch2'sCatch::Session().run(argc, argv)— no test logic of its own lives here at all, that's all undertests/(see below) — but first stashesconfigintotests/support/fixtures.h's owng_test_app_configglobal, before Catch2 ever runs a singleTEST_CASE. Why: without this, test code that creates a real container (test_rootless_run.cpp's ownrun_in_fixture(), below) had no way to reflect-c's settings at all — it always built a fresh, hardcoded defaultAppConfig{}for every container regardless of what-c/the real config file said, since Catch2TEST_CASEs are just plain functions with no way to receive parameters from the harness that invoked them. Confirmed by direct testing (per the user's own request): a hand-written config with everyunshare-*/with-*key set tofalse, used via-c <that file> -t -- "[integration][net]~[root]", correctly flips all 3 oftest_rootless_run.cpp's container-creating tests to failing (the two namespace-isolation checks, since nothing's actually isolated anymore, and the nohup-straggler regression test, since with no pid namespace and no cgroup — rootless, on this dev machine — nothing reaps the backgrounded process) while leaving every other category ([unit],[integration]~[net]) completely unaffected, exactly as expected. This file is#if ENABLE_TESTS-guarded (config.h, from Meson'senable_testsoption, default on — the same macro that already gated whethercatch2_depgets linked at all) so a-Denable_tests=falsebuild prints a clear "not compiled into this build" message and returns nonzero instead of failing to link — the#include "fixtures.h"/g_test_app_configusage is confined to the#if ENABLE_TESTSbranch specifically becausetests/support/fixtures.cpp(where it's defined) is itself only compiled into the binary whenenable_testsis on (meson.build'stest_sources), so referencing it unconditionally would break that build with a link error.-t's own leftover-args capture requires a literal--before any Catch2 option that looks like one of slocker-lite's own (-r/--reportercollides with-r/--run;-c/--sectionagain collides too, now with-c/--config-file) — a bare tag expression like-t -- "[unit]"needs it too by convention, thoughgetopt_long's own permutation happens to let a-t "[unit]"without--work anyway, since"[unit]"doesn't start with-. Distinct from the Meson-driven fixture smoke test undertests/(tests/gen_fixture.py/tests/run_test.py, described in "Build & test commands" below), which stays a separate, always-on, Python-driven mount/unmount/cleanup check.Test organization under
tests/(all in-process — every category calls this project's own already-header-exposed functions directly, no subprocess-spawning, no refactoring ofcommands.cpp's file-local functions needed; seeREADME.md's own "Testing" section for the user-facing category table/tag-expression cheat sheet):-
tests/unit/*.cpp([unit]) — one file per source area (test_port_forward.cpp,test_env_spec.cpp,test_network_subnet.cpp,test_cli_args.cpp) exercising pure/isolated functions with no side effects:parse_port_forward_spec(),resolve_env_specs(),network_subnet.h's CIDR validation/overlap/allocation/address arithmetic, andparse_args()itself against synthetic argv's. Two real bugs found runningparse_args()repeatedly in one process (never possible before this suite existed — a real invocation only ever calls it once, frommain()):getopt_long's own scanning position (optind) is process-global and was never reset between calls, so a secondparse_args()call would silently resume wherever the first left off; fixing that alone (optind = 1) wasn't enough either, since-h/-Vreturn out of thegetopt_longloop early (their ownreturn 0case), before a call ever completes its scan and letsgetopt_longnull out its own privatenextcharpointer — the next call then resumed scanning through that stale pointer into the previous call's already-destroyed argv strings. Fixed withoptind = 0(not1) at the top ofparse_args()itself — glibc documents that value specifically as "fully reinitialize private state before rescanning a new argv"; confirmed clean across repeated runs in both random and deterministic (--order lex) Catch2 ordering. -
tests/integration/test_config_bwrap_chain.cpp([integration], no net/root) — chainsconfig_file.h's read/write withbwrap.h's argv assembly: write a config file, load it back, resolve aNamespaceConfigthe same wayrun_container()does, confirmbuild_bwrap_args()'s resulting argv actually reflects it (disabledunshare-net/unshare-utsnever requested; an all-default config matches the live host's owndetect_bwrap_unshare_args()probe exactly).build_bwrap_args()is pure argv assembly given arootthat's just a string here, never accessed — no mounting, no privilege. -
tests/integration/test_rootless_run.cpp([integration][net], rootless) — runs a real busybox image throughdispatch_command()(commands.h) itself, the exact real-r/--runpath, in-process. Confirms bwrap's default sandboxing (no-n/-pat all) is genuinely isolating: a fresh net namespace with nothing but loopback, and pid/uts/ipc namespaces differing from the test process's own. Real findings, not assumed: bwrap's sandbox mounts--proc /procand--dev /devbut not/sysat all (ls /sys/class/netinside the sandbox: "No such file or directory", reproduced via the real CLI too, not just this test) — the loopback-only check instead reads/proc/net/dev(two header lines + one<iface>: ...line per interface). Also: spdlog's default sink writes to stdout, not stderr, same as the plain"mounted image at: ..."success line — so a naive stdout capture (tests/support/fixtures.h'sCapturedStdout, below) mixes slocker-lite's own status/log output in with the sandboxed command's real output; fixed by having the sandboxed command bracket its own output between two unique markers and extracting only what's strictly between them. Also has a regression test forrun_bwrap()'s own automatic post-exit straggler sweep (bwrap.cpp/session_cgroup.h's "Resolved" entries), reproducing the user's own reported shape end to end with a real container:nohup sleep 137 & exitinside the sandbox, thensleep_process_running()/sleep_process_gone_within()(.cpp-local, same/proc-scanning shape asbwrap.cpp's ownfind_fuse_overlayfs_pid()) confirm the backgrounded process is gone from the host's own process table afterward — checked by cmdline substring, not by pid, since a pid seen from inside an isolated pid namespace doesn't correspond to the same-numbered host pid; a bounded (2s) poll guards against the pid namespace's own kernel collapse-on- pid-1-exit guarantee (covering this case for free on any kernel that supports pid namespaces, this dev machine included) not necessarily being synchronously complete by the instantdispatch_command()returns. Passing here proves the outward, visible contract ("a stray process never survives a session") end to end, though on a pid-namespace-capable host it doesn't by itself prove the cgroup sweep specifically fired — seetest_session_cleanup.cpp's own[integration][root]test (below) for one that exerciseskill_via_cgroup()directly, since the real target device's own no-pid-namespace escape shape can't be forced via the CLI at all. Namedsleep_process_*(not the originalany_process_cmdline_*) since a real false positive was found via this exact test session: the original version searched for the literal text"sleep 137"anywhere in a candidate process's cmdline blob, which matched a manual diagnosticpkill -f 'sleep 137'cleanup command run by hand while investigating a separate issue — that command's own cmdline literally contains the search text as apkillpattern argument, despite not being a sleep process at all. Fixed by requiring an exact match instead:argv[0]'s basename is exactlysleepandargv[1]exactly equals the expected duration.SKIP()s when neither a pid namespace nor a working session cgroup is available under the current effective config (g_test_app_config,fixtures.h) and live kernel capability — checked viapid_namespace_would_isolate()(the same two-gate policy-and-kernel- support checkbuild_bwrap_args()itself applies) andsession_cgroup_would_work()(triescreate_directories()+access(..., W_OK)against a throwaway, never-joined probe path undersession_cgroup_path()— tried directly rather than guessed from e.g.geteuid(), since the real failure mode this needs to detect, no delegated subtree when rootless, is exactly a permissions question those calls can answer directly; never writes this process's own pid into the probe directory, so cleanup is just removing the still-empty directory). In that combination there is genuinely no mechanism left that could reap a reparented straggler — the documented, known residual limitation (session_cgroup.h's own entry above), not a regression — added specifically per the user's own follow-up request after using-cto force exactly that combination and correctly getting a failure instead of a skip. -
tests/integration/test_root_networking.cpp([integration][root][net]) — the persistent-netns/tap-relay/dns-resolver tests that originally lived directly in this file, ported to taggedTEST_CASEs (every assertion usesCHECK, notREQUIRE, so a failure partway through still reaches the same unconditional cleanup at the end — these manage real host-side namespaces/bridges/tap devices that must not leak just because an earlier assertion failed; simpleif/pid guards skip meaningless dependent steps instead). All of the real-bug narrative originally written here — thefork()-vs-unshare()race caught bywait_for_isolated_net_namespace()-style polling, the persistent-tap-device redesign, theip link delteardown fix — is unchanged in substance, just now describing that file instead of this one. One further real bug found porting this to Catch2: the tap-relay test's owncreate_tap_relay()call forks a relay child that relies onSIGTERM's default disposition to terminate cleanly oncestop_tap_relay()signals it (network_tap_relay.cpp's own relay loop deliberately installs no handler) — but Catch2 installs its own fatal-signal handler around a runningTEST_CASE, which that forked child inherits, so its ordinary shutdown signal got caught by the inherited handler in the child instead, producing a spurious "FAILED ... due to a fatal error condition: SIGTERM" report interleaved into the real output (confirmed cosmetic only — exit code and assertion count were correct either way). Fixed by resettingSIGTERMtoSIG_DFLjust around thecreate_tap_relay()call and restoring it right after — only the disposition at fork time is inherited, so nothing about how long the relay then keeps running matters. No production code changed for this; it's purely an artifact of forking network primitives from within a Catch2-instrumented process. -
tests/integration/test_session_cleanup.cpp([integration][root], no[net]) — regression test forrun_bwrap()'s own automatic post-exit straggler sweep (bwrap.cpp/session_cgroup.h's own "Resolved" entry above). Deliberately exerciseskill_via_cgroup()(kill_session.h) directly against a real cgroup with two plain forked processes (one standing in for the tracked bwrap pid,setsid()-ing away a second before exiting) rather than through the full mount/bwrap pipeline — reproducing the actual escape shape this fix targets (no pid namespace support at all) through a real sandboxed session isn't possible from the CLI on a single run (--unshare-pidis a config-file-onlyNamespaceConfigfield, not a flag), whereas the mechanism actually under test — cgroup membership surviving reparenting, andkill_via_cgroup()reaping it — needs no container/image/bwrap involvement at all. Hit the exact same Catch2-fatal-signal-handler-inheritance issuetest_root_networking.cpp's own tap-relay test already found (the straggler process, forked from this same Catch2-instrumented process, would otherwise catch its own expected shutdownSIGTERMvia the inherited handler and report a spurious failure) — fixed the same way, resettingSIGTERMtoSIG_DFLright before forking the straggler. -
tests/integration/test_compose_orchestrator.cpp([integration][root][net]) — the full-u/--up→-d/--downlifecycle against the real, checked-intest-compose/compose.yamlskeleton (not a scratch-written snippet — the whole point is testing this project's own hand-maintained fixture), matching the user's own explicit test plan: createtest-preexisting-netif it isn't already there,-u, confirm both services'environment/env_filevalues via their own log files, confirmtest-server's-p 18080:80actually relaystest-worker1's reply over a real TCP connection,-d, confirm both managed networks were torn down whiletest-preexisting-net(external) and the managed volume both survived, then explicitly delete the volume too so a later run can exercise its creation again from scratch. Deliberately does not useScratchXdgDirs(every other[root][net]test in this suite does) —test-preexisting-netisexternal: true, meaning real Compose's own convention already treats it as the user's own responsibility to set up once and keep reusing, not something to create-and-tear-down per test run; every managed resource this test's own-u/--upcreates is already "test-"-prefixed bycompose_project_name(), and this test's own-d/--downplus final volume deletion leave no managed residue behind regardless. A daemonized service's own-u/--updispatch_command()call can return before the sandboxed script has produced any output at all (report_daemon_started()fires the instantbwrap's own pid is known, not once the script itself has run —daemonize.h/bwrap.cpp), so both the log-content and the port-forward checks poll (plainnanosleep()-based, matchingkill_session.cpp's own style) rather than asserting immediately. The port-forward check discovers the host's own real global IPv4 address (ip -4 -o addr show scope global, the same techniquenetwork_bridge.cpp's own uplink code already uses to discover real routing state rather than hardcoding it) instead of connecting via127.0.0.1— a documented, known limitation of this project's own port forwarding (NAT hairpinning,port_forward.h) means a loopback connection never actually reaches the container regardless of whether forwarding itself works. Verified end to end on this dev machine (root, via the scopeddoasrule), twice in a row to confirm the volume-deletion step genuinely makes the whole lifecycle repeatable: 27 assertions passed both times, and--list-containers/--list-networks/--list-volumes/psall confirmed clean afterward (only the pre-existing, unrelatedtest-preexisting-net/other real volumes remained, untouched).Two real bugs found and fixed running this same test on the actual Android target device, not assumed (both in
is_network_in_use()/ its caller —network_bridge.{h,cpp}/commands.cpp— never in this test file itself, which needed no changes beyond gaining better diagnostics — see below):is_network_in_use()treated any nonzero exit fromip -o link show master <bridge>as "the check itself failed" and failed closed (assumed in use). But the device's own minimalipbuild exits 1, not 0, for the "bridge exists, nothing attached" case this project's dev machine reports as exit 0 with identical (empty) output — confirmed by direct on-device inspection (nsenter --net=... -- ip -o link show master <bridge>returned empty stdout with exit 1 for a bridge that genuinely had nothing attached). Fixed by splitting into two checks: first confirm the bridge device itself is reachable at all (ip link show <bridge>, unfiltered — fail closed only if that fails or is empty), then decide "in use" purely from whether the filtered membership query's own output is non-empty, regardless of its exit code.- Even with that fixed, the device still failed the same way:
-d/--downcheckedis_network_in_use()mere milliseconds afterkill_session()returned, and it was still correct — the tap device genuinely hadn't been detached from the bridge yet. Root cause:kill_session()only waits for the sandboxed process itself (via its cgroup) to die; the separate, independently scheduled daemonized process that started it (runningrun_mounted_container(), blocked in its ownwaitpid()on bwrap) still has its own post-exit cleanup left to run (stop_tap_relay()among it) before a tap+relay join's host-side device is actually removed from the bridge — confirmed in the device's own debug log, where the kill and the "still in use" check landed single-digit milliseconds apart. Fixed with a newnetwork_becomes_unused()(commands.cpp,.cpp-local), retryingis_network_in_use()for up to 10s (nanosleep()-based, matching this project's existing polling style) instead of giving up on the very first still-attached answer.
Diagnostics added while chasing this, kept permanently: both
-u/-d's own captured stdout (CapturedStdoutotherwise silently swallowscompose_up_command()/compose_down_command()'s ownfmt::print()/spdlogoutput — spdlog's default sink is stdout, same as this project's plain status lines) are now attached via Catch2'sINFO(), which only actually prints alongside a failing assertion in the same scope — without this, the device failure would have had no diagnostic trail at all. The port-forward reply check's ownREQUIRE(reply.has_value())was also softened toCHECK-- a flaky reply on one run must never skip the-d/--downcleanup below it.Verified end to end on the real Android target device itself (root, over SSH — see
reference_device_ssh_access.md): after both fixes, the full-u/-dlifecycle test passes cleanly (18 assertions), and the entire[integration][root][net]suite (73 assertions, 14 test cases, including this one) passes with nothing regressed.--list-networks/--list-containers/psall confirmed clean afterward. -
tests/support/fixtures.{h,cpp}—g_test_app_config(a plainAppConfigglobal, default-constructed) is set once byrun_self_tests()(self_test.cpp, see above) from the current-trun's own effective, possibly-c/--config-file-resolved config, before Catch2 runs anything;test_rootless_run.cpp's ownrun_in_fixture()reads a copy of it for every container it creates instead of a hardcoded default, so-cactually reaches that test's own namespace-policy resolution. Declared here (notself_test.{h,cpp}) specifically becauseself_test.cppis always compiled (test or not), while this file — and thus this global's actual definition — only exists in the binary at all whenenable_testsis on;self_test.cpp's own#include "fixtures.h"/ write to it is confined to its#if ENABLE_TESTSbranch for exactly that reason.find_busybox_fixture()(searchesimages/busybox.tarrelative to cwd, this project's own established manual-testing convention;nulloptif absent, so[net]testsSKIP()rather than fail — seetests/setup-tests.py, below),ScratchXdgDirs(RAII: pointsXDG_CONFIG_HOME/XDG_STATE_HOMEat a freshmkdtemp()directory for its lifetime, restoring the previous environment and removing the directory on destruction, so integration tests never touch the real developer's own config/state), andCapturedStdout(RAII: redirects this process's own fd 1 — and anything a forked/exec'd child inherits from it — to a throwaway temp file for its lifetime). Real bug found via ~10-30 repeated combined[unit]+[integration]runs, not assumed:ScratchXdgDirs's constructor originally built itsmkdtemp()template vector from two separate temporarystd::stringobjects (.begin()off one,.end()off the other) — mixing iterators from different containers is undefined behavior, here manifesting as an intermittent, heap-address-dependentstd::length_error: cannot create std::vector larger than max_size()inside whichever test happened to run adjacent to it. Fixed by using a single named string instance for both ends of the range. -
tests/setup-tests.py— idempotent fixture fetcher forimages/busybox.tar: does nothing if it already exists, otherwise triesskopeo→podman→dockerin that order (skopeo/podmanboth reliably produce a genuine OCI Image Layout tar; a plaindocker saveonly does if the containerd image store happens to be enabled, so the result is verified —oci-layout/index.jsonactually present at the tar root — regardless of which tool produced it, falling through to the next option otherwise), clear instructions + nonzero exit if none are available and no fixture already exists.
meson.buildonly compiles any oftests/unit//tests/integration//tests/support/into theslocker-litebinary at all whenenable_testsis on (mirroringconfig.h's ownENABLE_TESTSguard —TEST_CASEs needcatch2_depactually linked, which itself is conditional on the same option), and registers two moretest()entries (unit-tests:-t -- "[unit]";integration-tests:-t -- "[integration]~[net]") alongside the original fixture smoke test — only the categories safe to run unprivileged with no network setup;[net]/[root]stay manual-only, run by a developer on a real machine, matching how this project's self-tests were never part ofmeson testeither. -
-
env_spec.{h,cpp}—resolve_env_specs()turns an ordered list ofEnvSpec {is_file, value}(seecli_args.{h,cpp}above) into a flat, ordered list of(key, value)pairs. A literal (--env) is split at its first=(the value may itself contain=; the key must be non-empty). A file (--env-file) is read line by line: blank/whitespace-only lines and lines whose first non-whitespace character is#are skipped (comments), with a trailing\rstripped first for CRLF files; every other line is parsed the same way as a literal. Logs a specific error and returnsnullopton the first hard failure (malformed line, empty key, or an unreadable file) — deliberately stops at the first line, not "skip and warn", since an env file with a typo should fail loudly rather than silently omit a variable a container might depend on.build_sandbox_env()(bwrap.cpp, see below) appends the resolved list after its own built-inPATH/HOME/PWD/TERM— no deduplication needed there, sincerun_process_foreground()'s ownsetenv(..., 1)loop already lets the later occurrence in iteration order win for a repeated key, so an explicit--env PATH=...still overrides the default. -
oci_image.{h,cpp}— validates/parses the OCI Image Layout tar (libarchive + nlohmann_json) and extracts layer blobs.list_oci_images()scans a directory (non-recursively) for*.tar/*.tar.*files and, for each valid OCI archive, derives an image name/tag viaread_image_ref()from itsindex.jsonmanifest annotations (io.containerd.image.namepreferred, elseorg.opencontainers.image.ref.name), falling back to the archive's filename and"latest"respectively.read_image_ref()is public (not just an internal helper oflist_oci_images()) precisely sorun_container()(commands.cpp) can reuse the exact same logic to name a single image tar's session pid file (seepid_file.{h,cpp}below) instead of duplicating it.read_oci_image_config()reads the image config blob referenced by the manifest and extractsUser(split on:intoOciImageConfig::user/group),ExposedPorts,Env,Volumes, and the effective default command (Entrypoint ++ Cmd).user/groupand the default command are consumed by-r/--run, and every field is displayed by-i/--inspect(seecommands.{h,cpp}above) —ExposedPorts/Env/Volumesare otherwise still just captured for when networking/volumes are implemented. -
containers_storage.{h,cpp}— wraps thecontainers-storageCLI (import-layer,mount,unmount,layer --json,delete-layer), forcingfuse-overlayfsas the overlaymount_program.cleanup_layer_chain()walks a layer's parent chain (children before parents) deleting each one. -
bwrap.{h,cpp}—detect_bwrap_unshare_args()probes the kernel (via a forkedunshare(2)per namespace type) for which--unshare-xxxflagsbwrapcan actually use;build_bwrap_args()/run_bwrap()assemble and run the sandboxed command.build_bwrap_args()also takes astd::vector<ResolvedVolumeMount>(seevolume_mount.hbelow) and appends one writable--bind <host_directory> <container_path>per entry.wrap_for_root_namespace()isrun_bwrap()'s own nsenter-wrapping logic pulled out into a reusable, exported function — it's also whatvolume_mount.cpp's copy-into-an-empty-volume step uses to reach the image's content when running rootless (see below);run_bwrap()itself now just calls it once on the assembledbwrapargv.build_bwrap_args()/run_bwrap()also take aNamespaceConfig(bwrap.h) — one plainboolfield pernamespace_probesentry (user/ipc/pid/net/uts/cgroup, defaulttrue), resolved byrun_container()(commands.cpp) fromAppConfig's sixglobal.unshare-*keys (config_file.h, see above) once, up front —bwrap.{h,cpp}itself never touchesAppConfig/YAML, only this already-resolved struct. For each flagdetect_bwrap_unshare_args()finds the kernel supports,build_bwrap_args()additionally requires the matchingNamespaceConfigfield to betrue(looked up via a.cpp-localnamespace_policy_enabled()if-chain overnamespace_probes'names) before actually passing it tobwrap— kernel support and policy are separate gates, both must allow a type. This replaced an earlier hardcoded special case that always dropped--unshare-netregardless of policy or kernel support (without any network setup, e.g.slirp4netns, unsharing it just left the sandbox with no network at all) —netnow goes through the exact same policy path as every other type, defaulting to enabled like the rest. This is a deliberate, user-acknowledged transitional behavior change: as of this, a plain-r/--runwith no config file override gets a real network namespace and thus no network access at all, untilslirp4netnsintegration (the next task on this same branch) actually sets one up;global.unshare-net: offrestores the prior no-isolation behavior in the meantime.detect_bwrap_unshare_args()itself is untouched by any of this — still an unfiltered kernel-capability probe, unrelated to policy (no longer surfaced via-t/--test, seeself_test.{h,cpp}below).build_bwrap_args()/run_bwrap()also take an optionalhostname(from--hostname, long-option only): passed through as bwrap's own--hostnameonly when--unshare-utsis actually among the flagsbwrapis being given (bwrap itself refuses--hostnamewithout it) — otherwise logs a warning and leaves the sandbox's hostname alone, since a stock Android kernel in degraded mode may not support a UTS namespace at all. Never requests--unshare-userwhen running as root: root doesn't need a fresh user namespace for privilege, and bwrap's own single-mapping uid/gid setup for one triggers the kernel's unprivileged-userns setgroups() restriction, which showed up as every other supplementary group collapsing to the overflow gid ("nobody") inid, andsuinside the sandbox failing with "can't set groups: Operation not permitted". Because of that, bwrap's own--uid/--gid(which require--unshare-user) aren't usable when running as root either —--user/--groupwork around this: when set,build_bwrap_args()/run_bwrap()bind-mount the separateslocker-lite-priv-drophelper (see below) into the sandbox at a fixed hidden path and route the real command through it as<uid>:<gid> -- <command...>. This only actually works without a user namespace (i.e. running as root) — under--unshare-user, the sandbox's uid map has only one valid entry, so the helper's ownsetuid()fails cleanly there instead of silently doing nothing.run_bwrap()fails fast (returns -1) if the helper can't be found next to this binary when--userwas requested, rather than silently running the command as root.run_bwrap()also takes acontainer_nameand tracks the running session with it: it passes a lambda asrun_process_foreground()'s newon_startcallback (seeprocess.{h,cpp}below) that callscreate_session_lock(container_name, pid)(pid_file.{h,cpp}, see below) the instant the realbwrappid is known, then callsrelease_session_lock()oncerun_process_foreground()returns (covering every exit path — normal, nonzero, or a forwarded-signal exit — since that call always blocks until the child has actually exited). Between that return andrelease_session_lock()/remove_session_cgroup(),run_bwrap()also unconditionally sweeps the session's own cgroup (session_cgroup_pids(),kill_via_cgroup()—session_cgroup.h/kill_session.h) for any process still left in it, force-stopping it before cleanup proceeds — seesession_cgroup.h's own "Resolved" entry for the full detail on why (a daemonized/reparented straggler could otherwise outlive the session regardless of how it ended).build_bwrap_args()no longer passes--clearenv/--setenvtobwrapitself; instead,build_sandbox_env()builds the sandboxed command's exact environment (PATH,HOME,PWD— hardcoded to"/", matching--chdir's own value; note per bwrap's own man page--clearenvnever actually unsetPWDin the first place, so this isn't a straight port of a prior--setenv— andTERM, only if the host process has one, followed byextra_env— the resolved--env/--env-filelist fromresolve_env_specs()(env_spec.h), appended last so it can override the built-in defaults for the same key) andrun_bwrap()passes it straight torun_process_foreground()'s ownenvoverride (seeprocess.{h,cpp}below). This works becausebwrap(andnsenter, when interposed viawrap_for_root_namespace()) doesn't alter its own inherited environment unless told to, and neither doesslocker-lite-priv-drop(justsetgroups()/setgid()/setuid()/execvp(), no env manipulation) — so controlling it once, at the outermost exec, is sufficient for it to reach the final sandboxed command unchanged. Because that outermost exec now uses this same explicitly-built environment,build_bwrap_args()/wrap_for_root_namespace()resolvebwrap's andnsenter's own argv[0] to an absolute path viafind_in_path()(called from this process's own, unmodified environment, beforefork()) instead of leaving them as bare names — confirmed by direct testing:--env PATH=...used to breakexecvp()'s ability to even locatebwrap/nsenter(bare-name lookup happens in the child, using the already-overridden PATH), not just what the sandboxed command itself sees. With the fix, only the sandboxed command's own lookup is affected by a--env PATH=...override (as expected — same as overridingPATHin any real shell before running a bare command name), andbwrap/nsenterare always found regardless.run_bwrap()also takes an optionalon_bwrap_pid_knowncallback, invoked alongside (not instead of) the session-lock-creation lambda, at the exact sameon_starttiming —-D/--daemonize(daemonize.{h,cpp}, see below) hooks in here viareport_daemon_started()to learn the real pid at the same instant everything else that needs it does, rather than needing its own separate pid-discovery mechanism. That sameon_startlambda also callscreate_session_cgroup()(session_cgroup.h, see below), right alongsidecreate_session_lock(), so--killcan later find every process the session ever starts via its dedicated cgroup;remove_session_cgroup()is called from the same post-run_process_foreground()spotrelease_session_lock()already is. -
priv_drop_helper.cpp→ the separateslocker-lite-priv-dropbinary (its ownexecutable()target inmeson.build, built with-static). Deliberately has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is fully statically linked: it gets bind-mounted into the container image's own filesystem, which won't haveslocker-lite's own shared library dependencies — a dynamically linked binary bind-mounted that way fails outright ("error while loading shared libraries"), which is exactly what happened before this was split out (the original approach bind-mountedslocker-lite's own — dynamically linked — binary via/proc/self/exeand reexeced it; kept only as a lesson, not as working code). Usage:slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]; doessetgroups(0,…)→setgid()→setuid()→execvp(), in that order (dropping the group needsCAP_SETGID, which is lost oncesetuid()drops root).find_priv_drop_helper()and thepriv_drop::path/priv_drop::helper_nameconstants live inbwrap.h(not just internal tobwrap.cpp) specifically soexec_in_session()(exec_session.cpp, see below) can reuse the exact same already-bind-mounted helper for-x/--exec's own--user/--groupsupport, instead of a second copy needing to be bind-mounted for it (which wouldn't even be possible —-x/--execjoins an already-running session's mount namespace, it doesn't get to add bind mounts to it).find_priv_drop_helper()itself only checks this binary's own host-side existence; it says nothing about whether a given session actually has it bind-mounted (only true when that session's-r/--runresolved a user in the first place). -
user_spec.{h,cpp}—resolve_user_and_group()resolves a user/group spec (each a name or numeric id) against the container's own/etc/passwd//etc/groupcontent (not the host's, and not a path — callers own reading it, since the two current callers get that content two different ways:run_container()reads it directly off the merged mount path, whileexec_in_session()fetches it overnsenter, since a running session's mount namespace isn't otherwise reachable from this process — see below).nulloptcontent for either file means "unreadable/absent"; a numeric user with no group still resolves fine without it (defaults gid to the same numeric value as the uid) but a named one doesn't.ResolvedUser(bwrap.h) also carrieshome, looked up by the final resolved uid's/etc/passwdentry (field 5) regardless of whetheruserwas given as a name or a number; falls back to"/root"for uid 0 or"/"otherwise when there's no matching row.build_sandbox_env()(bwrap.cpp) sets the sandboxed process'sHOMEfrom this —"/root"only when no user override applies at all (no--user, no image-declaredconfig.User).run_container()(commands.cpp) callsresolve_user_and_group()with either the explicit--user/--groupflags, or, when--userwasn't given, the image's own declaredconfig.User(OciImageConfig::user/group) — so a container defaults to running as whatever user the image itself declares, not root, unless the image declares none. -
process.{h,cpp}— argv-based subprocess helpers (fork/execvp, no shell):run_process()captures stdout (used forcontainers-storagecalls),run_process_foreground()inherits all of stdio (used for the interactivebwraprun). Alsofind_in_path(), a shared$PATHlookup.run_process_foreground()installs a SIGINT/SIGTERM handler around itswaitpid()that forwards the signal to the running child and keeps waiting instead of letting the default disposition killslocker-liteitself — without this, Ctrl-C (orkill) during-r'sbwraprun would skiprun_container()'s unmount/cleanup entirely, leaving the layer imported and/or mounted.run_process_foreground()also takes an optionalon_startcallback, invoked with the child's real pid right afterfork()succeeds (before the signal handlers go up and it blocks inwaitpid()) — the only point where that pid is knowable, and still accurate even whenargvitself execs into something else first (e.g.nsenterhanding off to the final command via its own in-placeexecvp()— a pid never changes acrossexec()).run_bwrap()(bwrap.cpp) is the one caller that uses it, for session pid-file tracking (seepid_file.{h,cpp}below).run_process_foreground()also takes an optionalenv(list of key/value pairs): when set, the forked child replaces its entire environment viaclearenv()/setenv()(plain POSIX, not the GNU-onlyexecvpe()— the target platform includes musl) beforeexecvp(), instead of inheriting this process's own.nullopt(the default) leaves the child's environment untouched.run_bwrap()is again the one caller that uses this, viabuild_sandbox_env()(bwrap.cpp) — see there. -
pid_file.{h,cpp}— tracks one running-r/--runsession (a livebwrapprocess) as a locked pid file, so an outside process (or a laterslocker-liteinvocation) can tell whether it's still running.sanitize_for_filename()(anything outside[A-Za-z0-9._-]→_, falling back to"container"if that leaves nothing) is exported here (not just.cpp-local) specifically sosession_cgroup.{h,cpp}(see below) can reuse the exact same<name>-<pid>naming rule for its own per-session cgroup directory without drifting from this file's own.xdg_state_dir()($XDG_STATE_HOME/slocker-lite, or the$HOME/.local/state/...fallback) is likewise exported (moved out of this file's own anonymous namespace) sopersistent_netns.{h,cpp}(see below) andnetwork_join.cpp's own per-address lease files (xdg_state_dir() / "net-leases") can resolve their own subdirectories under the same state root without a second, drifting copy of this resolution logic.session_pid_file_path()resolves$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>(falling back to$HOME/.local/state/...whenXDG_STATE_HOMEis unset/empty — same resolution pattern asconfig_file_path()below, for state instead of config), sanitizingcontainer_namefirst (anything outside[A-Za-z0-9._-]→_, since an image name/tag can contain/or:).session_log_file_path()is a sibling resolving to$XDG_STATE_HOME/slocker-lite/logs/<container_name>-<pid>.loginstead — same sanitization, same$XDG_STATE_HOME/$HOMEfallback, just a different subdirectory and a.logextension — used bydaemonize.{h,cpp}(see below) for-D/--daemonize's log file.create_session_lock()creates the file (O_CREAT|O_WRONLY|O_TRUNC|O_CLOEXEC, mode 0644 —O_CLOEXECmatters: this fd must never leak into the sandboxed command's own fd table), writes the pid as text, and takes an exclusive, non-blockingflock()on it — held only by that fd, so its lifetime tracksslocker-lite's own process lifetime (released automatically on any exit, including a crash), which lines up withbwrapitself being invoked with--die-with-parent. Any external tool can check liveness the same way: attempt the same exclusive non-blockingflock()on the file — success means nothing holds it anymore (stale, safe to remove),EWOULDBLOCKmeans a live process still does.release_session_lock()closes the fd (releasing the flock immediately) and removes the file. Every failure path here (can't create the directory/file, can't lock, can't remove) is aspdlog::warn, never fatal — session tracking is best-effort and must never block or fail-r/--runitself.list_sessions()implements--list-processes(commands.cpp'slist_processes_command()): scans the samerun/directory and reports oneSessionInfo {pid, container_name, running}per readable pid file.pidis read from the file's own contents, not parsed from the filename (ambiguous for names that themselves contain-);container_nameis then recovered by stripping that exact-<pid>suffix back off the filename.runningreuses the same liveness check any external tool would do — a non-blocking exclusiveflock()that succeeds means the file is actually stale, sorunningis false in that case; the lock is always released again immediately either way, never left held by the check itself. A file that can't be opened or doesn't parse as a pid (e.g. removed mid-scan) is silently skipped, not reported as an error — scanning a live directory is inherently racy. Bothlist_sessions()andclean_stale_sessions()(the latter implements--clean-processes) share a privateopen_session_file()helper for the open/read-pid/recover-name step.clean_stale_sessions()doesn't just remove whatever a separatelist_sessions()call reported as not running — it re-takes the same non-blockingflock()used to test liveness and holds it across theremove()call itself, per file, so the stale check and the removal stay atomic against a new session starting in the gap between a check and a later removal. Only files it actually removes are reported back (asSessionInfos withrunning=false); still-locked (running) files are left untouched and not reported. -
persistent_netns.{h,cpp}— generic, narrow infrastructure for keeping a network namespace alive with no process in it, the wayip netns adddoes; nointern/externpolicy or bridge logic here (that'snetwork_bridge.{h,cpp}, see below, which is what actually callscreate_persistent_netns()for aninternnetwork).persistent_netns_path()resolvesxdg_state_dir() / "netns" / sanitize_for_filename(name)(pid_file.h, see above).persistent_netns_exists()checks whether that path is actually a live bind-mounted namespace, not just a stale/never- mounted file:stat()s the path and its parent directory and comparesst_dev— a genuine bind mount always has a different device number than its parent, the same "is this a mountpoint" technique used elsewhere. Never needs root itself (juststat()).create_persistent_netns()forks a child (never touches the caller's own network namespace —unshare(2)affects only the calling process) thatunshare(CLONE_NEWNET)s its own fresh namespace, bind-mounts its/proc/self/ns/netonto the target path, then exits immediately — the bind mount itself is what keeps the namespace alive from then on, independent of the now-exited child, exactlyip netns add's own technique. RequiresCAP_SYS_ADMIN(root) for the bind mount, matching this feature's current root-only scope (seedocs/networking-design.md) — best-effort like this project's other host-state primitives (session locks, cgroups): logs and returnsfalseon any failure (already exists, fork/unshare/mount failure) rather than throwing.remove_persistent_netns()unmounts then removes the file. -
network_bridge.{h,cpp}— stands up (or confirms already-standing) a network's actual host-side state:ensure_network_provisioned()is idempotent by design (checksip link show <bridge>first and does nothing further if it's already there) — this is deliberately also the reboot- reconciliation mechanism, not a separate code path: nothing about a network's live state (bridge, veths, iptables rules; the persistent namespace itself forintern) survives a reboot except itsconfig.yamlentry, so calling this again after one just recreates whatever's missing.bridge_name()derives a stable interface name from the network's own name via a hand-rolled 32-bit FNV-1a ("slk" + 8 hex chars, 11 characters, comfortably under Linux'sIFNAMSIZ - 1= 15-character limit regardless of how long the network name is) — deliberately notstd::hash<std::string>, whose exact value is implementation-defined and not guaranteed stable across a rebuild with a different standard library, which would silently orphan an already-provisioned bridge a rebuilt binary can no longer find by the name it now computes. Every command, both kinds, is wrapped throughnsenter --net=<persistent path>(wrap_for_network()) into the network's own dedicated namespace (persistent_netns.h, created here first if it doesn't exist yet) — the same patternwrap_for_root_namespace()(bwrap.cpp) already uses for the rootlesscontainers-storagemount's namespace, just targeting a persistent bind-mounted path instead of a live pid's/procentry.externused to run every command directly, unwrapped, since its bridge used to live in the host's own root namespace — see this entry's own "Resolved:externhad no connectivity at all on the real device" paragraph further below for why that changed: confirmed by direct on-device testing to be the actual cause of a real, reproducible bug, not just an implementation choice.bridge_name()/wrap_for_network()are both exported (not just this file's own internal helpers) specifically sonetwork_join.{h,cpp}(see below) can attach a container's veth to the exact same bridge, in the exact same place, this file provisioned it in.provision_bridge()(.cpp-local): creates the bridge, assigns it the gateway address fromnetwork_subnet.h'sipv4_gateway_address()(andipv6_gateway_address()ifnetwork.ipv6), brings it up, then —externonly —sysctl -w net.ipv4.ip_forward=1and oneiptablesPOSTROUTING/MASQUERADErule for the subnet (! -o <bridge>, the samedocker0shape, so bridge-local inter-container traffic isn't unnecessarily NAT'd; both idempotent global host sysctls, not per-bridge, so no separate "already enabled" tracking is needed), plus, ifipv6, the IPv6 forwarding sysctl — but deliberately noip6tablesMASQUERADE rule: thefd00::/8ULA addressesnetwork_subnet.hallocates are non-globally-routable by design (RFC 4193, the IPv6 equivalent of RFC1918 private space), so NAT66 for them isn't correct IPv6 practice to begin with (also confirmed not universally supported on the real target device — itsip6tablesbuild lacks aMASQUERADEtarget entirely).extern's IPv6 side is thus same-bridge reachability only, exactly whatintern's IPv6 already was — and confirmed on the real target device, not just theorized, that this is the only option regardless: neitherip6tablesnornftablescan even create an IPv6 NAT table on that kernel at all ("Not supported").check_network_dependencies()gates on the tool set each kind actually needs (ip/nsenteralways, both now reaching their bridge through a private namespace;iptables/sysctladditionally forextern— noip6tables, even whenipv6, since none is ever called) — same shape/spirit ascommands.cpp's owncheck_required_dependencies(), kept separate since the tool set here depends on the network's own kind/ipv6setting.create_network_command()(commands.cpp, see above) callsensure_network_provisioned()after resolving subnets but before persisting the config entry — a network that fails to provision isn't saved, so a later join doesn't find a config entry for something that doesn't actually exist on the host. Historical verification note: an earlier verification pass (before the "Resolved" paragraph below) confirmed a realexternnetwork's bridge/gateway/ip_forward/MASQUERADErules all coming up correctly directly in the host's root namespace — that description is now superseded; see below for the current, corrected architecture and why it changed.ensure_network_provisioned()no longer short-circuits onbridge_exists()alone either: the uplink step below (externonly) now runs on every call, even when the bridge already existed, via its own separate idempotency check.teardown_network_state()is the reverse:--delete-network-full(commands.cpp'sdelete_network_command()) calls it to tear down exactly whatensure_network_provisioned()stood up. Forextern: first tears down the uplink (teardown_uplink_state(), see below), then removes theiptablesMASQUERADE rule for the container subnet (noip6tablescounterpart, since none is ever added — seeensure_network_provisioned()'s own comment above) — via a.cpp-localteardown_step(), the same shape asrun_admin_command()but logging a warning, not an error, on failure, since a step failing because that piece was already gone by hand is the expected, common case this exists to handle (exactlyensure_network_provisioned()'s own existence-check caveat above — this is precisely how a manually-removed MASQUERADE rule can go from "won't come back on recreate" to "cleanly torn down and recreated" once--delete-network-fullexists at all). Both kinds then remove the whole persistent namespace (persistent_netns.h) in one step, which destroys everything left inside it — the bridge, and forexternthe uplink's own private-namespace-side tap device, both included — with no separateip link delneeded for those. Deliberately never touches the IPv4/IPv6 forwarding sysctlsprovision_bridge()enables forextern— those are global host state shared across everyexternnetwork, not per-network, so turning them off here could break others still relying on them. Verified end-to-end on this dev machine (root, via the scopeddoasrule): anexternnetwork's bridge and MASQUERADE rule were both confirmed gone after--delete-network-full(ip link showreporting "Device does not exist"), and recreating a network with the same name afterward correctly went throughprovision_bridge()again from scratch (confirmed via the debug log) instead of short-circuiting on a stalebridge_exists()check — fixing exactly the gap a user reported (a manually-removed MASQUERADE rule never came back on--delete-network+ recreate, since the old bridge was silently still there); aninternnetwork's persistent namespace was similarly confirmed fully removed and recreatable without conflict.Resolved:
externhad no connectivity at all on the real device. Full incident writeup indocs/networking-design.md's own section of the same name — this entry just covers the resulting code. Root cause: the bridge lived directly in the host's own root namespace, and (almost certainly) Android's ownnetd-managed iptables/routing policy applies only there, never to a genuinely isolated namespace — exactly whyinternwas unaffected the whole time.wrap_for_network()no longer special-casesextern(see its own entry above) — this one change alone fixed gateway reachability, both IPv4 and IPv6, but also removesextern's only path outside by construction, restored by a new uplink: a second, point-to-point tap+relay link (reusingnetwork_tap_relay.h, see its own entry below for the newattach_host_side_to_bridge=falsemode this needed) between the network's private namespace and the host's root namespace, on its own small deterministic169.254.0.0/16transit subnet (uplink_transit_addresses()/uplink_transit_subnet(),.cpp-local, samefnv1a()-based derivationbridge_name()already uses).ensure_uplink_provisioned()(.cpp-local, called fromensure_network_provisioned()forexternonly): idempotent via its ownuplink_provisioned()device-existence check; creates the relay (uph<hash>in host root,upn<hash>in the private namespace), assigns each end an address, sets the private namespace's own default route via the uplink, enablesip_forward, and adds aMASQUERADErule in host root for the transit subnet — plus three more pieces, each independently required and each found by real on-device testing, not assumed (full detail, including exactly how each was diagnosed, indocs/networking-design.md's own section): aniptables -I FORWARD 1accept rule (inserted at the front — Android's owntetherctrl_FORWARDchain unconditionally drops everything reaching it, so an appended rule is structurally unreachable), an outboundip rulerouting the uplink's own traffic into whichever tablediscover_default_table()(.cpp-local, parsestable <N>out ofip route get 8.8.8.8's own output — not hardcoded, adapts to whichever real network is currently active) names, and a return-pathip rulerouting traffic to the transit subnet into the plainmaintable regardless of which real interface a reply arrives on.TapRelayHandlegainedroot_side_tap_name(network_tap_relay.h) since, unlike a real container join's own container-side tap (torn down for free once that session's namespace goes away), the uplink's host-root-side tap never disappears on its own —stop_tap_relay()removes it explicitly when set. The relay's own pid is recorded to$XDG_STATE_HOME/slocker-lite/network-uplinks/<network>(uplink_state_path()) since it must outlive the singleensure_network_provisioned()call that created it, potentially spanning many separateslocker-liteinvocations beforeteardown_uplink_state()(.cpp-local, the reverse of every step above, best-effort throughout) eventually stops it. Verified completely end-to-end on the real target device, from a clean state: gateway IPv4 0% loss, gateway IPv6 0% loss, and a real outside destination (8.8.8.8) 0% loss (3/3 replies) through a container on a freshly createdexternnetwork, both via the veth path and via--no-veth's tap+relay fallback; also verified on this dev machine (self-test, a plain veth join,--no-veth, andintern— still unaffected, no uplink,ip route's own "Network unreachable" for outside as intended). IPv6 outside connectivity was investigated separately and found not practically fixable on this device (confirmednft add table ip6 ...itself fails — no IPv6 NAT support in this kernel at all, via eitherip6tablesornftables; the alternative, NDP-proxying real addresses out of the device's own global prefix, was ruled out too, since that prefix rotates every ~10 minutes on the network tested against) — so it stays local-only by deliberate decision, matching whatintern's IPv6 side already was.Resolved:
-p/--port-forwardhad no connectivity at all on anexternnetwork. Reported after the connectivity/multi-network-join fixes above had already shipped: a server listening on anexternnetwork wasn't reachable via-p, from the host or from a real outside client, even though the network's own gateway/outside connectivity (verified above) worked fine. Root cause, confirmed by direct testing (ip route get <container-ip>from host root): movingextern's bridge into a private namespace fixed gateway reachability but also meant host root had no route to the container subnet at all — it fell through to whatever the host's own default route happened to be (the real LAN gateway) — so-p's ownDNATrule (added in host root, targeting the container's real IP directly —port_forward.cpp) had nowhere to send the rewritten packet. Fixed with two pieces inensure_uplink_provisioned(), both confirmed independently necessary by direct testing — the exact same "a route alone isn't enough on Android" lesson the uplink's own outbound/return-pathip rules above already learned, just for the container subnet instead of the transit subnet: a host-root route to the container subnet through the uplink's own netns-side address, plus a matchingip rule add priority 100 to <container-subnet> lookup main. Without the second piece, the route added by the first is silently never consulted: confirmed viaip rule showon the real device that Android's own lower-priority-number policy rules — a genericfwmark 0/0x10000 lookup 99catch-all among them, matching any untouched/forwarded packet — intercept the packet and route it into an unrelated table with no route to the container subnet, long before rule evaluation would ever reachmain. A related robustness bug found while testing this fix, not the original bug itself: a failedensure_uplink_provisioned()used to only callstop_tap_relay(), leaving everyip rule/iptablespiece already added (all deterministic, hash-derived names tied to the network name) live on the host — reproduced directly during testing (an incidental collision between two concurrent test invocations triggered a first failure, whose leftover state then made every subsequent attempt for the same network name fail identically and permanently, "File exists" on anip rule addthat was never removed, until fixed by hand or a full device reboot). Fixed by recording the relay's pid to the uplink state file as soon as it's known (before any of the steps that can fail), so a failure can call the exact sameteardown_uplink_state()a real--delete-network-fullwould use to roll back everything already added, instead of a second, partial, drifting copy of that cleanup logic. Verified end-to-end on the real target device: a busyboxhttpdon a freshly (re-)createdexternnetwork, reached via-p 18080:80both from the device's own shell (against its real LAN IP, not127.0.0.1— seeport_forward.h's own already-documented NAT-hairpinning limitation below for why that specific case still doesn't work, unrelated to this fix) and from a genuinely separate external machine on the same LAN, got a real HTTP response back both times, reproducibly across repeated fresh network creations.probe_veth_support()(added for the tap+relay fallback, seedocs/networking-design.md's addendum andnetwork_join.{h,cpp}below): the real target device supportstun/tapbut notveth(CONFIG_VETHcommonly stripped from mobile kernels), so joins there can't use the veth-pair mechanism this file/network_join.cppotherwise assume. Probes kernel support the same waybwrap.cpp'skernel_supports_namespace()probes namespace types: forks a child thatunshare(CLONE_NEWNET)s into a throwaway namespace and attemptsip link add ... type veth peer name ...there (via the existingrun_process()) — the whole namespace, and anything created in it, vanishes with the child, so no cleanup is needed either way. Cached in a function-local static (a fixed fact about the running kernel, not something that varies per network, so a container joining several networks in one run only probes once).should_use_veth(network)combines this with the network's ownvethpolicy flag (config_file.h'sNetworkEntry::veth, defaulttrue) the same "capability and policy are independent gates" waynamespace_policy_enabled()(bwrap.cpp) already combines kernel support withglobal.unshare-*policy — both must allow veth for it to actually be used. Verified on this dev machine (root, via the scopeddoasrule):probe_veth_support()correctly returnstruehere (a realip link add ... type veth ...succeeds), and--no-vethat network-creation time correctly persistsNetworkEntry::veth = false, makingshould_use_veth()returnfalseeven though the kernel itself supports veth — this dev machine's own way to exercise the tap+relay fallback (seenetwork_tap_relay.{h,cpp}, not yet built) without needing the actual veth-less target device. -
network_join.{h,cpp}— joins a just-started-r/--runsession to each network named in-n.join_networks()first waits (bounded, 3s, 20ms-intervalnanosleep()polling —wait_for_isolated_net_namespace(),.cpp-local) forresolve_namespace_pid()(sandbox_process.h) to name a child whose net namespace is actually isolated (namespace_isolated(outer_pid, ns_pid, "net")) — necessary becauserun_bwrap()'son_bwrap_pid_knownfires right afterfork(), beforebwraphas done any of its own namespace setup, so that child may not even exist yet the instant this is called; while it doesn't,resolve_namespace_pid()falls back to returningouter_piditself, so comparing a namespace to itself naturally keeps the loop going without a separate "does a child exist yet" check. Known limitation, not solved here: for a very short-lived sandboxed command, the whole session can exit before this poll ever catches up (confirmed by testing:-n <net> -- echo hireliably timed out) —bwrapexecs straight into the target command with no hook point in between namespace creation and exec, so there's no way for this project to guarantee network setup completes before a near-instant command already has too. Real (long-running) networked services are unaffected — confirmed by testing (see below). For each named network: looked up inconfig.networks(an unknown name is a per-network error, not fatal to the others);ensure_network_provisioned()(network_bridge.h) covers post-reboot recreation; then, pershould_use_veth(network)(network_bridge.h— combines the network's ownvethpolicy flag with a kernel-capability probe, see that file's own entry above), either a veth pair is created wherever that network's bridge lives (wrap_for_network(), reused fromnetwork_bridge.h), the bridge-side end attached and brought up, the container-side end moved into the session's own namespace (ip link set ... netns <ns_pid>) and renamedeth<N>, or, when veth isn't available or the network was created with--no-veth,create_tap_relay()(network_tap_relay.h, see below) is used instead, producing the same end state (a readyeth<N>in the container's namespace) via two tap devices and a relay process rather than a kernel veth pair.eth<N>'sNis the network's position in the-nlist, so multiple joins each get a distinct interface, regardless of which strategy created it — everything downstream (IP assignment, routes, the address returned toport_forward.h) is identical either way, since it only ever operates oneth<N>by name. Unlike a veth pair (torn down by the kernel automatically once the session's namespace goes away, whatever else fails), a tap relay is an independent process with no such automatic cleanup — if any step aftercreate_tap_relay()succeeds fails later injoin_one_network()(address exhaustion, a failedip addr add/route command), afail()helper (.cpp-local, only present when a relay was actually created) callsstop_tap_relay()before returningnullopt, so a partial failure doesn't leak the relay process. Address allocation,pick_free_address(), needed a real fix during testing, not just design: an interface's actual assigned IP lives inside its own private per-container namespace, invisible from the bridge's own namespace — an earlier version queriedip -o addr show master <bridge>(only the host side of each veth, with no address of its own, is visible there) and always saw nothing, so two concurrently-running containers on the same network were both handed the identical address (confirmed by testing:10.168.0.2twice). Fixed by giving each candidate address its own tiny lock file underxdg_state_dir() / "net-leases"(pid_file.h) and holding an exclusive, non-blockingflock()on it via an intentionally never-close()d fd — the same techniquepid_file.h's ownSessionLockuses for session liveness, released automatically by the kernel the instant this process exits for any reason, no explicit release step or cleanup sweep needed. Picking a free address is then just "the first candidate (network_subnet.h'sipv4_host_address()/ipv6_host_address(),n = 2, 3, ...) whose lock file isn't already held." For anexternjoin,ip route replace default via <gateway> dev eth<N>(replace, notadd, so a container joining a second extern network doesn't fail outright with "File exists" — whichever extern network is joined last ends up as the effective default route;interngets no default route at all, matching the design's "no route out exists" intent — the connected route for the local subnet is already automatic once an address is assigned, no explicit route command needed for same-bridge reachability regardless of kind). Every step failure is logged specifically (which command, which network) and best-effort:join_networks()returns oneJoinedNetwork {network, container_ip, relay}per network that actually joined (in-norder, so shorter than the request list on any partial failure), never fatal to the already-running session (network setup can only happen afterbwrap's own namespace exists, i.e. potentially after the sandboxed command is already running) — this return value exists specifically forport_forward.h(see below) to resolve a-pspec against which networks/IPs are actually usable, not as a pass/fail signal on its own;relay(nulloptfor a veth-joined network) is whatrun_container()(commands.cpp) collects to callstop_tap_relay()on afterrun_bwrap()returns, mirroring how it already collectsactive_port_forwardsfor-p's own cleanup. An emptynetwork_namesreturns immediately (no namespace wait at all), so callers that always invoke this onceon_bwrap_pid_knownfires for any reason (commands.cppalso fires it for-D/--daemonizealone, with no-n) don't pay for a wait that has nothing to do. Veth teardown needs no explicit code: the kernel destroys an entire veth pair (both ends, including the one still attached to the bridge) the instant either end's owning namespace is destroyed, so a session's veths disappear on their own once its namespace does — only the bridge/iptables/persistent- namespace state is deliberately left behind (network_bridge.h's reboot-reconciliation design); a tap relay instead needs the explicitstop_tap_relay()call described above, since it's an independent process with no namespace of its own to be torn down by. Verified end-to-end on this dev machine (root, via a scopeddoasrule): two concurrently-running containers on the sameinternnetwork got distinct addresses and could ping each other; anintern-joined container could not reach the outside (Network unreachable); anextern-joined container reached the real internet through the bridge's NAT; a container joining both aninternand anexternnetwork simultaneously got two working interfaces (eth0/eth1) with neither one breaking the other.Real, separate bug found while testing this commit — since fixed (
exec_session.{h,cpp}, see that file's own entry below):-x/--execdeliberately never joined thenetnamespace type, written back when this project genuinely never isolated networking at all, so there was nothing to join. Once-r/--runsometimes isolates networking (whenever any-nwas given),-x/--exec'ing into such a session saw the host's network stack instead of the container's — confirmed directly: execing into a session running a network-isolatedhttpdshowed the host's own unrelated listening ports and failed to reach the container's own service on127.0.0.1. Fixed by joiningnettoo, the same way-x/--execalready joinsmnt/uts/ipc/pid/cgroup/userwhen they differ from the caller's own — reverified afterward: execing into that same session now correctly shows the container's owneth0and reaches its own service on127.0.0.1, while execing into a plain session with no-nat all is unaffected (still just loopback, whether or not the kernel happened to give it its own otherwise-empty net namespace via the defaultglobal.unshare-netpolicy).Real bug reported from the real target device (
-n <extern network> -- /bin/sh, tap+relay fallback): the container-side tap device wasn't always immediately visible. The user's own log showednsenter --net=/proc/<ns_pid> /ns/net -- ip addr add 10.168.0.2/24 dev eth0failing with"Cannot find device \"eth0\""right afternetwork_tap_relay.h's relay had already created it — and confirmed by hand that simply retrying the whole session a few times eventually worked. A first fix added a bounded (~500ms) retry around the steps that touch the just-createdcontainer_if— later found insufficient (see below) and removed again;join_one_network()now uses a plain, single-attemptrun()for every step, same as before any of this.A tempting "fix" investigated and ruled out by direct A/B testing on this dev machine, not just reasoned about: the obvious first instinct — have the relay itself self-verify the device is visible (a same-process check via its own
run_process()call, immediately afteropen_tap(), before ever reporting success) — was tried first, innetwork_tap_relay.cpp'srelay_child_main(). It made things worse, not better: it made the container-side device permanently invisible to every externalnsenterafterward, 100% reproducibly (confirmed with a 10-second retry budget — never once became visible), on a mechanism that had otherwise worked correctly and instantly on every single real session tested earlier this same day, with no retries ever needed. Root cause not fully understood (something about forking a subprocess that inherits the tap fd — opened viaopen("/dev/net/tun", O_RDWR), deliberately notO_CLOEXEC— while still holding it open, immediately after device creation, appears to corrupt the device's external visibility specifically on this kernel; the same process's own view of the device it just created stayed correct throughout). The lesson that survived into the final fix: never add an internal, same-process/fd-holding self-check to the relay.The retry fix above turned out to be insufficient: a further round of real-device testing showed a different failure —
ip addr addagainst the container-side device would sometimes succeed, only for the very next command against that same device (ip link set eth0 up) to fail with "Cannot find device", exhausting every retry. The device wasn't merely slow to become visible after creation; it was disappearing on its own, consistent with the underlyingioctl(TUNSETIFF)-created device (noIFF_PERSIST) having a more fragile lifetime on that kernel than "stays alive as long as the one fd that created it stays open." Per the user's own suggested direction, the fix was structural, not another retry: both tap devices are now created ahead of time via an externalip tuntap add dev <name> mode tap(create_persistent_tap(),network_tap_relay.cpp— see that file's own entry below for the full detail), which sidesteps the whole class of symptom by making the device a genuinely persistent netdevice with no tie to any fd or process. All retry logic (run()is used unconditionally, everywhere) was removed as part of this — the earlier retry was compensating for a problem this fix removes outright, not one it makes more likely to need retrying. -
port_forward.{h,cpp}— implements-p.parse_port_forward_spec()splits"[<network>:]<host-port>:<container-port>[/tcp|udp]"on':'(2 or 3 fields; the network name is deliberately restricted to excluding':'--is_valid_network_name(),network_subnet.h-- specifically so this split stays unambiguous) and validates both ports are1..65535(.cpp-localparse_port()) -- pure syntax/range parsing, no knowledge of which networks exist or joined; that'sadd_port_forward()'s job, called later oncejoin_networks()(network_join.h) has actually run. The optional/tcp//udpsuffix (PortForwardProtocol,port_forward.h-- plainenum class, no prefix, same convention asNetworkKind) is stripped off the trailing container-port field beforeparse_port()ever sees it (container_port_stris an owned copy for exactly this, unlikehost_port_str, which stays an alias since it never carries a suffix) -- an unrecognized value is a hard parse error (exact lowercase match only,"tcp"/"udp", same case-sensitivity precedent asapply_log_level()'svalid_levelscheck, notconfig_file.cpp's case-insensitive YAML parsing, a different context); omitted defaults totcp, so every pre-existing-pspec keeps working unchanged. BothPortForwardSpecandActivePortForwardcarry the resolvedprotocolfield, and a.cpp-localiptables_proto()converts it to the exact stringiptables's own-pflag expects.add_port_forward()resolvesspec.networkagainst theJoinedNetworklist -- by name if given (erroring if that network wasn't successfully joined, or isn'textern: aninternnetwork's bridge has no path from the host at all, so forwarding into one could never work), or, if unset, the container's sole joinedexternnetwork (erroring if none or more than one, rather than guessing). Then adds one iptablesDNATrule, matchingspec.protocol(-p tcpor-p udp), to bothnat PREROUTINGandnat OUTPUT-- a real bug caught by testing, not assumed:PREROUTING-only leftcurl <this host's own real IP>:<host-port>, run on this same host, connection-refused, sincePREROUTINGonly ever sees packets arriving from an actual network interface, never locally-generated ones (those go throughOUTPUTinstead) -- the same split Docker's own DNAT setup already accounts for. Also adds oneFORWARD ACCEPTrule for the destination, matching the same protocol (in case of a defaultFORWARD DROPpolicy, which would otherwise silently eat the forwarded traffic even though theDNATitself succeeded); if a later rule fails after an earlier one already landed, those are removed again so a failure doesn't leave a half-applied mapping. Known limitation, not solved here, also found by testing:curl localhost:<host-port>(or any127.0.0.0/8destination) specifically still doesn't work even with bothDNATchains covered -- confirmed to be a separate problem, NAT hairpinning, and equally true for UDP (the martian- source check is at the IP layer, not the TCP state machine): onceDNATrewrites the destination to the container's IP, the packet still carries its original source address (127.0.0.1); the container's own kernel sees an inbound packet claiming to be from loopback arriving on a non-loopback interface (eth<N>) and drops it as a martian source. (Anet.ipv4.conf.{all,lo}.route_localnet=1sysctl was tried and confirmed not to fix this on its own, then removed again rather than left in as dead/superstitious code.) A full fix needs source masquerading scoped to exactly this case (matching only host-local traffic, not genuine external clients -- unconditionally masquerading would lose the real client IP for those, a regression) or a userland proxy, the approach Docker itself historically used for the same reason -- out of scope here;curl <this host's real, externally-reachable IP>: <host-port>(verified working) is the actually-relevant path-pexists for.remove_port_forward()(commands.cpp'srun_container(), called for eachActivePortForwardcollected duringon_bwrap_pid_known, afterrun_bwrap()returns) removes the exact same rulesadd_port_forward()added -- best-effort, logs a warning on failure, never fatal. Verified end-to-end on this dev machine (root, via a scopeddoasrule): a container serving HTTP on anexternnetwork with-p 8080:80was reachable viacurl <host's real IP>:8080from the host; the rule was confirmed gone (connection refused) after the session was killed.UDP support added.
-p <host-port>:<container-port>/udpproduces the exact same three-rule shape (2 DNAT + 1 FORWARD ACCEPT) with-p udpinstead of-p tcpthroughout -- no other argv-shape change needed, sinceiptables's own-p/--dport/-j DNAT --to-destinationand-d ... --dport ... -j ACCEPTforms are identical between the two protocol modules. No dedup/coexistence tracking exists inadd_port_forward()(it unconditionally issuesiptables -Aon every call), so the same port pair can be forwarded once per protocol without collision -- e.g.-p 53:53/tcp -p 53:53/udpfor a DNS-like service produces two textually distinct rule sets that add/remove independently. Verified end-to-end on this dev machine (root, via the scopeddoasrule): on a freshly createdexternnetwork,-p 18080:80/tcp -p 18081:80/udpagainst one busybox container running bothhttpd(TCP) andnc -u -l -p 80 > /tmp/udp-received.txt(UDP) simultaneously --curl <host's real IP> :18080got the expected TCP response, and a raw UDP datagram sent to<host's real IP>:18081(via a small Pythonsocket.SOCK_DGRAMscript, noncavailable on this dev host) was confirmed to have actually reached the container by-x/--execing in andcating the received-data file afterward. Session teardown removed exactly the four rules that were added (confirmed via the debug log's matching-Dlines for both protocols). An invalid protocol suffix (-p 8080:80/xyz) errored cleanly with the new parse-time message, before touching iptables or even attempting the mount/run, and cleaned up the layer import it had already done.--clean-processesagainst hand-planted stale port-forward records (a rootless test, same shape as the original crash-orphan sweep test below -- root isn't needed for the sweep logic, only the underlyingiptables -Dcalls) correctly swept both an old-format 3-field record (defaulting totcp, per the parsing note above) and a new-format 4-fieldudprecord, each attempting the right-p tcp/-p udpremoval and reportingremoved stale port-forward rules for '<name>', while leaving a third record matching a still-running session completely untouched. Still to verify: real-device confirmation that UDP DNAT behaves the same way through Android's iptables/tetherctrl_FORWARDchain as TCP already does (assumed protocol-agnostic by the rule mechanics, not yet proven there) -- seeTODO.md/this file's own status tracking for whether that's landed yet.Crash-orphan sweep (commit 6 of
docs/networking-design.md's sequence): unlikejoin_networks()'s veths (torn down automatically by the kernel once the session's namespace goes away) or the bridges/ persistent namespaces themselves (deliberately meant to outlive any one session —network_bridge.h's reboot-reconciliation design), a-pmapping's iptables rules are host-global state with no automatic teardown at all — ifslocker-liteitself is killed/crashes before reaching its ownremove_port_forward()calls, those rules simply outlive the session forever otherwise (bwrapitself dies immediately in that case too, via--die-with-parent, so the container never becomes a stray process needing separate handling — only these rules can).port_forward_state_path()resolvesxdg_state_dir() / "port-forwards" / "<container_name>-<pid>"— deliberately the exact same naming scheme assession_pid_file_path()(pid_file.h), soclean_stale_port_forwards()can cross-reference this directory's filenames directly againstlist_sessions()'s ownSessionInfo::pathto reuse its liveness check, rather than re-deriving pid liveness a second, drifting way.record_port_forwards()writes one line per mapping ("<host_port> <container_ip> <container_port> <proto>",<proto>the same"tcp"/"udp"stringiptables_proto()produces) to that path — a no-op if there's nothing to record.clean_stale_port_forwards()(commands.cpp'sclean_processes_command(), alongsideclean_stale_sessions()) scans that directory: a record whose filename doesn't match any currently-running session is stale — every line is parsed back into anActivePortForwardand removed (remove_port_forward()) before the record file itself is deleted; a record whose session is still running is left completely untouched. Line parsing is deliberately per-line (std::getline+std::istringstream), not one bigwhile (in >> a >> b >> c >> proto): chaining a 4th>>directly would fail-and-short-circuit the wholewhilecondition on an older, pre-UDP-support 3-field line before the loop body (the actual removal) ever ran for it, silently leaking that rule forever with no error logged. The per-line parse instead reads the first three fields, skips the line entirely only if those fail, and otherwise defaults an absent or unrecognized 4th token totcp— same forward-compatible posture asconfig_file.cpp's "unknown keys ignored" policy. Verified via a controlled scratch test (root wasn't needed for the logic itself — only the underlyingiptables -Dcalls, already proven working as root above; killing a root-ownedslocker-liteprocess directly, bypassing--kill's own graceful cgroup-based teardown, wasn't achievable through the scopeddoasrule this session has, which only permits runningslocker-liteitself): a real running session's own pid file was used to construct a matching port-forward record (left untouched by the sweep, confirmed still present afterward) alongside a fabricated record for a nonexistent pid (correctly identified as stale, its rule-removal attempted — visibly failing only for lack of root in this particular rootless test — and its record file actually removed, reported asremoved stale port-forward rules for 'faketest-999999'). -
network_tap_relay.{h,cpp}— a tap-backed substitute for one veth pair, used whenshould_use_veth()(network_bridge.h) is false: either the running kernel doesn't support veth at all (the real target device's kernel supportstun/tapbut lacksCONFIG_VETH, commonly stripped from mobile kernels — seedocs/networking-design.md's tap+relay addendum), or the network was created with--no-veth(cli_args.{h,cpp}) specifically to exercise this path on a veth-capable machine. Why tap can't just replace veth 1:1: a veth pair is two real kernel netdevices, switched between (or into a bridge) entirely by the kernel; a tap device only has one kernel-side netdevice — the other "end" is a raw-Ethernet- frame file descriptor only a userspace process can read/write, so there's no second kernel endpoint to attach to a bridge (the same reasonslirp4netns/QEMU's own tap networking need a userspace process on the fd side).create_tap_relay(network, bridge, host_tap_name, container_ns_pid, container_if_name)reproduces a veth pair's role with two tap devices and one relay process that copies bytes between them, reusing the existing bridge as the switching fabric sonetwork_bridge.cpp'sprovision_bridge()/NAT setup needs no changes at all: a host-side tap device (created wherevernetwork's bridge lives —network_bridge.h'swrap_for_network()namespace, reached here via a directsetns()rather than that function'snsenter-argv-wrapping, since this whole sequence must keep running, and later hold onto live fds, across each namespace switch, not just for the duration of one external command) gets enslaved tobridgeexactly like veth's host-side end does innetwork_join.cpp'sjoin_one_network(); a container-side tap device gets created directly inside the namespace named bycontainer_ns_pid, namedcontainer_if_name(e.g.eth0) from the start — no peer-name-then-rename dance needed, unlike veth.host_tap_nameis caller-provided (not derived here) specifically so a future caller (join_one_network(), once this is wired in) can reuse its own existing fnv1a-based veth-naming scheme rather than this file growing a second, drifting copy of that six-line hash. Each device is now created two-step:create_persistent_tap(name)(.cpp-local) first runs an externalip tuntap add dev <name> mode tap, thenopen_tap()(.cpp-local, mostly unchanged)open("/dev/net/tun")+ioctl(TUNSETIFF, IFF_TAP | IFF_NO_PI)s onto that already-existing device (IFF_NO_PIso both ends agree on raw-frame framing with no extra header) —open_tap()now only attaches an fd to a device, it no longer creates one. This split replaced an earlier, simpler design whereopen_tap()alone both created (via the sameioctl, with noIFF_PERSIST) and attached, on the assumption the device would then simply disappear on its own once its one-and-only fd closed, the same "no explicit teardown" property veth already has — see this entry's own "tap devices need to be created persistently" paragraph further down for why that assumption turned out to be wrong on the real target device, anddocs/networking-design.md's matching section for the full incident writeup. The relay child's entire setup sequence — enter the network's own namespace first, always now (persistent_netns_path(),persistent_netns.h— used to beinternonly; seenetwork_bridge.{h,cpp}'s own "Resolved:externhad no connectivity" entry above for whyexternneeds this too now), create+ attach the host-side tap,setns()into the container's namespace (the already-open host-side fd stays valid across this switch — fds aren't namespace-scoped, only their creation is, the same propertywrap_for_root_namespace(),bwrap.cpp, already relies on), create the container-side tap — reports success/failure back tocreate_tap_relay()over apipe2(O_CLOEXEC)(same handshake shapedaemonize(),daemonize.cpp, already uses), then falls into an unboundedpoll()/read()/write()loop copying raw frames bidirectionally between the two fds — this loop is the actual "veth wire," just implemented once in userspace instead of by the kernel. NoSIGTERMhandler is installed in the relay: default disposition (terminate) already closes both fds on the way out.stop_tap_relay()sendsSIGTERM, reaps the process, then explicitlyip link dels the host-side device (wrap_for_network(handle.network, ...), reaching wherever it lives — the network's own persistent namespace, both kinds — henceTapRelayHandlecarrying its ownNetworkEntry) — now required since the device is persistent and no longer disappears just because the relay's fd closed (see below). The container-side device needs no matching step: it lives inside the container's own network namespace, which the kernel already tears down (every interface inside it, persistent or not, along with it) once the session itself ends — except the uplink's own second tap (network_bridge.cpp'sensure_uplink_provisioned()), which lives directly in the host's root namespace instead and never goes away on its own;TapRelayHandlegainedroot_side_tap_namefor exactly this case, andstop_tap_relay()removes it too (unwrapped, always host root by construction) when set. Likewisecreate_tap_relay()gained anattach_host_side_to_bridgeparameter (defaulttrue, no change for either existing call site) —falseskips themaster <bridge>step entirely and just brings the host-side tap up plain, used by the uplink since it's deliberately a point-to-point routed link, not another bridge port. Oncecreate_tap_relay()returns successfully,container_if_nameis a completely ordinary interface from the container's own point of view —join_one_network()'s existing IP assignment/route/DNAT-target-address code (unchanged, not yet wired to call this) needs no changes at all. Seeself_test.{h,cpp}above for how this file's own create/attach/teardown cycle was verified end-to-end in isolation first, before being wired intojoin_one_network().Real fd-leak bug caught by direct testing, not assumed: the relay child, unlike every other forked child elsewhere in this project, never
exec()s — soO_CLOEXECon fds created before this fork (e.g.daemonize.cpp's own report-pipe write end, still open in the forking process at this point sincereport_daemon_started()— which closes it — hasn't run yet whenjoin_networks()is called) never takes effect, since it only closes fds acrossexec(), not across a fork that never execs. Without a fix, the relay child inherited a live copy of that pipe's write end and never closed it, sodaemonize()'s read-until-EOF in the original, pre-fork process blocked forever, even afterreport_daemon_started()closed its own copy — a pipe only reports EOF once every copy of its write end, across every process, is closed. Confirmed directly:-r -D -n <no-veth network> -- sleep 600hung indefinitely; killing the session (which reachesstop_tap_relay()viarun_container()'s own post-run_bwrap()cleanup, closing the leaked copy) immediately unblocked the original process. Fixed byclose_inherited_fds()(.cpp-local): scans/proc/self/fdand closes everything except stdin/stdout/stderr and the report pipe's own write end, called as the very first thing inrelay_child_main(). A first-attempt companion fix — adding the relay's pid to the session's own cgroup (session_cgroup.h) so--killwould reach it directly, since a relay is a sibling of bwrap rather than a descendant and so would never inherit cgroup membership on its own — was tried and then reverted:remove_session_cgroup()runs insiderun_bwrap(), beforerun_container()ever gets to callstop_tap_relay(), so the cgroup was still non-empty (the relay still in it) at removal time, and every session using this fallback left a stray, never-removed cgroup directory behind (rmdirfailing withEBUSY, confirmed by testing). Since the ordinary flow already stops the relay correctly on its own (killing bwrap unblocksrun_bwrap()'s ownwaitpid(), lettingrun_container()finish its normal cleanup,stop_tap_relay()included) and the only gap left by not doing this is a benign, self-resolving race in--kill's own "has it fully stopped" check (a genuine crash of the whole session process, not just bwrap, is a separate, already-scoped concern — see the crash-orphan sweep below), the added complexity wasn't worth it.Real bug reported from the real target device: tap devices need to be created persistently, not tied to the relay's own fd lifetime. Two rounds of real-device testing (
network_join.cpp's own entry above has the full incident writeup) found the container-side device intermittently either not immediately visible after creation, or — worse, found on the second round — visible and usable for one command (e.g.ip addr addsucceeding) and then gone for the very next one (ip link set ... upfailing with "Cannot find device"), on a kernel where the bareioctl(TUNSETIFF)-created (noIFF_PERSIST) device evidently has a more fragile lifetime than "stays alive as long as its one creating fd stays open." Per the user's own suggested direction, the fix (seecreate_persistent_tap()above) creates both tap devices ahead of time via an externalip tuntap add dev <name> mode tap— the same technique QEMU/libvirt use to let an unprivileged process attach to a tap device set up ahead of time — turning each into a genuinely persistent netdevice with no tie to any fd or process at all, the same as a veth pair already is. All retry logic from the first round's fix (network_join.cpp'srun_with_retry(),self_test.cpp'swait_for_container_device_visible()) was removed once this structural fix made it unnecessary — see both files' own entries.Verified end-to-end on this dev machine (root, via the scoped
doasrule), using a--no-vethexternnetwork specifically to exercise this path: two real containers joined the same network, each getting a distinct address (10.168.0.2/10.168.0.3) via the tap+relay path with no veth involved at all, and pinged each other successfully (0% packet loss, confirmed repeatably). Gateway/outside reachability — originally reported as an unconfirmed gap here, since resolved: neither container could initially reach the network's own gateway IP, despite ARP resolving correctly (ruling out an L2/relay-framing problem) and the identical bridge/subnet working perfectly via veth instead (ruling out every environment-level explanation — host firewall,rp_filter, tried at several scopes and confirmed not to fix it — since those would affect both paths identically). Actual cause, found once retested on a clean host: accumulated leftover bridges/iptables rules from many earlier rounds of manual testing —--delete-network(before--delete-network-fullexisted, see that flag's own entry below) never tore down live host state, so stale rules/bridges from unrelated earlier test networks were still present and interfering. After manually clearing all of it and retesting fresh: a--no-veth externnetwork's gateway and a real external host both answered ICMP with 0% loss, and a raw TCP connect (nc) to an external host completed cleanly (a separatewgetsegfault against the same host was confirmed to be an unrelated busybox bug, reproducing identically regardless of join mechanism). Peer-to-peer connectivity and gateway/outside reachability are both now confirmed working through the tap+relay fallback on this dev machine —--delete-network-fullexists specifically so this class of stale-state-masking-as-a-bug can't recur.Re-verified end-to-end on this dev machine after the persistent-device redesign above, again with
--no-vethforcing the fallback: a single container repeatedly used its tap-relay-backedeth0across several commands in a row (ip link show,ip addr show, two rounds ofping) with no disappearance between commands — the exact symptom the real device hit — and both gateway ping and outside/internet ping (8.8.8.8) succeeded at 0% loss. Session cleanup left no leftover host-side tap device behind (only the bridge itself, deliberately left standing per this project's reboot-reconciliation design);-t/--test's owntap-relay create/attach/teardowncase (updated perself_test.{h,cpp}'s own entry above) passes reliably across repeated runs.Real bug reported from the real target device: joining 2+ networks in one
-r/--runleft every network after the first permanently unreachable, regardless of extern/intern. Root-caused viastrace -fon the real device (the user's own suggestion, after several inconclusive timing-based experiments): the second network's own relay died on its literal first frame —write(fd_container, ..., 86) = -1 EIO, immediately followed byexit_group(0).EIOwriting to a tap fd means the device isn't administratively up yet, and it genuinely wasn't:create_tap_relay()returns, and this relay starts polling, the instant the container-side tap device is created;join_one_network()(network_join.cpp), a different process, still has its ownip addr add/ip link set <if> upsteps left to run afterward for that same device — confirmed via the trace's own timestamps,ip addr add ... dev eth1ran after the relay's fatal write. For the first network joined this race is narrow enough that no frame ever arrives first; for the second (and any later) network, something reliably delivers a frame before the interface is up, and the previous code treated anywrite()failure as fatal — exiting for good on that singleEIO, so the network never worked again for the rest of the session. Fixed in the relay's frame-forwarding loop by retrying specifically onEIO/ENETDOWN(both mean "not up yet", a startup race, not a torn-down namespace) with a short bounded backoff (up to 50 × 20ms = 1s) instead of exiting immediately — generous compared to the ~14ms gap actually observed in the trace. Methodology note: an earlierstrace -fattempt, wrapped intimeout 30, produced a misleadingly corrupted trace — GNUtimeoutsends its kill signal to the whole process group by default, andstrace's own tracing overhead was large enough (mount steps that normally take seconds took 3+ minutes under trace) that the real wall-clock timeout elapsed mid-setup, killing several traced children prematurely; dropping thetimeoutwrapper entirely produced a clean, complete trace. Verified end-to-end on the real target device with both 2 and 3internnetworks joined simultaneously in one session, all gateways reachable at 0% packet loss, clean teardown, no leftover state.Crash-orphan sweep, the direct tap+relay analog of
port_forward.h's own (see its own entry below): unlike a veth pair or a session's own bridge/persistent-namespace state, a relay process is host-global state with no automatic teardown at all ifslocker-liteitself is killed/crashes before reaching its ownstop_tap_relay()calls (bwrapstill dies immediately in that case via--die-with-parent, so only the relay — never the sandboxed container itself — can actually leak).tap_relay_state_path()resolvesxdg_state_dir() / "tap-relays" / "<container_name>-<pid>"— the same naming schemesession_pid_file_path()/port_forward_state_path()already use, soclean_stale_tap_relays()can cross-reference filenames directly againstlist_sessions()'s ownSessionInfo::path.record_tap_relays()writes one line per relay ("<relay_pid> <host_tap_name> <kind> <network_name>",<kind>="extern"/"intern",<network_name>last since it's the one field that can contain whitespace) to that path — a no-op if there's nothing to record; the<kind>/<network_name>fields were added alongside the persistent-tap-device redesign above, so a later sweep can reconstruct aNetworkEntryand reach the right namespace to remove the now-persistent host-side device too, not just kill the relay process.clean_stale_tap_relays()(commands.cpp'sclean_processes_command(), alongsideclean_stale_sessions()/clean_stale_port_forwards()) scans that directory: a record whose filename doesn't match any currently-running session is stale — every relay pid listed isSIGKILLed (best-effort; an already-dead pid, or one this process was never the parent of, isn't treated as an error, since this sweep runs from a separate later invocation that can'twaitpid()an orphan it didn't fork — its true parent's own exit, orinitafter reparenting, reaps it), the host-side tap device it named is removed (ip link del, viawrap_for_network()against aNetworkEntryreconstructed from the record's own<kind>/<network_name>fields — best-effort, same asstop_tap_relay()'s own removal) before the record file itself is deleted; a record whose session is still running is left completely untouched. Verified via a controlled scratch test, the same shapeport_forward.h's own sweep test used: root wasn't needed for the sweep logic itself (only real tap/bridge creation needs it), so this ran as a plain rootless daemonized session (-D, no-n) to get a real, live pid + container name, alongside two hand-written record files in that same rootless$XDG_STATE_HOME— one matching the live session (confirmed left untouched by--clean-processes) and one fabricated for a nonexistent pid (confirmed identified as stale, itskill()attempt failing harmlessly withESRCH, and its record file actually removed, reported asremoved stale tap-relay processes for 'faketest-999999'); killing the real session and re-running--clean-processesthen correctly swept its own now-stale record too. -
network_dns.{h,cpp}— per-session DNS resolution viadnsmasq: a container joined to a network can resolve any other container's--hostnameon any network the two share, plushost.containers.internal(podman's own convention) resolving to the firstexternnetwork's own gateway, if any.is_dnsmasq_available()(find_in_path("dnsmasq")) gates everything here — DNS resolution is best-effort, not a hard dependency the wayip/nsenterare; missingdnsmasqdegrades with a warning rather than failing-r/--run. Onednsmasqinstance per session, deliberately not per network: an earlier per-network design (mirroring how the uplink provisions one persistent process per network) was rejected before being built, once a real correctness gap was worked out — a container joined to two networks would list twonameserverlines in/etc/resolv.conf, and standard stub resolvers (glibc/musl/busybox) don't fall through to the next nameserver on NXDOMAIN, only on timeout, so a name that exists only on the second network would silently fail to resolve. Running one instance per session instead — entered into the container's own network namespace (resolve_namespace_pid(),sandbox_process.h) and bound to127.0.0.1:53inside it, so/etc/resolv.confis always justnameserver 127.0.0.1regardless of which/how many networks were joined — sidesteps the whole problem: there's only ever one nameserver to ask, and it already knows about every network that specific container joined via dnsmasq's own repeatable--hostsdir=<dir>(inotify-based, no reload signal needed).dns_hosts_dir(network_name)is$XDG_STATE_HOME/slocker-lite/dns-hosts/ <sanitized-network-name>— one shared directory per network;record_dns_host()/remove_dns_host_record()write/remove one<sanitized-container-name>-<pid>file per (network, session) into it (a plain/etc/hosts-syntax line,<ip> <hostname>, only written when--hostnamewas actually given), so sibling sessions on the same network can discover each other — and, since a session's own record lands in the same directory its own resolver watches, self-resolution works for free, no special-casing needed.start_dns_resolver()builds thensenter --net=/proc/<ns_pid>/ns/net -- dnsmasq ...argv (one--hostsdirper joined network) and forks/execs it directly (noprocess.hhelper fits:run_process()/run_process_foreground()both block until exit, wrong for a long-running daemon, andclose_inherited_fds()-style fd hygiene isn't needed here the waynetwork_tap_relay.cpp's relay needs it, since this process doesexec(), soO_CLOEXECjust works normally). Three real bugs found via direct testing while building this, not assumed, each confirmed by isolating the change and re-testing:- dnsmasq only writes
--pid-filewhile actually daemonizing —-d/--no-daemon(tried first, for a simpler "the forked pid is the real pid" model) suppresses it entirely, confirmed directly: dnsmasq started and successfully read the hosts file (visible in its own log output) but the pid-file this function polls for (same bounded-poll shape asnetwork_join.cpp'swait_for_isolated_net_namespace()) never appeared. Fixed by letting dnsmasq daemonize normally — the forked/exec'd process is then only the intermediate one (reaped immediately, not tracked), and the real, final daemon pid is read back from the pid-file itself once it appears. - dnsmasq drops root privileges to an unprivileged user by default, which
then couldn't read anything under
$XDG_STATE_HOMEat all (typically/root/.local/state/slocker-lite/..., mode0700— a non-root user can't even traverse into/root) — every query came backREFUSED, traced tobad dynamic directory ...: Permission deniedin dnsmasq's own log. Fixed with an explicit--user=root --group=root, matching this project's existing root-only networking model (bwrap.cppnever requests--unshare-userwhen already root, for the same "no privilege drop needed/wanted here" reasoning) — tracked as a security follow-up inTODO.md(run it as a low-privilege user instead, with the relevant state relocated somewhere that user can reach). - an AAAA query for a name with only an A record (every record here is
IPv4-only, matching
JoinedNetwork::container_ip's own existing scope) came backREFUSEDrather than a clean "no data" answer — confirmed to breakping <name>even though the exact same name's A record had just resolved correctly vianslookupmoments earlier, sinceping(like mostgetaddrinfo()-based tools) queries both A and AAAA together and treatsREFUSEDon either as a hard failure for the whole lookup, not merely "no IPv6 available." Fixed with--filter-AAAA(turns an AAAA answer into a clean empty one instead).host.containers.internalneeded a second, related fix: serving it via dnsmasq's own--address=/name/ipoption kept returningREFUSEDfor AAAA even with--filter-AAAAgiven — confirmed--addressrecords aren't treated the same internally as ordinary hosts-file entries — so it's instead served via a small, session-private--addn-hosts=<file>(plain hosts-file syntax, generated once per session, removed bystop_dns_resolver()alongside the process itself) exactly like every other record here.stop_dns_resolver()(SIGTERM+waitpid(), plus removing thehost.containers.internalfile if one was generated) andremove_dns_resolver_record()(removes the pid-file — the same file dnsmasq itself wrote, so no separate "record" write step was ever needed, just a removal one) are called as a pair fromrun_container()(commands.cpp) afterrun_bwrap()returns, the same two-calls shapestop_tap_relay()/remove_tap_relay_record()already use — a real gap caught by testing this exact pairing, not assumed: an earlier version only calledstop_dns_resolver(), and since dnsmasq's own pid-file was never separately removed,--clean-processes(harmlessly, but not cleanly) picked up every finished session's own leftover record on its next run instead of finding nothing to sweep.clean_stale_dns_resolvers()covers three independent crash-orphan sweeps — the resolver process itself (dns-resolvers/<container>-<pid>,SIGKILL, samelist_sessions()-cross-reference staleness check every other sweep in this project uses), each network's own per-session hosts record (dns-hosts/<network>/<container>-<pid>, just deleted — dnsmasq's own inotify watch, wherever some other still-running session's resolver is watching that directory, notices the removal on its own), and thehost.containers.internalfile (dns-internal-hosts/<container>-<pid>, likewise just deleted) — called fromclean_processes_command()(commands.cpp) alongside the three existing sweeps.--no-dns(cli_args.{h,cpp}, long-option only, plain boolean flag, no value) opts out even whendnsmasqis available.build_bwrap_args()/run_bwrap()(bwrap.{h,cpp}) gained ainject_dns_resolv_confbool, resolved once inrun_container()from!network_specs.empty() && is_dnsmasq_available() && !no_dns_flag(keeping the policy decision out ofbwrap.cpp, the same patternNamespaceConfigalready uses) — when true, a single static, idempotently-generated file (ensure_generated_resolv_conf(), content always justnameserver 127.0.0.1\n, since every session's resolver binds there regardless of which networks it joined) is--ro-bind-mounted over/etc/resolv.conf, whichbuild_bwrap_args()otherwise never touches at all (confirmed: whatever's baked into the OCI image is what's live in the sandbox by default, no prior bind-mount or generation existed here). No special-casing needed for-x/--exec(already joins the same net namespace as the original session when one was requested, so it shares the same loopback and thus the same running resolver for free) or-D/--daemonize(spawned/stopped by the exact same callback/cleanup path as any other run).-t/--testexercises the full create/answer/teardown cycle in isolation (root-only skip, same as the persistent-netns/tap-relay tests): a throwaway network namespace stands in for a real session's own (same techniquetest_tap_relay()already uses), a hand-rolled minimal DNS query (build_dns_a_query()/query_dns_a_record(),self_test.cpp— real UDP wire format, not just "the process started") confirms the resolver actually answers a hand-written hosts record correctly. Verified end-to-end both on this dev machine and on the real Android target device: two containers on a shared network resolve each other (including self-resolution) and canpingby name; a container joined to both aninternand anexternnetwork resolves both itsinternpeer andhost.containers.internalsimultaneously — the specific scenario the per-network-instance design would have broken.
- dnsmasq only writes
-
session_cgroup.{h,cpp}— gives--kill(kill_session.{h,cpp}, see below) a reliable way to find every process a session ever started, however deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2 group from the moment it starts.cgroup_v2_available()checks for/sys/fs/cgroup/cgroup.controllers(the same signal systemd's own unified-hierarchy detection uses) — only cgroup v2 is supported; v1 (which splits per-controller into separate hierarchies with no unifiedcgroup.procsat the top) is deliberately out of scope, since this project's real target (Android) has used the unified v2 hierarchy by default since Android 12.session_cgroup_path()is deterministic —/sys/fs/cgroup/slocker-lite/<name>-<pid>/, reusingpid_file.h's ownsanitize_for_filename()— so no separate lookup state is needed anywhere.create_session_cgroup()is called fromrun_bwrap()'son_startcallback (bwrap.cpp, see below), the same spotcreate_session_lock()already fires from:create_directories()'s the leaf directory (also creating theslocker-lite/parent the first time — a plain grouping cgroup, no resource controllers are ever enabled on it viacgroup.subtree_control, so the "no internal processes" restriction that comes with actually delegating controllers never applies here) and writes the bwrap pid into itscgroup.procs. From that point on, every process bwrap (or anything it execs into) forks inherits this cgroup automatically, permanently — including anything that later daemonizes/double-forks and gets reparented, unlike pid-namespace child membership (only the processesclone()itself creates) or process-group membership (many daemonizing services explicitlysetpgid()/setsid()away from it on purpose). Best-effort, mirroringcreate_session_lock(): returnsnullopt(logging a warning, never fatal) if cgroup v2 isn't available, or the directory can't be created/written (no delegated subtree when running rootless, or an SELinux policy blocking cgroupfs writes even for a root-euid process, are both real, confirmed-by- testing causes on the two environments this project actually runs on).remove_session_cgroup()(called from the same post-run_process_foreground()spotrelease_session_lock()already is) only succeeds once the cgroup is empty.session_cgroup_pids()readscgroup.procs— this is the actual answer to "gather every process running inside the container": unlike anything derived from/procparent-pid chains or pid namespaces, cgroup membership reliably includes every process the session ever started.session_cgroup_supports_kill()/kill_session_cgroup()wrap thecgroup.killknob (Linux 5.14+): writing"1"to it atomicallySIGKILLs every process currently in the cgroup in one step.Resolved: a daemonized/escaped straggler could survive the session ending, regardless of how it ended. Reported by the user (Ctrl-C on a foreground session could leave processes running if they'd created a new session of their own —
forward_signal_to_foreground_child(),process.cpp, only ever forwards the signal to the single tracked bwrap pid) — and, on reflection, a normal exit (or-D/--daemonize) had the exact same gap, since nothing ever swept the session's own cgroup automatically in any of those cases; only an explicit, separate--killdid. Fixed not in the signal handler itself (genuinely awkward:kill_session()'s own cgroup-first strategy selection does blocking polling/waitpid()s, unsafe from a signal handler, and re-deriving the session there vialist_sessions()would seerun_bwrap()'s own still-openSessionLockfd as "still running", sinceflock()ownership is per open file description, not per process) but inrun_bwrap()(bwrap.cpp) itself:kill_via_cgroup()(previouslykill_session.cpp-local) was exported (kill_session.h) and is now called directly, right afterrun_process_foreground()returns and beforeremove_session_cgroup()runs, wheneversession_cgroup_pids()shows anything still left — unconditionally, regardless of whyrun_process_foreground()just returned (normal exit, or bwrap forwarding a caught SIGINT/SIGTERM). Since a dead process is removed from its own cgroup automatically, bwrap's own pid is already gone fromcgroup.procsby that point, so this sweep only ever finds genuine leftover processes, never bwrap itself.-D/--daemonizeneeded no special-casing at all: it re-enters this exact samerun_bwrap()call from within its own already-forked/setsid()'d child, so the sweep runs there too, for free — there's only ever the one call site. The grace period used here (straggler_grace_period_seconds,bwrap.cpp, a file-local constant) is deliberately much shorter thankill_session()'s own manual--killdefault (3s vs. 10s): this runs on everyrun_bwrap()return, so the overwhelmingly common zero-stragglers case must stay instant (it does —session_cgroup_pids()returning empty short-circuitskill_via_cgroup()'s ownpoll_until()immediately, no delay at all), while a genuine straggler still gets a brief chance to exit gracefully before being force-killed. Verified on this dev machine, root, via the scopeddoasrule: a new[integration][root]regression test (tests/integration/test_session_cleanup.cpp) confirmskill_via_cgroup()actually reaps a process that forks,setsid()s away, and outlives its own parent — deliberately exercised directly against a real cgroup with two plain forked processes rather than through the full mount/bwrap pipeline, since the actual escape shape under test (a kernel with no pid namespace support at all, so a daemonizing process reparents completely outside any namespace) isn't something a single-r/--runinvocation can force via the CLI —--unshare-pidis a config-file-onlyNamespaceConfigfield, not a flag. On a kernel that does support pid namespaces (this dev machine included), the default case already gets equivalent protection for free straight from the kernel — killing a pid namespace's own pid 1, whether via a normal exit or a forced kill, collapses the whole namespace regardless of this fix — so this sweep's real-world benefit is concentrated on kernels like the real target device's own, which has neither pid namespace nor (as of this writing) confirmed cgroup delegation; on-device re-verification of both is still needed (seeTODO.md). Known residual limitation, unchanged: a kernel with neither cgroup v2 nor pid namespace support still has no automatic way to reach a reparented straggler — the same fundamental gapkill_via_tracked_pid()(the weakest of--kill's own three strategies, below) already represents.Resolved: a genuine race could leave the sweep unable to reach the straggler at all, specifically without a pid namespace. Found by the user's own explicit follow-up request to test the "pid namespace off, cgroup on" combination as root (via
-c/--config-file's now-wired-in effect on-t, seetests/support/fixtures.h's own entry below) — the nohup-straggler regression test (test_rootless_run.cpp) failed consistently, even as root with cgroup v2 genuinely available and writable. Root-caused directly, not assumed: inspecting/sys/fs/cgroup/slocker-lite/<session>/cgroup.procswhile the session was still running showed only bwrap's own outer pid — its own child (the actual sandboxedshprocess, and thesleepit later backgrounded) was never a member at all. Cause:create_session_cgroup()used to be called fromrun_bwrap()'son_startcallback, which runs in the parent, concurrently with the just-fork()'d child execing into bwrap and bwrap then doing its own internalclone()of the sandboxed target — a genuine race between the parent's own directory-create-plus-write (several syscalls) and bwrap's own setup. Without--unshare-pid, bwrap has meaningfully less setup work to do (no new pid namespace to create), making it reliably fast enough to win that race and clone its target before the parent's own write intocgroup.procsever completed — leaving that target, and everything it later spawns, permanently outside the tracked cgroup. This apparently didn't manifest with--unshare-pidrequested (bwrap's extra setup work there was consistently slow enough for the parent to win instead) or on the real target device (confirmed working there earlier, likely for the same reason, or because cgroup delegation wasn't actually the mechanism catching it there) — but was never a guaranteed property either way, just a timing coincidence.Fix:
run_process_foreground()(process.{h,cpp}) gained a newbefore_execparameter — a callback invoked in the child, synchronously, immediately beforeexecvp()— alongside the existingon_start(which still runs in the parent, unchanged, for the session lock andon_bwrap_pid_known).run_bwrap()now creates the session cgroup from insidebefore_exec(create_session_cgroup(container_name, getpid())) instead of fromon_start— since the child cannot proceed toexecvp()(and thus cannot start any of bwrap's own internal forking) until this call has already returned, the race is closed structurally, not by timing luck. The parent's ownon_startcallback still reconstructs the (fully deterministic)SessionCgroup{session_cgroup_path(container_name, pid)}unconditionally, regardless of whether the child-side creation actually succeeded —session_cgroup_pids()/remove_session_cgroup()already tolerate a nonexistent directory gracefully either way, the same as they already did for any othercreate_session_cgroup()failure (e.g. no cgroup v2 delegation when rootless). Ordinary (non-async-signal-safe) work in a post-fork()/pre-exec()child is safe here since this project has no threads — the same reasoningdaemonize()'s own child branch already relies on for its own, more extensive pre-exec setup.Verified end-to-end on this dev machine, root, via the scoped
doasrule: reproduced the failure consistently (3/3 trials, each from a freshly-cleaned state) against a-c-supplied config withunshare-pid: falseand every other field at its default, using the livecgroup.procsinspection above to confirm the exact mechanism; after the fix, the identical reproduction passed consistently (3/3 isolated trials, plus repeated full-category runs). A second, unrelated bug surfaced and was fixed while verifying this: the regression test's ownany_process_cmdline_contains()did a substring search across a candidate process's entire cmdline blob, which could false-positive against an unrelated process that merely mentions the marker text somewhere in its own arguments — confirmed directly: a manual diagnosticpkill -f 'sleep 137'cleanup command run by hand during this same investigation was itself briefly matched as if it were the sleep process. Renamed tosleep_process_running()/sleep_process_gone_within()and tightened to requireargv[0]'s basename to be exactlysleepandargv[1]to exactly match the expected duration, eliminating that class of false positive. -
sandbox_process.{h,cpp}— process-tree/namespace-resolution utilities shared byexec_session.{h,cpp}andkill_session.{h,cpp}(see both below); pulled into their own file (rather than staying private toexec_session.cpp, whereresolve_namespace_pid()originally lived) once--killneeded the exact same "find the real sandboxed child" logic, to avoid a second, drifting copy.resolve_namespace_pid()is unchanged from its originalexec_session.cppform (see that entry for the full reasoning: bwrap's own outer/tracked pid never actually enters the pid/uts/ipc/cgroup namespaces it creates for its clone()'d child, only that child does). Two new utilities added alongside it forkill_session():pid_namespace_isolated(outer_pid, ns_pid)compares/proc/<outer_pid>/ns/pidand/proc/<ns_pid>/ns/pid's ownreadlink()targets directly — true only when bwrap'sclone()actually created a separate pid namespace for its child (--unshare-pidwas requested and the kernel supported it), the precondition for the kernel's own guarantee that killing a pid namespace's pid 1 forcibly tears down every remaining process in it. Generalized intonamespace_isolated(outer_pid, ns_pid, ns_type)(parametrized over which/proc/<pid>/ns/<ns_type>entry to compare) oncenetwork_join.{h,cpp}(see below) needed the exact same check for"net"instead of"pid"—pid_namespace_isolated()is now justnamespace_isolated(outer_pid, ns_pid, "pid"), kept as its own function sincekill_session.halready depends on that exact name/signature.collect_descendant_pids(root)generalizesresolve_namespace_pid()'s own/proc/<n>/statppid-scanning fallback to collect a whole transitive tree (root included) instead of just one child, sharing the actual stat-parsing loop between both via a privatebuild_ppid_map()(one/procpass, used by both the single-child lookup and the full-tree collection). Reliable specifically whenrootis a genuinely isolated pid namespace's own pid 1: anything that reparents within it (e.g. a daemonizing service) is guaranteed by the kernel to land back onrootitself, unlike on a kernel without pid namespace support, where it escapes to the host's real pid 1 instead (seekill_session.{h,cpp}below for exactly this scenario, confirmed on a real target device). -
daemonize.{h,cpp}— implements-D/--daemonize's fork/detach mechanics.daemonize(container_name)sets up apipe2(..., O_CLOEXEC)pair (so it never leaks intobwrap/the sandboxed command, same reasoning as the pid file's ownO_CLOEXEC) andfork()s. The child callssetsid()— deliberately here, not via re-adding bwrap's own--new-session(removed earlier, see thebuild_bwrap_args()comment):--new-sessiononly callssetsid()for the deeply-nested sandboxed command inside bwrap's own namespace setup, leaving the outerbwrap/nsenter/slocker-liteprocesses still attached to the original session and still receiving its signals (e.g. aSIGHUPwhen the controlling terminal closes) — not real daemonization. Callingsetsid()in our own forked child, before it execs intonsenter/bwrap, detaches the entire chain at once, sinceexec()never changes session membership — confirmed by direct testing (ps -o pid,sid,pgid,tty): the daemon child becomes its own session leader with no controlling tty, andbwrap(a later descendant) shares that same session, also with no tty. The child alsosigaction()sSIGHUPtoSIG_IGN(survives the laterexec()intonsenter/bwrap, unlike a real handler, whichexec()resets to default — confirmed by sendingSIGHUPdirectly to a running daemonizedbwrappid and it staying alive), then redirects stdin to/dev/nulland stdout/stderr to a log file atsession_log_file_path(container_name, getpid())(pid_file.h) — named after its own pid since the real session pid (bwrap's) isn't known yet. If the log directory/file can't be set up at all, that's a hard failure here (_exit(1)), not best-effort — silently losing the very output--daemonizewas asked to capture would defeat the point of the flag. The child reports"LOG <path>\n"over the pipe immediately (so the parent can show a useful location even on failure) and returnsnulloptto its caller (run_container(),commands.cpp), which then falls through into the rest of that function's existing body completely unchanged — the daemonized child is what runs the whole rest ofrun_container(), including the unmount/ cleanup that already existed afterrun_bwrap()returns; no separate watcher/reaper process exists. The parent blocks reading the pipe until EOF, returning the accumulated"LOG "/"PID "lines as aDaemonizeResult— the caller then prints it and exits immediately without running any session logic itself.report_daemon_started(container_name, pid)(called fromrun_bwrap()'s newon_bwrap_pid_knowncallback — seebwrap.{h,cpp}below — the instant the realbwrappid is known) renames the pid-named log file to<container_name>-<pid>.log, re-reports the updated"LOG "line (a real bug caught by testing: the parent's first"LOG "line names the pre-rename, daemon-pid-named path — without a second one, the parent would print a stale filename that doesn't match where the file actually ends up), then"PID <pid>\n"and closes its own end of the pipe — must happen here, explicitly, rather than waiting for the pipe to close naturally at the end of the (potentially very long) daemon's lifetime, or the parent would block for as long as the session runs instead of returning promptly. The pipe's write fd and the current log path are tracked as private file-scope state indaemonize.cpp(matchingprocess.cpp's owng_foreground_child_pidpattern for "there's only ever one of these per process" runtime state), sincereport_daemon_started()is called later, from a different function, not threaded explicitly through every call in between. -
exec_session.{h,cpp}— implements-x/--exec <pid>: joins an already-running-r/--runsession's namespaces viansenterand runs a command inside it in the foreground.exec_in_session()first confirmspidis a tracked, running session vialist_sessions()(pid_file.h) — same liveness check--list-processes/--clean-processesalready use, no new logic needed there. Key discovery, confirmed by direct testing, not assumed:pid(the onerun_process_foreground()captured and pid-file-tracked when-rlaunchedbwrap) is bwrap's own outer process — it sets up the mount and user namespaces itself, thenclone()s the actual sandboxed command into fresh pid/uts/ipc/cgroup namespaces, andclone()'s namespace-creation flags only ever affect the newly created child, never the caller. So the outer process itself never actually enters those namespaces — comparing/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}against this process's own showed them identical, while onlymnt/userdiffered.resolve_namespace_pid()(sandbox_process.{h,cpp}— moved out of this file once--killneeded the exact same logic, see that entry) finds that real inner process so this can join its namespaces instead. For each of{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid, net→--net, cgroup→--cgroup, user→--user}—netused to be excluded here (this project never isolated networking at all, back when this comment was first written), but now that a session started with-n/--network(network_join.h) genuinely does get an isolated net namespace, skipping it left-x/--execseeing the host's network stack instead of the container's — confirmed directly (execing into a network-isolated session showed the host's own unrelated listening ports and couldn't reach the container's own service on127.0.0.1), fixed by including it the same way as the other optional types: a session with no isolated net namespace at all (i.e.netidentical to ours) just has this entry skipped like any other, so nothing changes for a session that never joined a network.readlink()s both/proc/<ns_pid>/ns/<type>and/proc/self/ns/<type>and only passes nsenter's corresponding--type=/proc/<ns_pid>/ns/<type>flag when they differ — an identical-namespace re-entry attempt can fail outright (setns()'s ownEINVALrestriction on re-entering a namespace you're already in), so skipping is deliberate, not just an optimization.mntis the one type where a read failure (permission denied, or the process vanished) is treated as fatal, since without it "joining the container" is meaningless; every other type just degrades to a skip. Always appends--preserve-credentials: without it,nsenter --useralso tries tosetuid()/setgid()/setgroups()to the target's identity within the new user namespace, which fails outright (setgroups failed: Operation not permitted) against thesetgroups-denied unprivileged user namespace bwrap creates whenever-r/--runisn't root — confirmed by hitting this exact failure during manual testing before adding the flag. Runs the finalnsenter ... -- <command>via the existingrun_process_foreground()(process.h) — same inherited stdio and SIGINT/SIGTERM forwarding as every other foreground external command, no new process-running logic needed.exec_in_session()also takes optional--user/--group(mirroring-r/--run's own): given, they resolve against the session's own/etc/passwd//etc/group(fetched viacatrun through the samensenterjoin, since this process can't otherwise see into that namespace, then handed toresolve_user_and_group()—user_spec.h, see below); if unset, defaults to whatever uid/gid the session's own sandboxed command is already running as (read from/proc/<ns_pid>/status), rather than root/the caller — fixing a real bug (reported after this project's own-x/--execand priv-drop features had both shipped separately): without this,-x/--execalways ran as whatever the host invocation was, ignoring any--user/--groupthe session itself was started with. Either way, the resolved identity is applied by runningcommandthrough the session's already bind-mountedslocker-lite-priv-drophelper (priv_drop::path,bwrap.h) — reused as-is, not bind-mounted again (-x/--execcan't add bind mounts to an already-running session's namespace anyway). Skipped entirely when the resolved uid and gid are both 0: a session that was never given a resolvable user at-r/--runtime never got the helper bind-mounted at all, and dropping to 0:0 would be a no-op regardless; a missing helper for a genuinely non-root resolution instead surfaces asnsenter's own "No such file or directory" once it tries to execpriv_drop::path, diagnostic enough on its own. Second real bug, caught by direct testing on a rootless dev machine before this shipped: when the session's own-r/--runused--unshare-user(i.e. ran rootless — see the root-vs-rootless paragraph below), "root inside the container" is achieved purely through the kernel's own uid mapping for that namespace, not a real privilege drop — so/proc/<ns_pid>/status's uid/gid, read from outside that namespace, shows the host-mapped id (e.g.1000), not the container-relative one (0). Treating that as "needs a priv-drop to 1000" is wrong two ways: the helper is typically never bind-mounted for a session with no resolved--user, and even when it is,setuid()fails outright under the single-entry uid map an unprivileged user namespace gets (confirmed directly:failed to drop privileges to 0:0: Operation not permitted). Fixed by tracking whether theusernamespace type was actually one of the ones joined (it only is when it differs from this process's own, i.e. exactly when-r/--runused--unshare-user) and, when so, leaving the default identity unresolved (no priv-drop) for that case — joining that same user namespace with--preserve-credentials(already done regardless) already reproduces the container's own view correctly via that same kernel mapping, with nothing further needed. Verified end-to-end on this same rootless dev machine: a daemonized-r --runbusybox session with no declared user,--exec'd with no--user, now correctly showsuid=0(root)(previously would have attempted, and failed, a priv-drop to the host-mapped uid); an explicit--exec --user 0against the same session correctly resolves to0:0and skips the priv-drop step;--exec --user portageagainst it correctly resolves the name to its real250:250via the fetched/etc/passwdand then fails clearly (helper not bind-mounted, since the session itself had no declared user) rather than silently running as the wrong identity. -
kill_session.{h,cpp}— implements--kill <pid>, stopping a tracked, running-r/--runsession and everything it started.kill_session()validatespidthe same wayexec_in_session()does (vialist_sessions(),pid_file.h). Real bug reported by the user against their own actual target device, confirmed via a captured session log: a plainkill <tracked_bwrap_pid>doesn't kill everything a container started — their/initscriptphp-fpm --daemonizes (double-forks, detaches) thenexec caddy ...s (replaces itself); after killing the tracked pid, bothcaddyand thephp-fpmmaster+workers kept running as orphans. Root cause: on that device,bwrap's--unshare-pidisn't actually in effect at all —detect_bwrap_unshare_args()(bwrap.cpp) only requests--unshare-xxxflags the kernel actually supports, and that kernel doesn't support pid namespaces (independently confirmed elsewhere this session, seeexec_session.{h,cpp}'s ownCONFIG_CHECKPOINT_RESTOREbug above) — sophp-fpm --daemonizereparents to the host's own pid 1, completely disconnected from the sandboxed session; the classic "kill a pid namespace's pid 1, the kernel guarantees the whole namespace collapses" trick simply doesn't apply there. Per the user's own explicit request (they want to choose the mechanism per host capability, and may need an even more basic one later for some hypothetical older device),kill_session()picks between three independently-named strategies, selected dynamically per session (not a single cached host-wide capability flag, since e.g. cgroup creation can fail for session-specific reasons like permissions even on a host that generally supports cgroups) — each runs its own completeSIGTERM→ wait-up-to-grace_period_seconds(10s default, no CLI flag) → forced-SIGKILLescalation internally, with no cross-strategy fallback-after-failure chaining:kill_via_cgroup()— preferred whenever the session has a non-empty dedicated cgroup (session_cgroup_pids(),session_cgroup.h):SIGTERMto every pid currently in it, and, if forcing is needed, either the atomiccgroup.killknob or a fresh re-read-and-SIGKILLsweep (fresh, not the original snapshot, since a process could have forked a new child after the graceful sweep but before dying). The only mechanism that reliably reaches every process regardless of pid namespace support. Exported (moved out of this file's own anonymous namespace, declared inkill_session.h) sincerun_bwrap()(bwrap.cpp) reuses it directly for its own automatic post-exit straggler sweep — seesession_cgroup.h's own "Resolved" entry above for why that caller calls this directly rather than going throughkill_session(pid)itself. Critical correctness point, caught during design review before this shipped: the "is it stopped yet" poll must gate on the cgroup being empty, notlist_sessions()'s running flag — that flag only reflects the pid file's flock, released the moment the tracked outerbwrappid exits, and theSIGTERMsweep necessarily hitsbwrapitself too (it's a cgroup member) —bwrapdies and gets reaped in well under a second, long before slower descendants (caddyshutting down gracefully,php-fpmfinishing in-flight requests) actually exit. Gating on the pid file instead would make the poll resolve "done" almost immediately, the forced-kill step would never run, and the original bug would reproduce with unused machinery around it.kill_via_pid_namespace()— used when no cgroup exists for the session, butresolve_namespace_pid()/pid_namespace_isolated()(sandbox_process.h) confirm--unshare-pidwas genuinely in effect for it.SIGTERMscollect_descendant_pids(ns_pid)(reliable here specifically because reparenting within a genuinely isolated pid namespace always lands back on that namespace's own pid 1); if forcing is needed, a singleSIGKILLtons_pidalone is guaranteed complete by the kernel itself, independent of whatever the graceful sweep missed. "Stopped" is simplykill(ns_pid, 0)failing withESRCH. Verified end-to-end on this project's rootless dev machine (which does support pid namespaces, unlike the user's real target device): a daemonized busybox session runningsh -c 'sleep 300 & exec sleep 300'(mirroring the daemonize-then-exec shape of the original bug) was fully cleaned up by--kill, including the backgrounded child, with no leftover processes, mounts, or layers; a second run usingsh -c 'trap "" TERM; sleep 300'(ignoringSIGTERMentirely) confirmed the forced-SIGKILLescalation path too, taking the full 10s grace period before the pid namespace's own collapse-on-kill guarantee cleaned it up regardless.kill_via_tracked_pid()— fallback when neither of the above applies: signals the trackedbwrappid directly,SIGTERMthenSIGKILL, pollinglist_sessions()for "stopped" since that's the only signal available without a cgroup or an isolated pid namespace to check directly. Exactly today's manual-killbehavior — least complete, but always available, and strictly no worse than before this feature existed. This is the path the user's own real target device actually takes today (no cgroup delegation confirmed working there yet; no pid namespace support at all) — a future, even more basic strategy (for some hypothetical still-more-limited device) would slot in here the same way, per the user's own explicit request to keep this extensible.
poll_until()/sleep_ms()(.cpp-local) usenanosleep()in anEINTR-retry loop — matching this project's existing direct-POSIX style (process.cppalready retrieswaitpid()the same way) — rather than<thread>/<chrono>(unused anywhere else in this project). -
config_file.{h,cpp}— reads/writes (via libyaml's document API,<yaml.h>) two separate local YAML files, not one:config_file_path()($XDG_CONFIG_HOME/slocker-lite/config.yaml, falling back to$HOME/.config/slocker-lite/config.yaml) holds only theglobalsection;persistent_file_path()(same directory,persistent.yaml) holdsvolumes/networks— both resolved through one shared.cpp-localconfig_dir()so the two paths can never drift relative to each other. Split specifically so-c/--config-file(cli_args.{h,cpp}) can safely redirect just the global section for one invocation:volumes/networksare real, provisioned host state (named volumes, live bridges/namespaces), and letting-ctouch them too would risk a mistake shadowing or corrupting real persistent state.load_global_config(path)parses onlypath'sglobalmapping (anyvolumes/networksphysically present are never read at all — this is what makes-csafe even against an old-format file that still has them);load_persistent_config(path)is the mirror image, reading onlyvolumes/networksand ignoringglobal. Both share one.cpp-localparse_yaml_file(path)(open +yaml_parser_load(); a missing file yields an empty, root-less document rather thannullopt, so "doesn't exist" and "exists but empty" are indistinguishable to callers — both already read back as "nothing set";nulloptonly for a genuine parse failure) instead of duplicating that scaffolding twice.write_global_config(path, config)/write_persistent_config(path, config)are the write-side mirror, sharing a.cpp-localwrite_yaml_document(path, document)for the create-directory/open-file/emitter dance — carefully preserving the exact document-ownership rule libyaml requires (yaml_emitter_dump()consumes/deletes the document itself onceyaml_emitter_open()succeeds; a failure before that point means this function must delete it explicitly instead, exactly as the original single combined write function already had to). Supportedglobalkeys:log-level; sixunshare-<type>keys (unshare-user/unshare-ipc/unshare-pid/unshare-net/unshare-uts/unshare-cgroup, one perbwrap.cpp's ownnamespace_probesentry) controlling whether-r/--runrequests each of bwrap's--unshare-xxxflags; and twowith-<feature>keys (with-veth/with-ipv6,AppConfig::with_veth/with_ipv6) giving-n/--network's own creation-timeveth/ipv6policy (NetworkEntry, above) a persistent default, used whenever the corresponding--with-veth/--with-ipv6CLI flag (cli_args.{h,cpp}, see above) isn't given — every other long option is a one-shot flag, not a setting, so it doesn't belong in a persistent config file. All eight of these boolean keys share one small.cpp-localBoolGlobalKey {key, field}pairing table (two arrays,unshare_keysandnetwork_default_keys, both consumed by sharedload_bool_keys()/write_bool_keys()helpers rather than repeating the same find-parse-or-warn / emit-if-set loop body per group) and accept"1"/"on"/"yes"/"true"(enabled) or"0"/"off"/"no"/"false"(disabled), case-insensitively —parse_bool_flag(), exported (not just this file's own internal helper) specifically socli_args.cpp's own--with-ipv6/--with-vethvalue parsing accepts exactly the same forms as the config file itself, rather than a second, drifting copy. An unset key defaults to enabled, and an unrecognized value logs aspdlog::warnand is treated as unset (default enabled) rather than failing the whole config load — consistent with this file's existing forward-compatible/ignore-malformed- entries policy (only malformed YAML syntax is a hard error). A missing file returns a default-constructed (empty)AppConfig, not an error; unknown sections/keys (and malformed individual volume entries) are likewise ignored for forward-compatibility.main()(see above) applies the mergedAppConfig's ownlog_level(via the existingapply_log_level()) once loaded, but only when!args.log_level_flag_given— seemain.cpp's own entry for why config loading had to move to afterparse_args()(to know-c's value first) and what that meant for preserving log-level precedence.write_persistent_config()writesvolumes/networksback out — used by-v/--volume(create_volume_command(),commands.cpp, which now callspersistent_file_path()directly rather than taking aconfig_pathparameter it no longer needs) to persist a newVolumeEntry {name, directory}into thevolumessection, leavingconfig.yamlcompletely untouched.VolumeEntry/thevolumessection is a distinct concept fromOciImageConfig::volumes: this is a user-definedname -> host directorymapping created via-v/--volume, not an image's own declared mount points (still unconsumed, seeoci_image.{h,cpp}above).AppConfig/config.volumesis looked up by name inresolve_volume_mount()(volume_mount.{h,cpp}, see below), which is how-r/--run's own-vusage finds a named volume's host directory.run_container()(commands.cpp) resolves the sixunshare-*fields (eachvalue_or(true)) into aNamespaceConfig(bwrap.h, see below) once, up front, and passes it torun_bwrap()—bwrap.{h,cpp}itself has no dependency on this file or on YAML parsing at all, only on the already-resolved, defaults-applied struct.networkssection (seedocs/networking-design.mdfor the full feature): unlikevolumes(a flatname -> directoryscalar mapping), each network entry is itself a nested mapping (kind/subnet/ipv6/subnet6), since one network needs more than a single value to describe.NetworkEntry(kindisNetworkKind::extern_/intern— trailing underscore onextern_sinceexternis a reserved C++ keyword and can't be an enumerator name — parsed from the YAML strings"extern"/"intern") round-trips throughAppConfig::networksthe same wayVolumeEntrydoes; an entry with an unrecognizedkind(or missingkind/subnet) is skipped on load, same forward-compatible policy as everything else here.ipv6reusesparse_bool_flag(), defaulting totrue(enabled) if absent or unparseable;subnet6is only read/written whenipv6is true.write_persistent_config()writes each network as its own nested mapping undernetworks,ipv6re-serialized as canonical"true"/"false"like theunshare-*keys.veth(defaulttrue) round-trips the same way asipv6(parse_bool_flag(), written as canonical"true"/"false", always written regardless of value — unlikesubnet6, there's no companion field whose presence depends on it) — seenetwork_bridge.h'sprobe_veth_support()/should_use_veth()above and--with-veth/global.with-veth(cli_args.{h,cpp}/config_file.{h,cpp}) below for what it controls.create_network_command()/delete_network_command()(commands.cpp) likewise callpersistent_file_path()directly now, dropping theconfig_pathparameter they used to take.migrate_legacy_config_if_needed()— the one-time-per-upgrade bridge from the old single-file format to the split above: reusesload_persistent_config()againstconfig_file_path()(the global file's own path) to detect legacyvolumes/networksstill sitting there — that loader only ever reads those two sections, so pointing it at an old-formatconfig.yamlnaturally surfaces whatever's left, with no separate detection logic needed. If found, merges them intopersistent_file_path()(a name collision against an already-existing entry there aborts the entire migration for this run, touching neither file, with a warning identifying the conflict — never silently drops or overwrites data; expected to be exceedingly rare, sincepersistent.yamldoesn't exist at all the first time this runs for a given install), then rewritesconfig.yamlviawrite_global_config()— which, by construction, never writesvolumes/networksat all, completing the strip. Logs an info-level summary of what moved. A cheap no-op (single read, no writes) whenconfig.yamlhas no legacy data, which is the common case on every run after the first. Always operates on the fixed default paths, regardless of-c/--config-file— migration only ever concerns the defaultconfig.yaml, never whatever a given invocation's-cpoints at instead, so an alternate global-only file can never be mistaken for — or have its ownvolumes/networks, if any, migrated from — the real persistent config. Called unconditionally, early inmain(), before resolving which file supplies that invocation's own global section (seemain.cpp's own entry above). -
network_subnet.{h,cpp}— pure CIDR arithmetic backing-n/--network's subnet allocation andnetwork_bridge.{h,cpp}'s (see below) gateway-address computation; no kernel/ip/iptablescalls of its own.is_valid_network_name()(non-empty, no':') mirrorsvolume_mount.h'sis_valid_volume_name()(which rejects'/') —':'specifically becauseport_forward.h's-psyntax splits a spec on it; a network name containing one would make that parse ambiguous.is_valid_ipv4_cidr()/is_valid_ipv6_cidr()andipv4_cidrs_overlap()/ipv6_cidrs_overlap()all build on one.cpp-localparse_cidr()(viainet_pton(), not hand-rolled parsing) producing a plain byte-vector address (4 bytes for IPv4, 16 for IPv6) + prefix length, and one sharedbytes_overlap()byte/bit-mask comparison generic over that byte length — IPv4 and IPv6 overlap checking are the same algorithm, not two parallel implementations.allocate_ipv4_subnet()/allocate_ipv6_subnet()(commands.cpp'screate_network_command()) scan10.168.<n>.0/24/fdf0:f243:f06f:<168+n>::/64fornin0..255and return the first one that doesn't overlap any existing network's subnet (via the overlap checks above, not just other auto-allocated ones — a manually--subnet-overridden network is checked too).fdf0:f243:f06f::/48is a randomly generated ULA (RFC 4193) — replaced the originalfd00:168:0::/48, which was never actually randomly generated, just a memorable placeholder, at the user's own request ("since this is an ULA, let's use a randomly generated prefix"); the168offset on the subnet-id hextet (confirmed with the user viaAskUserQuestion, over the alternative of dropping it and starting both at0) keeps the same project-recognizable stamp the old scheme's fixed 2nd hextet had, just as a constant offset now rather than a numerically-identical index — the samenrange still drives both v4 and v6 allocation, so the common case (no manual overrides) still allocates deterministically paired blocks per network, just offset by168on the v6 side instead of matching exactly. Since IPv6 hextets are hexadecimal,n + 168 >= 10(i.e. always, given the offset) renders as a valid but numerically-different-from-n + 168address when read back as hex (e.g.n=15→183decimal → renders as...:183::/64, which is hex0x183, not183) — purely cosmetic, allocation correctness doesn't depend on this matching numerically at all.ipv4_gateway_address()/ipv6_gateway_address()(network_bridge.cpp'sprovision_bridge()) andipv4_host_address()/ipv6_host_address()(network_join.cpp'spick_free_address(), see below —n = 2, 3, ...for individual containers) are all thin wrappers around one shared.cpp-localhost_address(af, cidr, n): maskscidrdown to its network address first (mask_to_network(), in case it — e.g. a manual--subnet/--subnet6— wasn't already a canonical network address), then addsnas a big-endian integer into the trailing host-portion bytes with proper carry propagation (generic over address length, so the same code handles both IPv4's 4 bytes and IPv6's 16 without two parallel implementations), rejectingnoutright if it doesn't fit the address's host-bit width. The gateway functions are justhost_address(af, cidr, 1)— the.1convention this project's bridges use. Reuses the sameparse_cidr()as the validation/overlap functions above. -
volume_mount.{h,cpp}—is_valid_volume_name()(no/, checked by bothcreate_volume_command()and to tell a-vspec's name/path apart) andresolve_volume_mount(), called once per-voccurrence fromrun_container()when running with-r. A spec with no/is looked up inconfig.volumesby name (error if unknown); one with/is treated as a host directory path andcreate_directories()'d if missing. If the resulting host directory is empty,initialize_volume_directory()reconciles it against the image's own directory at the given container path: if that image directory is non-empty, its contents are copied in first; then, whether or not there was content to copy, the host directory's own mode/ownership/timestamps (and xattrs/ACLs where supported) are always set to match the image directory's own — real bug fixed by the user, not assumed: an earlier version only ever copied when the image directory was non-empty, so an image declaring an empty directory with specific ownership/permissions (e.g. a data directory owned by a non-root uid/gid) got a host directory with defaultcreate_directories()permissions instead, and even the non-empty case never reconciled the directory's own attributes (only each copied entry's). The existence check, content copy, and attribute reconciliation all run as a singlesh -cinvocation wrapped throughwrap_for_root_namespace()(bwrap.h) — not a plainstd::filesystemcheck — because a rootlesscontainers-storage mount's content isn't visible to this process at all withoutnsenter, the same constraintrun_bwrap()itself works around (see the root-vs-rootless paragraph below).cp -a --preserve=mode,ownership,timestamps,links[,xattr] --attributes-only -Tdoes the attribute-reconciliation step; whether,xattris included is decided by a directsetxattr()/removexattr()probe on the host directory (no new library dependency — Linux POSIX ACLs are themselves stored as xattrs, so this one probe stands in for both, logging a singlespdlog::warnif unsupported).-T/--no-target-directoryis required on that secondcp— confirmed by direct testing: without it, since the host directory already exists, plaincp SRC DSTcopiesSRCintoDSTas a nestedDST/basename(SRC)subdirectory instead of reconcilingDST's own attributes, which is exactly the bug this fix closes. A nonzerocpexit is only ever a warning, never fatal — often just an ownership-preservation shortfall when not running as root. Verified end-to-end againstimages/gitea.tar's real declared/etc/gitea//var/lib/giteavolumes under a real rootless mount: the resulting host directories' mode/ownership matched the image's own declared values in both cases, and no mounts/layers were left behind afterward. -
yaml_util.{h,cpp}—scalar_value()/find_in_mapping()pulled out ofconfig_file.cpp's own former.cpp-local pair (config.yaml's own shape is purely nested scalar mappings, so it never needed more) oncecompose_file.cpp(below) needed the exact same two, plus a newsequence_items()(every item of aYAML_SEQUENCE_NODE, in order) that Compose's own list-valued keys need and config.yaml's shape never did. Bothconfig_file.cppandcompose_file.cppdepend on this now, so the two never drift into two slightly-different copies of the same libyaml document-traversal boilerplate. -
compose_file.{h,cpp}—load_compose_file()parses and validates a Docker/Podman Compose YAML file (docker-compose.yaml/compose.yaml), extracting only the subset of fields slocker-lite supports and silently ignoring everything else (build,deploy,restart,healthcheck, and any other unrecognized top-level section) — the same forward- compatible "unknown keys ignored" policyconfig_file.cppalready uses forconfig.yaml. Unlike that policy, though, a supported key with an unrecognized or malformed value (a missingimage, a badstop_grace_period, an undeclared network/volume reference, a duplicate name, an unresolvable/self-referentialdepends_on, an unsupporteddepends_oncondition, an unparseableports/volumesentry) is a hard parse error, not a silent skip — a Compose file is user-authored input describing an actual deployment, not a version-spanning settings file, so a mistake in it should surface clearly. No dedicated C++ library for parsing/validating Compose files exists (confirmed by research before starting this); the alternative — pulling in the officialcompose-spec.jsonJSON Schema plus a schema-validator library — was rejected in favor of hand-writing the parser against the already-presentlibyamldependency, matching this project's own "only extract/support the fields actually consumed" precedent (oci_image.cpp'sOciImageConfig) rather than validating against the full upstream spec.ComposeServicecapturesimage/container_name/command(both Compose's list form and its scalar shell-string form, the latter wrapped as{"sh", "-c", <string>})/environment+env_file(both list and mapping forms forenvironment, scalar-or-list forenv_file; folded into a single orderedstd::vector<EnvSpec>—env_spec.h's own struct, reused as-is — with everyenv_fileentry first and everyenvironmententry after, regardless of which key came first in the YAML, so handing this straight toresolve_env_specs()later reproduces real Compose's "environment always overrides env_file" precedence for free via that function's own existing "later wins" mechanism)/depends_on(both the short list form and the longcondition:mapping form; onlyservice_started/service_healthyare accepted, the latter folded into "started" — no real healthcheck support exists yet — any other value, or a self-referential or undeclared-service reference, is a hard error;restart/requiredsub-keys are silently ignored, not implemented)/stop_grace_period(a hand-rolled Go-style duration parser --parse_duration_seconds(),.cpp-local -- acceptingh/m/s/msunits, optionally combined like"1m30s", rounded to whole seconds; no unit or an unrecognized one is a parse error)/networks(list or per-network mapping form, the latter's nested fields likealiasesignored; every name validated against the file's own top-levelnetworks:)/ports(list or single-scalar form; each entry reusesport_forward.h's ownparse_port_forward_spec()directly rather than a second parser, since Compose's own"<host>:<container>[/proto]"syntax is a strict subset of what that function already accepts — a network-qualified-pprefix is never present in a Compose ports entry, soPortForwardSpec::networkalways comes back unset here, letting a later-presolution pick the service's own soleexternnetwork the same way an ordinary CLI-pwith no prefix already does; known gap, not fixed: a host-IP-prefixed entry like"127.0.0.1:8080:80", valid real Compose syntax, has the same three-colon-separated-field shape as a network-qualified spec, so it's misread as if"127.0.0.1"were a network name instead of rejected outright, only failing later at network-resolution time with a confusing error)/volumes(short"SRC:DST[:MODE]"string form only,MODEexactlyro/rw; whetherSRCis a bind-mount host path or a named-volume reference is decided the same wayvolume_mount.h's own-vspec parsing already does --is_valid_volume_name(), reused directly: no/means a name, checked against the compose file's own top-levelvolumes:; anything else is a path, resolved to an absolute, lexically-normalized one relative to the compose file's own directory, ready forresolve_volume_mount()the same way a plain-v <host-dir> <container-path>spec already is; the parsedread_onlyflag isn't enforced anywhere yet sinceresolve_volume_mount()/build_bwrap_args()only ever bind writable — captured here rather than silently lost, for when read-only bind support exists).ComposeNetwork'sexternal: true(real Compose syntax, means "must already exist, not managed by Compose") maps toComposeNetworkMode::external— checked against the realpersistent.yamlat "up" time, not by this parser, which only records that the network must already exist; otherwiseinternal: true/false(defaultfalse, matching real Compose's own default) directly selects this project's ownintern/externNetworkKindfor a (not-yet-implemented) orchestrator to create.ComposeVolumerecords only a declared name for now — Compose auto-provisions a host directory for a named volume with no further fields, which this parser doesn't do (an orchestrator concern, not yet implemented); it exists here only so a service's own named-volume references can be validated against it. Cross-references (depends_on/networks/named-volumevolumes) are validated in a second pass after every service/network/volume has been parsed, so declaration order in the YAML never matters. Scope decisions confirmed with the user before implementation: for a key with multiple real Compose syntax forms, best-effort support both forms rather than only whichever one a first example happened to use (matching theenvironment/command/depends_on/env_file/networks/portshandling above); this pass is scoped to the parser module plus unit tests only (tests/unit/test_compose_file.cpp,[unit], exercising every supported form and validation error against small hand-written YAML snippets, not the checked-intest-compose/compose.yamlskeleton — see below) — noMode/CLI flag,commands.cppdispatch case, or actual container orchestration exists yet. Atest-compose/compose.yaml(with matchingworker//server/script directories) was hand-drafted interactively at the repo root first, specifically to pin down which Compose fields/forms this project would commit to supporting before any parser code was written — two busybox services (atest-workerthatnc -lk-listens and echoes a fixed reply, and atest-serverthat joins both aninternand anexternnetwork plus a third pre-createdexternal: truenetwork, port-forwards from theexternside, and relays the worker's own reply) exercisingcontainer_name,environment/env_file,depends_on(condition: service_started— realdocker composeitself requires an actual healthcheck forservice_healthy, confirmed by hand against real Docker before commit),stop_grace_period, bind-mounted script directories, and one named volume (/var/log, for persistent logging) alongside the bind mounts — verified against a real Docker installation before being committed. That file is reserved for later, higher-level integration tests once an orchestrator exists, not for this parser's own unit tests, since its content is expected to keep changing as more of the orchestrator gets built on top of it. -
compose_orchestrator.{h,cpp}— the-u/--uporchestrator (five separate steps/commits, matching the user's own explicit sequencing request) and-d/--down(stop_compose_services(), its own later addition, see below):resolve_compose_images()— matches each service's ownimage:reference againstlist_oci_images(images_directory)(oci_image.h— the exact function-l/--list-imagesalready uses, reused as-is).split_image_reference()(.cpp-local) splits"image[:tag]"on the last':', but only when nothing after it contains a'/'(so a registryhost:portprefix, e.g."myregistry:5000/busybox", isn't misread as a tag) — a deliberately simplified image-reference split, not a full one. Testing-only fallback: if no image's own declared name:tag matches, but a tar file's literal filename (.tar/.tar.<compression>stripped, the same conventionlist_oci_images()itself already uses as a fallback name) matches the requested name, that file is used anyway regardless of what its own manifest actually declares — logged as aspdlog::warn, not silent, since it's a deliberately loose name-shaped guess, not real image resolution (added per the user's own explicit request, for testing against a locally built/renamed image whose embedded name:tag doesn't match its own filename at all). Fails the whole resolution (not just the one missing service) if any service's image still isn't found by either rule, since mounting happens for every service before starting any of them (next step) specifically so a missing/slow image for any one service is caught up front.mount_compose_images()— mounts every resolved image (mount_image(), exported fromcommands.cpp's own former anonymous-namespace pair, see below) before starting any service — mounting can be slow on some devices, so this deliberately front-loads every mount rather than interleaving mount+start per service, per the user's own explicit request. On any failure partway through, unmounts+cleans up every image already mounted in the same call, so a failed-u/--upnever leaves a partial mount set behind.provision_compose_networks_and_volumes()— ensures every network/volume the compose file needs actually exists, creating whatever's managed (ComposeNetworkMode::managed, or any declared top-level volume —compose_file.hhas noexternalconcept for volumes yet) and not already present, viacreate_network_command()/create_volume_command()(also exported fromcommands.cpp's own former anonymous namespace, reused exactly as-n/--network/-v/--volumealready do — so a managed network/volume this creates is genuinely no different from one a user created by hand). Every created/reused name is prefixed withcompose_project_name()— the sanitized basename of the compose file's own parent directory (sanitize_for_filename(),pid_file.h), matching real Docker Compose's own default project-naming convention — so two unrelated compose projects can each declare e.g. a network named"backend"without colliding in slocker-lite's single, flatpersistent.yamlnetworks/volumes namespace. Finding an already-existing entry under its project-prefixed name is deliberately not an error — per the user's own explicit direction (a-u/--upre-run against the same compose file, e.g. after an interrupted previous one, should reuse rather than fail) — with no attempt to verify it still matches what the compose file currently declares.external: truenetworks map to themselves, unprefixed (real, pre-existing networks by definition, already confirmed to exist byvalidate_compose_external_state()before this ever runs). A managed volume's host directory is auto-chosen underxdg_state_dir()/"compose-volumes"/<actual-name>(pid_file.h). Returns aComposeProvisionedNames{networks, volumes}map from each compose-declared name to the actual slocker-lite name, needed by the next step to translate a service's ownnetworks:/named-volumevolumes:references.start_compose_services()— starts every service in dependency order (topological_service_order(),.cpp-local, Kahn's algorithm — no cycle-detection needed here, sinceload_compose_file()already rejects adepends_oncycle before aComposeFileis ever produced), each one daemonized (as if-D/--daemonizehad been given) against its own already-mounted image and already-provisioned networks/volumes. A service whose dependency failed to start (or was itself skipped for the same reason) is skipped too, logged clearly, never started against a dependency that isn't actually running. A service's own session identity (pid-file/log/cgroup naming,container_namethroughout the rest of this project) is its explicitcontainer_name:if given, used verbatim (matching real Compose's own semantics for that field), else"<project_name>_<service_name>"; its DNS hostname (what a sibling service resolves it by, via the per-session DNS resolver,network_dns.h) is instead its explicitcontainer_name:or its bare compose service name, never project-prefixed — real Compose resolves services by their bare service key regardless of project name, andtest-compose/compose.yaml's own skeleton relies on exactly that (its server reaches the worker by the plain hostname it declared). Since this loops over multiple services within one process, each daemonized start followsdaemonize()'s own documented contract adapted for a loop rather than a single top-level dispatch: in the parent branch (a real pid, or a hard failure), the loop just records the outcome and moves on to the next service, never blocking; in the freshly forked child branch, the only way out is an explicit_exit()right afterrun_mounted_container()returns — it must never fall back into the loop and attempt to start another service, unlike a single top-level-r -Dinvocation, which just letsmain()return naturally once its own one-and-only session ends. Each service's ownports:(ComposeService::ports, already parsed intoPortForwardSpecbyload_compose_file()) is reserialized back into the raw"<host-port>:<container-port>[/tcp|udp]"stringsrun_mounted_container()'s ownport_forward_specsparameter expects — reusing its existing parse-then-resolve pipeline unchanged rather than needing a second entry point; never anetwork:prefix, since Compose's ownports:syntax has none, so a later-presolution picks the service's own soleexternnetwork, the same "unqualified-p" default an ordinary CLI-palready has. An explicit--user/--group, wired the same way:user: "user[:group]"(ComposeService::user/group,compose_file.h— split on the first':', the exact same way an image's own declaredUSERis split,oci_image.cpp's ownread_oci_image_config()) is passed straight through torun_mounted_container()'s ownuser/groupparameters; when unset, that function already falls back to the image's own declared user, the exact same default-r/--runhas when--userisn't given.compose_state_file_path()/record_compose_services()— write the compose file's own real, absolute path as a header line (needed becausecompose_state_file_path()'s own filename only encodes a sanitized, lossy version of it —sanitize_for_filename(),pid_file.h— so this is the only place the exact path is recoverable from), then one line per started service ("<service_name> <container_name> <pid>"), toxdg_state_dir()/"compose"/sanitize_for_filename(<absolute compose path>), the same state-file-naming patternport_forward.h's/network_tap_relay.h's own crash-orphan records already use — read back by both-d/--down(stop_compose_services(), see below) and--list-containers(list_compose_containers(), below). Overwrites any previous run's own record for the same file -- a known, expected limitation, since-d/--downremoves the file once it acts on it, but nothing reconciles an unclean previous run (e.g. a crash) against a fresh-u/--up's own new record (there's no way yet to tell which of an older run's services are still actually running versus already stopped by hand).
--list-containers(list_compose_containers(),list_containers_command()incommands.cpp) — scans every compose state file underxdg_state_dir()/"compose"/and reports one row per recorded service: compose file path, service name, container name, pid, and status.runningis a real, live check — each recorded pid is cross-referenced againstlist_sessions()(pid_file.h, the same advisory-flock()liveness test every other list/clean command in this project already uses), not merely "this line exists in the state file" (which only ever reflects the most recent-u/--up, perrecord_compose_services()'s own doc comment) — a compose-started session's own pid file is created exactly the same way any other session's is (run_bwrap()'s ownon_startcallback, reached identically viarun_mounted_container()), so it's already present inlist_sessions()'s own result with no special-casing needed.commands.cpp's ownpad_column()(new,.cpp-local) factors out the tab-alignment scheme every other list command in this file already duplicates inline per-column, since this one needed it across four independent columns rather than two or three. Verified manually:--list-containersagainst a real running 2-service compose stack correctly showed both asrunningwith their real compose path/service/container/pid; killing one and re-running correctly flipped just that one row toexitedwhile the other stayedrunning— confirming the status reflects real liveness, not just presence in the state file.-d/--down(stop_compose_services(),commands.cpp'scompose_down_command()) — unlike-u/--up, takes only a single optional parameter (the compose file name; no images directory at all, per the user's own explicit request), since stopping a stack needs neither to mount nor resolve any image: everything required (service name, container name, pid) is already in the state file-u/--upwrote. Reads that file back, callskill_session()(kill_session.h— the exact same gracefulSIGTERM-then-SIGKILL, cgroup-aware stop--kill <pid>already uses) on every recorded pid, then removes the state file. If the compose file at the given path still exists and parses, each service's ownstop_grace_period_seconds(parsed since the very first commit ofcompose_file.cppbut never actually consumed until now) is used as that service's own grace period instead ofkill_session()'s hardcoded 10s default — best-effort: a service no longer found there (edited/moved/deleted since-u/--upran) just falls back to that default, since the state file alone already has everything strictly required.load_and_validate_compose()(the shared resolve-images-directory helper-u/--upuses) is untouched and now-u/--up-only.Networks and volumes are handled asymmetrically (
compose_down_command(), after the session-stopping step above), per the user's own explicit direction: volumes always persist, matching real Compose's own default — never touched at all here, since a compose-managed volume is just as much "the user's own data" as a hand-created one once it exists. Each managed (non-external) network, though, is torn down — but only onceis_network_in_use()(new,network_bridge.{h,cpp}) confirms nothing still has a live interface on its bridge. This needs the compose file's ownnetworks:declarations (the state file only ever recorded services, never networks) to know each network's project-prefixed actual name and whether it's managed vs.external— so it's a separateload_compose_file()call from the onestop_compose_services()already does internally for grace periods; if the compose file no longer exists or fails to parse, network cleanup is skipped entirely (logged, not an error) while the session-stopping half above still ran unaffected.external: truenetworks are never touched either way, since compose never created them in the first place.is_network_in_use(network)runsip -o link show master <bridge>inside the network's own persistent namespace (via the already-exportedwrap_for_network()) — a veth-joined or tap+relay-joined container's own host-side end is enslaved to the bridge the same way regardless of which join mechanism was used, so this catches both uniformly, and catches any attached user, not just this compose file's own services (exactly the safety property the user asked for: "should not [persist] unless they are still in use"). Theexternuplink's own tap devices are deliberately never bridge members (routed, not switched — seenetwork_tap_relay.h's ownattach_host_side_to_bridge = falsemode), so they never cause a false "in use" positive. Fails closed (returnstrue, "assume it's still in use") if the check itself can't even run (namespace/bridge missing,ip/nsenternot found) — an inconclusive check must never green-light deleting a network that might still be needed.delete_network_command()(exported fromcommands.cpp's own former anonymous-namespace scope, same pure-refactor pattern as the other exports below) does the actual teardown once confirmed unused, identical to--delete-network-full.Verified end to end on the real target machine (root, via the scoped
doasrule): a compose file with oneinternnetwork and one service — after-uthen-d, the network was correctly deleted (ip -o link show masterreturned empty right after the service stopped, since a veth pair is torn down by the kernel automatically once its owning namespace goes away). Re-running-u, then separately joining an unrelated ad-hoc-r --run -n <same-network> -Dsession to the same network before running-dagain: the compose service was still correctly stopped, but the network was correctly left alone ("network '...' is still in use, leaving it") — confirmed still present via--list-networks— until the ad-hoc session was killed by hand.This required splitting
-u/-d's previously-shared CLI parsing (cli_args.cpp):-u/--upkeeps its two-tokenrequired_argument-images-directory-plus-optional-compose-file shape, while-d/--downbecame its ownno_argumentoption with a single manually-peeked optional trailing token (same "doesn't look like the next flag" guard as-u's own second token) —ParsedArgs::compose_images_directoryis now only ever set forMode::compose_up, left unset forMode::compose_down.Verified manually, end to end:
-ufollowed by a bare-d(no images directory) against a real 2-service stack correctly finds and stops both via the recorded state file, removes it, and leaves no processes behind (confirmed via--list-containers, now empty, andps); a-drun with nothing recorded for that compose file exits cleanly ("no running services found for ...", exit 0), not an error.commands.cppexports needed for all of the above (each a pure refactor out of its own former anonymous-namespace scope, no behavior change, verified viameson testplus manual-r/--runsmoke tests after each):MountedImage/mount_image(),create_volume_command(),create_network_command(), and a newrun_mounted_container()—run_container()(-r/--run) itself now only decidescontainer_name, handles the-D/--daemonizefork (which must happen before mounting so the daemon's own log file can be named from its very first line — unchanged from before), and callsmount_image(); everything after that (volume/env/user resolution, namespace policy, network/port-forward/DNS setup, runningbwrap, unmount/cleanup) moved intorun_mounted_container(), taking an already-mounted image instead of mounting its own — reused as-is bystart_compose_services()above against an image it mounted itself, rather than a second, drifting copy of ~200 lines of already-debugged logic.Verified manually, end to end, rootless: a two-service compose file (
worker, andwebwithdepends_on: worker, no networks/volumes so the whole thing stays rootless-testable) correctly resolves, mounts both images, starts both daemonized in dependency order with the expected per-service--hostname, confirmed genuinely running via--list-processes/ps, writes a correct compose state file, and--killagainst the recorded pids leaves no processes behind. A single-service compose file with a managed named volume correctly creates it project-prefixed, and a second-u/--uprun against the same file correctly reuses it without error. Network provisioning itself reuses already-provencreate_network_command()/ensure_network_provisioned()as-is (no new logic there) but wasn't separately re-verified live, since it needs root.
Errors are logged via spdlog::error; every external command is also traced at debug
level in run_process()/run_process_foreground() (src/process.cpp) — visible via
SPDLOG_LEVEL=debug, since spdlog's default level is info — and a failed external
command additionally logs a spdlog::warn, which is visible by default (no env var
needed). The final "mounted image at: ..." success line is direct stdout program
output, not a log.
Because containers-storage mount runs rootless, it reexecs itself into a private
user+mount namespace to gain the privilege it needs for the overlay mount — which
leaves the result invisible to a plain shell or child process outside that namespace.
Confirmed containers-storage unshare does not rejoin an already-running mount's
namespace; only nsenter targeting the live fuse-overlayfs daemon's PID does.
-r/--run handles this automatically by locating that PID and running bwrap via
nsenter into its namespaces (wrap_for_root_namespace(), src/bwrap.h) — reused
as-is by volume_mount.cpp's copy-into-an-empty-volume step, since that also needs
to read image content that's otherwise invisible outside the same namespace.
Running as root sidesteps all of this: no privilege
reexec is needed, so the mount is already directly visible in the current namespace,
and nsenter --user=... into it then fails ("reassociate to namespace 'ns/user'
failed: Invalid argument") since the caller is already in that same user namespace.
-r/--run detects geteuid() == 0 and skips nsenter automatically in that case;
--no-nsenter forces it off manually for any other situation where the mount turns
out to already be directly visible.
Mutable global state and multi-container support: an audit ahead of planned
docker-compose support (running multiple containers at once) found exactly three
pieces of mutable global/file-scope state in src/: g_mount_program
(containers_storage.{h,cpp}, the resolved fuse-overlayfs path — genuinely
process-wide, invariant across containers), g_foreground_child_pid
(process.cpp, plus run_process_foreground()'s process-wide SIGINT/SIGTERM
handler installation — tracks one foreground child at a time), and
g_report_fd/g_log_path (daemonize.cpp, one in-flight -D/--daemonize
handshake's report-pipe fd and log path). Decision, confirmed by the user:
multi-container/compose support will run each container's session in its own
forked OS process — the same model -D/--daemonize already uses — rather than
one process managing multiple containers concurrently without forking. Under
that model, "one OS process" and "one running container" stay the same thing
they already are today, so none of these globals need to become per-container
state — each forked child only ever tracks/signals one foreground child and
handles one daemonize handshake, exactly as today. This is a load-bearing
constraint for however the compose orchestrator ends up implemented: it must
fork (not thread, not run an in-process event loop over N containers) one child
per service, each child reusing run_container()'s existing single-container
code path unchanged.
Build & test commands
Build directory is buildDir/ (already configured).
- Configure (only needed if
buildDir/is missing or deleted):meson setup buildDir - Build:
meson compile -C buildDir(orninja -C buildDir) — also buildsbuildDir/slocker-lite-priv-drop, the statically-linked helper-r --userneeds (seepriv_drop_helper.cppin "Project state") - Run the executable:
./buildDir/slocker-lite --mount <image.tar>(see--helpfor the full flag list:--mount,-r/--run,--umount,--cleanup,-l/--list-images,-i/--inspect,-x/--exec,--kill,--no-nsenter,-D/--daemonize,--user,--group,--hostname,--env,--env-file,-v/--volume,--list-volumes,--delete-volume,--delete-volume-full,-n/--network,--extern,--intern,--subnet,--with-ipv6,--subnet6,--with-veth,--list-networks,--delete-network,-p/--port-forward,--no-dns,--list-processes,--clean-processes,-u/--up,-d/--down,--list-containers,-c/--config-file,-w/--write-config,-t/--test [-- <catch-command-line-options>],--log-level,-h/--help,-V/--version) - Run tests:
meson test -C buildDir(the[unit]+ safe[integration]categories only — seeself_test.{h,cpp}'s own entry above andREADME.md's "Testing" section for the full-t/--testcategory/tag breakdown, including the[net]/[root]categories that stay manual-only)
Code style
- Null-pointer checks: prefer
if (!ptr)/if (ptr)overif (ptr == nullptr)/if (ptr != nullptr). - Constants: no
kHungarian-notation prefix.enum classvalues are already qualified by the enum's own name (e.g.Mode::run,OciPortProtocol::tcp), so plain snake_case enumerators are enough on their own. Free-standing constants also use plain snake_case; when several are conceptually related, group them under a namednamespaceinstead of relying on a shared prefix to imply the grouping (e.g.cli_args.cpp'sgetopt_longlong-option codes live innamespace options { constexpr int log_level = ...; }, andbwrap.cpp's priv-drop-helper path/binary-name pair live innamespace priv_drop { ... }) — nest the named namespace inside the file's existing anonymous namespace where one is already present, so internal linkage is unchanged. AkXxx-named identifier that turns out not to actually beconst(mutable global/static state) instead follows this codebase's existingg_prefix convention (e.g.containers_storage.cpp'sg_mount_program, matchingprocess.cpp'sg_foreground_child_pidanddaemonize.cpp'sg_report_fd/g_log_path).
Licensing
- Every
.c/.cpp/.hfile undersrc/must start with the GPLv2-or-later copyright header (see any existing file undersrc/for the exact text). - After adding a new source file under
src/, run./add-license.shfrom the repo root to prepend the header (it readscopyright-headerand inserts it viased, skipping files that already have it, so it's safe to re-run at any time).
Build configuration notes
meson.buildsetswarning_level=3andcpp_std=c++20— keep new code warning-clean under-Wall -Wextra -Wpedantic-equivalent settings.- The single Meson
test()target runsslocker-liteagainst a fixture OCI image tar generated at build time bytests/gen_fixture.py(acustom_target) and checks its exit code (no test framework is wired in yet).