Add -p/--port-forward: iptables DNAT into extern-joined containers

Commit 5/6 of the network isolation feature (docs/networking-design.md).
port_forward.{h,cpp}: parse_port_forward_spec() parses
"[<network>:]<host-port>:<container-port>"; add_port_forward()
resolves the network (by name, or the container's sole extern network)
against join_networks()'s result and adds the DNAT/FORWARD rules;
remove_port_forward() undoes them. join_networks() (network_join.{h,cpp})
now returns the joined networks with their assigned IPs (was a bare
bool) so port-forward setup knows where to send traffic. -p requires
-r, is repeatable, network names may no longer contain ':' (needed to
keep the spec syntax unambiguous -- is_valid_network_name(),
network_subnet.h).

Two real corrections from testing, not assumed:
- The DNAT rule needs both nat PREROUTING and nat OUTPUT -- PREROUTING
  never sees locally-generated packets (e.g. curl run on the same
  host), only OUTPUT does. PREROUTING-only left the host's own real IP
  connection-refused despite the container being directly reachable.
- curl localhost:<port> still doesn't work even with both chains --
  a separate problem, NAT hairpinning: the container sees an inbound
  packet claiming a loopback source arriving on a non-loopback
  interface and drops it as martian. A net.ipv4.conf.*.route_localnet
  sysctl was tried and confirmed not to fix this alone, then removed
  rather than left in as dead code. Not solved here (would need scoped
  source masquerading or a userland proxy); curl <host's real IP> is
  the actually-relevant, verified-working path for real clients.

Also surfaced (unrelated to -p, found while testing it, not fixed
here): -x/--exec doesn't join the net namespace -- written when this
project never isolated networking at all -- so it currently sees the
host's own network stack instead of a network-isolated session's own.

Verified end-to-end as root (via a scoped doas rule): a container
serving HTTP on an extern network with -p 8080:80 was reachable via
curl <host's real IP>:8080; the rule was confirmed gone after the
session was killed.
This commit is contained in:
2026-08-30 13:28:23 +00:00
parent 8cc967e748
commit 5c87cac430
13 changed files with 528 additions and 49 deletions
+113 -10
View File
@@ -84,7 +84,13 @@ Source layout (all under `src/`):
`--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.
flag, long-only is no real loss" precedent. `-p/--port-forward` (`'p'` was
free) is repeatable the same accumulate-now, resolve-after-the-loop way as
`-n` (`ParsedArgs::port_forward_specs`, raw `"[<network>:]<host-port>:
<container-port>"` strings — actual parsing happens later, in
`port_forward.h`, since resolving which network a spec refers to needs
runtime join state that doesn't exist yet at parse time), but has no
standalone use at all: rejected post-loop unless combined with `-r`.
- `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
@@ -138,7 +144,11 @@ Source layout (all under `src/`):
design and `config_file.{h,cpp}` below for `NetworkEntry`. Joining a network
from `-r/--run` (repeatable `-n <name>`, `ParsedArgs::network_specs`, see
`cli_args.{h,cpp}` above) is handled by `run_container()`, further below,
via `network_join.{h,cpp}` (see below). `create_network_command()` rejects a duplicate name first, then
via `network_join.{h,cpp}` (see below). `create_network_command()` rejects a
name containing `':'` first (`is_valid_network_name()`, `network_subnet.h`
— needed since `port_forward.h`'s `-p` syntax splits a spec on `':'`; a
network name containing one would make that parse ambiguous), then a
duplicate name, 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()`/
@@ -216,7 +226,25 @@ Source layout (all under `src/`):
`join_networks(pid, network_specs, app_config)` (`network_join.h`, see
below) — networks are joined *before* the daemonize report is sent, so a
`-D`-daemonized caller doesn't get control back until network setup has
already had its chance to run.
already had its chance to run. `join_networks()` itself early-returns (no
namespace wait at all) when `network_specs` is empty, so calling it
unconditionally whenever `on_bwrap_pid_known` fires for *any* reason (e.g.
`-D/--daemonize` alone, no `-n`) doesn't cost anything. `-p`'s
`port_forward_specs` (`cli_args.h`) are syntax/range-parsed
(`parse_port_forward_spec()`, `port_forward.h`) up front too — `ok = false`
on a bad spec, same as other validation failures — but *resolving* which
network each targets can only happen after `join_networks()` returns (it
needs to know which networks actually joined, and their assigned IPs), so
that happens in the same `on_bwrap_pid_known` callback, right after the
`join_networks()` call: `add_port_forward()` per spec, collecting the
ones that actually landed into a `std::vector<ActivePortForward>` declared
in `run_container()`'s own scope (captured by reference) — read again
*after* `run_bwrap()` returns to `remove_port_forward()` each one. This
two-places split (add during the callback, remove after `run_bwrap()`
returns) mirrors how `join_networks()`'s own veths don't need an explicit
removal step (the kernel tears them down once the session's namespace
goes away) while port-forward rules — host-global, named, persistent
iptables state — very much do.
- `self_test.{h,cpp}``run_self_tests()` implements `-t/--test`, this
project's own built-in self-test mode (distinct from the Meson-driven
fixture smoke test under `tests/`, described in "Build & test commands"
@@ -613,10 +641,18 @@ Source layout (all under `src/`):
local subnet is already automatic once an address is assigned, no explicit
route command needed for same-bridge reachability regardless of kind).
Every step failure is logged specifically (which command, which network)
and best-effort: `join_networks()` returns `true` only if every requested
network joined, but a failure never kills the already-running session
(network setup can only happen after `bwrap`'s own namespace exists, i.e.
potentially after the sandboxed command is already running). Veth teardown
and best-effort: `join_networks()` returns one `JoinedNetwork {network,
container_ip}` per network that actually joined (in `-n` order, so shorter
than the request list on any partial failure), never fatal to the
already-running session (network setup can only happen after `bwrap`'s own
namespace exists, i.e. potentially after the sandboxed command is already
running) — this return value exists specifically for `port_forward.h`
(see below) to resolve a `-p` spec against which networks/IPs are actually
usable, not as a pass/fail signal on its own. An empty `network_names`
returns immediately (no namespace wait at all), so callers that always
invoke this once `on_bwrap_pid_known` fires for any reason (`commands.cpp`
also fires it for `-D/--daemonize` alone, with no `-n`) don't pay for a
wait that has nothing to do. Veth teardown
needs no explicit code: the kernel destroys an entire veth pair (both
ends, including the one still attached to the bridge) the instant *either*
end's owning namespace is destroyed, so a session's veths disappear on
@@ -630,6 +666,69 @@ Source layout (all under `src/`):
internet through the bridge's NAT; a container joining both an `intern`
and an `extern` network simultaneously got two working interfaces
(`eth0`/`eth1`) with neither one breaking the other.
**Real, separate bug found while testing this commit, not yet fixed** (out
of scope for the networking feature's own commit sequence, noted here so
it isn't lost): `exec_session.cpp`'s `-x/--exec` deliberately never joins
the `net` namespace type (see that file's own entry below — written when
this project genuinely never isolated networking at all, so there was
nothing to join). Now that `-r/--run` sometimes *does* isolate networking
(whenever any `-n` was given), `-x/--exec`'ing into such a session sees the
*host's* network stack instead of the container's — confirmed directly:
execing into a session running a network-isolated `httpd` showed the
host's own unrelated listening ports and failed to reach the container's
own service on `127.0.0.1`. `exec_session.cpp` needs updating to join
`net` too, the same way it already joins `mnt`/`uts`/`ipc`/`pid`/`cgroup`/
`user` when they differ from the caller's own.
- `port_forward.{h,cpp}` — implements `-p`. `parse_port_forward_spec()`
splits `"[<network>:]<host-port>:<container-port>"` on `':'` (2 or 3
fields; the network name is deliberately restricted to excluding `':'` --
`is_valid_network_name()`, `network_subnet.h` -- specifically so this
split stays unambiguous) and validates both ports are `1..65535`
(`.cpp`-local `parse_port()`) -- pure syntax/range parsing, no knowledge of
which networks exist or joined; that's `add_port_forward()`'s job, called
later once `join_networks()` (`network_join.h`) has actually run.
`add_port_forward()` resolves `spec.network` against the `JoinedNetwork`
list -- by name if given (erroring if that network wasn't successfully
joined, or isn't `extern`: an `intern` network's bridge has no path from
the host at all, so forwarding into one could never work), or, if unset,
the container's sole joined `extern` network (erroring if none or more
than one, rather than guessing). Then adds one iptables `DNAT` rule to
**both** `nat PREROUTING` *and* `nat OUTPUT` -- a real bug caught by
testing, not assumed: `PREROUTING`-only left `curl <this host's own real
IP>:<host-port>`, run *on this same host*, connection-refused, since
`PREROUTING` only ever sees packets arriving from an actual network
interface, never locally-generated ones (those go through `OUTPUT`
instead) -- the same split Docker's own DNAT setup already accounts for.
Also adds one `FORWARD ACCEPT` rule for the destination (in case of a
default `FORWARD DROP` policy, which would otherwise silently eat the
forwarded traffic even though the `DNAT` itself succeeded); if a later
rule fails after an earlier one already landed, those are removed again so
a failure doesn't leave a half-applied mapping. **Known limitation, not
solved here, also found by testing**: `curl localhost:<host-port>` (or any
`127.0.0.0/8` destination) specifically still doesn't work even with both
`DNAT` chains covered -- confirmed to be a separate problem, NAT
hairpinning: once `DNAT` rewrites the destination to the container's IP,
the packet still carries its *original* source address (`127.0.0.1`); the
container's own kernel sees an inbound packet claiming to be *from*
loopback arriving on a non-loopback interface (`eth<N>`) and drops it as a
martian source. (A `net.ipv4.conf.{all,lo}.route_localnet=1` sysctl was
tried and confirmed *not* to fix this on its own, then removed again
rather than left in as dead/superstitious code.) A full fix needs source
masquerading scoped to exactly this case (matching only host-local
traffic, not genuine external clients -- unconditionally masquerading
would lose the real client IP for those, a regression) or a userland
proxy, the approach Docker itself historically used for the same reason --
out of scope here; `curl <this host's real, externally-reachable IP>:
<host-port>` (verified working) is the actually-relevant path `-p` exists
for. `remove_port_forward()` (`commands.cpp`'s `run_container()`, called
for each `ActivePortForward` collected during `on_bwrap_pid_known`, after
`run_bwrap()` returns) removes the exact same rules `add_port_forward()`
added -- best-effort, logs a warning on failure, never fatal. **Verified
end-to-end on this dev machine (root, via a scoped `doas` rule)**: a
container serving HTTP on an `extern` network with `-p 8080:80` was
reachable via `curl <host's real IP>:8080` from the host; the rule was
confirmed gone (connection refused) after the session was killed.
- `session_cgroup.{h,cpp}` — gives `--kill` (`kill_session.{h,cpp}`, see
below) a reliable way to find every process a session ever started, however
deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2
@@ -972,7 +1071,11 @@ Source layout (all under `src/`):
`unshare-*` keys.
- `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_ipv4_cidr()`/
computation; no kernel/`ip`/`iptables` calls of its own. `is_valid_network_name()`
(non-empty, no `':'`) mirrors `volume_mount.h`'s `is_valid_volume_name()`
(which rejects `'/'`) — `':'` specifically because `port_forward.h`'s `-p`
syntax splits a spec on it; a network name containing one would make that
parse ambiguous. `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
@@ -1100,8 +1203,8 @@ 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`, `--list-processes`, `--clean-processes`, `-w/--write-config`,
`-t/--test`, `--log-level`, `-h/--help`, `-V/--version`)
`--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`
## Code style
+5 -4
View File
@@ -95,6 +95,7 @@ slocker-lite -V|--version
| `-n, --network <name>` | 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 <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 as its own `eth<N>` 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 <name>` | Remove a named network from the config. |
| `-p, --port-forward [<network>:]<host-port>:<container-port>` | With `--run`, forward a TCP port from the host into the container. `<network>` 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)). |
| `--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. |
@@ -206,10 +207,10 @@ is likewise managed by `-n/--network` rather than hand-edited — see
persistent-network feature design. `-n/--network` both creates a network
(standing up its real bridge/iptables state, root-only) and, combined with
`-r/--run`, joins a container to one or more of them with a real veth
interface and address on each. `-p` port forwarding isn't implemented yet
(a later commit), so nothing outside the container can reach in until then.
A missing config file is fine either way (nothing is overridden, and one
gets created the first time `-v/--volume`/`-n/--network` is used).
interface and address on each; `-p/--port-forward` then forwards a host port
into a container on one of its `--extern` networks. 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
+13
View File
@@ -266,6 +266,19 @@ pass.
- Verify: `-p 8080:80` against a container on an extern network answering
on port 80 is reachable via `curl localhost:8080` from the host; the
rule is gone after the container exits.
- **Landed with two real corrections found by testing** (see
`CLAUDE.md`'s `port_forward.{h,cpp}` entry for the full detail): the
`DNAT` rule needs both `PREROUTING` *and* `OUTPUT` (locally-generated
traffic never traverses `PREROUTING`); and `curl localhost:<port>`
specifically still doesn't work even so (NAT hairpinning — the
container sees an inbound packet claiming a loopback source on a
non-loopback interface and drops it as martian) — verified instead via
`curl <host's real IP>:<port>`, the actually-relevant path for real
clients. Also surfaced, unrelated to `-p` itself but found while
testing it: `-x/--exec` doesn't join the `net` namespace (written back
when this project never isolated networking at all), so it currently
sees the *host's* network stack, not a network-isolated session's own —
not fixed as part of this commit.
6. **Crash-orphan cleanup sweep**
- Extend `--clean-processes` (or add a dedicated `--clean-networks`,
+1 -1
View File
@@ -23,7 +23,7 @@ slocker_lite = executable('slocker-lite',
'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/network_subnet.cpp', 'src/persistent_netns.cpp', 'src/network_bridge.cpp',
'src/network_join.cpp'],
'src/network_join.cpp', 'src/port_forward.cpp'],
include_directories : include_directories('.'),
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
install : true)
+23 -3
View File
@@ -64,7 +64,7 @@ constexpr int list_networks = 274;
constexpr int delete_network = 275;
} // namespace options
constexpr std::array<struct option, 35> long_options = {{
constexpr std::array<struct option, 36> long_options = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
@@ -99,6 +99,7 @@ constexpr std::array<struct option, 35> long_options = {{
{"subnet6", required_argument, nullptr, options::network_subnet6},
{"list-networks", no_argument, nullptr, options::list_networks},
{"delete-network", required_argument, nullptr, options::delete_network},
{"port-forward", required_argument, nullptr, 'p'},
{nullptr, 0, nullptr, 0},
}};
@@ -106,7 +107,8 @@ 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>]...\n"
" [-n <network>]... [-- <command> [args...]]\n"
" [-n <network>]... [-p [<network>:]<host-port>:<container-port>]...\n"
" [-- <command> [args...]]\n"
" {0} -u|--umount <layer-id>\n"
" {0} -c|--cleanup <layer-id>\n"
" {0} -l|--list-images <directory>\n"
@@ -231,6 +233,12 @@ void print_usage(const char* prog) {
" (or \"(no ipv6)\")\n"
" --delete-network <name>\n"
" remove a named network from the config\n"
" -p, --port-forward [<network>:]<host-port>:<container-port>\n"
" with --run, forward a TCP port from the host\n"
" into the container. <network> is optional --\n"
" defaults to the container's sole --extern\n"
" network (an error if it joined more than one);\n"
" may be repeated\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"
@@ -289,7 +297,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:l:v:i:x:Dwn:", long_options.data(), nullptr)) != -1) {
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:l:v:i:x:Dwn:p:", long_options.data(), nullptr)) != -1) {
switch (opt) {
case 'h':
print_usage(argv[0]);
@@ -418,6 +426,12 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
case options::network_subnet6:
out.network_subnet6_flag = optarg;
break;
case 'p':
// Repeatable, only meaningful with -r -- same accumulate-now,
// resolve-after-the-loop shape as -n above, except -p has no
// standalone use at all (checked post-loop).
out.port_forward_specs.push_back(optarg);
break;
case options::no_nsenter:
out.disable_nsenter = true;
break;
@@ -510,6 +524,12 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
}
}
if (!out.port_forward_specs.empty() && out.mode != Mode::run) {
spdlog::error("-p/--port-forward can only be used together with --run");
print_usage(argv[0]);
return 1;
}
if (out.mode == Mode::none) {
print_usage(argv[0]);
return 1;
+7
View File
@@ -70,6 +70,13 @@ struct ParsedArgs {
std::optional<std::string> network_subnet_flag;
bool network_no_ipv6_flag = false;
std::optional<std::string> network_subnet6_flag;
// -p/--port-forward occurrences, raw
// "[<network>:]<host-port>:<container-port>" strings (repeatable, only
// meaningful with -r) -- parsed into
// PortForwardSpecs later, in commands.cpp (port_forward.h), since
// resolving which network a spec refers to needs runtime join state that
// doesn't exist yet at parse time.
std::vector<std::string> port_forward_specs;
// 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;
+41 -8
View File
@@ -43,6 +43,7 @@
#include "network_subnet.h"
#include "oci_image.h"
#include "pid_file.h"
#include "port_forward.h"
#include "process.h"
#include "self_test.h"
#include "user_spec.h"
@@ -380,6 +381,10 @@ 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) {
if (!is_valid_network_name(name)) {
spdlog::error("network name '{}' must not contain ':'", name);
return 1;
}
for (const auto& network : config.networks) {
if (network.name == name) {
spdlog::error("a network named '{}' already exists", name);
@@ -548,7 +553,8 @@ int run_container(const std::filesystem::path& image_tar,
const std::optional<std::string>& hostname,
const std::vector<std::pair<std::string, std::string>>& volume_specs,
const std::vector<EnvSpec>& env_specs, bool daemonize_flag,
const std::vector<std::string>& network_specs, const AppConfig& app_config) {
const std::vector<std::string>& network_specs,
const std::vector<std::string>& port_forward_specs, const AppConfig& app_config) {
// Only depends on image_tar, so this can run before mount_image() -- moved
// up here (rather than right before the run_bwrap() call, as before) so
// daemonize() below can use the real container name for the log file from
@@ -615,6 +621,18 @@ int run_container(const std::filesystem::path& image_tar,
ok = false;
}
// Syntax/range only -- which network each spec resolves to isn't known
// until join_networks() has actually run, once bwrap starts.
std::vector<PortForwardSpec> parsed_port_forwards;
for (const auto& spec : port_forward_specs) {
auto parsed = parse_port_forward_spec(spec);
if (!parsed) {
ok = false;
continue;
}
parsed_port_forwards.push_back(*parsed);
}
// Falls back to the image's own declared user (config.User) when --user wasn't
// given on the command line, rather than always defaulting to root.
std::optional<std::string> effective_user = user;
@@ -664,15 +682,26 @@ int run_container(const std::filesystem::path& image_tar,
}
}
// Populated inside on_bwrap_pid_known (below), read again after
// run_bwrap() returns so each rule can be removed -- port forwards are
// host-global, named, persistent iptables state, unlike the veths
// join_networks() sets up, which the kernel tears down on its own once
// the session's namespace goes away.
std::vector<ActivePortForward> active_port_forwards;
std::function<void(pid_t)> on_bwrap_pid_known;
if (daemonize_flag || !network_specs.empty()) {
on_bwrap_pid_known = [&](pid_t pid) {
// Joining networks first: report_daemon_started() below is what
// unblocks the parent (-D/--daemonize) waiting on the pipe, so
// network setup should have already had its chance to run by
// then rather than racing an already-returned parent.
if (!network_specs.empty()) {
join_networks(pid, network_specs, app_config);
// Joining networks (and, in turn, port-forwarding into them)
// first: report_daemon_started() below is what unblocks the
// parent (-D/--daemonize) waiting on the pipe, so setup should
// have already had its chance to run by then rather than racing
// an already-returned parent.
auto joined = join_networks(pid, network_specs, app_config);
for (const auto& spec : parsed_port_forwards) {
if (auto active = add_port_forward(spec, joined)) {
active_port_forwards.push_back(*active);
}
}
if (daemonize_flag) {
report_daemon_started(container_name, pid);
@@ -689,6 +718,10 @@ int run_container(const std::filesystem::path& image_tar,
}
}
for (const auto& active : active_port_forwards) {
remove_port_forward(active);
}
if (!unmount_layer(mounted->top_layer_id)) {
spdlog::error("failed to unmount layer {}", mounted->top_layer_id);
}
@@ -764,7 +797,7 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config
}
return run_container(args.mode_arg, args.command, use_nsenter, args.user_flag, args.group_flag,
args.hostname_flag, args.volume_specs, args.env_specs, args.daemonize_flag,
args.network_specs, config);
args.network_specs, args.port_forward_specs, config);
}
}
return 1; // unreachable
+28 -19
View File
@@ -145,9 +145,9 @@ std::optional<std::string> pick_free_address(const NetworkEntry& network, bool i
return std::nullopt;
}
bool join_one_network(pid_t ns_pid, const NetworkEntry& network, int if_index) {
std::optional<std::string> join_one_network(pid_t ns_pid, const NetworkEntry& network, int if_index) {
if (!ensure_network_provisioned(network)) {
return false;
return std::nullopt;
}
std::string bridge = bridge_name(network.name);
@@ -160,50 +160,50 @@ bool join_one_network(pid_t ns_pid, const NetworkEntry& network, int if_index) {
if (!run(wrap_for_network(network, {"ip", "link", "add", host_veth, "type", "veth", "peer", "name", peer_veth}),
"create veth pair", network.name)) {
return false;
return std::nullopt;
}
if (!run(wrap_for_network(network, {"ip", "link", "set", host_veth, "master", bridge}), "attach veth to bridge",
network.name) ||
!run(wrap_for_network(network, {"ip", "link", "set", host_veth, "up"}), "bring host veth up",
network.name)) {
return false;
return std::nullopt;
}
if (!run(wrap_for_network(network, {"ip", "link", "set", peer_veth, "netns", fmt::to_string(ns_pid)}),
"move veth into the container's namespace", network.name)) {
return false;
return std::nullopt;
}
std::string container_if = fmt::format("eth{}", if_index);
if (!run(wrap_in_container(ns_pid, {"ip", "link", "set", peer_veth, "name", container_if}),
"rename the container's interface", network.name)) {
return false;
return std::nullopt;
}
auto container_ip = pick_free_address(network, false);
if (!container_ip) {
spdlog::error("no free IPv4 address available on network '{}'", network.name);
return false;
return std::nullopt;
}
if (!run(wrap_in_container(ns_pid, {"ip", "addr", "add", *container_ip, "dev", container_if}),
"assign the container's IPv4 address", network.name)) {
return false;
return std::nullopt;
}
if (network.ipv6) {
auto container_ip6 = pick_free_address(network, true);
if (!container_ip6) {
spdlog::error("no free IPv6 address available on network '{}'", network.name);
return false;
return std::nullopt;
}
if (!run(wrap_in_container(ns_pid, {"ip", "-6", "addr", "add", *container_ip6, "dev", container_if}),
"assign the container's IPv6 address", network.name)) {
return false;
return std::nullopt;
}
}
if (!run(wrap_in_container(ns_pid, {"ip", "link", "set", container_if, "up"}), "bring the container's interface up",
network.name)) {
return false;
return std::nullopt;
}
if (network.kind == NetworkKind::extern_) {
@@ -228,33 +228,42 @@ bool join_one_network(pid_t ns_pid, const NetworkEntry& network, int if_index) {
}
}
return true;
// Bare IP, no prefix -- what port_forward.h needs as a DNAT target;
// pick_free_address() returns the CIDR form since that's what `ip addr
// add` needs above.
return container_ip->substr(0, container_ip->find('/'));
}
} // namespace
bool join_networks(pid_t bwrap_outer_pid, const std::vector<std::string>& network_names, const AppConfig& config) {
std::vector<JoinedNetwork> join_networks(pid_t bwrap_outer_pid, const std::vector<std::string>& network_names,
const AppConfig& config) {
std::vector<JoinedNetwork> joined;
if (network_names.empty()) {
return joined; // nothing to do -- skip the namespace wait entirely
}
auto ns_pid = wait_for_isolated_net_namespace(bwrap_outer_pid, 3000);
if (!ns_pid) {
spdlog::error("timed out waiting for the session's own network namespace; not joining any network");
return false;
return joined;
}
bool all_ok = true;
int if_index = 0;
for (const auto& name : network_names) {
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; not joining it", name);
all_ok = false;
++if_index;
continue;
}
if (!join_one_network(*ns_pid, *it, if_index)) {
if (auto container_ip = join_one_network(*ns_pid, *it, if_index)) {
joined.push_back({*it, *container_ip});
} else {
spdlog::error("failed to join network '{}'", name);
all_ok = false;
}
++if_index;
}
return all_ok;
return joined;
}
+23 -4
View File
@@ -23,10 +23,24 @@
#include "config_file.h"
// One network a session successfully joined, and the IPv4 address it was
// assigned there -- what port_forward.h needs to resolve a -p spec (by name,
// or by "the container's sole extern network" when none is given) to an
// actual DNAT target.
struct JoinedNetwork {
NetworkEntry network;
std::string container_ip;
};
// Joins a just-started -r/--run session (identified by bwrap_outer_pid, the
// same pid pid_file.h/session_cgroup.h already track) to each named network
// in `network_names`. Waits (bounded) for the session's own isolated network
// namespace to actually exist first -- bwrap's outer/tracked pid never
// in `network_names` -- an empty list returns immediately (an empty result),
// skipping the namespace wait below entirely, so callers that always invoke
// this (e.g. commands.cpp's on_bwrap_pid_known, which also has
// -D/--daemonize's own reason to fire regardless of whether any networks
// were requested) don't pay for a wait that has nothing to do. Waits
// (bounded) for the session's own isolated network namespace to actually
// exist first -- bwrap's outer/tracked pid never
// itself enters the namespaces it creates for its clone()'d child (see
// sandbox_process.h's resolve_namespace_pid()), and that child may not even
// exist yet the instant this is called (run_bwrap()'s on_bwrap_pid_known
@@ -45,5 +59,10 @@
// but doesn't abort joining the rest, and never kills the already-running
// session -- network setup can only happen after bwrap's own namespace
// exists, i.e. after the sandboxed command may already be running. Returns
// true only if every requested network joined successfully.
bool join_networks(pid_t bwrap_outer_pid, const std::vector<std::string>& network_names, const AppConfig& config);
// one JoinedNetwork per network that actually joined successfully (in `-n`
// order) -- shorter than `network_names` (down to empty) if any failed;
// failures are already logged individually, this return value is for
// callers (port_forward.h) that need to know which networks/IPs are
// actually usable, not for a pass/fail check on its own.
std::vector<JoinedNetwork> join_networks(pid_t bwrap_outer_pid, const std::vector<std::string>& network_names,
const AppConfig& config);
+2
View File
@@ -140,6 +140,8 @@ constexpr int max_allocation_index = 255;
} // namespace
bool is_valid_network_name(std::string_view name) { return !name.empty() && name.find(':') == std::string_view::npos; }
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(); }
+8
View File
@@ -19,10 +19,18 @@
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "config_file.h"
// True if `name` is usable as a network name: non-empty and contains no ':'
// -- mirrors volume_mount.h's is_valid_volume_name() (which rejects '/'),
// but ':' specifically because port_forward.h's -p syntax
// ("[<network>:]<host-port>:<container-port>") splits on it; a network name
// containing ':' would make that parse ambiguous.
bool is_valid_network_name(std::string_view name);
// 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);
+189
View File
@@ -0,0 +1,189 @@
// 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 "port_forward.h"
#include <algorithm>
#include <cstdlib>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "config_file.h"
#include "process.h"
namespace {
std::optional<int> parse_port(const std::string& text) {
char* end = nullptr;
long parsed = std::strtol(text.c_str(), &end, 10);
if (text.empty() || !end || *end != '\0' || parsed < 1 || parsed > 65535) {
return std::nullopt;
}
return static_cast<int>(parsed);
}
} // namespace
std::optional<PortForwardSpec> parse_port_forward_spec(const std::string& text) {
std::vector<std::string> parts;
size_t start = 0;
while (true) {
size_t colon = text.find(':', start);
parts.push_back(text.substr(start, colon == std::string::npos ? std::string::npos : colon - start));
if (colon == std::string::npos) {
break;
}
start = colon + 1;
}
if (parts.size() != 2 && parts.size() != 3) {
spdlog::error("invalid -p spec '{}': expected [<network>:]<host-port>:<container-port>", text);
return std::nullopt;
}
std::optional<std::string> network;
const std::string& host_port_str = parts[parts.size() - 2];
const std::string& container_port_str = parts[parts.size() - 1];
if (parts.size() == 3) {
network = parts[0];
}
auto host_port = parse_port(host_port_str);
auto container_port = parse_port(container_port_str);
if (!host_port || !container_port) {
spdlog::error("invalid -p spec '{}': ports must be 1-65535", text);
return std::nullopt;
}
return PortForwardSpec{network, *host_port, *container_port};
}
std::optional<ActivePortForward> add_port_forward(const PortForwardSpec& spec,
const std::vector<JoinedNetwork>& joined) {
const JoinedNetwork* target = nullptr;
if (spec.network) {
auto it = std::find_if(joined.begin(), joined.end(),
[&](const JoinedNetwork& j) { return j.network.name == *spec.network; });
if (it == joined.end()) {
spdlog::error("-p: network '{}' was not successfully joined", *spec.network);
return std::nullopt;
}
if (it->network.kind != NetworkKind::extern_) {
spdlog::error("-p: network '{}' is intern, not extern -- port forwarding requires an extern network",
*spec.network);
return std::nullopt;
}
target = &*it;
} else {
const JoinedNetwork* found = nullptr;
int extern_count = 0;
for (const auto& j : joined) {
if (j.network.kind == NetworkKind::extern_) {
found = &j;
++extern_count;
}
}
if (extern_count == 0) {
spdlog::error("-p: no extern network was joined to forward into");
return std::nullopt;
}
if (extern_count > 1) {
spdlog::error(
"-p: container joined more than one extern network; specify which with "
"-p <network>:<host-port>:<container-port>");
return std::nullopt;
}
target = found;
}
std::string dest = fmt::format("{}:{}", target->container_ip, spec.container_port);
std::string host_port_str = fmt::to_string(spec.host_port);
std::string container_port_str = fmt::to_string(spec.container_port);
// Both PREROUTING and OUTPUT: PREROUTING only ever sees packets arriving
// from an actual network interface -- a locally-generated packet (e.g.
// `curl <this host's own real IP>:<host-port>`, run *on this same
// host*) goes through OUTPUT instead, never PREROUTING at all.
// Confirmed by testing: PREROUTING-only left that case
// connection-refused, while the container's own address was directly
// reachable the whole time -- the same PREROUTING/OUTPUT split Docker's
// own DNAT setup already accounts for.
//
// Known limitation, not solved here: `curl localhost:<host-port>` (or
// any 127.0.0.0/8 destination) specifically still doesn't work, even
// with both chains covered above -- confirmed by testing that it's a
// separate problem from the PREROUTING/OUTPUT split: NAT hairpinning.
// Once DNAT rewrites the destination to the container's IP, the packet
// still carries its *original* source address (127.0.0.1); the
// container's own kernel sees an inbound packet claiming to be *from*
// loopback arriving on a non-loopback interface (its `eth<N>`) and
// drops it as a martian source. A full fix needs source masquerading
// for exactly this case (matching only host-local traffic, not genuine
// external clients -- losing the real client IP for those would be a
// regression) or a userland proxy (the approach Docker itself
// historically used for the same reason) -- out of scope here; `curl
// <this host's real, externally-reachable IP>:<host-port>` (verified
// working, see docs/networking-design.md) is the actually-relevant path
// for external clients, which is what -p exists for.
for (auto* chain : {"PREROUTING", "OUTPUT"}) {
if (run_process({"iptables", "-t", "nat", "-A", chain, "-p", "tcp", "--dport", host_port_str, "-j", "DNAT",
"--to-destination", dest})
.exit_code != 0) {
spdlog::error("-p: failed to add {} DNAT rule for host port {}", chain, spec.host_port);
for (auto* added : {"PREROUTING", "OUTPUT"}) {
if (added == chain) {
break;
}
run_process({"iptables", "-t", "nat", "-D", added, "-p", "tcp", "--dport", host_port_str, "-j",
"DNAT", "--to-destination", dest});
}
return std::nullopt;
}
}
if (run_process({"iptables", "-A", "FORWARD", "-p", "tcp", "-d", target->container_ip, "--dport",
container_port_str, "-j", "ACCEPT"})
.exit_code != 0) {
spdlog::error("-p: failed to add FORWARD accept rule for host port {}; removing the DNAT rules too",
spec.host_port);
for (auto* chain : {"PREROUTING", "OUTPUT"}) {
run_process({"iptables", "-t", "nat", "-D", chain, "-p", "tcp", "--dport", host_port_str, "-j", "DNAT",
"--to-destination", dest});
}
return std::nullopt;
}
return ActivePortForward{spec.host_port, target->container_ip, spec.container_port};
}
void remove_port_forward(const ActivePortForward& active) {
std::string dest = fmt::format("{}:{}", active.container_ip, active.container_port);
std::string host_port_str = fmt::to_string(active.host_port);
std::string container_port_str = fmt::to_string(active.container_port);
for (auto* chain : {"PREROUTING", "OUTPUT"}) {
if (run_process({"iptables", "-t", "nat", "-D", chain, "-p", "tcp", "--dport", host_port_str, "-j", "DNAT",
"--to-destination", dest})
.exit_code != 0) {
spdlog::warn("failed to remove {} DNAT rule for host port {}", chain, active.host_port);
}
}
if (run_process({"iptables", "-D", "FORWARD", "-p", "tcp", "-d", active.container_ip, "--dport",
container_port_str, "-j", "ACCEPT"})
.exit_code != 0) {
spdlog::warn("failed to remove FORWARD accept rule for host port {}", active.host_port);
}
}
+75
View File
@@ -0,0 +1,75 @@
// 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 "network_join.h"
// One -p spec, parsed but not yet resolved against which networks a
// container actually joined (that only happens once join_networks() has
// run) -- `network` names which one to target, or is unset to mean "the
// container's sole extern network."
struct PortForwardSpec {
std::optional<std::string> network;
int host_port;
int container_port;
};
// Parses "[<network>:]<host-port>:<container-port>" (2 or 3 ':'-separated
// fields) into a PortForwardSpec -- ports must be 1-65535. nullopt (logging a
// specific error) on any syntax/range problem.
std::optional<PortForwardSpec> parse_port_forward_spec(const std::string& text);
// One -p mapping that's actually live -- returned by add_port_forward() so
// remove_port_forward() can later remove the *exact* same iptables rules (a
// deletion has to match the addition's rule spec precisely).
struct ActivePortForward {
int host_port;
std::string container_ip;
int container_port;
};
// Resolves `spec.network` against `joined` (from join_networks()) -- by name
// if given (erroring if that network wasn't successfully joined, or isn't
// `extern`: an `intern` network's bridge has no path from the host at all,
// so forwarding into one could never work), or, if unset, the container's
// sole joined `extern` network (erroring if none or more than one). Then
// adds a `DNAT` rule (`nat` `PREROUTING` *and* `nat` `OUTPUT`, TCP only)
// sending `spec.host_port` to the resolved container's
// IP:`spec.container_port` -- both chains, not just `PREROUTING`, since
// `PREROUTING` only ever sees packets arriving from an actual network
// interface; a locally-generated packet (`curl localhost:<host-port>`, or
// the host's own real IP, run *on this same host*) goes through `OUTPUT`
// instead (confirmed by testing: `PREROUTING`-only left exactly that case
// connection-refused while the container was directly reachable the whole
// time -- the same split Docker's own DNAT setup already accounts for).
// Also adds one `FORWARD` `ACCEPT` rule for that same destination (needed in
// case of a default `FORWARD DROP` policy, which would otherwise silently
// eat the forwarded traffic even though the `DNAT` itself succeeded). If a
// later rule fails after earlier ones already landed, those are removed
// again so a failure doesn't leave a half-applied mapping. Best-effort/
// non-fatal like join_networks() itself: logs and returns nullopt on
// failure, never kills the already-running session.
std::optional<ActivePortForward> add_port_forward(const PortForwardSpec& spec, const std::vector<JoinedNetwork>& joined);
// Removes the two rules add_port_forward() added for `active`. Best-effort:
// logs a warning on failure, never fatal (mirroring release_session_lock()'s
// own never-fatal cleanup).
void remove_port_forward(const ActivePortForward& active);