terok_shield
terok_shield
¶
terok-shield: nftables-based egress firewalling for Podman containers.
Public API facade. The Shield class coordinates collaborators:
- HookMode (
hooks.mode) — per-container nft ruleset lifecycle - DnsResolver (
dns.resolver) — domain resolution and caching - ProfileLoader (
profiles) — allowlist profile composition - RulesetBuilder (
nft.rules) — nftables ruleset generation - AuditLogger (
audit) — per-container JSONL audit trail - CommandRunner (
run) — subprocess execution boundary
Core and support modules are imported lazily — from terok_shield
import ShieldConfig does not pull in nft, dnsmasq, or subprocess
helpers. Heavy imports are deferred until Shield is instantiated.
HOOK_ENTRYPOINT_NAME = 'terok-shield-hook'
module-attribute
¶
Canonical filename of the shield OCI hook entrypoint script.
Used (a) under ~/.local/share/containers/oci/hooks.d/ for user-wide
installation and (b) under each per-container state_dir after
Shield.pre_start() materialises it. Keeping both sites consuming
the same constant means renaming the entrypoint is a single edit.
COMMANDS = CommandTree((_lazy('status', 'Show shield configuration overview', 'observe:STATUS'), _lazy('prepare', 'Prepare shield and print podman flags', 'launch:PREPARE'), _lazy('run', 'Launch a shielded container via podman', 'launch:RUN'), _lazy('resolve', 'Resolve DNS profiles and cache IPs', 'launch:RESOLVE'), _lazy('allow', 'Live-allow a domain or IP for a container', 'control:ALLOW'), _lazy('deny', 'Live-deny a domain or IP for a container', 'control:DENY'), _lazy('down', 'Switch container to the DOWN posture (accept + log)', 'control:DOWN'), _lazy('up', 'Restore deny-all mode for a container', 'control:UP'), _lazy('reset', 'Forget DNS-learned allow state (back to authored policy seeds)', 'control:RESET'), _lazy('quarantine', 'Total network blackout (drop all, log dropped traffic)', 'control:QUARANTINE'), _lazy('rules', 'Show current nft rules for a container', 'control:RULES'), _lazy('watch', 'Stream shield events — audit log, NFLOG packets, and DNS blocks on the dnsmasq tiers', 'stream:WATCH'), _lazy('simple-clearance', 'Terminal clearance fallback — prompts operator for each blocked connection (no D-Bus)', 'stream:SIMPLE_CLEARANCE'), _lazy('logs', 'Show audit log entries', 'observe:LOGS'), _lazy('profiles', 'List available shield profiles', 'observe:PROFILES'), _lazy('setup', 'Install global OCI hooks for restart persistence', 'launch:SETUP'), _lazy('check-environment', 'Check podman environment for compatibility issues', 'observe:CHECK_ENVIRONMENT'), _lazy('preview', 'Show ruleset that would be applied', 'control:PREVIEW')))
module-attribute
¶
logger = logging.getLogger(__name__)
module-attribute
¶
__version__ = _meta_version('terok-shield')
module-attribute
¶
__all__ = ['ArgDef', 'COMMANDS', 'CommandDef', 'DnsTier', 'EnvironmentCheck', 'ExecError', 'HOOK_ENTRYPOINT_NAME', 'HooksInstaller', 'Shield', 'ShieldConfig', 'ShieldMode', 'ShieldRuntime', 'ShieldState', 'ensure_user_hooks_dir_configured', 'recorded_dns_tier', 'user_hooks_dir_configured']
module-attribute
¶
DnsTier
¶
Bases: Enum
How domain allowlists reach the nft allow sets.
dnsmasq with --nftset adds every answered address to the
allow sets before the reply reaches the workload. Follows IP rotation, covers subdomains, accepts wildcard entries, names blocked domains.
DNSMASQ_STATIC: a dnsmasq built without nftset support. The allow sets are
resolved once at launch; the query log still names blocked domains.
LOOKUP: no dnsmasq. The allow sets are resolved once at launch with
dig or drill.
GETENT: no dnsmasq and no lookup tool. The allow sets are resolved once
at launch with getent hosts.
DNSMASQ_LIVE = 'dnsmasq-live'
class-attribute
instance-attribute
¶
DNSMASQ_STATIC = 'dnsmasq-static'
class-attribute
instance-attribute
¶
LOOKUP = 'lookup'
class-attribute
instance-attribute
¶
GETENT = 'getent'
class-attribute
instance-attribute
¶
live
property
¶
True when the allow sets follow DNS answers as they arrive.
runs_dnsmasq
property
¶
True when a per-container dnsmasq serves the container's DNS.
hint
property
¶
What a degraded tier lacks and what restores the live one; empty for the live tier.
parse(recorded)
classmethod
¶
The tier a recorded name means; None for a name that is not a tier.
A retired name reads as the tier it named, so a container recorded under it restarts instead of being recreated.
Source code in src/terok_shield/config.py
ShieldConfig(state_dir, mode=ShieldMode.HOOK, default_profiles=('dev-standard',), loopback_ports=(), audit_enabled=True, profiles_dir=None, runtime=ShieldRuntime.DEFAULT, dns_cache_dir=None, dnsmasq_path=None)
dataclass
¶
Per-container shield configuration.
The library is a pure function of its inputs. Given a
ShieldConfig with state_dir, it writes to that directory
and nowhere else. No env-var reading, no config-file parsing.
state_dir
instance-attribute
¶
mode = ShieldMode.HOOK
class-attribute
instance-attribute
¶
default_profiles = ('dev-standard',)
class-attribute
instance-attribute
¶
loopback_ports = ()
class-attribute
instance-attribute
¶
audit_enabled = True
class-attribute
instance-attribute
¶
profiles_dir = None
class-attribute
instance-attribute
¶
runtime = ShieldRuntime.DEFAULT
class-attribute
instance-attribute
¶
dns_cache_dir = None
class-attribute
instance-attribute
¶
Resolved-allowlist cache shared across containers.
The one deliberate exception to the state_dir-only rule: many tasks with the
same allowlist share one resolve. None selects
dns_cache_dir under the shield state
root. Only the tiers that resolve at launch use it.
dnsmasq_path = None
class-attribute
instance-attribute
¶
The dnsmasq binary to run; None finds one on the current host PATH.
Set it for a dnsmasq built outside the distro package, for example one built with nftset support in the operator's home.
ShieldMode
¶
ShieldRuntime
¶
Bases: Enum
Container runtime category — drives DNS-reachability assumptions.
crun / runc / youki. The container shares the netns,
so dnsmasq on 127.0.0.1 is reachable directly.
KRUN: libkrun microVM. The guest has its own loopback isolated
from the netns, so dnsmasq must bind to a link-local address
on netns lo that the guest can reach via passt.
DEFAULT = 'default'
class-attribute
instance-attribute
¶
KRUN = 'krun'
class-attribute
instance-attribute
¶
from_runtime_name(name)
classmethod
¶
Map a podman --runtime <name> string (or None) to the enum.
Centralises the wire-format vocabulary so callers don't repeat
"krun" → KRUN mappings inline. Anything other than
"krun" (including None and unknown runtime names) maps
to DEFAULT — the loopback-shared-with-netns assumption holds
for every runtime shield has been tested against besides krun.
Source code in src/terok_shield/config.py
ShieldState
¶
Bases: Enum
Per-container shield state, derived from the live nft ruleset.
QUARANTINE: Total network blackout — all traffic dropped, dropped traffic logged. UP: Normal enforcing mode (deny-all with allowlists). DOWN: Accept-by-default posture, private-range protection retained (RFC 1918 + RFC 4193). DISENGAGED: Accept-everything posture — no deny set, no private-range or hard-deny reject. OFFLINE: No ruleset found (container stopped or unshielded). ERROR: Ruleset present but unrecognised.
QUARANTINE = 'quarantine'
class-attribute
instance-attribute
¶
UP = 'up'
class-attribute
instance-attribute
¶
DOWN = 'down'
class-attribute
instance-attribute
¶
DISENGAGED = 'disengaged'
class-attribute
instance-attribute
¶
OFFLINE = 'offline'
class-attribute
instance-attribute
¶
ERROR = 'error'
class-attribute
instance-attribute
¶
HooksInstaller(target_dir=_default_target_dir())
dataclass
¶
Persistent installation of terok-shield's OCI hook pair.
The createRuntime/poststop hook pair must persist across container
restarts: podman ≥ 5.x drops per-container --hooks-dir on
stop/start (containers/podman#17935), so global hooks are the
only reliable activation path until that upstream regression is
fixed.
Scripts, ballast, and JSON descriptors all land in target_dir
(default: namespace_state_dir("shield") / "hooks").
containers.conf is patched to register that path so podman
discovers the descriptors on the next container start.
Symmetric lifecycle: install
writes, uninstall
removes. Both are idempotent.
target_dir = field(default_factory=_default_target_dir)
class-attribute
instance-attribute
¶
Directory the hook scripts, ballast, and JSON descriptors all live in.
check_setup(*, live=False)
¶
Check Shield's receipt and hooks, optionally probing launch prerequisites.
Source code in src/terok_shield/hooks/install.py
install()
¶
Install global standalone hooks after preflight; certify only verified work.
Source code in src/terok_shield/hooks/install.py
uninstall()
¶
Remove every hook file install would write.
Idempotent — missing files are tolerated. containers.conf
is left untouched: other terok packages may still register
their own hooks_dir entries the operator wants to keep.
Source code in src/terok_shield/hooks/install.py
is_installed()
¶
True when target_dir carries the canonical createRuntime hook JSON.
Use check_setup for receipt, interpreter, and artifact validation.
Source code in src/terok_shield/hooks/install.py
ExecError(cmd, rc, stderr)
¶
Bases: Exception
Raised when a subprocess fails.
Store command details and format the error message.
Source code in src/terok_shield/run.py
EnvironmentCheck(dns_tier='', ok=True, podman_version=(0,), hooks='not-installed', health='ok', issues=list(), needs_setup=False, setup_hint='')
dataclass
¶
Result of Shield.check_environment.
Machine-readable fields for programmatic consumers (terok TUI, scripts).
Human-readable issues and setup_hint for CLI display.
Attributes:
| Name | Type | Description |
|---|---|---|
ok |
bool
|
True if no issues found. |
podman_version |
tuple[int, ...]
|
Detected podman version tuple. |
hooks |
str
|
Hook installation type ( |
health |
str
|
Environment health ( |
dns_tier |
str
|
Active DNS resolution tier, a |
issues |
list[str]
|
List of human-readable issue descriptions. |
needs_setup |
bool
|
True if one-time setup is required. |
setup_hint |
str
|
Setup instructions (empty if not needed). |
dns_tier = ''
class-attribute
instance-attribute
¶
ok = True
class-attribute
instance-attribute
¶
podman_version = (0,)
class-attribute
instance-attribute
¶
hooks = 'not-installed'
class-attribute
instance-attribute
¶
health = 'ok'
class-attribute
instance-attribute
¶
issues = field(default_factory=list)
class-attribute
instance-attribute
¶
needs_setup = False
class-attribute
instance-attribute
¶
setup_hint = ''
class-attribute
instance-attribute
¶
Shield(config, *, runner=None, audit=None, dns=None, profiles=None, ruleset=None, hub_events=None)
¶
Public API facade — coordinates collaborators per container.
Delegates to HookMode for netns/nft operations, DnsResolver
for name resolution, ProfileLoader for allowlists,
RulesetBuilder for ruleset generation, and AuditLogger for
the audit trail. All collaborators are injectable for testing.
Create the shield facade.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
ShieldConfig
|
Shield configuration (must include state_dir). |
required |
runner
|
CommandRunner | None
|
Command runner (default: |
None
|
audit
|
AuditLogger | None
|
Audit logger (default: from config.state_dir). |
None
|
dns
|
DnsResolver | None
|
DNS resolver (default: from runner). |
None
|
profiles
|
ProfileLoader | None
|
Profile loader (default: from config.profiles_dir). |
None
|
ruleset
|
RulesetBuilder | None
|
Ruleset builder (default: from config loopback_ports). |
None
|
hub_events
|
HubEventEmitter | None
|
Best-effort emitter for |
None
|
Source code in src/terok_shield/__init__.py
config = config
instance-attribute
¶
runner = runner or SubprocessRunner()
instance-attribute
¶
audit = audit or AuditLogger(audit_path=StateBundle(config.state_dir).audit, enabled=config.audit_enabled)
instance-attribute
¶
dns = dns or DnsResolver(runner=self.runner, host_cache_dir=config.dns_cache_dir)
instance-attribute
¶
profiles = profiles or ProfileLoader(user_dir=config.profiles_dir or Path('/nonexistent'))
instance-attribute
¶
ruleset = ruleset or RulesetBuilder(loopback_ports=config.loopback_ports)
instance-attribute
¶
hub_events = hub_events or HubEventEmitter()
instance-attribute
¶
check_environment()
¶
Check the podman environment for compatibility issues.
Proactive check for API consumers (e.g. terok). Returns an
EnvironmentCheck with detected issues and setup hints.
Does not raise — the caller decides how to handle issues.
Source code in src/terok_shield/__init__.py
status()
¶
Return current shield status information.
pre_start(container, profiles=None, *, security_deny=(), provider_allow=(), project_allow=(), override=())
¶
Prepare shield for container start. Returns extra podman args.
The four tier arguments are the orchestrator-generated policy tiers, which shield writes into the bundle so callers pass data and never touch the layout: security_deny → t20 (vault hosts denied direct), provider_allow → t30 (provider egress), project_allow → t40 (git remote + custom, merged with the composed profiles), override → t10 (break-glass allow above the deny; a CIDR is accepted but logged as a warning).
Source code in src/terok_shield/__init__.py
refresh(container, profiles=None, *, security_deny=(), provider_allow=(), project_allow=(), override=())
¶
Recompute an existing container's policy bundle before a plain restart.
Same tier arguments as pre_start,
but for a container that already exists: rewrites the tiers and
static-resolution caches and regenerates the pre-applied artifacts
(ruleset.nft, dnsmasq config), so the next podman start
enforces current policy instead of the bundle frozen at creation.
Returns nothing — the container keeps its launch-time podman args.
Source code in src/terok_shield/__init__.py
allow(container, target)
¶
Live-allow a domain or IP for a running container.
Source code in src/terok_shield/__init__.py
deny(container, target)
¶
Live-deny a domain or IP for a running container.
Source code in src/terok_shield/__init__.py
rules(container)
¶
down(container, container_id, *, disengaged=False)
¶
Switch a running container to the DOWN posture.
container is the operator-facing podman name (audit log key); container_id is the full podman UUID — the routing key for the per-container hub socket the supervisor listens on. The caller knows both at every emit site, so neither carries a default.
With disengaged, the container takes the DISENGAGED posture instead: nothing is enforced — no deny set, no private-range or hard-deny reject — and every new connection is only logged.
Source code in src/terok_shield/__init__.py
quarantine(container)
¶
Total network blackout — drop all traffic, log dropped traffic.
up(container, container_id)
¶
Restore normal deny-all mode for a running container.
container / container_id — see
down.
Source code in src/terok_shield/__init__.py
reset(container)
¶
Forget DNS-learned allow state, keeping the authored policy seeds.
The live tier accumulates every IP the workload legitimately
resolved; reset returns the allow sets to their just-launched
contents (policy literals only) without touching the deny tier or
the operator's runtime overlay.
Source code in src/terok_shield/__init__.py
state(container)
¶
preview(*, down=False, disengaged=False)
¶
Generate the ruleset that would be applied to a container.
resolve(profiles=None, *, force=False)
¶
Resolve DNS profiles and cache the results.
Source code in src/terok_shield/__init__.py
profiles_list()
¶
tail_log(n=50)
¶
recorded_dns_tier(state_dir)
¶
The DNS tier a shielded container launched with, read from state_dir.
Thin public wrapper over
StateBundle.read_dns_tier
so callers that only want the tier need not know the bundle layout.
Source code in src/terok_shield/state.py
ensure_user_hooks_dir_configured(hooks_dir=None)
¶
Ensure ~/.config/containers/containers.conf lists hooks_dir.
The canonical SSOT for the rootless OCI hooks directory across
every terok package: shield calls it at setup time; other
installers (e.g. terok-sandbox's per-container supervisor) call
it before dropping their own descriptors so they don't have to
re-implement the containers.conf patcher. Idempotent.
hooks_dir defaults to namespace_state_dir("shield") / "hooks"
— shield's canonical install location under paths.root.
Creates the conf file if absent. Inserts hooks_dir into the
existing [engine] section or appends a new section if none
exists. Skips silently when hooks_dir is already listed. When
a different hooks_dir is configured, appends ours to the list
rather than failing — the operator owns containers.conf and may
have intentionally pinned other locations.
Pure line-based editing — comments and formatting are preserved.
Source code in src/terok_shield/hooks/install.py
user_hooks_dir_configured(hooks_dir)
¶
Whether the user's containers.conf registers this package-owned hook directory.
Source code in src/terok_shield/hooks/install.py
__getattr__(name)
¶
Lazy import for re-exported core/support layer names.