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