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:
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user