state
state
¶
Per-container state bundle layout contract.
Every shielded container gets an isolated state directory. This module
is the single source of truth for where files live within it — all
paths are derived from a single state_dir root through
StateBundle.
Bundle layout::
{state_dir}/
├── policy/ # v15 tiered +/- policy (one file per tier set)
│ ├── 10-override # → t10_override
│ ├── 20-security-deny # → t20_security_deny
│ ├── 30-provider-allow # → t30_provider_allow
│ ├── 40-project-allow # → t40_project_allow
│ └── live # runtime overlay (folded into its tiers)
├── resolved.ips # derived: resolved allow IPs (t40 set seed)
├── override_resolved.ips # derived: resolved t10 override IPs (above-deny seed)
├── deny_resolved.ips # derived: resolved t20 security-deny IPs (deny seed)
├── ruleset.nft # pre-generated nft ruleset (gateways baked in)
├── upstream.dns # upstream DNS address
├── dns.tier # active DNS tier
├── dnsmasq.command # symbolic/operator launch choice
├── dnsmasq.bin # live executable identity for cleanup
├── network.mode # rootless network mode (pasta/slirp4netns)
├── loopback.ports # per-container host-loopback TCP ports (newline-separated)
├── dnsmasq.conf # generated dnsmasq configuration
├── dnsmasq.pid # dnsmasq PID (in container netns)
├── dnsmasq.log # dnsmasq query log (for shield watch)
├── resolv.conf # bind-mounted over /etc/resolv.conf on every tier
├── container.id # podman container ID (short, 12-char hex)
└── audit.jsonl # per-container audit log
BUNDLE_VERSION = 18
module-attribute
¶
Integer version of the state bundle layout.
Bumped whenever the file layout changes in a backwards-incompatible way. The OCI hook hard-fails if the annotation version does not match — deliberately no compatibility window and no migration: containers prepared by a different generation fail fast at restart with a message naming the remedy (re-create the task; a running container keeps running untouched, and the task workspace rides its mounts). The hook and package must agree on this protocol.
Current shape (v18): symbolic dnsmasq launch choice is separate from live process identity. Global hooks are setup-owned, not per-container files.
v16: v15 plus two derived seed caches —
override_resolved.ips (t10 break-glass) and deny_resolved.ips
(t20 security-deny). Both tiers are now statically resolved, so each is
repopulated by address on every shield down/up rebuild instead
of depending on the DNS plane to re-learn it. (v15
replaced the six v14 split allow/deny files with the tiered policy/
bundle of unified +/- files plus the derived resolved.ips
cache.) Earlier shapes are recoverable via
git log -L /^BUNDLE_VERSION/:src/terok_shield/state.py.
POLICY_DIR = 'policy'
module-attribute
¶
TIER_FILES = {'override': '10-override', 'security_deny': '20-security-deny', 'provider_allow': '30-provider-allow', 'project_allow': '40-project-allow'}
module-attribute
¶
LIVE_FILE = 'live'
module-attribute
¶
STATE_DIR_MODE = 448
module-attribute
¶
Permission mode for state_dir and its subdirectories.
Owner-only. The OCI hook in _oci_state.py rejects state_dir if
st_mode & 0o022 (group- or world-writable), because a loose mode
would let any local peer drop a ruleset.nft for the hook to apply
with CAP_NET_ADMIN. mkdir(mode=…) is masked by umask, so
the writer side has to chmod after creation to guarantee the bit
pattern the validator demands.
EffectivePolicy(override, security_deny, provider_allow, project_allow, live)
dataclass
¶
Per-tier policy entries read from the bundle, in authority order.
live is the runtime overlay (shield allow/deny); the engine
folds its + entries into the project-allow set and its - entries
into the security-deny set.
override
instance-attribute
¶
security_deny
instance-attribute
¶
provider_allow
instance-attribute
¶
project_allow
instance-attribute
¶
live
instance-attribute
¶
all_entries()
¶
Every entry across tiers, top-to-bottom in authority order.
localhost_ports()
¶
allow_domains()
¶
deny_domains()
¶
dnsmasq_domains()
¶
Effective dnsmasq nftset list: admitted domains minus denied.
deny_ips()
¶
deny_targets()
¶
Denied domains + literal IPs to resolve (localhost excluded) — the deny-resolver input.
The t20 security-deny must hold addresses to survive a
shield down rebuild and to catch direct-IP access that never
touches the DNS plane, so its domains are statically resolved into
deny_resolved
(mirroring the t10 override treatment).
Source code in src/terok_shield/state.py
effective_ips()
¶
Admitted literal IPs minus denied (the non-resolved part of the set seed).
allow_targets()
¶
Admitted domains + literal IPs to resolve (localhost excluded) — the resolver input.
override_targets()
¶
Break-glass override domains + literal IPs to resolve (localhost excluded).
The t10 override is a separate above-deny nft set — not part of the
ordinary allow tiers
(allow_targets),
so it is resolved and seeded independently.
Source code in src/terok_shield/state.py
wildcard_domains()
¶
Admitted *. entries; only a tier that resolves live can enforce them.
override_domains()
¶
Break-glass override domains — the DNS-plane punch-through set.
A t10 override host is usually also denied by t20 (that is the point of an override), so the dnsmasq sinkhole generator must treat these names as allowed — otherwise the override host would NXDOMAIN and the statically seeded t10 set would never see a connection.
Source code in src/terok_shield/state.py
StateBundle(state_dir)
dataclass
¶
File-layout contract for a single shielded container's state_dir.
Frozen so the per-task instance is safe to pass through hook
callbacks without anyone smuggling a mutated state_dir into a
later stage. Every property is a pure derivation off state_dir;
the IO methods (read_effective,
read_effective_ips,
read_denied_ips,
ensure_dirs) bundle
the small handful of read-and-compose / setup helpers that previously
floated as free functions taking state_dir repeatedly.
state_dir
instance-attribute
¶
ruleset
property
¶
Path to the pre-generated nft ruleset file.
upstream_dns
property
¶
Path to the persisted upstream DNS address.
dns_tier
property
¶
Path to the persisted DNS tier value.
network_mode
property
¶
Path to the persisted rootless network mode (pasta/slirp4netns).
Detected once at pre_start and read back by HookMode.refresh,
which derives the ruleset's gateway addresses from it — a restart
rebuilds the bundle without paying for a podman info probe, and
cannot pick a mode the running container was not launched with.
loopback_ports
property
¶
Path to the per-container host-loopback TCP ports list.
Written by HookMode.pre_start from the caller-supplied
ShieldConfig.loopback_ports (the per-container triple of
gate / token-broker / ssh-signer ports the supervisor binds).
Read back by shield_up / shield_down when they rebuild
the nft ruleset — so a fresh Shield constructed without
the override still emits the correct
tcp dport <p> ip daddr 10.0.2.2 accept rules.
policy_dir
property
¶
Directory holding the per-tier +/- policy files.
policy_live
property
¶
Path to the runtime overlay (shield allow/deny append here).
resolved_cache
property
¶
Derived per-container cache of resolved allow IPs (the t40 set seed).
Separate from the authored policy/ tiers so resolution can be
reused across task starts and invalidated independently — keyed on
policy_mtime.
override_resolved
property
¶
Derived per-container cache of resolved override IPs (the t10 set seed).
The t10 override sits above the security-deny tier and is a separate nft set, so it is resolved and cached apart from the allow tiers. Statically resolved at pre_start — break-glass entries are rare and specific, and dnsmasq interception would populate t40 (below the deny), defeating the override.
deny_resolved
property
¶
Derived per-container cache of resolved security-deny IPs (the t20 set seed).
Denied domains must reach the packet filter as addresses: the deny
set is what survives a shield down (the down posture keeps enforcing it),
and an address-level deny also catches direct-IP access that never
consults the DNS plane. Statically resolved at pre_start on every
DNS tier, cached apart from the allow-side resolved.ips so the
two invalidate independently.
dnsmasq_conf
property
¶
Path to the generated dnsmasq configuration file.
dnsmasq_pid
property
¶
Path to the dnsmasq PID file (PID is in the container netns).
dnsmasq_command
property
¶
Path to the symbolic or explicitly configured dnsmasq launch choice.
dnsmasq_bin
property
¶
Path to the live dnsmasq executable identity, never a launch choice.
dnsmasq_log
property
¶
Path to the dnsmasq query log (consumed by shield watch).
resolv_conf
property
¶
Path to the resolv.conf bind-mounted over /etc/resolv.conf on every tier.
container_id
property
¶
Path to the persisted podman container ID file.
reader_pid
property
¶
Path where the bridge hook tracks the live NFLOG reader PID.
audit
property
¶
Path to the per-container audit log.
meta_path
property
¶
Persisted-meta-path pointer file under state_dir.
Mirrors the resource-side META_PATH_FILE_NAME constant — one
filename on both sides of the hook boundary so package code that
reads it (Shield.up()/down()) and resource code that
writes it (the bridge createRuntime hook) can never drift
on path convention.
read_dns_tier()
¶
The tier pre_start recorded for this container, or None when there is none.
None means the container was never shielded, or the file is not a
tier name; a retired name reads as the tier it named.
Source code in src/terok_shield/state.py
read_loopback_ports()
¶
Read persisted loopback ports; empty tuple when the file is absent.
Source code in src/terok_shield/state.py
tier_path(tier)
¶
Path to one tier's policy file (tier is a TIER_FILES key).
policy_mtime()
¶
Newest mtime among the policy files (0.0 when none exist yet).
Feeds the resolver's content-aware freshness check: a resolved cache older than this means the authored allowlist changed since we resolved.
Source code in src/terok_shield/state.py
read_tier(path)
¶
write_tier(tier, content)
¶
Write a tier file only when content differs.
Skipping no-op writes preserves the file's mtime, which the resolver's content-aware freshness keys on — so an unchanged allowlist stays a cache hit across task starts instead of forcing a re-resolution.
Source code in src/terok_shield/state.py
read_effective()
¶
Read and compose every tier into an EffectivePolicy.
Source code in src/terok_shield/state.py
overlay_set(action, target)
¶
Upsert {action}{target} into the runtime overlay (policy/live).
The target is validated through the parser (a malformed domain/IP
raises). Any prior entry for target is dropped first, so a later
shield allow flips an earlier deny (and vice-versa) rather
than stacking.
Source code in src/terok_shield/state.py
read_denied_ips()
¶
The tier-20 security-deny set seed: literal denied IPs + resolved denied domains.
Unions the current literal - IPs (security-deny tier + runtime
overlay) with the statically resolved
deny_resolved
cache. This is what shield down/up repopulate the deny set
from — a denied domain must keep denying by address across every
rebuild, or the down posture would silently un-deny it.
Source code in src/terok_shield/state.py
read_effective_ips()
¶
The tier-40 project-allow set seed: resolved allow IPs minus denied.
Unions the derived resolved_cache
(literal allow IPs plus resolved allow-domains, refreshed at pre_start)
with the policy tiers' current literal allow IPs — so a runtime
shield allow of a raw IP survives a shield up rebuild even
before the next resolution — then subtracts the denied IPs.
Source code in src/terok_shield/state.py
read_override_ips()
¶
The tier-10 override set seed: literal override IPs + resolved override domains.
Unions the current literal + override IPs with the statically
resolved override_resolved
cache. Denies are not subtracted — the whole point of an override is
to sit above the security-deny tier.
Source code in src/terok_shield/state.py
ensure_dirs()
¶
Create the state directory and its required subdirectories.
Both directories are forced to
STATE_DIR_MODE
(0o700) on every call — the OCI hook rejects anything
looser, and a prior run under a permissive umask (Fedora's
default 0o002 is a common offender) would otherwise leave
the bundle stranded.
Source code in src/terok_shield/state.py
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.