Add -n/--network config CRUD: schema, subnet/IPv6 allocation, CLI

Commit 1/6 of the network isolation feature (docs/networking-design.md):
config-only, no host-side effects yet. Adds NetworkEntry {name, kind,
subnet, ipv6, subnet6} and a networks config-file section parallel to
volumes; network_subnet.{h,cpp} for CIDR validation, overlap checking,
and auto-allocation (10.168.<n>.0/24 / fd00:168:0:<n>::/64, paired,
--subnet/--subnet6 overrides); -n/--network create/list/delete CLI,
dual-purpose like -v/--volume (alone creates, repeatable with -r to
join -- joining isn't wired up yet, just accepted).

-n was already taken by --no-nsenter; moved that to long-option-only
(--no-nsenter), matching --kill's "rare flag, no real loss" precedent,
since --network will be far more heavily used.
This commit is contained in:
2026-08-30 12:39:28 +00:00
parent f5f1e8522b
commit ec09b96b56
10 changed files with 669 additions and 27 deletions
+77 -7
View File
@@ -47,7 +47,7 @@ Source layout (all under `src/`):
`1` for any parse error) when set; `nullopt` means `out` is ready for
`dispatch_command()`. `-D/--daemonize` (has a short form; `'D'` was free) is
a plain boolean flag (`ParsedArgs::daemonize_flag`, set in its own `case
'D':`, same pattern as `-n/--no-nsenter`). `--hostname <name>`/`--env
'D':`, same pattern as `--no-nsenter`). `--hostname <name>`/`--env
VAR=VALUE`/`--env-file <file>` (all long-option only, `--env`/`--env-file`
both repeatable) are collected here into `ParsedArgs::hostname_flag`/
`env_specs``--env` pushes `{false, optarg}`, `--env-file` pushes `{true,
@@ -67,7 +67,24 @@ Source layout (all under `src/`):
next flag if there isn't one), and only *after* the loop decides whether
that means one standalone `Mode::volume` call or, together with `-r`,
passes `volume_specs` through unresolved for `dispatch_command()`/
`run_container()` to handle.
`run_container()` to handle. `-n/--network` (see `docs/networking-design.md`
for 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 single `required_argument` token (just the
name) accumulated into `ParsedArgs::network_specs` — no manual second-token
consumption needed, `'n'`'s own `case` just does
`network_specs.push_back(optarg)`. The same post-loop split as `-v` decides
`Mode::network` (standalone, exactly one occurrence) vs. join-with-`-r`
(repeatable, no limit). `--extern`/`--intern`/`--subnet <cidr>`/`--no-ipv6`/
`--subnet6 <cidr>` (`ParsedArgs::network_extern_flag`/`network_intern_flag`/
`network_subnet_flag`/`network_no_ipv6_flag`/`network_subnet6_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::network` additionally
requires exactly one of `--extern`/`--intern`. **`-n` used to belong to
`--no-nsenter`**: reassigned here since `--network` will be far more
heavily used; `--no-nsenter` moved 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.
- `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 a `switch
@@ -115,6 +132,22 @@ Source layout (all under `src/`):
already gone) — see `config_file.{h,cpp}` below for what a "volume" means here (a
distinct concept from `OciImageConfig::volumes`). `dispatch_command()`'s
`Mode::volume`/`Mode::delete_volume`/`Mode::delete_volume_full` cases call these.
`create_network_command()`/`list_networks_command()`/`delete_network_command()`
are the direct network equivalents (`Mode::network`/`Mode::list_networks`/
`Mode::delete_network`) — see `docs/networking-design.md` for the full feature
design and `config_file.{h,cpp}` below for `NetworkEntry`. This commit is
config-only: no bridge, namespace, or iptables state is created yet, only the
config entry (later commits in the design doc's sequence wire up the actual
host-side networking). `create_network_command()` rejects a duplicate name
first, then resolves `subnet`/`subnet6`: an explicit `--subnet`/`--subnet6`
is 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()`). `list_networks_command()` reuses the same
independently-per-column tab-alignment scheme as `list_processes_command()`
(name/kind/subnet each aligned, then the IPv6 subnet — or `"(no ipv6)"`
appended unaligned as the trailing column, nothing follows it).
`write_config_command()` implements `-w/--write-config`: unlike
`create_volume_command()`/`delete_volume_command()`'s use of
`write_config_file()` (which only ever persists `AppConfig` fields that are
@@ -698,8 +731,9 @@ Source layout (all under `src/`):
(`process.cpp` already retries `waitpid()` the same way) — rather than
`<thread>`/`<chrono>` (unused anywhere else in this project).
- `config_file.{h,cpp}``load_config_file()` reads and parses (via libyaml's
document API, `<yaml.h>`) the `global` and `volumes` sections of the local YAML
config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,
document API, `<yaml.h>`) the `global`, `volumes`, and `networks` sections of
the local YAML config file located by `config_file_path()`
(`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,
falling back to `$HOME/.config/slocker-lite/config.yaml`). Supported `global` keys:
`log-level`, and six `unshare-<type>` keys (`unshare-user`/`unshare-ipc`/
`unshare-pid`/`unshare-net`/`unshare-uts`/`unshare-cgroup`, one per
@@ -735,6 +769,41 @@ Source layout (all under `src/`):
`NamespaceConfig` (`bwrap.h`, see below) once, up front, and passes it to
`run_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.
**`networks` section** (see `docs/networking-design.md` for the full
feature): unlike `volumes` (a flat `name -> directory` scalar 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` (`kind` is `NetworkKind::extern_`/`intern` — trailing
underscore on `extern_` since `extern` is a reserved C++ keyword and can't
be an enumerator name — parsed from the YAML strings `"extern"`/`"intern"`)
round-trips through `AppConfig::networks` the same way `VolumeEntry` does;
an entry with an unrecognized `kind` (or missing `kind`/`subnet`) is skipped
on load, same forward-compatible policy as everything else here. `ipv6`
reuses `parse_bool_flag()`, defaulting to `true` (enabled) if absent or
unparseable; `subnet6` is only read/written when `ipv6` is true.
`write_config_file()` writes each network as its own nested mapping under
`networks`, `ipv6` re-serialized as canonical `"true"`/`"false"` like the
`unshare-*` keys.
- `network_subnet.{h,cpp}` — pure CIDR arithmetic backing `-n/--network`'s
subnet allocation, no kernel/`ip`/`iptables` calls (those come in a later
commit per `docs/networking-design.md`'s sequence). `is_valid_ipv4_cidr()`/
`is_valid_ipv6_cidr()` and `ipv4_cidrs_overlap()`/`ipv6_cidrs_overlap()` all
build on one `.cpp`-local `parse_cidr()` (via `inet_pton()`, not hand-rolled
parsing) producing a plain byte-vector address (4 bytes for IPv4, 16 for
IPv6) + prefix length, and one shared `bytes_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`'s
`create_network_command()`) scan `10.168.<n>.0/24`/`fd00:168:0:<n>::/64` for
`n` in `0..255` and 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). The same `n` range for both is deliberate, so the common case (no
manual overrides) allocates visibly paired v4/v6 blocks per network — though
since IPv6 hextets are hexadecimal, `n >= 10` renders as a valid but
numerically-different-from-`n` address (e.g. `n=15` becomes `...:15::/64`,
which is hex `0x15` = 21) — purely cosmetic, allocation correctness doesn't
depend on the two matching numerically.
- `volume_mount.{h,cpp}``is_valid_volume_name()` (no `/`, checked by both
`create_volume_command()` and to tell a `-v` spec's name/path apart) and
`resolve_volume_mount()`, called once per `-v` occurrence from `run_container()`
@@ -794,7 +863,7 @@ reexec is needed, so the mount is already directly visible in the current namesp
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;
`-n/--no-nsenter` forces it off manually for any other situation where the mount turns
`--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
@@ -828,9 +897,10 @@ Build directory is `buildDir/` (already configured).
(see `priv_drop_helper.cpp` in "Project state")
- Run the executable: `./buildDir/slocker-lite -m <image.tar>` (see `--help` for the
full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`,
`-l/--list-images`, `-i/--inspect`, `-x/--exec`, `--kill`, `-n/--no-nsenter`, `-D/--daemonize`,
`-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`, `--list-processes`, `--clean-processes`, `-w/--write-config`,
`--delete-volume-full`, `-n/--network`, `--extern`, `--intern`, `--subnet`, `--no-ipv6`, `--subnet6`,
`--list-networks`, `--delete-network`, `--list-processes`, `--clean-processes`, `-w/--write-config`,
`-t/--test`, `--log-level`, `-h/--help`, `-V/--version`)
- Run tests: `meson test -C buildDir`
+19 -4
View File
@@ -77,7 +77,7 @@ slocker-lite -V|--version
| `-r, --run <image.tar>` | Mount, run `bwrap` in the foreground, then unmount and clean up on exit. Defaults to the image's own `Entrypoint`/`Cmd` (or `/bin/sh` if neither is set); pass `-- <command> [args...]` to override. |
| `-u, --umount <layer-id>` | Unmount a previously mounted layer (the ID printed by `--mount`/`--run`, or from `containers-storage layers`). |
| `-c, --cleanup <layer-id>` | Delete a layer and its ancestor chain from local storage (unmount it first). |
| `-n, --no-nsenter` | With `--run`, bind the mount directly instead of `nsenter`-ing into `fuse-overlayfs`'s namespace. Automatic when running as root; use this to force it off otherwise. |
| `--no-nsenter` | With `--run`, bind the mount directly instead of `nsenter`-ing into `fuse-overlayfs`'s namespace. Automatic when running as root; use this to force it off otherwise. |
| `-D, --daemonize` | With `--run`, fork into the background: detaches from the controlling terminal (`setsid()`), ignores `SIGHUP`, and redirects stdin from `/dev/null` and stdout/stderr to a log file under `$XDG_STATE_HOME/slocker-lite/logs/`. Prints the session's pid and log path, then returns — the same pid `--list-processes`/`-x/--exec` use. |
| `--user <user>` | With `--run`, run the command as this user (name or numeric uid) instead of the image's own declared user (or root, if it declares none). Resolved against the image's own `/etc/passwd`. Only takes effect when `--run` executes as root. |
| `--group <group>` | With `--user`, use this group (name or numeric gid) instead of the user's primary group. |
@@ -92,6 +92,9 @@ slocker-lite -V|--version
| `--list-volumes` | List all named volumes (see `-v/--volume`) with their host directory. |
| `--delete-volume <name>` | Remove a named volume from the config. The host directory is left untouched. |
| `--delete-volume-full <name>` | Like `--delete-volume`, but also recursively deletes the volume's host directory. |
| `-n, --network <name>` | Create/manage a persistent named network: requires exactly one of `--extern` (has a path to the host's real network) or `--intern` (only reachable by other containers on the same network). `--subnet <cidr>` overrides the auto-allocated IPv4 range (`10.168.0.0/24`, incrementing per network); `--no-ipv6` disables (and `--subnet6 <cidr>` overrides) the auto-allocated IPv6 range, on by default. With `--run`, instead joins `<name>` to the container; repeatable, no membership limit. See [`docs/networking-design.md`](docs/networking-design.md) — as of this, network creation is config-only: no bridge/namespace/iptables state exists yet. |
| `--list-networks` | List all named networks (see `-n/--network`) with their kind, IPv4 subnet, and IPv6 subnet (or `(no ipv6)`). |
| `--delete-network <name>` | Remove a named network from the config. |
| `--list-processes` | List running `--run` sessions found by their pid files under `$XDG_STATE_HOME/slocker-lite/run/`, with their pid, container name, and status (`running` or `exited`). |
| `--clean-processes` | Remove stale pid files (see `--list-processes`) left behind by sessions that are no longer running. |
| `-w, --write-config` | Write a complete config file (creating it, and its parent directory, if missing), filling in every option's current or default value. Useful to bootstrap one for hand-editing. Prints the config file's full path. |
@@ -176,6 +179,12 @@ global:
unshare-cgroup: on
volumes:
mydata: /home/user/slocker-volumes/mydata
networks:
mynet:
kind: extern
subnet: 10.168.0.0/24
ipv6: true
subnet6: fd00:168:0:0::/64
```
`global.log-level` sets the default log verbosity (an explicit `--log-level` on
@@ -191,9 +200,15 @@ set `unshare-net: off` if you need the sandbox to see the host's network in
the meantime. No other long options belong in a config file (one-shot
commands like `--mount`/`--run`/`--user` don't). The `volumes` section is
managed by `-v/--volume` (see above) rather than hand-edited — it's what
`-r/--run`'s own `-v` usage looks named volumes up in. A missing config file
is fine either way (nothing is overridden, and one gets created the first
time `-v/--volume` is used).
`-r/--run`'s own `-v` usage looks named volumes up in. The `networks` section
is likewise managed by `-n/--network` rather than hand-edited — see
[`docs/networking-design.md`](docs/networking-design.md) for the full
persistent-network feature design (as of this, network creation is
config-only: no bridge/namespace/iptables state exists yet, so
`global.unshare-net` above is still the only thing actually affecting a
sandboxed container's network access). A missing config file is fine either
way (nothing is overridden, and one gets created the first time
`-v/--volume`/`-n/--network` is used).
Run `-w/--write-config` to bootstrap a config file: it writes out every
supported option explicitly (filling in the current or default value for
+2 -1
View File
@@ -21,7 +21,8 @@ slocker_lite = executable('slocker-lite',
'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp',
'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp',
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp',
'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp'],
'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp',
'src/network_subnet.cpp'],
include_directories : include_directories('.'),
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
install : true)
+120 -6
View File
@@ -34,7 +34,13 @@ namespace {
// they need long-option vals outside the printable-char range short options use.
// --kill is different: 'k' was free, but long-option-only is deliberate here
// (not an availability gap) -- stopping a session is destructive enough that
// typo-prone brevity isn't worth it.
// typo-prone brevity isn't worth it. --no-nsenter used to be -n/--no-nsenter;
// 'n' was reassigned to the much more heavily-used -n/--network (see below),
// so --no-nsenter moved here too -- it's a rare debugging override, long-only
// is no real loss. --extern/--intern/--no-ipv6 (booleans) and --subnet/
// --subnet6 (values) only apply to -n/--network's standalone (create) use,
// not the --run-joining use, and have no natural short letter of their own
// worth spending.
namespace options {
constexpr int log_level = 256;
constexpr int user = 257;
@@ -48,9 +54,17 @@ constexpr int clean_processes = 264;
constexpr int env = 265;
constexpr int env_file = 266;
constexpr int kill = 267;
constexpr int no_nsenter = 268;
constexpr int network_extern = 269;
constexpr int network_intern = 270;
constexpr int network_subnet = 271;
constexpr int network_no_ipv6 = 272;
constexpr int network_subnet6 = 273;
constexpr int list_networks = 274;
constexpr int delete_network = 275;
} // namespace options
constexpr std::array<struct option, 27> long_options = {{
constexpr std::array<struct option, 35> long_options = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
@@ -59,7 +73,7 @@ constexpr std::array<struct option, 27> long_options = {{
{"umount", required_argument, nullptr, 'u'},
{"run", required_argument, nullptr, 'r'},
{"cleanup", required_argument, nullptr, 'c'},
{"no-nsenter", no_argument, nullptr, 'n'},
{"no-nsenter", no_argument, nullptr, options::no_nsenter},
{"daemonize", no_argument, nullptr, 'D'},
{"list-images", required_argument, nullptr, 'l'},
{"user", required_argument, nullptr, options::user},
@@ -77,13 +91,22 @@ constexpr std::array<struct option, 27> long_options = {{
{"env", required_argument, nullptr, options::env},
{"env-file", required_argument, nullptr, options::env_file},
{"write-config", no_argument, nullptr, 'w'},
{"network", required_argument, nullptr, 'n'},
{"extern", no_argument, nullptr, options::network_extern},
{"intern", no_argument, nullptr, options::network_intern},
{"subnet", required_argument, nullptr, options::network_subnet},
{"no-ipv6", no_argument, nullptr, options::network_no_ipv6},
{"subnet6", required_argument, nullptr, options::network_subnet6},
{"list-networks", no_argument, nullptr, options::list_networks},
{"delete-network", required_argument, nullptr, options::delete_network},
{nullptr, 0, nullptr, 0},
}};
void print_usage(const char* prog) {
fmt::print(
"usage: {0} -m|--mount <image.tar>\n"
" {0} -r|--run <image.tar> [-v <name-or-dir> <container-path>]... [-- <command> [args...]]\n"
" {0} -r|--run <image.tar> [-v <name-or-dir> <container-path>]...\n"
" [-n <network>]... [-- <command> [args...]]\n"
" {0} -u|--umount <layer-id>\n"
" {0} -c|--cleanup <layer-id>\n"
" {0} -l|--list-images <directory>\n"
@@ -94,6 +117,10 @@ void print_usage(const char* prog) {
" {0} --list-volumes\n"
" {0} --delete-volume <name>\n"
" {0} --delete-volume-full <name>\n"
" {0} -n|--network <name> --extern|--intern [--subnet <cidr>]\n"
" [--no-ipv6] [--subnet6 <cidr>]\n"
" {0} --list-networks\n"
" {0} --delete-network <name>\n"
" {0} --list-processes\n"
" {0} --clean-processes\n"
" {0} -w|--write-config\n"
@@ -113,7 +140,7 @@ void print_usage(const char* prog) {
" `containers-storage layers`)\n"
" -c, --cleanup <layer-id> delete a layer and its ancestor chain from\n"
" local storage (unmount it first with --umount)\n"
" -n, --no-nsenter with --run, bind the mount directly instead of\n"
" --no-nsenter with --run, bind the mount directly instead of\n"
" nsenter-ing into fuse-overlayfs's namespace\n"
" (this is automatic when running as root, where\n"
" the mount is already directly visible; pass\n"
@@ -189,6 +216,21 @@ void print_usage(const char* prog) {
" --delete-volume-full <name>\n"
" like --delete-volume, but also recursively\n"
" deletes the volume's host directory\n"
" -n, --network <name> create/manage a persistent named network:\n"
" requires exactly one of --extern (has a path\n"
" to the host's real network) or --intern (only\n"
" reachable by other containers on the same\n"
" network); --subnet <cidr> overrides the\n"
" auto-allocated IPv4 range, --no-ipv6 disables\n"
" (and --subnet6 <cidr> overrides) the\n"
" auto-allocated IPv6 range, on by default. With\n"
" --run, instead join <name> to the container;\n"
" may be repeated, no membership limit\n"
" --list-networks list all named networks (see -n/--network)\n"
" with their kind, IPv4 subnet, and IPv6 subnet\n"
" (or \"(no ipv6)\")\n"
" --delete-network <name>\n"
" remove a named network from the config\n"
" --list-processes list running --run sessions found by their pid\n"
" files under $XDG_STATE_HOME/slocker-lite/run/,\n"
" with their pid, container name, and status\n"
@@ -247,7 +289,7 @@ bool apply_log_level(std::string_view name) {
std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
opterr = 0;
int opt;
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:x:Dw", long_options.data(), nullptr)) != -1) {
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:l:v:i:x:Dwn:", long_options.data(), nullptr)) != -1) {
switch (opt) {
case 'h':
print_usage(argv[0]);
@@ -269,6 +311,8 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
case options::delete_volume_full:
case options::list_processes:
case options::clean_processes:
case options::list_networks:
case options::delete_network:
case options::kill: {
Mode requested;
switch (opt) {
@@ -314,6 +358,12 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
case options::list_processes:
requested = Mode::list_processes;
break;
case options::list_networks:
requested = Mode::list_networks;
break;
case options::delete_network:
requested = Mode::delete_network;
break;
default:
requested = Mode::clean_processes;
break;
@@ -345,6 +395,30 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
break;
}
case 'n':
// -n/--network takes a single token (the name), always via
// getopt's own required_argument -- repeatable, so it's
// accumulated here rather than set as a one-shot Mode like
// 't'/'m'/etc. above. Whether occurrences mean "join" (with
// -r) or "create" (alone, exactly one) is resolved after the
// loop, same as -v/--volume's own standalone-vs-with-run split.
out.network_specs.push_back(optarg);
break;
case options::network_extern:
out.network_extern_flag = true;
break;
case options::network_intern:
out.network_intern_flag = true;
break;
case options::network_subnet:
out.network_subnet_flag = optarg;
break;
case options::network_no_ipv6:
out.network_no_ipv6_flag = true;
break;
case options::network_subnet6:
out.network_subnet6_flag = optarg;
break;
case options::no_nsenter:
out.disable_nsenter = true;
break;
case 'D':
@@ -396,6 +470,46 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
out.mode = Mode::volume;
}
// Same standalone-vs-with-run split as -v/--volume above: -n/--network
// alone (exactly one occurrence) creates/manages a network; with -r, each
// occurrence joins one (no count limit -- a container can join any number
// of networks).
if (!out.network_specs.empty() && out.mode != Mode::run) {
if (out.mode != Mode::none) {
spdlog::error("--network can only be used standalone or together with --run");
print_usage(argv[0]);
return 1;
}
if (out.network_specs.size() > 1) {
spdlog::error("--network can only be used once outside of --run");
print_usage(argv[0]);
return 1;
}
out.mode = Mode::network;
}
bool network_create_flags_given = out.network_extern_flag || out.network_intern_flag ||
out.network_subnet_flag.has_value() || out.network_no_ipv6_flag ||
out.network_subnet6_flag.has_value();
if (network_create_flags_given && out.mode != Mode::network) {
spdlog::error(
"--extern/--intern/--subnet/--no-ipv6/--subnet6 require standalone --network (not --run)");
print_usage(argv[0]);
return 1;
}
if (out.mode == Mode::network) {
if (out.network_extern_flag == out.network_intern_flag) {
spdlog::error("--network requires exactly one of --extern or --intern");
print_usage(argv[0]);
return 1;
}
if (out.network_subnet6_flag && out.network_no_ipv6_flag) {
spdlog::error("--subnet6 and --no-ipv6 can't be used together");
print_usage(argv[0]);
return 1;
}
}
if (out.mode == Mode::none) {
print_usage(argv[0]);
return 1;
+13 -1
View File
@@ -43,7 +43,10 @@ enum class Mode {
clean_processes,
exec,
kill,
write_config
write_config,
network,
list_networks,
delete_network
};
// Everything parse_args() extracts from argv, ready to hand to
@@ -58,6 +61,15 @@ struct ParsedArgs {
std::optional<std::string> hostname_flag;
std::vector<std::pair<std::string, std::string>> volume_specs;
std::vector<EnvSpec> env_specs;
// -n/--network occurrences, name only (repeatable). With -r, each is a
// network to join; alone, exactly one names the network to create --
// together with the flags below (see docs/networking-design.md).
std::vector<std::string> network_specs;
bool network_extern_flag = false;
bool network_intern_flag = false;
std::optional<std::string> network_subnet_flag;
bool network_no_ipv6_flag = false;
std::optional<std::string> network_subnet6_flag;
// Trailing argv (after getopt_long stops), populated only for
// Mode::run/Mode::exec -- the command to run, or to run under -x/--exec.
std::vector<std::string> command;
+138
View File
@@ -38,6 +38,7 @@
#include "env_spec.h"
#include "exec_session.h"
#include "kill_session.h"
#include "network_subnet.h"
#include "oci_image.h"
#include "pid_file.h"
#include "process.h"
@@ -366,6 +367,133 @@ int delete_volume_command(const std::string& name, const std::filesystem::path&
return 0;
}
// -n/--network's standalone (create) use. Config-only for now -- no bridge,
// namespace, or iptables state is created yet (see docs/networking-design.md's
// commit sequence; that lands in later commits).
int create_network_command(const std::string& name, NetworkKind kind,
const std::optional<std::string>& subnet_override, bool ipv6,
const std::optional<std::string>& subnet6_override,
const std::filesystem::path& config_path, AppConfig& config) {
for (const auto& network : config.networks) {
if (network.name == name) {
spdlog::error("a network named '{}' already exists", name);
return 1;
}
}
std::string subnet;
if (subnet_override) {
if (!is_valid_ipv4_cidr(*subnet_override)) {
spdlog::error("invalid --subnet '{}': must be an IPv4 CIDR (e.g. 10.168.0.0/24)", *subnet_override);
return 1;
}
for (const auto& network : config.networks) {
if (ipv4_cidrs_overlap(*subnet_override, network.subnet)) {
spdlog::error("--subnet {} overlaps existing network '{}' ({})", *subnet_override, network.name,
network.subnet);
return 1;
}
}
subnet = *subnet_override;
} else {
auto allocated = allocate_ipv4_subnet(config.networks);
if (!allocated) {
spdlog::error("no free IPv4 subnet available (10.168.0.0/24 through 10.168.255.0/24 exhausted)");
return 1;
}
subnet = *allocated;
}
std::string subnet6;
if (ipv6) {
if (subnet6_override) {
if (!is_valid_ipv6_cidr(*subnet6_override)) {
spdlog::error("invalid --subnet6 '{}': must be an IPv6 CIDR", *subnet6_override);
return 1;
}
for (const auto& network : config.networks) {
if (network.ipv6 && ipv6_cidrs_overlap(*subnet6_override, network.subnet6)) {
spdlog::error("--subnet6 {} overlaps existing network '{}' ({})", *subnet6_override,
network.name, network.subnet6);
return 1;
}
}
subnet6 = *subnet6_override;
} else {
auto allocated6 = allocate_ipv6_subnet(config.networks);
if (!allocated6) {
spdlog::error("no free IPv6 subnet available");
return 1;
}
subnet6 = *allocated6;
}
}
config.networks.push_back({name, kind, subnet, ipv6, subnet6});
if (!write_config_file(config_path, config)) {
return 1;
}
fmt::print("created network '{}' ({}) -> {}{}\n", name, kind == NetworkKind::extern_ ? "extern" : "intern",
subnet, ipv6 ? fmt::format(", {}", subnet6) : std::string());
return 0;
}
int list_networks_command(const AppConfig& config) {
std::vector<std::string> names;
std::vector<std::string> kinds;
std::vector<std::string> subnets;
names.reserve(config.networks.size());
kinds.reserve(config.networks.size());
subnets.reserve(config.networks.size());
size_t max_name_len = 0;
size_t max_kind_len = 0;
size_t max_subnet_len = 0;
for (const auto& network : config.networks) {
names.push_back(network.name);
kinds.push_back(network.kind == NetworkKind::extern_ ? "extern" : "intern");
subnets.push_back(network.subnet);
max_name_len = std::max(max_name_len, names.back().size());
max_kind_len = std::max(max_kind_len, kinds.back().size());
max_subnet_len = std::max(max_subnet_len, subnets.back().size());
}
// Independently tracked per column, same scheme as list_processes_command().
size_t name_target_tabs = max_name_len / tab_width + 1;
size_t kind_target_tabs = max_kind_len / tab_width + 1;
size_t subnet_target_tabs = max_subnet_len / tab_width + 1;
for (size_t i = 0; i < config.networks.size(); ++i) {
size_t name_tabs_used = names[i].size() / tab_width;
size_t name_tabs_needed = name_target_tabs > name_tabs_used ? name_target_tabs - name_tabs_used : 1;
size_t kind_tabs_used = kinds[i].size() / tab_width;
size_t kind_tabs_needed = kind_target_tabs > kind_tabs_used ? kind_target_tabs - kind_tabs_used : 1;
size_t subnet_tabs_used = subnets[i].size() / tab_width;
size_t subnet_tabs_needed = subnet_target_tabs > subnet_tabs_used ? subnet_target_tabs - subnet_tabs_used : 1;
fmt::print("{}{}{}{}{}{}{}\n", names[i], std::string(name_tabs_needed, '\t'), kinds[i],
std::string(kind_tabs_needed, '\t'), subnets[i], std::string(subnet_tabs_needed, '\t'),
config.networks[i].ipv6 ? config.networks[i].subnet6 : std::string("(no ipv6)"));
}
return 0;
}
int delete_network_command(const std::string& name, const std::filesystem::path& config_path, AppConfig& config) {
auto it = std::find_if(config.networks.begin(), config.networks.end(),
[&](const NetworkEntry& network) { return network.name == name; });
if (it == config.networks.end()) {
spdlog::error("no network named '{}' exists", name);
return 1;
}
config.networks.erase(it);
if (!write_config_file(config_path, config)) {
return 1;
}
fmt::print("deleted network '{}'\n", name);
return 0;
}
// Writes out every supported config option explicitly, defaulting anything
// currently unset to its effective value, creating the file (and its parent
// directory) if it doesn't exist yet -- unlike create_volume_command()/
@@ -573,6 +701,16 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config
return kill_session(*args.kill_pid);
case Mode::write_config:
return write_config_command(config_path, config);
case Mode::network: {
NetworkKind kind = args.network_extern_flag ? NetworkKind::extern_ : NetworkKind::intern;
return create_network_command(args.network_specs.front(), kind, args.network_subnet_flag,
!args.network_no_ipv6_flag, args.network_subnet6_flag, config_path,
config);
}
case Mode::list_networks:
return list_networks_command(config);
case Mode::delete_network:
return delete_network_command(args.mode_arg, config_path, config);
case Mode::run: {
// As root, containers-storage mount doesn't need to reexec into a private
// user namespace to gain privilege, so the mount is already directly
+72
View File
@@ -165,6 +165,58 @@ std::optional<AppConfig> load_config_file(const std::filesystem::path& path) {
}
}
}
// Unlike volumes (a flat name -> directory mapping), each network entry
// is itself a nested mapping (kind/subnet/ipv6/subnet6), since a network
// needs more than one field to describe.
if (const yaml_node_t* networks = find_in_mapping(document, *root, "networks")) {
if (networks->type == YAML_MAPPING_NODE) {
for (auto* pair = networks->data.mapping.pairs.start;
pair < networks->data.mapping.pairs.top; ++pair) {
yaml_node_t* key_node = yaml_document_get_node(&document, pair->key);
yaml_node_t* value_node = yaml_document_get_node(&document, pair->value);
if (!key_node || key_node->type != YAML_SCALAR_NODE || !value_node ||
value_node->type != YAML_MAPPING_NODE) {
continue;
}
const yaml_node_t* kind_node = find_in_mapping(document, *value_node, "kind");
const yaml_node_t* subnet_node = find_in_mapping(document, *value_node, "subnet");
if (!kind_node || kind_node->type != YAML_SCALAR_NODE || !subnet_node ||
subnet_node->type != YAML_SCALAR_NODE) {
continue;
}
std::string_view kind_str = scalar_value(*kind_node);
NetworkEntry entry;
if (kind_str == "extern") {
entry.kind = NetworkKind::extern_;
} else if (kind_str == "intern") {
entry.kind = NetworkKind::intern;
} else {
continue; // unrecognized kind -- skip, forward-compatible
}
entry.name = std::string(scalar_value(*key_node));
entry.subnet = std::string(scalar_value(*subnet_node));
entry.ipv6 = true; // default when the key is absent/unparseable
if (const yaml_node_t* ipv6_node = find_in_mapping(document, *value_node, "ipv6")) {
if (ipv6_node->type == YAML_SCALAR_NODE) {
if (auto parsed = parse_bool_flag(scalar_value(*ipv6_node))) {
entry.ipv6 = *parsed;
}
}
}
if (entry.ipv6) {
if (const yaml_node_t* subnet6_node = find_in_mapping(document, *value_node, "subnet6")) {
if (subnet6_node->type == YAML_SCALAR_NODE) {
entry.subnet6 = std::string(scalar_value(*subnet6_node));
}
}
}
config.networks.push_back(std::move(entry));
}
}
}
}
yaml_document_delete(&document);
@@ -211,6 +263,26 @@ bool write_config_file(const std::filesystem::path& path, const AppConfig& confi
yaml_document_append_mapping_pair(&document, root, add_scalar(document, "volumes"), volumes);
}
if (!config.networks.empty()) {
int networks = add_mapping(document);
for (const auto& network : config.networks) {
int entry = add_mapping(document);
yaml_document_append_mapping_pair(
&document, entry, add_scalar(document, "kind"),
add_scalar(document, network.kind == NetworkKind::extern_ ? "extern" : "intern"));
yaml_document_append_mapping_pair(&document, entry, add_scalar(document, "subnet"),
add_scalar(document, network.subnet));
yaml_document_append_mapping_pair(&document, entry, add_scalar(document, "ipv6"),
add_scalar(document, network.ipv6 ? "true" : "false"));
if (network.ipv6) {
yaml_document_append_mapping_pair(&document, entry, add_scalar(document, "subnet6"),
add_scalar(document, network.subnet6));
}
yaml_document_append_mapping_pair(&document, networks, add_scalar(document, network.name), entry);
}
yaml_document_append_mapping_pair(&document, root, add_scalar(document, "networks"), networks);
}
FILE* file = std::fopen(path.c_str(), "w");
if (!file) {
spdlog::error("failed to open {} for writing", path.string());
+32 -8
View File
@@ -30,10 +30,31 @@ struct VolumeEntry {
std::string directory; // stored absolute + lexically-normalized
};
// `extern_` (trailing underscore: "extern" is a reserved C++ keyword, can't be
// used as an enumerator name) networks have a path to the host's real network;
// `intern` networks are only reachable by other containers joined to the same
// network, never from the host or outside. See docs/networking-design.md.
enum class NetworkKind {
extern_,
intern
};
// A persistent named network (-n/--network) that -r/--run containers can join.
// `subnet`/`subnet6` are CIDRs (IPv4/IPv6 respectively), either auto-allocated
// by network_subnet.h or given explicitly via --subnet/--subnet6 at creation
// time. `subnet6` is only meaningful (non-empty) when `ipv6` is true.
struct NetworkEntry {
std::string name;
NetworkKind kind;
std::string subnet;
bool ipv6 = true;
std::string subnet6;
};
// Fields that make sense to persist across invocations (one-shot flags like
// -m/-r/--user don't belong here). Only the "global" section's log-level/
// unshare-* keys and the "volumes" section are supported today; add more
// optional fields as more long options gain config-file support.
// unshare-* keys and the "volumes"/"networks" sections are supported today;
// add more optional fields as more long options gain config-file support.
struct AppConfig {
std::optional<std::string> log_level; // global.log-level
@@ -51,20 +72,23 @@ struct AppConfig {
std::optional<bool> unshare_cgroup; // global.unshare-cgroup (default: enabled)
std::vector<VolumeEntry> volumes; // volumes section: name -> directory
std::vector<NetworkEntry> networks; // networks section: name -> {kind, subnet, ipv6, subnet6}
};
// $XDG_CONFIG_HOME/slocker-lite/config.yaml, or $HOME/.config/slocker-lite/config.yaml
// if XDG_CONFIG_HOME is unset/empty.
std::filesystem::path config_file_path();
// Loads and parses `path`'s "global" and "volumes" sections. A missing file is not
// an error -- returns a default-constructed AppConfig (nothing set). Unknown
// sections/keys (and malformed individual volume entries) are ignored, so the
// Loads and parses `path`'s "global", "volumes", and "networks" sections. A
// missing file is not an error -- returns a default-constructed AppConfig
// (nothing set). Unknown sections/keys (and malformed individual volume/network
// entries -- e.g. a network with an unrecognized `kind`) are ignored, so the
// format stays forward-compatible. Malformed YAML syntax logs a specific error and
// returns nullopt.
std::optional<AppConfig> load_config_file(const std::filesystem::path& path);
// Writes `config` back to `path` as YAML (global + volumes sections), creating
// `path`'s parent directory if needed. Rewrites the whole file. Logs a specific
// error and returns false on failure.
// Writes `config` back to `path` as YAML (global + volumes + networks sections),
// creating `path`'s parent directory if needed. Rewrites the whole file. Logs a
// specific error and returns false on failure.
bool write_config_file(const std::filesystem::path& path, const AppConfig& config);
+145
View File
@@ -0,0 +1,145 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "network_subnet.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <fmt/core.h>
namespace {
struct ParsedCidr {
std::vector<uint8_t> addr; // 4 bytes (IPv4) or 16 bytes (IPv6)
int prefix;
};
std::optional<ParsedCidr> parse_cidr(int af, const std::string& cidr) {
auto slash = cidr.find('/');
if (slash == std::string::npos || slash == 0 || slash + 1 >= cidr.size()) {
return std::nullopt;
}
std::string addr_part = cidr.substr(0, slash);
std::string prefix_part = cidr.substr(slash + 1);
if (!std::all_of(prefix_part.begin(), prefix_part.end(),
[](unsigned char c) { return std::isdigit(c); })) {
return std::nullopt;
}
char* end = nullptr;
long prefix = std::strtol(prefix_part.c_str(), &end, 10);
int addr_len = (af == AF_INET) ? 4 : 16;
if (!end || *end != '\0' || prefix < 0 || prefix > addr_len * 8) {
return std::nullopt;
}
std::vector<uint8_t> buf(static_cast<size_t>(addr_len));
if (inet_pton(af, addr_part.c_str(), buf.data()) != 1) {
return std::nullopt;
}
return ParsedCidr{std::move(buf), static_cast<int>(prefix)};
}
// Byte-array CIDR overlap check shared by IPv4 (4-byte) and IPv6 (16-byte)
// addresses: widen the more specific prefix down to the shorter one's mask
// and compare.
bool bytes_overlap(const std::vector<uint8_t>& a, int prefix_a, const std::vector<uint8_t>& b, int prefix_b) {
int min_prefix = std::min(prefix_a, prefix_b);
int full_bytes = min_prefix / 8;
int rem_bits = min_prefix % 8;
for (int i = 0; i < full_bytes; ++i) {
if (a[static_cast<size_t>(i)] != b[static_cast<size_t>(i)]) {
return false;
}
}
if (rem_bits > 0) {
auto mask = static_cast<uint8_t>(0xFF << (8 - rem_bits));
if ((a[static_cast<size_t>(full_bytes)] & mask) != (b[static_cast<size_t>(full_bytes)] & mask)) {
return false;
}
}
return true;
}
// Highest index this project's own auto-allocation ever hands out --
// 10.168.0.0/24..10.168.255.0/24 and fd00:168:0:0::/64..fd00:168:0:255::/64,
// deliberately kept in sync between the two so the common case (no manual
// --subnet/--subnet6 overrides) allocates visibly paired v4/v6 blocks for
// the same network.
constexpr int max_allocation_index = 255;
} // namespace
bool is_valid_ipv4_cidr(const std::string& cidr) { return parse_cidr(AF_INET, cidr).has_value(); }
bool is_valid_ipv6_cidr(const std::string& cidr) { return parse_cidr(AF_INET6, cidr).has_value(); }
bool ipv4_cidrs_overlap(const std::string& a, const std::string& b) {
auto parsed_a = parse_cidr(AF_INET, a);
auto parsed_b = parse_cidr(AF_INET, b);
if (!parsed_a || !parsed_b) {
return false;
}
return bytes_overlap(parsed_a->addr, parsed_a->prefix, parsed_b->addr, parsed_b->prefix);
}
bool ipv6_cidrs_overlap(const std::string& a, const std::string& b) {
auto parsed_a = parse_cidr(AF_INET6, a);
auto parsed_b = parse_cidr(AF_INET6, b);
if (!parsed_a || !parsed_b) {
return false;
}
return bytes_overlap(parsed_a->addr, parsed_a->prefix, parsed_b->addr, parsed_b->prefix);
}
std::optional<std::string> allocate_ipv4_subnet(const std::vector<NetworkEntry>& existing) {
for (int n = 0; n <= max_allocation_index; ++n) {
std::string candidate = fmt::format("10.168.{}.0/24", n);
bool collides = std::any_of(existing.begin(), existing.end(), [&](const NetworkEntry& network) {
return ipv4_cidrs_overlap(candidate, network.subnet);
});
if (!collides) {
return candidate;
}
}
return std::nullopt;
}
std::optional<std::string> allocate_ipv6_subnet(const std::vector<NetworkEntry>& existing) {
// Formatted in plain decimal for readability, not zero-padded hex -- for
// n >= 10 this is still syntactically valid IPv6 (hex digits are a
// superset of decimal digits) and still guaranteed unique per n, it just
// doesn't numerically equal n when read back as hex (e.g. n=15 renders
// as "...::15::/64", which is hex 0x15 = 21, not fifteen). Purely
// cosmetic -- allocation correctness (uniqueness, no overlap) doesn't
// depend on the two matching numerically.
for (int n = 0; n <= max_allocation_index; ++n) {
std::string candidate = fmt::format("fd00:168:0:{}::/64", n);
bool collides = std::any_of(existing.begin(), existing.end(), [&](const NetworkEntry& network) {
return network.ipv6 && ipv6_cidrs_overlap(candidate, network.subnet6);
});
if (!collides) {
return candidate;
}
}
return std::nullopt;
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "config_file.h"
// True if `cidr` parses as a syntactically valid IPv4 CIDR ("a.b.c.d/n",
// 0 <= n <= 32) -- via inet_pton(AF_INET, ...), not hand-rolled parsing.
bool is_valid_ipv4_cidr(const std::string& cidr);
// True if `cidr` parses as a syntactically valid IPv6 CIDR ("addr/n",
// 0 <= n <= 128) -- via inet_pton(AF_INET6, ...). Syntax only, no semantic
// (e.g. ULA-range) check.
bool is_valid_ipv6_cidr(const std::string& cidr);
// True if the two IPv4 CIDRs' address ranges overlap at all (accounting for
// differing prefix lengths). Either failing to parse is treated as "no
// overlap" -- callers are expected to have already validated both with
// is_valid_ipv4_cidr().
bool ipv4_cidrs_overlap(const std::string& a, const std::string& b);
// Same idea for IPv6.
bool ipv6_cidrs_overlap(const std::string& a, const std::string& b);
// Finds the lowest-numbered unused "10.168.<n>.0/24" (n = 0..255) that
// doesn't overlap any subnet already used by `existing` networks (including
// a manually --subnet-overridden one, via ipv4_cidrs_overlap() -- not just
// other auto-allocated ones). nullopt if the whole range is exhausted.
std::optional<std::string> allocate_ipv4_subnet(const std::vector<NetworkEntry>& existing);
// Same idea for the paired IPv6 ULA block: "fd00:168:0:<n>::/64", checked
// against every existing network with ipv6 enabled.
std::optional<std::string> allocate_ipv6_subnet(const std::vector<NetworkEntry>& existing);