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:
@@ -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.
|
||||
Reference in New Issue
Block a user