Files
truf-server/app/host_agent_state.py
2026-09-30 20:30:56 +03:00

413 lines
14 KiB
Python

"""Durable fixed-path evidence for privileged runtime operations."""
import hashlib
import json
import os
from pathlib import Path
import stat
from host_agent_protocol import decode_request_payload, encode_request_payload
from runtime_security import fsync_directory, reject_reparse_components
HOST_ROOT_UID = 0
HOST_ROOT_GID = 0
HOST_RUNTIME_GID = 10001
HOST_STATE_ROOT = Path('/var/lib/truf/host-agent')
HOST_STATE_ROOT_MODE = 0o700
HOST_OPERATION_DIRECTORY = HOST_STATE_ROOT / 'operations'
HOST_OPERATION_DIRECTORY_MODE = 0o700
HOST_RESULT_DIRECTORY = HOST_STATE_ROOT / 'results'
HOST_RESULT_DIRECTORY_MODE = 0o750
HOST_FAILED_HOLD_PATH = HOST_STATE_ROOT / 'failed-hold.json'
MAX_STATE_BYTES = 16 * 1024
_PHASES = {
'prepared', 'forward_started', 'rollback_started',
'succeeded', 'failed', 'rolled_back', 'failed_hold',
}
_PUBLICATION_STATES = {'original', 'partial', 'candidate'}
_TERMINAL_RESULTS = {'succeeded', 'failed', 'rolled_back', 'failed_hold'}
_TRANSITIONS = {
'prepared': {'forward_started', 'rollback_started', 'failed'},
'forward_started': {'rollback_started', 'succeeded'},
'rollback_started': {'rolled_back', 'failed_hold'},
}
class HostStateError(RuntimeError):
def __init__(self, category, *, cancellation=None):
self.category = str(category)
self.cancellation = cancellation
super().__init__('host runtime state failed')
def _canonical(value):
try:
payload = json.dumps(
value, sort_keys=True, separators=(',', ':'), ensure_ascii=True,
allow_nan=False,
).encode('ascii')
except (TypeError, ValueError):
raise HostStateError('evidence') from None
if not payload or len(payload) > MAX_STATE_BYTES:
raise HostStateError('evidence')
return payload
def _require_directory(path, *, gid, mode):
try:
reject_reparse_components(path)
details = os.stat(path, follow_symlinks=False)
if not stat.S_ISDIR(details.st_mode):
raise OSError('not a directory')
if os.name != 'nt' and (
details.st_uid != HOST_ROOT_UID
or details.st_gid != gid
or stat.S_IMODE(details.st_mode) != mode
):
raise OSError('directory metadata')
except Exception:
raise HostStateError('filesystem') from None
def _read_file(path, *, gid, mode):
descriptor = None
try:
reject_reparse_components(Path(path).parent)
flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0)
if hasattr(os, 'O_BINARY'):
flags |= os.O_BINARY
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags)
before = os.fstat(descriptor)
if (
not stat.S_ISREG(before.st_mode) or before.st_nlink != 1
or (
os.name != 'nt'
and (
before.st_uid != HOST_ROOT_UID or before.st_gid != gid
or stat.S_IMODE(before.st_mode) != mode
)
)
):
raise OSError('file metadata')
with os.fdopen(descriptor, 'rb') as handle:
descriptor = None
payload = handle.read(MAX_STATE_BYTES + 1)
after = os.fstat(handle.fileno())
current = os.stat(path, follow_symlinks=False)
identity = lambda item: (
item.st_dev, item.st_ino, item.st_size,
getattr(item, 'st_mtime_ns', None),
None if os.name == 'nt' else getattr(item, 'st_ctime_ns', None),
)
if (
identity(before) != identity(after)
or identity(after) != identity(current)
or len(payload) > MAX_STATE_BYTES
):
raise OSError('file changed')
return payload
except FileNotFoundError:
raise
except Exception:
raise HostStateError('filesystem') from None
finally:
if descriptor is not None:
os.close(descriptor)
def _decode_canonical(payload):
try:
value = json.loads(payload.decode('ascii'))
except (UnicodeDecodeError, json.JSONDecodeError):
raise HostStateError('evidence') from None
if not isinstance(value, dict) or _canonical(value) != payload:
raise HostStateError('evidence')
return value
def _write_stage(path, payload, *, gid, mode):
stage = Path(path).parent / f'.{Path(path).name}.stage'
descriptor = None
created = False
published = False
try:
try:
details = os.stat(stage, follow_symlinks=False)
if (
not stat.S_ISREG(details.st_mode) or details.st_nlink != 1
or (os.name != 'nt' and details.st_uid != HOST_ROOT_UID)
):
raise OSError('unsafe stage')
os.unlink(stage)
fsync_directory(stage.parent)
except FileNotFoundError:
pass
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_CLOEXEC', 0)
if hasattr(os, 'O_BINARY'):
flags |= os.O_BINARY
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
descriptor = os.open(stage, flags, mode)
created = True
if os.name != 'nt':
os.fchmod(descriptor, mode)
details = os.fstat(descriptor)
if details.st_uid != HOST_ROOT_UID or details.st_gid != gid:
os.fchown(descriptor, HOST_ROOT_UID, gid)
view = memoryview(payload)
written = 0
while written < len(view):
count = os.write(descriptor, view[written:])
if count <= 0:
raise OSError('short write')
written += count
os.fsync(descriptor)
os.close(descriptor)
descriptor = None
os.replace(stage, path)
created = False
published = True
fsync_directory(Path(path).parent)
stored = _read_file(path, gid=gid, mode=mode)
if not hashlib.sha256(stored).digest() == hashlib.sha256(payload).digest():
raise HostStateError('evidence')
except HostStateError:
if published:
raise HostStateError('uncertain') from None
raise
except BaseException as error:
if published:
cancellation = error if not isinstance(error, Exception) else None
raise HostStateError(
'uncertain', cancellation=cancellation,
) from None
if not isinstance(error, Exception):
raise
raise HostStateError('filesystem') from None
finally:
if descriptor is not None:
os.close(descriptor)
if created:
try:
os.unlink(stage)
fsync_directory(stage.parent)
except OSError:
pass
def _publish_exact(path, payload, *, gid, mode):
try:
existing = _read_file(path, gid=gid, mode=mode)
except FileNotFoundError:
_write_stage(path, payload, gid=gid, mode=mode)
return payload
if existing != payload:
raise HostStateError('conflict')
return existing
def failed_hold_operation():
try:
payload = _read_file(
HOST_FAILED_HOLD_PATH, gid=HOST_ROOT_GID, mode=0o600,
)
except FileNotFoundError:
return None
value = _decode_canonical(payload)
if (
set(value) != {
'schema', 'operation_id', 'action', 'forward_category',
'publication_state', 'containment_confirmed',
}
or value.get('schema') != 1
or not isinstance(value.get('operation_id'), str)
or value.get('publication_state') not in _PUBLICATION_STATES
or type(value.get('containment_confirmed')) is not bool
or not isinstance(value.get('forward_category'), str)
):
raise HostStateError('evidence')
return value['operation_id']
class HostOperationState:
def __init__(self, request):
self.request = decode_request_payload(encode_request_payload(request))
self.operation_path = (
HOST_OPERATION_DIRECTORY / f'{self.request.operation_id}.json'
)
self.result_path = HOST_RESULT_DIRECTORY / f'{self.request.operation_id}.json'
def _phase_record(
self, phase, publication_state, *, forward_category=None,
safe_detail=None, containment_confirmed=None,
):
if (
phase not in _PHASES
or publication_state not in _PUBLICATION_STATES
or forward_category is not None
and not isinstance(forward_category, str)
or safe_detail is not None
and not isinstance(safe_detail, str)
or containment_confirmed is not None
and type(containment_confirmed) is not bool
):
raise HostStateError('evidence')
return {
'schema': 1,
'operation_id': self.request.operation_id,
'action': self.request.action.value,
'active_config_sha256': self.request.active_config_sha256,
'active_secrets_sha256': self.request.active_secrets_sha256,
'candidate_config_sha256': self.request.candidate_config_sha256,
'candidate_secrets_sha256': self.request.candidate_secrets_sha256,
'phase': phase,
'publication_state': publication_state,
'forward_category': forward_category,
'safe_detail': safe_detail,
'containment_confirmed': containment_confirmed,
}
def _read_phase(self):
payload = _read_file(self.operation_path, gid=HOST_ROOT_GID, mode=0o600)
value = _decode_canonical(payload)
if set(value) != set(self._phase_record('prepared', 'original')):
raise HostStateError('evidence')
expected = self._phase_record(
value.get('phase'), value.get('publication_state'),
forward_category=value.get('forward_category'),
safe_detail=value.get('safe_detail'),
containment_confirmed=value.get('containment_confirmed'),
)
if value != expected:
raise HostStateError('evidence')
return value
def initialize(self, publication_state='original'):
_require_directory(
HOST_STATE_ROOT, gid=HOST_ROOT_GID, mode=HOST_STATE_ROOT_MODE,
)
_require_directory(
HOST_OPERATION_DIRECTORY,
gid=HOST_ROOT_GID,
mode=HOST_OPERATION_DIRECTORY_MODE,
)
hold = failed_hold_operation()
if hold is not None and hold != self.request.operation_id:
raise HostStateError('failed_hold')
try:
return self._read_phase()
except FileNotFoundError:
value = self._phase_record('prepared', publication_state)
_write_stage(
self.operation_path, _canonical(value),
gid=HOST_ROOT_GID, mode=0o600,
)
return value
def advance(
self, expected_phase, next_phase, publication_state, *,
forward_category=None, safe_detail=None, containment_confirmed=None,
):
current = self._read_phase()
value = self._phase_record(
next_phase, publication_state,
forward_category=forward_category,
safe_detail=safe_detail,
containment_confirmed=containment_confirmed,
)
if current == value:
return value
if (
current['phase'] != expected_phase
or next_phase not in _TRANSITIONS.get(expected_phase, set())
):
raise HostStateError('state')
_write_stage(
self.operation_path, _canonical(value),
gid=HOST_ROOT_GID, mode=0o600,
)
return value
def terminal_result(self):
try:
payload = _read_file(
self.result_path, gid=HOST_RUNTIME_GID, mode=0o640,
)
except FileNotFoundError:
return None
value = _decode_canonical(payload)
if (
set(value) != {
'schema', 'operation_id', 'action', 'result', 'safe_category',
'safe_detail', 'resulting_identity',
}
or value.get('schema') != 1
or value.get('operation_id') != self.request.operation_id
or value.get('action') != self.request.action.value
or value.get('result') not in _TERMINAL_RESULTS
):
raise HostStateError('evidence')
return value
def publish_result(
self, result, *, safe_category, safe_detail, resulting_identity,
):
if result not in _TERMINAL_RESULTS:
raise HostStateError('evidence')
if result == 'succeeded':
if safe_category is not None or safe_detail is not None:
raise HostStateError('evidence')
elif not isinstance(safe_category, str) or not isinstance(safe_detail, str):
raise HostStateError('evidence')
if resulting_identity is not None and (
not isinstance(resulting_identity, dict)
or set(resulting_identity) != {
'active_config_sha256', 'active_secrets_sha256',
}
or any(
not isinstance(value, str) or len(value) != 64
for value in resulting_identity.values()
)
):
raise HostStateError('evidence')
value = {
'schema': 1,
'operation_id': self.request.operation_id,
'action': self.request.action.value,
'result': result,
'safe_category': safe_category,
'safe_detail': safe_detail,
'resulting_identity': resulting_identity,
}
_require_directory(
HOST_RESULT_DIRECTORY,
gid=HOST_RUNTIME_GID,
mode=HOST_RESULT_DIRECTORY_MODE,
)
_publish_exact(
self.result_path, _canonical(value), gid=HOST_RUNTIME_GID, mode=0o640,
)
return value
def publish_failed_hold(
self, *, forward_category, publication_state, containment_confirmed,
):
marker = {
'schema': 1,
'operation_id': self.request.operation_id,
'action': self.request.action.value,
'forward_category': str(forward_category),
'publication_state': publication_state,
'containment_confirmed': bool(containment_confirmed),
}
_publish_exact(
HOST_FAILED_HOLD_PATH, _canonical(marker),
gid=HOST_ROOT_GID, mode=0o600,
)
return marker