1120 lines
39 KiB
Python
1120 lines
39 KiB
Python
from dataclasses import dataclass, field
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
|
|
from host_agent_protocol import (
|
|
HostAgentAction,
|
|
HostAgentProtocolError,
|
|
HostAgentRequest,
|
|
decode_request_payload,
|
|
encode_request_payload,
|
|
)
|
|
from host_agent_state import HostStateError, failed_hold_operation
|
|
from runtime_document import (
|
|
MAX_CONFIG_DOCUMENT_BYTES,
|
|
MAX_SECRETS_DOCUMENT_BYTES,
|
|
RuntimeDocumentError,
|
|
preview_runtime_documents,
|
|
)
|
|
from runtime_security import (
|
|
PrivateFileLock,
|
|
durable_replace,
|
|
fsync_directory,
|
|
reject_reparse_components,
|
|
)
|
|
|
|
|
|
HOST_ROOT_UID = 0
|
|
HOST_ROOT_GID = 0
|
|
HOST_RUNTIME_UID = 10001
|
|
HOST_RUNTIME_GID = 10001
|
|
|
|
HOST_ACTIVE_DIRECTORY = Path('/etc/truf/runtime')
|
|
HOST_ACTIVE_DIRECTORY_MODE = 0o755
|
|
HOST_ACTIVE_CONFIG_PATH = HOST_ACTIVE_DIRECTORY / 'config.yaml'
|
|
HOST_ACTIVE_SECRETS_PATH = HOST_ACTIVE_DIRECTORY / 'secrets.yaml'
|
|
|
|
HOST_CANDIDATE_DIRECTORY = Path('/var/lib/truf/runtime-document-candidates')
|
|
HOST_CANDIDATE_DIRECTORY_MODE = 0o700
|
|
HOST_CONFIG_CANDIDATE_PATH = HOST_CANDIDATE_DIRECTORY / 'config.yaml'
|
|
HOST_SECRETS_CANDIDATE_PATH = HOST_CANDIDATE_DIRECTORY / 'secrets.yaml'
|
|
|
|
HOST_APPLY_DIRECTORY = Path('/run/truf-host-agent')
|
|
HOST_APPLY_LOCK_PATH = HOST_APPLY_DIRECTORY / 'apply.lock'
|
|
HOST_BACKUP_ROOT = Path('/var/lib/truf/host-agent/backups')
|
|
HOST_BACKUP_DIRECTORY_MODE = 0o700
|
|
HOST_TEMPLATE_PATH = Path(__file__).with_name('config.linux.yaml')
|
|
|
|
|
|
class HostApplyError(RuntimeError):
|
|
def __init__(self, category):
|
|
self.category = category
|
|
super().__init__('host runtime apply failed')
|
|
|
|
|
|
_STOPPED_RUNTIME_AUTHORITY = object()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _StoppedRuntimeProof:
|
|
operation_id: str
|
|
purpose: str
|
|
nonce: object = field(repr=False)
|
|
authority: object = field(repr=False)
|
|
|
|
|
|
def _new_stopped_runtime_proof(operation_id, *, purpose='forward'):
|
|
if purpose not in ('forward', 'rollback'):
|
|
raise HostApplyError('state')
|
|
return _StoppedRuntimeProof(
|
|
str(operation_id), purpose, object(), _STOPPED_RUNTIME_AUTHORITY,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _FileSnapshot:
|
|
payload: bytes = field(repr=False)
|
|
sha256: str
|
|
byte_count: int
|
|
identity: tuple
|
|
|
|
|
|
def _expected_identity(request):
|
|
return {
|
|
'active_config_sha256': request.active_config_sha256,
|
|
'active_secrets_sha256': request.active_secrets_sha256,
|
|
'candidate_config_sha256': request.candidate_config_sha256,
|
|
'candidate_secrets_sha256': request.candidate_secrets_sha256,
|
|
}
|
|
|
|
|
|
def _require_directory(path, *, uid, 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 != uid
|
|
or details.st_gid != gid
|
|
or stat.S_IMODE(details.st_mode) != mode
|
|
):
|
|
raise OSError('directory metadata')
|
|
return details
|
|
except HostApplyError:
|
|
raise
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
|
|
|
|
def _file_identity(details):
|
|
return (
|
|
details.st_dev,
|
|
details.st_ino,
|
|
details.st_size,
|
|
getattr(details, 'st_mtime_ns', None),
|
|
None if os.name == 'nt' else getattr(details, 'st_ctime_ns', None),
|
|
)
|
|
|
|
|
|
def _require_file_metadata(details, *, uid, gid, mode=None, immutable=False):
|
|
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
|
|
raise OSError('file type')
|
|
if os.name == 'nt':
|
|
return
|
|
if details.st_uid != uid or details.st_gid != gid:
|
|
raise OSError('file owner')
|
|
permissions = stat.S_IMODE(details.st_mode)
|
|
if mode is not None and permissions != mode:
|
|
raise OSError('file mode')
|
|
if immutable and permissions & 0o022:
|
|
raise OSError('mutable trusted file')
|
|
|
|
|
|
def _snapshot_file(
|
|
path,
|
|
maximum,
|
|
*,
|
|
uid,
|
|
gid,
|
|
mode=None,
|
|
immutable=False,
|
|
expected_sha256=None,
|
|
):
|
|
descriptor = None
|
|
payload = None
|
|
try:
|
|
path = reject_reparse_components(path)
|
|
flags = os.O_RDONLY
|
|
if hasattr(os, 'O_BINARY'):
|
|
flags |= os.O_BINARY
|
|
if hasattr(os, 'O_CLOEXEC'):
|
|
flags |= os.O_CLOEXEC
|
|
if hasattr(os, 'O_NOFOLLOW'):
|
|
flags |= os.O_NOFOLLOW
|
|
descriptor = os.open(path, flags)
|
|
before = os.fstat(descriptor)
|
|
_require_file_metadata(
|
|
before, uid=uid, gid=gid, mode=mode, immutable=immutable,
|
|
)
|
|
with os.fdopen(descriptor, 'rb') as handle:
|
|
descriptor = None
|
|
payload = handle.read(maximum + 1)
|
|
after = os.fstat(handle.fileno())
|
|
current = os.stat(path, follow_symlinks=False)
|
|
_require_file_metadata(
|
|
after, uid=uid, gid=gid, mode=mode, immutable=immutable,
|
|
)
|
|
_require_file_metadata(
|
|
current, uid=uid, gid=gid, mode=mode, immutable=immutable,
|
|
)
|
|
if _file_identity(before) != _file_identity(after):
|
|
raise OSError('file changed during read')
|
|
if _file_identity(after) != _file_identity(current):
|
|
raise OSError('file path changed during read')
|
|
if len(payload) > maximum:
|
|
raise HostApplyError('size')
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
if expected_sha256 is not None and not hmac.compare_digest(
|
|
digest, expected_sha256,
|
|
):
|
|
raise HostApplyError('identity')
|
|
return _FileSnapshot(
|
|
payload=payload,
|
|
sha256=digest,
|
|
byte_count=len(payload),
|
|
identity=_file_identity(current),
|
|
)
|
|
except HostApplyError:
|
|
raise
|
|
except Exception:
|
|
payload = None
|
|
raise HostApplyError('filesystem') from None
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
|
|
|
|
def _create_owned_file(path, payload, *, uid, gid, mode, expected_sha256):
|
|
descriptor = None
|
|
created = False
|
|
try:
|
|
reject_reparse_components(Path(path).parent)
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
if hasattr(os, 'O_BINARY'):
|
|
flags |= os.O_BINARY
|
|
if hasattr(os, 'O_CLOEXEC'):
|
|
flags |= os.O_CLOEXEC
|
|
if hasattr(os, 'O_NOFOLLOW'):
|
|
flags |= os.O_NOFOLLOW
|
|
descriptor = os.open(path, flags, mode)
|
|
created = True
|
|
if os.name != 'nt':
|
|
os.fchmod(descriptor, mode)
|
|
details = os.fstat(descriptor)
|
|
if details.st_uid != uid or details.st_gid != gid:
|
|
os.fchown(descriptor, 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
|
|
return _snapshot_file(
|
|
path,
|
|
len(payload),
|
|
uid=uid,
|
|
gid=gid,
|
|
mode=mode,
|
|
expected_sha256=expected_sha256,
|
|
)
|
|
except BaseException:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if created:
|
|
try:
|
|
os.unlink(path)
|
|
fsync_directory(Path(path).parent)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _remove_stale_stage(path):
|
|
try:
|
|
reject_reparse_components(Path(path).parent)
|
|
details = os.stat(path, follow_symlinks=False)
|
|
if stat.S_ISDIR(details.st_mode):
|
|
raise OSError('stage is a directory')
|
|
os.unlink(path)
|
|
fsync_directory(Path(path).parent)
|
|
except FileNotFoundError:
|
|
return
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
|
|
|
|
def _read_descriptor(descriptor, maximum):
|
|
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
chunks = []
|
|
remaining = maximum + 1
|
|
while remaining:
|
|
chunk = os.read(descriptor, min(remaining, 64 * 1024))
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
return b''.join(chunks)
|
|
|
|
|
|
def _open_displaced_guard(path, original, maximum, *, allow_root=False):
|
|
if os.name == 'nt':
|
|
return None
|
|
descriptor = None
|
|
try:
|
|
flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0)
|
|
descriptor = os.open(path, flags)
|
|
details = os.fstat(descriptor)
|
|
try:
|
|
_require_file_metadata(
|
|
details,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
)
|
|
except OSError:
|
|
if not allow_root:
|
|
raise
|
|
_require_file_metadata(
|
|
details,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
)
|
|
payload = _read_descriptor(descriptor, maximum)
|
|
if (
|
|
_file_identity(details) != original.identity
|
|
or len(payload) > maximum
|
|
or not hmac.compare_digest(payload, original.payload)
|
|
):
|
|
raise HostApplyError('identity')
|
|
return descriptor
|
|
except HostApplyError:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
raise
|
|
except Exception:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
raise HostApplyError('filesystem') from None
|
|
|
|
|
|
def _verify_displaced_guard(descriptor, original, maximum):
|
|
if descriptor is None:
|
|
return
|
|
try:
|
|
payload = _read_descriptor(descriptor, maximum)
|
|
details = os.fstat(descriptor)
|
|
if (
|
|
not stat.S_ISREG(details.st_mode)
|
|
or details.st_dev != original.identity[0]
|
|
or details.st_ino != original.identity[1]
|
|
or len(payload) > maximum
|
|
or not hmac.compare_digest(payload, original.payload)
|
|
):
|
|
raise HostApplyError('identity')
|
|
except HostApplyError:
|
|
raise
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
|
|
|
|
def _adopt_published_file(path, source):
|
|
descriptor = None
|
|
try:
|
|
staged = _snapshot_file(
|
|
path,
|
|
source.byte_count,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=source.sha256,
|
|
)
|
|
if not hmac.compare_digest(staged.payload, source.payload):
|
|
raise HostApplyError('identity')
|
|
flags = os.O_RDWR | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0)
|
|
descriptor = os.open(path, flags)
|
|
details = os.fstat(descriptor)
|
|
_require_file_metadata(
|
|
details,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
)
|
|
if _file_identity(details) != staged.identity:
|
|
raise HostApplyError('identity')
|
|
if os.name != 'nt':
|
|
os.fchown(descriptor, HOST_RUNTIME_UID, HOST_RUNTIME_GID)
|
|
os.fchmod(descriptor, 0o600)
|
|
os.fsync(descriptor)
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
fsync_directory(Path(path).parent)
|
|
final = _snapshot_file(
|
|
path,
|
|
source.byte_count,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=source.sha256,
|
|
)
|
|
if not hmac.compare_digest(final.payload, source.payload):
|
|
raise HostApplyError('identity')
|
|
except HostApplyError:
|
|
raise
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
|
|
|
|
def _snapshot_active(path, maximum, *, allow_root=False, expected_sha256=None):
|
|
if os.name == 'nt':
|
|
return _snapshot_file(
|
|
path,
|
|
maximum,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=expected_sha256,
|
|
), False
|
|
try:
|
|
reject_reparse_components(path)
|
|
details = os.stat(path, follow_symlinks=False)
|
|
runtime_owned = (
|
|
details.st_uid == HOST_RUNTIME_UID
|
|
and details.st_gid == HOST_RUNTIME_GID
|
|
)
|
|
root_owned = (
|
|
details.st_uid == HOST_ROOT_UID
|
|
and details.st_gid == HOST_ROOT_GID
|
|
and not runtime_owned
|
|
)
|
|
if not runtime_owned and not (allow_root and root_owned):
|
|
raise OSError('active file owner')
|
|
uid = HOST_ROOT_UID if root_owned else HOST_RUNTIME_UID
|
|
gid = HOST_ROOT_GID if root_owned else HOST_RUNTIME_GID
|
|
return _snapshot_file(
|
|
path,
|
|
maximum,
|
|
uid=uid,
|
|
gid=gid,
|
|
mode=0o600,
|
|
expected_sha256=expected_sha256,
|
|
), root_owned
|
|
except HostApplyError:
|
|
raise
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
|
|
|
|
def _same_snapshot(left, right):
|
|
return (
|
|
left.sha256 == right.sha256
|
|
and left.byte_count == right.byte_count
|
|
and left.identity == right.identity
|
|
)
|
|
|
|
|
|
class HostApplySession:
|
|
"""One fixed-path host apply claim held under the global apply lock."""
|
|
|
|
def __init__(
|
|
self, request, database, *, package_capabilities=None,
|
|
package_capability_provider=None,
|
|
):
|
|
try:
|
|
request = decode_request_payload(encode_request_payload(request))
|
|
except HostAgentProtocolError:
|
|
raise HostApplyError('request')
|
|
self.request = request
|
|
self.database = database
|
|
self.package_capabilities = package_capabilities
|
|
self.package_capability_provider = package_capability_provider
|
|
self.claim = None
|
|
self._lock = None
|
|
self._active = None
|
|
self._candidates = None
|
|
self._selected = None
|
|
self._backup_complete = False
|
|
self._publication_complete = False
|
|
self._publication_state = 'original'
|
|
self._pending_adoption = set()
|
|
self._used_proof_nonces = set()
|
|
self._failed_hold_replay = False
|
|
self._entered = False
|
|
|
|
def __enter__(self):
|
|
if self._entered:
|
|
raise HostApplyError('state')
|
|
try:
|
|
self._lock = PrivateFileLock(HOST_APPLY_LOCK_PATH).acquire()
|
|
except BlockingIOError:
|
|
raise HostApplyError('busy') from None
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
self._entered = True
|
|
try:
|
|
try:
|
|
held_operation = failed_hold_operation()
|
|
if (
|
|
held_operation is not None
|
|
and held_operation != self.request.operation_id
|
|
):
|
|
raise HostApplyError('failed_hold')
|
|
self._failed_hold_replay = (
|
|
held_operation == self.request.operation_id
|
|
)
|
|
except HostStateError:
|
|
raise HostApplyError('failed_hold') from None
|
|
connection = getattr(self.database, 'conn', None)
|
|
if connection is None or not connection.is_postgres:
|
|
raise HostApplyError('authority')
|
|
try:
|
|
self.claim = self.database.claim_runtime_operation_execution(
|
|
operation_id=self.request.operation_id,
|
|
action=self.request.action.value,
|
|
expected_identity=_expected_identity(self.request),
|
|
)
|
|
except Exception:
|
|
raise HostApplyError('authority') from None
|
|
if self._failed_hold_replay:
|
|
return self
|
|
self._prepare()
|
|
return self
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
def _prepare(self):
|
|
_require_directory(
|
|
HOST_ACTIVE_DIRECTORY,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=HOST_ACTIVE_DIRECTORY_MODE,
|
|
)
|
|
candidate_names = {
|
|
HostAgentAction.APPLY_CONFIG: ('config',),
|
|
HostAgentAction.APPLY_SECRETS: ('secrets',),
|
|
HostAgentAction.APPLY_BOTH: ('config', 'secrets'),
|
|
HostAgentAction.RESTART: (),
|
|
}[self.request.action]
|
|
active = {}
|
|
root_owned = set()
|
|
root_original = set()
|
|
active_paths = {
|
|
'config': (HOST_ACTIVE_CONFIG_PATH, MAX_CONFIG_DOCUMENT_BYTES),
|
|
'secrets': (HOST_ACTIVE_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES),
|
|
}
|
|
for name, (path, maximum) in active_paths.items():
|
|
active[name], is_root_owned = _snapshot_active(
|
|
path,
|
|
maximum,
|
|
allow_root=(
|
|
bool(self.claim.get('replayed')) and name in candidate_names
|
|
),
|
|
)
|
|
if is_root_owned:
|
|
root_owned.add(name)
|
|
candidates = {}
|
|
if candidate_names:
|
|
_require_directory(
|
|
HOST_CANDIDATE_DIRECTORY,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=HOST_CANDIDATE_DIRECTORY_MODE,
|
|
)
|
|
if 'config' in candidate_names:
|
|
candidates['config'] = _snapshot_file(
|
|
HOST_CONFIG_CANDIDATE_PATH,
|
|
MAX_CONFIG_DOCUMENT_BYTES,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=self.request.candidate_config_sha256,
|
|
)
|
|
if 'secrets' in candidate_names:
|
|
candidates['secrets'] = _snapshot_file(
|
|
HOST_SECRETS_CANDIDATE_PATH,
|
|
MAX_SECRETS_DOCUMENT_BYTES,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=self.request.candidate_secrets_sha256,
|
|
)
|
|
original_hashes = {
|
|
'config': self.request.active_config_sha256,
|
|
'secrets': self.request.active_secrets_sha256,
|
|
}
|
|
published = []
|
|
for name, snapshot in active.items():
|
|
if hmac.compare_digest(snapshot.sha256, original_hashes[name]):
|
|
if name in root_owned:
|
|
if not self.claim.get('replayed') or name not in candidate_names:
|
|
raise HostApplyError('identity')
|
|
root_original.add(name)
|
|
continue
|
|
candidate = candidates.get(name)
|
|
if (
|
|
self.claim.get('replayed')
|
|
and candidate is not None
|
|
and hmac.compare_digest(snapshot.sha256, candidate.sha256)
|
|
and hmac.compare_digest(snapshot.payload, candidate.payload)
|
|
):
|
|
published.append(name)
|
|
continue
|
|
raise HostApplyError('identity')
|
|
if published or root_original:
|
|
_require_directory(
|
|
HOST_BACKUP_ROOT,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=HOST_BACKUP_DIRECTORY_MODE,
|
|
)
|
|
operation_directory = HOST_BACKUP_ROOT / self.request.operation_id
|
|
_require_directory(
|
|
operation_directory,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=HOST_BACKUP_DIRECTORY_MODE,
|
|
)
|
|
maximums = {
|
|
'config': MAX_CONFIG_DOCUMENT_BYTES,
|
|
'secrets': MAX_SECRETS_DOCUMENT_BYTES,
|
|
}
|
|
for name in candidate_names:
|
|
active[name] = _snapshot_file(
|
|
operation_directory / f'{name}.yaml',
|
|
maximums[name],
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=original_hashes[name],
|
|
)
|
|
template = _snapshot_file(
|
|
HOST_TEMPLATE_PATH,
|
|
MAX_CONFIG_DOCUMENT_BYTES,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
immutable=True,
|
|
)
|
|
try:
|
|
capability_provider = self.package_capability_provider
|
|
active_capabilities = (
|
|
capability_provider(active['config'].payload)
|
|
if capability_provider is not None else self.package_capabilities
|
|
)
|
|
effective_config = candidates.get('config', active['config']).payload
|
|
candidate_capabilities = (
|
|
capability_provider(effective_config)
|
|
if capability_provider is not None else self.package_capabilities
|
|
)
|
|
preview_runtime_documents(
|
|
active['config'].payload,
|
|
active['secrets'].payload,
|
|
config_template_payload=template.payload,
|
|
package_capabilities=active_capabilities,
|
|
)
|
|
preview_runtime_documents(
|
|
effective_config,
|
|
candidates.get('secrets', active['secrets']).payload,
|
|
config_template_payload=template.payload,
|
|
package_capabilities=candidate_capabilities,
|
|
)
|
|
except (RuntimeDocumentError, OSError, ValueError):
|
|
raise HostApplyError('validation') from None
|
|
finally:
|
|
active_capabilities = candidate_capabilities = effective_config = None
|
|
self._active = active
|
|
self._candidates = candidates
|
|
self._selected = candidate_names
|
|
if published or root_original:
|
|
self._backup_complete = True
|
|
self._publication_complete = len(published) == len(candidate_names)
|
|
self._publication_state = (
|
|
'candidate'
|
|
if self._publication_complete else (
|
|
'partial' if published else 'original'
|
|
)
|
|
)
|
|
self._pending_adoption = root_owned.intersection(published)
|
|
|
|
@property
|
|
def publication_state(self):
|
|
if not self._entered or (
|
|
self._active is None and not self._failed_hold_replay
|
|
):
|
|
raise HostApplyError('state')
|
|
return self._publication_state
|
|
|
|
def original_identity(self):
|
|
if not self._entered or self._active is None:
|
|
raise HostApplyError('state')
|
|
return {
|
|
'active_config_sha256': self._active['config'].sha256,
|
|
'active_secrets_sha256': self._active['secrets'].sha256,
|
|
}
|
|
|
|
def _consume_stopped_proof(self, stopped_proof, purpose):
|
|
if (
|
|
not isinstance(stopped_proof, _StoppedRuntimeProof)
|
|
or stopped_proof.authority is not _STOPPED_RUNTIME_AUTHORITY
|
|
or stopped_proof.operation_id != self.request.operation_id
|
|
or stopped_proof.purpose != purpose
|
|
or stopped_proof.nonce in self._used_proof_nonces
|
|
):
|
|
raise HostApplyError('state')
|
|
self._used_proof_nonces.add(stopped_proof.nonce)
|
|
|
|
def backup(self):
|
|
if not self._entered or self._active is None:
|
|
raise HostApplyError('state')
|
|
if self._backup_complete:
|
|
return
|
|
if not self._selected:
|
|
self._backup_complete = True
|
|
return
|
|
_require_directory(
|
|
HOST_BACKUP_ROOT,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=HOST_BACKUP_DIRECTORY_MODE,
|
|
)
|
|
operation_directory = HOST_BACKUP_ROOT / self.request.operation_id
|
|
try:
|
|
os.mkdir(operation_directory, HOST_BACKUP_DIRECTORY_MODE)
|
|
fsync_directory(HOST_BACKUP_ROOT)
|
|
except FileExistsError:
|
|
pass
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
_require_directory(
|
|
operation_directory,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=HOST_BACKUP_DIRECTORY_MODE,
|
|
)
|
|
for name in self._selected:
|
|
source = self._active[name]
|
|
destination = operation_directory / f'{name}.yaml'
|
|
try:
|
|
stored = _create_owned_file(
|
|
destination,
|
|
source.payload,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=source.sha256,
|
|
)
|
|
except FileExistsError:
|
|
stored = _snapshot_file(
|
|
destination,
|
|
source.byte_count,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=source.sha256,
|
|
)
|
|
except HostApplyError:
|
|
raise
|
|
except Exception:
|
|
raise HostApplyError('filesystem') from None
|
|
if not hmac.compare_digest(stored.payload, source.payload):
|
|
raise HostApplyError('identity')
|
|
fsync_directory(operation_directory)
|
|
self._backup_complete = True
|
|
|
|
def _revalidate(self):
|
|
current_active = {
|
|
'config': _snapshot_file(
|
|
HOST_ACTIVE_CONFIG_PATH,
|
|
MAX_CONFIG_DOCUMENT_BYTES,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=self._active['config'].sha256,
|
|
),
|
|
'secrets': _snapshot_file(
|
|
HOST_ACTIVE_SECRETS_PATH,
|
|
MAX_SECRETS_DOCUMENT_BYTES,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=self._active['secrets'].sha256,
|
|
),
|
|
}
|
|
for name, original in self._active.items():
|
|
if not _same_snapshot(current_active[name], original):
|
|
raise HostApplyError('identity')
|
|
candidate_paths = {
|
|
'config': (HOST_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES),
|
|
'secrets': (HOST_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES),
|
|
}
|
|
for name, original in self._candidates.items():
|
|
path, maximum = candidate_paths[name]
|
|
current = _snapshot_file(
|
|
path,
|
|
maximum,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=original.sha256,
|
|
)
|
|
if not _same_snapshot(current, original):
|
|
raise HostApplyError('identity')
|
|
|
|
def revalidate_for_stop(self):
|
|
if not self._entered or self._active is None or not self._backup_complete:
|
|
raise HostApplyError('state')
|
|
if self._publication_state == 'partial':
|
|
raise HostApplyError('partial')
|
|
if self._publication_complete:
|
|
self._revalidate_published()
|
|
else:
|
|
self._revalidate()
|
|
|
|
def _revalidate_published(self):
|
|
active_paths = {
|
|
'config': (HOST_ACTIVE_CONFIG_PATH, MAX_CONFIG_DOCUMENT_BYTES),
|
|
'secrets': (HOST_ACTIVE_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES),
|
|
}
|
|
still_root_owned = set()
|
|
for name, (path, maximum) in active_paths.items():
|
|
expected = self._candidates.get(name, self._active[name])
|
|
current, root_owned = _snapshot_active(
|
|
path,
|
|
maximum,
|
|
allow_root=name in self._pending_adoption,
|
|
expected_sha256=expected.sha256,
|
|
)
|
|
if not hmac.compare_digest(current.payload, expected.payload):
|
|
raise HostApplyError('identity')
|
|
if root_owned:
|
|
still_root_owned.add(name)
|
|
candidate_paths = {
|
|
'config': (HOST_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES),
|
|
'secrets': (HOST_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES),
|
|
}
|
|
for name, original in self._candidates.items():
|
|
path, maximum = candidate_paths[name]
|
|
current = _snapshot_file(
|
|
path,
|
|
maximum,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=original.sha256,
|
|
)
|
|
if not _same_snapshot(current, original):
|
|
raise HostApplyError('identity')
|
|
operation_directory = HOST_BACKUP_ROOT / self.request.operation_id
|
|
maximums = {
|
|
'config': MAX_CONFIG_DOCUMENT_BYTES,
|
|
'secrets': MAX_SECRETS_DOCUMENT_BYTES,
|
|
}
|
|
for name in self._selected:
|
|
current = _snapshot_file(
|
|
operation_directory / f'{name}.yaml',
|
|
maximums[name],
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=self._active[name].sha256,
|
|
)
|
|
if not _same_snapshot(current, self._active[name]):
|
|
raise HostApplyError('identity')
|
|
self._pending_adoption = still_root_owned
|
|
|
|
def replace(self, stopped_proof):
|
|
if not self._entered or self._active is None or not self._backup_complete:
|
|
raise HostApplyError('state')
|
|
self._consume_stopped_proof(stopped_proof, 'forward')
|
|
if self._publication_state == 'partial':
|
|
raise HostApplyError('partial')
|
|
if not self._selected:
|
|
return {
|
|
'active_config_sha256': self._active['config'].sha256,
|
|
'active_secrets_sha256': self._active['secrets'].sha256,
|
|
}
|
|
if self._publication_complete:
|
|
self._revalidate_published()
|
|
for name in tuple(self._pending_adoption):
|
|
path = (
|
|
HOST_ACTIVE_CONFIG_PATH
|
|
if name == 'config' else HOST_ACTIVE_SECRETS_PATH
|
|
)
|
|
_adopt_published_file(path, self._candidates[name])
|
|
self._pending_adoption.clear()
|
|
self._revalidate_published()
|
|
return {
|
|
'active_config_sha256': (
|
|
self._candidates.get('config', self._active['config']).sha256
|
|
),
|
|
'active_secrets_sha256': (
|
|
self._candidates.get('secrets', self._active['secrets']).sha256
|
|
),
|
|
}
|
|
active_paths = {
|
|
'config': HOST_ACTIVE_CONFIG_PATH,
|
|
'secrets': HOST_ACTIVE_SECRETS_PATH,
|
|
}
|
|
staged = {}
|
|
guards = {}
|
|
publication_started = False
|
|
try:
|
|
for name in self._selected:
|
|
source = self._candidates[name]
|
|
stage = HOST_ACTIVE_DIRECTORY / (
|
|
f'.{name}.yaml.{self.request.operation_id}.stage'
|
|
)
|
|
_remove_stale_stage(stage)
|
|
_create_owned_file(
|
|
stage,
|
|
source.payload,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=source.sha256,
|
|
)
|
|
staged[name] = stage
|
|
fsync_directory(HOST_ACTIVE_DIRECTORY)
|
|
self._revalidate()
|
|
maximums = {
|
|
'config': MAX_CONFIG_DOCUMENT_BYTES,
|
|
'secrets': MAX_SECRETS_DOCUMENT_BYTES,
|
|
}
|
|
for name in self._selected:
|
|
guards[name] = _open_displaced_guard(
|
|
active_paths[name], self._active[name], maximums[name],
|
|
)
|
|
for name in self._selected:
|
|
publication_started = True
|
|
durable_replace(staged[name], active_paths[name])
|
|
staged.pop(name)
|
|
source = self._candidates[name]
|
|
_verify_displaced_guard(
|
|
guards[name], self._active[name], maximums[name],
|
|
)
|
|
descriptor = guards.pop(name)
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
_adopt_published_file(active_paths[name], source)
|
|
except BaseException as exc:
|
|
for stage in staged.values():
|
|
try:
|
|
os.unlink(stage)
|
|
fsync_directory(HOST_ACTIVE_DIRECTORY)
|
|
except OSError:
|
|
pass
|
|
if publication_started:
|
|
self._publication_state = 'partial'
|
|
raise HostApplyError('partial') from None
|
|
if isinstance(exc, HostApplyError):
|
|
raise
|
|
raise HostApplyError('filesystem') from None
|
|
finally:
|
|
for descriptor in guards.values():
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
self._publication_complete = True
|
|
self._publication_state = 'candidate'
|
|
return {
|
|
'active_config_sha256': (
|
|
self._candidates.get('config', self._active['config']).sha256
|
|
),
|
|
'active_secrets_sha256': (
|
|
self._candidates.get('secrets', self._active['secrets']).sha256
|
|
),
|
|
}
|
|
|
|
def restore_backups(self, stopped_proof):
|
|
if not self._entered or self._active is None or not self._backup_complete:
|
|
raise HostApplyError('state')
|
|
self._consume_stopped_proof(stopped_proof, 'rollback')
|
|
if not self._selected:
|
|
return self.original_identity()
|
|
|
|
active_paths = {
|
|
'config': (HOST_ACTIVE_CONFIG_PATH, MAX_CONFIG_DOCUMENT_BYTES),
|
|
'secrets': (HOST_ACTIVE_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES),
|
|
}
|
|
candidate_paths = {
|
|
'config': (HOST_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES),
|
|
'secrets': (HOST_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES),
|
|
}
|
|
operation_directory = HOST_BACKUP_ROOT / self.request.operation_id
|
|
_require_directory(
|
|
operation_directory,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=HOST_BACKUP_DIRECTORY_MODE,
|
|
)
|
|
for name in self._selected:
|
|
maximum = active_paths[name][1]
|
|
backup = _snapshot_file(
|
|
operation_directory / f'{name}.yaml',
|
|
maximum,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=self._active[name].sha256,
|
|
)
|
|
if not hmac.compare_digest(backup.payload, self._active[name].payload):
|
|
raise HostApplyError('identity')
|
|
for name, expected in self._candidates.items():
|
|
path, maximum = candidate_paths[name]
|
|
current = _snapshot_file(
|
|
path,
|
|
maximum,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=expected.sha256,
|
|
)
|
|
if not _same_snapshot(current, expected):
|
|
raise HostApplyError('identity')
|
|
|
|
observed = {}
|
|
observed_root = set()
|
|
states = {}
|
|
for name, (path, maximum) in active_paths.items():
|
|
current, root_owned = _snapshot_active(
|
|
path,
|
|
maximum,
|
|
allow_root=name in self._selected,
|
|
)
|
|
observed[name] = current
|
|
if root_owned:
|
|
observed_root.add(name)
|
|
if hmac.compare_digest(current.payload, self._active[name].payload):
|
|
states[name] = 'original'
|
|
elif (
|
|
name in self._selected
|
|
and hmac.compare_digest(
|
|
current.payload, self._candidates[name].payload,
|
|
)
|
|
):
|
|
states[name] = 'candidate'
|
|
else:
|
|
raise HostApplyError('identity')
|
|
|
|
staged = {}
|
|
guards = {}
|
|
restoration_started = False
|
|
try:
|
|
for name in self._selected:
|
|
if states[name] == 'original':
|
|
continue
|
|
source = self._active[name]
|
|
stage = HOST_ACTIVE_DIRECTORY / (
|
|
f'.{name}.yaml.{self.request.operation_id}.rollback'
|
|
)
|
|
_remove_stale_stage(stage)
|
|
_create_owned_file(
|
|
stage,
|
|
source.payload,
|
|
uid=HOST_ROOT_UID,
|
|
gid=HOST_ROOT_GID,
|
|
mode=0o600,
|
|
expected_sha256=source.sha256,
|
|
)
|
|
staged[name] = stage
|
|
fsync_directory(HOST_ACTIVE_DIRECTORY)
|
|
for name, (path, maximum) in active_paths.items():
|
|
current, _ = _snapshot_active(
|
|
path,
|
|
maximum,
|
|
allow_root=name in self._selected,
|
|
)
|
|
if not _same_snapshot(current, observed[name]):
|
|
raise HostApplyError('identity')
|
|
for name in staged:
|
|
path, maximum = active_paths[name]
|
|
guards[name] = _open_displaced_guard(
|
|
path, observed[name], maximum, allow_root=True,
|
|
)
|
|
for name in self._selected:
|
|
if name not in staged and name not in observed_root:
|
|
continue
|
|
path, maximum = active_paths[name]
|
|
restoration_started = True
|
|
self._publication_state = 'partial'
|
|
if name in staged:
|
|
durable_replace(staged[name], path)
|
|
staged.pop(name)
|
|
_verify_displaced_guard(guards[name], observed[name], maximum)
|
|
descriptor = guards.pop(name)
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
_adopt_published_file(path, self._active[name])
|
|
except BaseException as exc:
|
|
for stage in staged.values():
|
|
try:
|
|
os.unlink(stage)
|
|
fsync_directory(HOST_ACTIVE_DIRECTORY)
|
|
except OSError:
|
|
pass
|
|
if restoration_started:
|
|
raise HostApplyError('rollback') from None
|
|
if isinstance(exc, HostApplyError):
|
|
raise
|
|
raise HostApplyError('filesystem') from None
|
|
finally:
|
|
for descriptor in guards.values():
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
|
|
for name, (path, maximum) in active_paths.items():
|
|
current = _snapshot_file(
|
|
path,
|
|
maximum,
|
|
uid=HOST_RUNTIME_UID,
|
|
gid=HOST_RUNTIME_GID,
|
|
mode=0o600,
|
|
expected_sha256=self._active[name].sha256,
|
|
)
|
|
if not hmac.compare_digest(current.payload, self._active[name].payload):
|
|
raise HostApplyError('rollback')
|
|
self._publication_complete = False
|
|
self._publication_state = 'original'
|
|
self._pending_adoption.clear()
|
|
return self.original_identity()
|
|
|
|
def close(self):
|
|
lock = self._lock
|
|
self._lock = None
|
|
self._entered = False
|
|
self.package_capabilities = None
|
|
self.package_capability_provider = None
|
|
self._active = None
|
|
self._candidates = None
|
|
self._selected = None
|
|
self._publication_complete = False
|
|
self._publication_state = 'original'
|
|
self._pending_adoption.clear()
|
|
self._used_proof_nonces.clear()
|
|
if lock is not None:
|
|
lock.release()
|
|
|
|
def __exit__(self, exc_type, value, traceback):
|
|
self.close()
|
|
return False
|