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 = (CommandDef(name='status', help='Show shield configuration overview', handler=_handle_status, args=(ArgDef(name='container', nargs='?', help='Container name — prints firewall state (up/down/disengaged/offline/error)'),)), CommandDef(name='prepare', help='Prepare shield and print podman flags', extras=_NEEDS_CTR_STANDALONE, args=(ArgDef(name='--profiles', type=_csv_list, help="Override default profiles (comma-separated, e.g. 'dev,pypi')"), ArgDef(name='--json', action='store_true', dest='output_json', help='JSON output'))), CommandDef(name='run', help='Launch a shielded container via podman', extras=_NEEDS_CTR_STANDALONE, args=(ArgDef(name='--profiles', type=_csv_list, help="Override default profiles (comma-separated, e.g. 'dev,pypi')"),)), CommandDef(name='resolve', help='Resolve DNS profiles and cache IPs', extras=_NEEDS_CTR_STANDALONE, args=(ArgDef(name='--force', action='store_true', help='Bypass cache freshness'),)), CommandDef(name='allow', help='Live-allow a domain or IP for a container', handler=_handle_allow, extras=_NEEDS_CTR, args=(ArgDef(name='target', help='Domain name or IP address to allow'),)), CommandDef(name='deny', help='Live-deny a domain or IP for a container', handler=_handle_deny, extras=_NEEDS_CTR, args=(ArgDef(name='target', help='Domain name or IP address to deny'),)), CommandDef(name='down', help='Switch container to bypass mode (accept-all + log)', handler=_handle_down, extras=_NEEDS_CTR, args=(_CONTAINER_ID_ARG, ArgDef(name='--all', action='store_true', dest='allow_all', help='Also allow private-range traffic'))), CommandDef(name='up', help='Restore deny-all mode for a container', handler=_handle_up, extras=_NEEDS_CTR, args=(_CONTAINER_ID_ARG,)), CommandDef(name='quarantine', help='Total network blackout (drop all, log dropped traffic)', handler=_handle_quarantine, extras=_NEEDS_CTR), CommandDef(name='rules', help='Show current nft rules for a container', handler=_handle_rules, extras=_NEEDS_CTR), CommandDef(name='watch', help='Stream shield events — DNS blocks, audit log, NFLOG packets (requires dnsmasq tier)', handler=_handle_watch, extras=_NEEDS_CTR), CommandDef(name='simple-clearance', help='Terminal clearance fallback — prompts operator for each blocked connection (no D-Bus)', handler=_handle_simple_clearance, extras=_NEEDS_CTR), CommandDef(name='logs', help='Show audit log entries', handler=_handle_logs, extras=_NEEDS_CTR, args=(ArgDef(name='-n', type=int, default=50, help='Number of recent entries'),)), CommandDef(name='profiles', help='List available shield profiles', handler=_handle_profiles), CommandDef(name='setup', help='Install global OCI hooks for restart persistence', extras=_STANDALONE), CommandDef(name='check-environment', help='Check podman environment for compatibility issues', handler=_handle_check_environment), CommandDef(name='preview', help='Show ruleset that would be applied', handler=_handle_preview, args=(ArgDef(name='--down', action='store_true', help='Show bypass ruleset'), ArgDef(name='--all', action='store_true', dest='allow_all', help='Omit private-range reject rules (requires --down)'))))
module-attribute
¶
logger = logging.getLogger(__name__)
module-attribute
¶
__version__ = _meta_version('terok-shield')
module-attribute
¶
__all__ = ['ArgDef', 'BinaryCheck', 'COMMANDS', 'CommandDef', 'EnvironmentCheck', 'ExecError', 'HOOK_ENTRYPOINT_NAME', 'HooksInstaller', 'NftNotFoundError', 'Shield', 'ShieldConfig', 'ShieldMode', 'ShieldNeedsSetup', 'ShieldRuntime', 'ShieldState', 'check_firewall_binaries', 'check_krun_binaries', 'ensure_user_hooks_dir_configured']
module-attribute
¶
ShieldConfig(state_dir, mode=ShieldMode.HOOK, default_profiles=('dev-standard',), loopback_ports=(), audit_enabled=True, profiles_dir=None, runtime=ShieldRuntime.DEFAULT)
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
¶
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: Bypass mode with private-range protection (RFC 1918 + RFC 4193). DISENGAGED: Bypass mode without private-range protection. 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.
install()
¶
Write entrypoints, ballast, and descriptors to target_dir.
Both hook pairs (nft + reader) and the shared ballast are written unconditionally — the reader hook soft-fails on missing clearance, so installing it on a shield-only host costs nothing and removes a configuration knob. The standalone NFLOG reader resource is copied to its canonical per-user path.
containers.conf is patched to list target_dir in
hooks_dir so podman discovers the descriptors.
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.
A presence probe, not a version check — the
Shield.check_environment
path compares the ballast's BUNDLE_VERSION separately.
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='per-container', 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 ( |
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 = 'per-container'
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))
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
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 | |
status()
¶
Return current shield status information.
pre_start(container, profiles=None)
¶
Prepare shield for container start. Returns extra 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, *, allow_all=False)
¶
Switch a running container to bypass mode.
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.
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
state(container)
¶
preview(*, down=False, allow_all=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)
¶
__getattr__(name)
¶
Lazy import for re-exported core/support layer names.