Firewall Modes¶
terok-shield uses hook mode — an OCI hook that applies nftables rules inside each container's own network namespace.
Hook mode¶
Self-contained. Uses an OCI hook to apply nftables rules inside each container's own network namespace.
┌──────────────────────────────────────────┐
│ Container netns │
│ │
│ ┌────────────────────────────────────┐ │
│ │ nftables (applied by OCI hook) │ │
│ │ policy: DROP │ │
│ │ allow: DNS, lo, @allow_v4/v6 │ │
│ │ reject: RFC1918, RFC4193 │ │
│ └────────────────────────────────────┘ │
│ │
│ Workload (CAP_NET_ADMIN dropped) │
└──────────────────────────────────────────┘
How it works¶
Shield.pre_start()installs hooks into the container's state directory, processes the allowlist profiles, and pre-generates the complete nft ruleset toruleset.nft. DNS handling differs by tier:- dnsmasq tier: domain names are written to
profile.domainsfor dnsmasq--nftsetruntime resolution; only raw IP entries are resolved and written toprofile.allowed. dnsmasq populates the nft allow sets at runtime as the container makes DNS queries. - dig / getent tier: all entries (domains and raw IPs) are resolved to IPs
at pre-start time and written to
profile.allowed; no runtime resolution.
Returns podman args with OCI annotations (state_dir, loopback_ports,
version, upstream_dns, dns_tier)
2. When podman creates a container with the terok.shield.profiles annotation,
it fires the stdlib-only hook script at the createRuntime stage
3. The hook reads state_dir from annotations, applies ruleset.nft inside the
container's network namespace via nsenter, discovers the gateway from
/proc/{pid}/net/route, and starts a per-container dnsmasq instance if the
dnsmasq tier is active
4. dnsmasq runs inside the container's network namespace with --nftset pointing
to the allow_v4/allow_v6 sets — every DNS resolution automatically adds the
resolved IPs to the live nft allow sets
5. The workload starts with CAP_NET_ADMIN and CAP_NET_RAW dropped, so it
cannot modify the rules
Chain evaluation order¶
loopback → established → DNS → gateway ports → loopback ports → allow_v4/v6 → private-range reject (RFC1918 + RFC4193) → deny all
When to use¶
- Single containers or small deployments
- When you want per-container isolation (each container has its own firewall)
- Simplest setup — just needs
nftbinary
Per-container state bundle¶
Each container's hooks and state are isolated in its own directory:
{state_dir}/
├── hooks/ # OCI hook descriptors
├── terok-shield-hook # Hook entrypoint (stdlib-only Python)
├── ruleset.nft # Pre-generated nft ruleset
├── gateway # Discovered gateway IP
├── profile.allowed # IPs from pre-start DNS resolution
├── profile.domains # Domain names for dnsmasq config
├── live.allowed # IPs from allow/deny
├── live.domains # Domains added at runtime
├── deny.list # Persistent deny overrides
├── denied.domains # Domains denied at runtime
├── dnsmasq.conf # Generated dnsmasq config (dnsmasq tier)
├── dnsmasq.pid # dnsmasq PID (dnsmasq tier)
├── resolv.conf # Bind-mounted /etc/resolv.conf (dnsmasq tier)
├── upstream.dns # Persisted upstream DNS address
├── dns.tier # Persisted active DNS tier
└── audit.jsonl # Per-container audit log
Running containers¶
Via the CLI (recommended for standalone usage):
Via the Python API (this is how terok uses terok-shield as a library):
from terok_shield import Shield, ShieldConfig
shield = Shield(ShieldConfig(state_dir=Path("~/.local/state/terok-shield/containers/my-ctr")))
extra_args = shield.pre_start("my-ctr", ["dev-standard"])
# pass extra_args to podman run
dnsmasq and the nft allow sets¶
When dnsmasq is active, the allow sets are populated dynamically — no manual
terok-shield allow calls are needed for domains already in the profile.
Every dig, getaddrinfo, or HTTP request that triggers a DNS lookup inside
the container adds the resolved IPs to allow_v4/allow_v6 automatically.
To watch the sets grow in real time:
Future modes
Additional modes for different network topologies may be added in the future.