Architecture¶
Firewall mode¶
Hook mode¶
Uses OCI hooks to apply per-container nftables rules inside the container's own network namespace. Each container gets an isolated firewall. Works with pasta (rootless default) and slirp4netns.
Lifecycle: Shield.pre_start() installs the OCI hook (idempotent), resolves DNS,
writes profile.allowed, pre-generates the complete nft ruleset to ruleset.nft,
and returns podman args with annotations. On each container start, the OCI hook
reads state_dir from annotations, applies the pre-generated ruleset.nft inside
the container's network namespace, and optionally starts a per-container dnsmasq
instance. Gateway addresses are baked into the ruleset at generation time — no
runtime /proc discovery.
Allowlisting¶
Allowlists are .txt files with one entry per line — domain names or raw
IP/CIDRs. Lines starting with # are comments.
Bundled defaults use domain names because they're stable across IP rotations and easy to audit. DNS resolution uses the best available tier:
- dnsmasq (preferred) — a per-container dnsmasq instance is started by the OCI
hook with
nftset=config entries (one per domain, targetingallow_v4andallow_v6), automatically populating the nft allow sets on every resolution at runtime. Handles IP rotation without manual intervention. Container DNS is redirected to the per-container dnsmasq (127.0.0.1, or a link-local address under krun) via aresolv.confvolume mount. - dig — pre-start
dig +short A/AAAAresolution; IPs cached inprofile.allowedwithst_mtime-based freshness (default 1 hour). - getent — fallback when
digis also absent.
detect_dns_tier() selects the tier automatically based on available binaries,
dnsmasq compile-time nftset support, and whether an enforcing AppArmor profile
confines dnsmasq away from the state directory (see AppArmor).
Bundled profiles¶
| Profile | Contents |
|---|---|
base.txt |
OS repos (Ubuntu, Debian, Fedora, Alpine), NTP, OCSP/CRL |
dev-standard.txt |
GitHub, Docker Hub, PyPI, npm, crates.io, Go proxy, GitLab |
dev-python.txt |
PyPI, conda-forge, readthedocs |
dev-node.txt |
npm, Yarn, jsDelivr, unpkg |
nvidia-hpc.txt |
CUDA toolkit, NGC, NVIDIA repos |
Users can add custom profiles in $XDG_CONFIG_HOME/terok/shield/profiles/.
Persistent deny¶
Operator deny decisions must survive shield up and container restarts.
The mechanism:
deny.list— a per-container file instate_dir; every denied IP is appended here- Denied IPs also go into dedicated nft deny sets (
deny_v4/deny_v6), rejected and logged with theDENIEDprefix; the deny sets are enforced even in bypass mode (shield down) - On deny: remove from the allow set and
live.allowed, add to the deny set, append todeny.list - On allow: if the IP is in
deny.list, remove it there and from the deny set (un-deny), then add to the allow set andlive.allowed - On reload (
shield_up): compute effective allow IPs as(profile.allowed ∪ live.allowed) − deny.listand repopulate the deny sets fromdeny.list
Deny-list reconciliation happens in state.py (StateBundle.read_effective_ips())
before ruleset generation; nft/rules.py receives a flat IP list with denied
entries already subtracted.
IP normalization¶
safe_ip() normalizes all IPs to their canonical string form via
ipaddress.ip_address() / ip_network(). This ensures string comparisons
across state files are reliable regardless of input notation (e.g.
2001:0db8::1 and 2001:db8::1 both normalize to 2001:db8::1).
State bundle layout¶
{state_dir}/
├── hooks/
│ ├── terok-shield-createRuntime.json
│ └── terok-shield-poststop.json
├── terok-shield-hook # entrypoint script (stdlib-only)
├── ruleset.nft # pre-generated nft ruleset (gateways baked in)
├── upstream.dns # persisted upstream DNS address
├── dns.tier # persisted active DNS tier
├── loopback.ports # per-container host-loopback TCP ports
├── profile.allowed # IPs from DNS resolution (preset)
├── profile.domains # domain names for dnsmasq config
├── live.allowed # IPs from manual allow/deny
├── live.domains # domains added at runtime via allow_domain
├── deny.list # persistent deny overrides
├── denied.domains # domains denied at runtime via deny_domain
├── dnsmasq.conf # generated dnsmasq configuration (dnsmasq tier)
├── dnsmasq.pid # dnsmasq PID (dnsmasq tier)
├── dnsmasq.log # dnsmasq query log (for `shield watch`)
├── resolv.conf # bind-mounted over /etc/resolv.conf (dnsmasq tier)
├── container.id # podman container ID (short, 12-char hex)
└── audit.jsonl # per-container audit log
pre_start() always writes the hooks/ descriptors and the
terok-shield-hook entrypoint into the bundle, but podman only uses them
when per-container --hooks-dir persists across restarts. It does not
today — podman drops a per-container --hooks-dir across stop/start
(containers/podman#17935) —
so shield instead installs the hooks once into a global directory
(<state_root>/shield/hooks, e.g. ~/.local/share/terok/shield/hooks) and
registers that directory in podman's containers.conf (hooks_dir under
[engine]; ~/.config/containers/containers.conf for rootless). terok-shield
setup installs the global hooks and patches containers.conf; the rest of the
bundle above stays per-container regardless.
Data flow diagrams¶
deny_ip flow:
deny_ip(container, ip)
│
├── safe_ip(ip) validate + normalize
│
├── nft delete element remove from allow set
│ (best-effort, catch (IP may not be in set if
│ ExecError) already denied earlier)
│
├── remove from live.allowed always runs regardless
│ of nft success
│
├── nft add element add to deny_v4/v6 set
│ (best-effort) (blocks dnsmasq re-allow)
│
└── append to deny.list (deduplicated; deny decisions
stick across restarts)
allow_ip flow:
allow_ip(container, ip)
│
├── safe_ip(ip) validate + normalize
│
├── ip in deny.list?
│ └── yes → remove from deny.list (un-deny)
│ + nft delete from deny set
│
├── nft add element add to allow set
│ (timeout 0s on the dnsmasq tier,
│ so it never auto-expires)
│
└── append to live.allowed (deduplicated)
shield_up (effective IP merge):
StateBundle.read_effective_ips()
│
├── read_allowed_ips()
| |
│ ├── profile.allowed ──┐
│ └── live.allowed ─────┤
│ │
│ ▼
│ union (dedup, profile-first)
│
├── read_denied_ips()
| |
│ └── deny.list ──→ deny set
│
└── effective = allowed − denied
│
▼
add_elements_dual() flat IP list to nft
(nft/rules.py boundary) (deny.list already subtracted;
deny sets repopulated from deny.list)
The OCI hook does not merge at start time — it applies the pre-generated
ruleset.nft, which pre_start() built from the same effective-IP merge.
Audit logging¶
JSON-lines lifecycle logs¶
Each container has its own audit log at {state_dir}/audit.jsonl. Every
lifecycle step logs a separate entry — actions are setup, allowed,
denied, shield_up, shield_down, and shield_quarantine:
{"ts":"...","container":"myproj-1","action":"setup","detail":"profiles=dev-standard"}
{"ts":"...","container":"myproj-1","action":"allowed","dest":"93.184.216.34","detail":"target=example.com"}
{"ts":"...","container":"myproj-1","action":"shield_down","detail":"allow_all=True"}
Audit logging is best-effort — write failures are logged as warnings and ignored to avoid blocking container operations.
Kernel per-packet logs¶
nftables rules log per-packet events via NFLOG (log group 100) — written
to the kernel log and delivered over netlink to userspace consumers
(terok-shield watch, the per-container NFLOG reader):
TEROK_SHIELD_ALLOWED:new connections to the allow set, logged and counted (not rate-limited -- established traffic is accepted earlier in the chain)TEROK_SHIELD_DENIED:traffic rejected by the explicit deny set (operator refused)TEROK_SHIELD_PRIVATE:non-allowlisted private-range traffic rejected (RFC 1918 + RFC 4193/4291)TEROK_SHIELD_BLOCKED:traffic rejected by the terminal default-deny rule (unclassified)TEROK_SHIELD_BYPASS:traffic passing while the shield is bypassed
Public API¶
The package exports a Shield facade class for integration with
terok:
from pathlib import Path
from terok_shield import Shield, ShieldConfig
shield = Shield(ShieldConfig(state_dir=Path("/path/to/state")))
| Method | Purpose |
|---|---|
pre_start(container, profiles) |
Install hooks, resolve DNS, return extra podman args |
allow(container, target) |
Live-allow a domain/IP for a running container |
deny(container, target) |
Live-deny a domain/IP (best-effort) |
down(container, container_id, allow_all=False) |
Switch to bypass mode (accept-all + log) |
up(container, container_id) |
Restore deny-all mode |
quarantine(container) |
Total network blackout (drop all, log dropped traffic) |
state(container) |
Query container shield state (QUARANTINE, UP, DOWN, DISENGAGED, OFFLINE, ERROR) |
rules(container) |
Return current nft ruleset for a container |
resolve(profiles, force=False) |
Resolve DNS profiles and cache results |
status() |
Return mode, profiles, audit config |
check_environment() |
Probe podman/hooks/DNS-tier health for consumers |
preview(down, allow_all) |
Show ruleset that would be applied |
container_id on up / down is the full podman UUID; it routes the
best-effort shield_up/shield_down hub events to the supervisor's
per-container socket.
ShieldConfig is a frozen dataclass with required state_dir: Path and
optional mode, default profiles, loopback ports, profiles dir, audit
settings, and container runtime category (ShieldRuntime). The library
never reads environment variables or config files — all configuration
comes from the caller.
terok imports terok-shield as a library dependency and calls the Python API directly — never the CLI.
Module structure¶
| Module | Role |
|---|---|
__init__.py |
Shield public API facade + lazy re-exports |
nft/rules.py |
RulesetBuilder — ruleset generation, input validation, verification |
nft/constants.py |
Shared literals (NFT_TABLE, private ranges, log prefixes) — no logic |
config.py |
ShieldConfig, ShieldMode, ShieldState, ShieldRuntime, DnsTier, ShieldModeBackend protocol, annotation constants |
state.py |
StateBundle — per-container state bundle layout, effective IP merging |
hooks/mode.py |
HookMode strategy (OCI hooks, per-container netns, dnsmasq lifecycle) |
hooks/install.py |
Hook installation — entrypoints, JSON descriptors, containers.conf patch |
hooks/reader_install.py |
NFLOG reader resource installer |
dns/resolver.py |
DNS resolution via dig / getent, file-based caching |
dns/dnsmasq.py |
dnsmasq config generation, reload, domain add/remove |
dns/apparmor.py |
AppArmor confinement probe + DNS tier selection |
profiles.py |
Profile loading and composition |
audit.py |
JSON-lines audit logging (single file per container) |
run.py |
Subprocess wrappers (nft, nsenter, dig, podman) |
validation.py |
Input validation (container names, allowlist entries) |
util.py |
Small shared utilities |
paths.py |
Host-wide paths and filenames (hook entrypoint name, reader script path) |
podman_info/ |
podman info parsing, hooks-dir discovery, network mode/gateways |
commands.py |
Command registry — subcommand definitions, metadata, and reusable handlers |
cli/main.py |
Standalone CLI entry point + config construction from env/YAML |
watch.py, watchers/ |
terok-shield watch — DNS-log / audit-log / NFLOG event streams |
resources/nft_hook.py |
Stdlib-only OCI hook script — applies ruleset.nft, manages dnsmasq |
resources/reader_hook.py, resources/nflog_reader.py |
Bridge hook + per-container NFLOG reader |
resources/_oci_state.py |
Shared stdlib-only ballast imported by the hook scripts |
Module boundaries are enforced by tach
(tach.toml). nft/rules.py may only import from nft/constants.py and stdlib.