Add network isolation design doc

Captures the design discussion for persistent extern/intern networks,
-n/--network, and -p port forwarding: root-only Linux bridge+veth
(no slirp4netns needed once root is assumed), extern vs intern
differing only in where the bridge lives, iptables-based DNAT for -p,
and the subnet/IPv6/CLI/config-schema decisions. No code yet -- this
is the confirmed design to implement against.
This commit is contained in:
2026-08-30 12:03:12 +00:00
parent ace71e0474
commit f0aa913014
+170
View File
@@ -0,0 +1,170 @@
# Network isolation design
Status: design confirmed, not yet broken into a file-by-file implementation
plan; no code written yet. Captured 2026-08-30 on the `networking` branch.
## Goal
Add persistent, named networks that `-r/--run` containers can join, similar
in spirit to how `-v/--volume` already creates persistent named volumes:
- `extern` networks: reachable from/to the host's real network.
- `intern` networks: only reachable by other containers that joined the same
network, never from the host or outside.
- `-r/--run` takes one or more `-n/--network <name>` flags to join networks.
- `-p [<network>:]<host-port>:<container-port>` forwards a host port into a
container on one of its extern networks.
## Why not slirp4netns
`slirp4netns` (and `pasta`) exist specifically to provide networking to an
*unprivileged* network namespace without CAP_NET_ADMIN or host iptables
access — a strict 1-to-1 usermode-NAT translator between the host and exactly
one namespace. Neither tool bridges multiple namespaces together; Podman's
own rootless multi-container networks get inter-container connectivity by
putting every container of that network into **one shared** namespace holding
a real Linux bridge, with only that one namespace getting a `slirp4netns`/
`pasta` instance for outside access.
This project's target device is Android and normally has root available
(`slocker-lite-priv-drop`'s whole reason to exist is making root-mode
`-r/--run` first-class). **Root-only for now** — rootless networking is
explicitly deferred as its own future task. Given root is assumed, the
usermode-NAT workaround buys nothing: native Linux bridge/veth/iptables is
faster (kernel-native routing/NAT, no usermode packet copy), simpler (no
extra long-lived process per network to babysit), and is exactly what
Docker/Podman themselves do when running as root. `slirp4netns`/`pasta` are
dropped from the design entirely; the target device doesn't need either
installed for this feature.
`bwrap` itself has no "join an existing network namespace" flag (confirmed
via `bwrap --help`): it has `--userns FD`/`--pidns FD` (join-by-FD) for
user/pid, but only `--unshare-net` (always *create new*) for networking. To
put a container into a specific persistent namespace, the join happens the
same way this project already joins the rootless `containers-storage`
mount's namespace: wrap the `bwrap` invocation in `nsenter --net=<path>`
first, and don't ask `bwrap` for `--unshare-net` on that invocation.
`bwrap.cpp`'s existing `wrap_for_root_namespace()` is precisely this pattern
already (for `mnt`/`user`) and is directly reusable for `net`.
## Mechanism
Both `extern` and `intern` networks use the same core mechanism — a Linux
bridge, with one veth pair per joined container (container-side end moved
into that container's own, still separately-`--unshare-net`'ed namespace; the
bridge-side end attached to the network's bridge). **The only structural
difference is where the bridge lives:**
- **extern**: the bridge lives directly in the *host's own* root network
namespace, so it already has a path outside via the host's real routing.
Needs `net.ipv4.ip_forward=1` (and the IPv6 forwarding sysctl, if IPv6 is
enabled for that network) plus one iptables `MASQUERADE` (SNAT) rule for
the bridge's subnet — the same pattern `docker0` uses.
- **intern**: the bridge lives inside its own dedicated, free-standing
namespace (persistent the way `ip netns add` keeps a namespace alive with
no process in it — bind-mounting its `ns/net` file to a path that outlives
any one process). No route out exists at all: a real structural isolation
boundary, not merely "extern but without a NAT rule" (which would still be
reachable from the host itself).
Each container always keeps its own private net namespace, so its own
loopback and interface set never clash with another container's. Joining N
networks is just N veth pairs into that one namespace — genuine multi-network
membership falls out naturally, no namespace-sharing tricks needed.
## Port forwarding (`-p`)
No `slirp4netns` `hostfwd` API to lean on, so this is implemented directly:
- An iptables `DNAT` rule in `PREROUTING`:
`--dport <host-port> -j DNAT --to-destination <container-veth-ip>:<container-port>`.
- A `FORWARD` `ACCEPT` rule for the bridge's subnet — needed because a
default `FORWARD DROP` policy (plausible on a stock Android kernel) would
otherwise silently eat the forwarded traffic, the same gap Docker itself
works around.
Syntax: `-p [<network>:]<host-port>:<container-port>`. `<network>` is
optional — when omitted, resolves to whichever single extern network the
container joined; an error (not a silent guess) if the container joined more
than one extern network and didn't disambiguate.
## Subnet / IP allocation
- IPv4 auto-allocates a `/24` starting at `10.168.0.0/24`, incrementing per
network (`10.168.1.0/24`, `10.168.2.0/24`, ...), with an optional
`--subnet <cidr>` override at `-n/--network` creation time.
- **IPv6 is a per-network on/off option, defaulting to enabled.** When on,
also auto-allocates a ULA `/64` alongside the IPv4 block from a matching
incrementing base (proposed: `fd00:168:0:1::/64`, `fd00:168:0:2::/64`, ...
— mirroring the `168` from the v4 base so the two are visibly paired), with
a `--subnet6 <cidr>` override. `--no-ipv6` at creation time skips both the
v6 allocation and any `ip6tables`/IPv6-forwarding setup for that network
entirely.
## CLI surface
`-n/--network`, dual-purpose like `-v/--volume` (alone creates/manages a
network; combined with `-r` joins it) — with a simpler token shape than
`-v`'s, since a network join has no equivalent of a volume's
container-mount-path second argument:
- Alone: `-n <name> --extern|--intern [--subnet <cidr>] [--no-ipv6] [--subnet6 <cidr>]`
`<name>` is `-n`'s own `required_argument` (a single token, standard
`getopt_long`); kind and the subnet/ipv6 options are ordinary separate
flags rather than additional positional tokens.
- With `-r`: `-n <name>` is repeatable, one token (just the name) per
occurrence — joins that network. No membership limit.
- `--list-networks` / `--delete-network <name>` round out the set, mirroring
`--list-volumes` / `--delete-volume`.
## Config file schema
New top-level `networks` section in `config.yaml`, parallel to `volumes`
(`config_file.{h,cpp}`) — per entry: `name`, `kind` (`extern`/`intern`),
`subnet`, `ipv6` (bool), `subnet6` (if `ipv6`). `create_volume_command()` /
`list_volumes_command()` / `delete_volume_command()` (`commands.cpp`) are the
direct templates to follow, including the shared tab-aligned listing helper
for `--list-networks`.
A network's config-file entry is the durable source of truth, the same way a
volume's directory path is. The live host-side state (namespace file for
`intern`, bridge, veths, iptables rules) doesn't survive a reboot and is
reconciled lazily and transparently — checked and, if missing/stale,
recreated from the config entry — the first time anything needs it after a
reboot (no explicit "start" step; the first `-r/--run -n <name>` or `-p`
after boot just works).
## Tooling
Shell out to `iptables`/`ip6tables` (via `process.h`'s existing
`run_process()`, matching how `containers-storage`/`bwrap`/`fuse-overlayfs`
are already invoked) for `MASQUERADE`/`DNAT`/`FORWARD` rules, and to `ip` for
bridge/veth/namespace management. Both need an on-device availability check
the same way `check_required_dependencies()` already gates on
`containers-storage`/`bwrap`.
**iptables only, for now** — confirmed available on the real target device;
`nftables` is not currently installed there. `nft` support is real future
work (its own task, once it actually matters — e.g. a device that only ships
`nft`), not built preemptively as a dual-backend abstraction here.
## Host-global state discipline
Unlike a `bwrap` session (self-contained via its own namespaces),
bridges/veths/iptables rules are host-global, named, persistent resources
that outlive any single process. Needs the same create-on-start/
remove-on-stop discipline already used for pid locks (`pid_file.{h,cpp}`) and
cgroups (`session_cgroup.{h,cpp}`), plus a `--clean-processes`-style sweep for
anything orphaned by a crash, so a dead `slocker-lite` doesn't leave stray
bridges/veths/iptables rules behind forever.
## Explicitly out of scope for now
- **Rootless networking.** An earlier draft of this design considered a
hybrid strategy (real bridge+veth when root, a simpler shared-network-
namespace fallback when rootless, mirroring `--kill`'s multi-strategy
pattern). Shelved: root-only is sufficient for the actual target device
today, and the rootless fallback has real limitations (single-network
membership only, no per-container port isolation on it) not worth building
before there's an actual rootless use case.
- **nftables backend.** iptables only, see above.