diff --git a/CLAUDE.md b/CLAUDE.md index e84b049..61da62d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,11 +76,19 @@ Source layout (all under `src/`): `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 `/`--no-ipv6`/ - `--subnet6 ` (`ParsedArgs::network_extern_flag`/`network_intern_flag`/ - `network_subnet_flag`/`network_no_ipv6_flag`/`network_subnet6_flag`) only + `--subnet6 `/`--no-veth` (`ParsedArgs::network_extern_flag`/`network_intern_flag`/ + `network_subnet_flag`/`network_no_ipv6_flag`/`network_subnet6_flag`/ + `network_no_veth_flag`) only apply to the standalone (create) case and are rejected with a clear error if given any other way (e.g. alongside `-r`) — `Mode::network` additionally - requires exactly one of `--extern`/`--intern`. **`-n` used to belong to + requires exactly one of `--extern`/`--intern`. `--no-veth` forces + `NetworkEntry::veth` (`config_file.h`) to `false` at creation time, + overriding the default `true` — see `network_bridge.h`'s + `probe_veth_support()`/`should_use_veth()` for what this controls: lets + the tap+relay fallback (the real target device's kernel lacks `CONFIG_VETH` + — see `docs/networking-design.md`'s addendum) be exercised on a + veth-capable machine like this dev box, without needing the actual + veth-less hardware. **`-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 @@ -609,6 +617,32 @@ Source layout (all under `src/`): all came up correctly; a real `intern` network's bridge came up inside its own dedicated namespace with neither forwarding nor a NAT rule, confirming the structural (not merely policy) isolation the design calls for. + `probe_veth_support()` (added for the tap+relay fallback, see + `docs/networking-design.md`'s addendum and `network_join.{h,cpp}` below): + the real target device supports `tun`/`tap` but not `veth` + (`CONFIG_VETH` commonly stripped from mobile kernels), so joins there can't + use the veth-pair mechanism this file/`network_join.cpp` otherwise assume. + Probes kernel support the same way `bwrap.cpp`'s + `kernel_supports_namespace()` probes namespace types: forks a child that + `unshare(CLONE_NEWNET)`s into a throwaway namespace and attempts `ip link + add ... type veth peer name ...` there (via the existing `run_process()`) + — the whole namespace, and anything created in it, vanishes with the + child, so no cleanup is needed either way. Cached in a function-local + static (a fixed fact about the running kernel, not something that varies + per network, so a container joining several networks in one run only + probes once). `should_use_veth(network)` combines this with the network's + own `veth` policy flag (`config_file.h`'s `NetworkEntry::veth`, default + `true`) the same "capability and policy are independent gates" way + `namespace_policy_enabled()` (`bwrap.cpp`) already combines kernel support + with `global.unshare-*` policy — both must allow veth for it to actually + be used. **Verified on this dev machine (root, via the scoped `doas` + rule)**: `probe_veth_support()` correctly returns `true` here (a real `ip + link add ... type veth ...` succeeds), and `--no-veth` at network-creation + time correctly persists `NetworkEntry::veth = false`, making + `should_use_veth()` return `false` even though the kernel itself supports + veth — this dev machine's own way to exercise the tap+relay fallback (see + `network_tap_relay.{h,cpp}`, not yet built) without needing the actual + veth-less target device. - `network_join.{h,cpp}` — joins a just-started `-r/--run` session to each network named in `-n`. `join_networks()` first waits (bounded, 3s, 20ms-interval `nanosleep()` polling — `wait_for_isolated_net_namespace()`, @@ -1139,7 +1173,12 @@ Source layout (all under `src/`): 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. + `unshare-*` keys. `veth` (default `true`) round-trips the same way as + `ipv6` (`parse_bool_flag()`, written as canonical `"true"`/`"false"`, + always written regardless of value — unlike `subnet6`, there's no + companion field whose presence depends on it) — see `network_bridge.h`'s + `probe_veth_support()`/`should_use_veth()` above and `--no-veth` + (`cli_args.{h,cpp}`) below for what it controls. - `network_subnet.{h,cpp}` — pure CIDR arithmetic backing `-n/--network`'s subnet allocation and `network_bridge.{h,cpp}`'s (see below) gateway-address computation; no kernel/`ip`/`iptables` calls of its own. `is_valid_network_name()` @@ -1274,7 +1313,7 @@ Build directory is `buildDir/` (already configured). `-l/--list-images`, `-i/--inspect`, `-x/--exec`, `--kill`, `--no-nsenter`, `-D/--daemonize`, `--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`, `--delete-volume-full`, `-n/--network`, `--extern`, `--intern`, `--subnet`, `--no-ipv6`, `--subnet6`, - `--list-networks`, `--delete-network`, `-p/--port-forward`, `--list-processes`, `--clean-processes`, + `--no-veth`, `--list-networks`, `--delete-network`, `-p/--port-forward`, `--list-processes`, `--clean-processes`, `-w/--write-config`, `-t/--test`, `--log-level`, `-h/--help`, `-V/--version`) - Run tests: `meson test -C buildDir` diff --git a/README.md b/README.md index ad77d1c..aa3b4eb 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ slocker-lite -V|--version | `--list-volumes` | List all named volumes (see `-v/--volume`) with their host directory. | | `--delete-volume ` | Remove a named volume from the config. The host directory is left untouched. | | `--delete-volume-full ` | Like `--delete-volume`, but also recursively deletes the volume's host directory. | -| `-n, --network ` | Create/manage a persistent named network: requires exactly one of `--extern` (a real Linux bridge in the host's own namespace, with NAT/forwarding set up so containers on it reach the host's real network) or `--intern` (a bridge inside its own dedicated, routeless namespace, only reachable by other containers on the same network). `--subnet ` overrides the auto-allocated IPv4 range (`10.168.0.0/24`, incrementing per network); `--no-ipv6` disables (and `--subnet6 ` overrides) the auto-allocated IPv6 range, on by default. With `--run`, instead joins `` to the container as its own `eth` interface with an address from the network's subnet; repeatable, no membership limit. Root-only for now. See [`docs/networking-design.md`](docs/networking-design.md). | +| `-n, --network ` | Create/manage a persistent named network: requires exactly one of `--extern` (a real Linux bridge in the host's own namespace, with NAT/forwarding set up so containers on it reach the host's real network) or `--intern` (a bridge inside its own dedicated, routeless namespace, only reachable by other containers on the same network). `--subnet ` overrides the auto-allocated IPv4 range (`10.168.0.0/24`, incrementing per network); `--no-ipv6` disables (and `--subnet6 ` overrides) the auto-allocated IPv6 range, on by default. `--no-veth` forces the tap+relay join fallback even on a kernel that supports veth (useful for testing that path; it's otherwise chosen automatically whenever the running kernel lacks veth support). With `--run`, instead joins `` to the container as its own `eth` interface with an address from the network's subnet; repeatable, no membership limit. Root-only for now. See [`docs/networking-design.md`](docs/networking-design.md). | | `--list-networks` | List all named networks (see `-n/--network`) with their kind, IPv4 subnet, and IPv6 subnet (or `(no ipv6)`). | | `--delete-network ` | Remove a named network from the config. | | `-p, --port-forward [:]:` | With `--run`, forward a TCP port from the host into the container. `` is optional, defaulting to the container's sole `--extern` network (an error if it joined more than one without specifying). Repeatable. Reachable via the host's real, externally-facing IP; `localhost`/loopback access has a known NAT-hairpinning limitation (see [`docs/networking-design.md`](docs/networking-design.md)). | @@ -186,6 +186,7 @@ networks: subnet: 10.168.0.0/24 ipv6: true subnet6: fd00:168:0:0::/64 + veth: true ``` `global.log-level` sets the default log verbosity (an explicit `--log-level` on diff --git a/src/cli_args.cpp b/src/cli_args.cpp index 755f504..5064fd8 100644 --- a/src/cli_args.cpp +++ b/src/cli_args.cpp @@ -37,10 +37,10 @@ namespace { // 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. +// is no real loss. --extern/--intern/--no-ipv6/--no-veth (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; @@ -62,9 +62,10 @@ constexpr int network_no_ipv6 = 272; constexpr int network_subnet6 = 273; constexpr int list_networks = 274; constexpr int delete_network = 275; +constexpr int network_no_veth = 276; } // namespace options -constexpr std::array long_options = {{ +constexpr std::array long_options = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, @@ -97,6 +98,7 @@ constexpr std::array long_options = {{ {"subnet", required_argument, nullptr, options::network_subnet}, {"no-ipv6", no_argument, nullptr, options::network_no_ipv6}, {"subnet6", required_argument, nullptr, options::network_subnet6}, + {"no-veth", no_argument, nullptr, options::network_no_veth}, {"list-networks", no_argument, nullptr, options::list_networks}, {"delete-network", required_argument, nullptr, options::delete_network}, {"port-forward", required_argument, nullptr, 'p'}, @@ -120,7 +122,7 @@ void print_usage(const char* prog) { " {0} --delete-volume \n" " {0} --delete-volume-full \n" " {0} -n|--network --extern|--intern [--subnet ]\n" - " [--no-ipv6] [--subnet6 ]\n" + " [--no-ipv6] [--subnet6 ] [--no-veth]\n" " {0} --list-networks\n" " {0} --delete-network \n" " {0} --list-processes\n" @@ -225,7 +227,9 @@ void print_usage(const char* prog) { " network); --subnet overrides the\n" " auto-allocated IPv4 range, --no-ipv6 disables\n" " (and --subnet6 overrides) the\n" - " auto-allocated IPv6 range, on by default. With\n" + " auto-allocated IPv6 range, on by default; --no-veth\n" + " forces the tap-relay fallback even if the kernel\n" + " supports veth (useful for testing that path). With\n" " --run, instead join to the container;\n" " may be repeated, no membership limit\n" " --list-networks list all named networks (see -n/--network)\n" @@ -426,6 +430,9 @@ std::optional parse_args(int argc, char* argv[], ParsedArgs& out) { case options::network_subnet6: out.network_subnet6_flag = optarg; break; + case options::network_no_veth: + out.network_no_veth_flag = true; + break; case 'p': // Repeatable, only meaningful with -r -- same accumulate-now, // resolve-after-the-loop shape as -n above, except -p has no @@ -504,10 +511,10 @@ std::optional parse_args(int argc, char* argv[], ParsedArgs& out) { 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(); + out.network_subnet6_flag.has_value() || out.network_no_veth_flag; if (network_create_flags_given && out.mode != Mode::network) { spdlog::error( - "--extern/--intern/--subnet/--no-ipv6/--subnet6 require standalone --network (not --run)"); + "--extern/--intern/--subnet/--no-ipv6/--subnet6/--no-veth require standalone --network (not --run)"); print_usage(argv[0]); return 1; } diff --git a/src/cli_args.h b/src/cli_args.h index bd0657a..5d6a9a6 100644 --- a/src/cli_args.h +++ b/src/cli_args.h @@ -70,6 +70,10 @@ struct ParsedArgs { std::optional network_subnet_flag; bool network_no_ipv6_flag = false; std::optional network_subnet6_flag; + // Force the tap+relay fallback (network_tap_relay.h) even on a + // veth-capable kernel -- lets that path be exercised on a dev machine + // that isn't the real veth-less target device. + bool network_no_veth_flag = false; // -p/--port-forward occurrences, raw // "[:]:" strings (repeatable, only // meaningful with -r) -- parsed into diff --git a/src/commands.cpp b/src/commands.cpp index 3511910..28a6970 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -382,7 +382,7 @@ int delete_volume_command(const std::string& name, const std::filesystem::path& // docs/networking-design.md's commit sequence; that lands in a later commit). int create_network_command(const std::string& name, NetworkKind kind, const std::optional& subnet_override, bool ipv6, - const std::optional& subnet6_override, + const std::optional& subnet6_override, bool veth, const std::filesystem::path& config_path, AppConfig& config) { if (!is_valid_network_name(name)) { spdlog::error("network name '{}' must not contain ':'", name); @@ -443,7 +443,7 @@ int create_network_command(const std::string& name, NetworkKind kind, } } - NetworkEntry entry{name, kind, subnet, ipv6, subnet6}; + NetworkEntry entry{name, kind, subnet, ipv6, subnet6, veth}; if (!ensure_network_provisioned(entry)) { spdlog::error("failed to provision network '{}'; not saving it to the config", name); return 1; @@ -797,8 +797,8 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::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); + !args.network_no_ipv6_flag, args.network_subnet6_flag, + !args.network_no_veth_flag, config_path, config); } case Mode::list_networks: return list_networks_command(config); diff --git a/src/config_file.cpp b/src/config_file.cpp index 67b5b91..5b027be 100644 --- a/src/config_file.cpp +++ b/src/config_file.cpp @@ -213,6 +213,15 @@ std::optional load_config_file(const std::filesystem::path& path) { } } + entry.veth = true; // default when the key is absent/unparseable + if (const yaml_node_t* veth_node = find_in_mapping(document, *value_node, "veth")) { + if (veth_node->type == YAML_SCALAR_NODE) { + if (auto parsed = parse_bool_flag(scalar_value(*veth_node))) { + entry.veth = *parsed; + } + } + } + config.networks.push_back(std::move(entry)); } } @@ -278,6 +287,8 @@ bool write_config_file(const std::filesystem::path& path, const AppConfig& confi yaml_document_append_mapping_pair(&document, entry, add_scalar(document, "subnet6"), add_scalar(document, network.subnet6)); } + yaml_document_append_mapping_pair(&document, entry, add_scalar(document, "veth"), + add_scalar(document, network.veth ? "true" : "false")); 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); diff --git a/src/config_file.h b/src/config_file.h index eb3a1a7..14d12eb 100644 --- a/src/config_file.h +++ b/src/config_file.h @@ -49,6 +49,12 @@ struct NetworkEntry { std::string subnet; bool ipv6 = true; std::string subnet6; + // Whether to use a veth pair to join this network when the running kernel + // supports one (the default). Set false (--no-veth at creation time) to + // force the tap+relay fallback (network_tap_relay.h) even on a + // veth-capable kernel -- useful to exercise that path on a dev machine + // that isn't the real veth-less target device. + bool veth = true; }; // Fields that make sense to persist across invocations (one-shot flags like diff --git a/src/network_bridge.cpp b/src/network_bridge.cpp index 21d3474..733fa50 100644 --- a/src/network_bridge.cpp +++ b/src/network_bridge.cpp @@ -16,6 +16,10 @@ #include "network_bridge.h" +#include +#include +#include + #include #include #include @@ -152,6 +156,35 @@ bool check_network_dependencies(const NetworkEntry& network) { return all_found; } +bool probe_veth_support() { + static bool checked = false; + static bool supported = false; + if (checked) { + return supported; + } + checked = true; + + pid_t pid = fork(); + if (pid < 0) { + return supported = false; + } + if (pid == 0) { + if (unshare(CLONE_NEWNET) != 0) { + _exit(1); + } + auto result = run_process({"ip", "link", "add", "slk-veth-probe0", "type", "veth", "peer", "name", + "slk-veth-probe1"}); + _exit(result.exit_code == 0 ? 0 : 1); + } + int status = 0; + waitpid(pid, &status, 0); + supported = WIFEXITED(status) && WEXITSTATUS(status) == 0; + spdlog::debug("veth support: {}", supported ? "supported" : "not supported"); + return supported; +} + +bool should_use_veth(const NetworkEntry& network) { return network.veth && probe_veth_support(); } + bool ensure_network_provisioned(const NetworkEntry& network) { if (!check_network_dependencies(network)) { return false; diff --git a/src/network_bridge.h b/src/network_bridge.h index 09100d9..277a209 100644 --- a/src/network_bridge.h +++ b/src/network_bridge.h @@ -49,6 +49,27 @@ std::vector wrap_for_network(const NetworkEntry& network, std::vect // file's tool set depends on the network's own kind/ipv6 setting. bool check_network_dependencies(const NetworkEntry& network); +// Probes whether the running kernel supports veth (CONFIG_VETH) at all, +// independent of any one network's own `veth` policy flag +// (config_file.h's NetworkEntry::veth) -- the two are separate gates, same +// split as bwrap.cpp's own kernel-capability-vs-policy design for +// --unshare-xxx. Forks a child that unshare(CLONE_NEWNET)s into a throwaway +// namespace and attempts `ip link add ... type veth peer name ...` there +// (via the existing run_process()) -- the whole namespace, and anything +// created in it, vanishes with the child, so no cleanup is needed either +// way. The result is cached (function-local static) since a container +// joining several networks in one run would otherwise probe once per join +// for no reason -- this is a fixed fact about the running kernel, not +// something that varies per network. +bool probe_veth_support(); + +// A network should use veth to join a container (rather than the +// network_tap_relay.h fallback) exactly when its own `veth` policy flag +// allows it *and* the kernel actually supports veth -- both must hold, the +// same "policy and capability are independent gates" pattern +// namespace_policy_enabled() (bwrap.cpp) already uses for --unshare-xxx. +bool should_use_veth(const NetworkEntry& network); + // Ensures `network`'s bridge (and, for `intern`, its dedicated persistent // namespace -- persistent_netns.h) exists and is configured, creating // whatever's missing: