Network Containment
Network containment is SwarmOtter’s fail-closed data-plane routing model.
It applies to torrent-related traffic:
- Peer TCP.
- Peer UDP and uTP.
- DHT UDP.
- PEX-discovered peers.
- UDP tracker announces.
- HTTP and HTTPS tracker announces and supported scrape.
- Webseeds.
- Magnet metadata fetching.
- DNS used by torrent operations.
The API and Web UI are separate control-plane traffic and use
api.bind_address.
Traffic planes
SwarmOtter separates API/Web UI control traffic from torrent data-plane traffic. Network containment applies to the torrent data plane.
flowchart LR
subgraph control["Control plane"]
client["Browser or API client"] -->|"api.bind_address"| api["SwarmOtter API / Web UI"]
end
subgraph data["Torrent data plane"]
engine["SwarmOtter torrent engine"] -->|"containment boundary"| boundary["Required interface, source address, or contained namespace"]
boundary --> torrentNet["Peers, trackers, DHT, PEX peers, webseeds, and torrent DNS"]
end
For the Docker Compose deployment, the containment boundary is Gluetun:
flowchart TB
lan["LAN browser or API client"]
subgraph host["Docker host"]
published["Published port 9091"]
subgraph ns["Shared Gluetun network namespace"]
swarmotter["SwarmOtter service<br/>network_mode: service:vpn"]
firewall["Gluetun firewall"]
end
end
vpn["VPN tunnel"]
lan -->|"API / Web UI"| published --> swarmotter
swarmotter -->|"torrent data plane"| firewall --> vpn
Fail-closed behavior
When strict containment is enabled and the configured path is unavailable, SwarmOtter blocks torrent networking instead of falling back to the default route.
Strict mode is the default. Omitting [network] does not disable containment:
it leaves strict mode without an enforceable path and validation fails before
the control listener or background tasks start. Use explicit disabled mode
only for local development or a separately enforced boundary:
[network]
mode = "disabled"
flowchart TB
operation["Torrent operation"] --> check{"Required path healthy?"}
check -->|"yes"| path["Contained network path"]
check -->|"no"| blocked["Blocked fail closed"]
api["API / Web UI"] --> status["Status and remediation remain available"]
Common fail-closed conditions:
- The required interface does not exist.
- The required interface is down.
- The required interface has no usable IP address.
- A configured source address is no longer assigned.
- IPv6 is required but disabled in network or torrent configuration.
- The route cannot be validated when route validation is enabled.
- DNS cannot be validated when DNS validation is enabled.
- The required network namespace is unavailable.
- Socket binding fails.
Linux route and DNS path checks use ip route get. The official container and
native packages provide the required utility; direct and tarball installs must
install iproute2 on Debian/Ubuntu or iproute on Fedora/RHEL-family systems.
The API reports the current state at:
GET /api/v1/network/health
The Web UI displays the same health state.
Live gate, recovery intent, and bind failures
One process-wide gate covers binders, engines, trackers, peer sessions, webseeds, metadata, DHT, uTP, inbound listeners, and seeders. When a required path disappears, the gate blocks before socket-owning tasks are aborted. Every block advances the cancellation generation. A task from an older generation is therefore cancelled even if recovery follows before it next polls, and a connected stream cannot bridge a fail-closed interval. The API/Web UI listener is outside this data-plane gate and remains available for diagnostics and repair.
HTTP(S) tracker announce/scrape and webseed range reads use one contained HTTP/1 codec. Each redirect hop asks the binder to connect to the target host; the ordinary contained binder resolves it on the contained path, while an enabled SOCKS5 binder keeps the hostname for remote target DNS. TLS wraps only that stream. No connector, independent resolver, or pool can open a data-plane socket. HTTPS-to-HTTP redirects are rejected, decoded bodies are bounded, and exact webseed Range/Content-Range semantics are enforced. UDP scrape is unsupported and makes no network call; UDP announce remains contained and supported unless SOCKS5 TCP-only mode is enabled.
Only work demonstrably live at the block edge receives durable recovery intent. After recovery, SwarmOtter consumes that intent and resumes those downloads, metadata fetches, or active seeders. Paused, merely queued, ratio/idle-stopped, completed-without-a-live-seeder, and stale blocked records do not start because the path recovered.
A source, interface, UDP, or peer-listener bind failure blocks immediately and
reports socket_bind_failed; a generic strict-policy denial reports
blocked_fail_closed. These failures remain latched even if the interface probe
later reports healthy. To recover, submit an explicit full configuration with
PUT /api/v1/settings. SwarmOtter validates the peer-listener bind and, unless
SOCKS5 TCP-only mode is enabled, a contained ephemeral UDP bind before clearing
the latch. If validation or persistence fails, the old configuration remains
active and traffic stays blocked. A partial settings patch, health tick, or
torrent resume does not clear the latch.
SOCKS5 TCP proxy
[network.socks5] is an opt-in TCP CONNECT layer, not a replacement for the
configured containment path. The daemon uses the contained binder to resolve
and connect to the proxy itself. TCP peer IP addresses use SOCKS IP-address
requests; HTTP(S) tracker, scrape, and webseed hostnames use SOCKS domain
requests so target DNS happens at the proxy. If a proxy connection or handshake
fails, the target is not retried directly.
SOCKS5 no-authentication and RFC 1929 username/password authentication are supported. The password is redacted from Settings reads and update results. A blank password in a full Settings save retains the stored value only when the username is unchanged.
The supported proxy mode is deliberately TCP-only. Enabling it requires:
[network.socks5]
enabled = true
host = "proxy.example"
[torrent]
utp_enabled = false
[dht]
enabled = false
The proxy binder blocks UDP sockets and direct target resolution, so UDP
tracker, DHT, and uTP traffic cannot silently escape outside the proxy. SOCKS5
UDP ASSOCIATE and proxy-provided inbound forwarding are not implemented. Peer
listeners remain bound to the configured contained path. NAT-PMP/UPnP mapping
uses the same containment boundary directly for local router traffic; it is not
a proxy or torrent-egress fallback. Network diagnostics expose only the
socks5_enabled and socks5_udp_blocked state, never proxy host or
credentials.
Dynamic interface binding
For DHCP or SLAAC addresses, bind to an interface instead of an address:
[network]
mode = "strict"
required_interface = "br0"
allow_ipv6 = true
fail_closed = true
validate_route = true
validate_dns = true
[torrent]
allow_ipv6 = true
On Linux, SwarmOtter enforces sockets with device-bound sockets. IPv4 and IPv6
connections are both allowed when the interface has usable addresses and both
network.allow_ipv6 and torrent.allow_ipv6 are true. Hostname resolution is
allowed only when DNS is also proven constrained to the configured path, such
as systemd-resolved link DNS reported by resolvectl dns br0.
DNS policy
DNS is part of torrent traffic. In strict containment, hostname resolution must not escape through an unconstrained resolver.
Use one of these patterns:
- Bind to an interface whose DNS is visible to the Linux probe, such as
systemd-resolved link DNS from
resolvectl dns br0. - Use a contained network namespace or container network where DNS is part of the contained path.
- Use IP-literal peers, trackers, and bootstrap nodes when DNS containment is not available.
- Set
validate_dns = truewhen you want network health to reportdns_not_constrainedproactively instead of discovering it at tracker/DHT resolution time.
Health states
| State | Meaning |
|---|---|
healthy | Torrent networking can use the configured contained path. |
disabled | Network containment is disabled. |
interface_missing | The configured interface name is not visible to the daemon. |
interface_down | The configured interface exists but is down. |
no_interface_address | The interface has no usable IPv4 or allowed IPv6 address. |
source_address_missing | A configured source address is not assigned. |
route_invalid | Route validation failed. |
socket_bind_failed | The daemon could not bind a socket to the configured path. |
dns_not_constrained | DNS validation was requested but could not be proven safe. |
network_namespace_unavailable | The daemon is not in the required namespace. |
blocked_fail_closed | Strict containment blocked traffic. |
Privileged local acceptance test
Build and invoke the harness as your normal user. It requests sudo internally
only for ip namespace/link operations:
cargo build --locked -p swarmotterd
scripts/test-network-containment-transition.sh \
"$PWD/target/debug/swarmotterd"
The harness uses no external network or default route. It creates two
PID-qualified namespaces, generates a lawful payload and torrent, and runs a
compact HTTP tracker plus throttled TCP BitTorrent seed in the peer namespace.
The raw torrent is registered through the real API and must show partial
tracker-discovered peer-wire progress. The harness then deletes the daemon veth
and requires interface_missing, network_blocked, empty data-plane scheduler
diagnostics, stable verified bytes, and a responsive /health route. The
tracker, seed, generator, and API clients have no capabilities; SwarmOtter gets
only CAP_NET_RAW for SO_BINDTODEVICE. Cleanup removes both namespaces and
fixture processes.