atlas-iac/services/hermes/scripts/node_account_io.py
2026-08-17 07:58:44 -03:00

206 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Crash-safe, metadata-preserving host account database I/O."""
from __future__ import annotations
import fcntl
import os
import stat
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
MAX_XATTRS = 64
MAX_XATTR_NAME = 255
MAX_XATTR_VALUE = 256 * 1024
MAX_XATTR_TOTAL = 1024 * 1024
LOCK_TIMEOUT_SECONDS = 15.0
class HardeningError(RuntimeError):
"""Raised before an unsafe or ambiguous host-account change."""
@dataclass(frozen=True)
class FileSnapshot:
"""Bounded file contents and all security-relevant inode metadata."""
value: bytes
device: int
inode: int
mode: int
uid: int
gid: int
size: int
mtime_ns: int
ctime_ns: int
xattrs: tuple[tuple[str, bytes], ...]
def _stat_identity(item: os.stat_result) -> tuple[int, ...]:
return (
item.st_dev,
item.st_ino,
item.st_mode,
item.st_uid,
item.st_gid,
item.st_size,
item.st_mtime_ns,
item.st_ctime_ns,
)
def _xattrs(path_or_fd: Path | int) -> tuple[tuple[str, bytes], ...]:
try:
names = sorted(os.listxattr(path_or_fd, follow_symlinks=False))
except (TypeError, ValueError):
names = sorted(os.listxattr(path_or_fd))
if len(names) > MAX_XATTRS:
raise HardeningError("host account file has too many extended attributes")
values: list[tuple[str, bytes]] = []
total = 0
for name in names:
if len(name.encode("utf-8")) > MAX_XATTR_NAME:
raise HardeningError("host account file xattr name is too long")
try:
value = os.getxattr(path_or_fd, name, follow_symlinks=False)
except (TypeError, ValueError):
value = os.getxattr(path_or_fd, name)
total += len(value)
if len(value) > MAX_XATTR_VALUE or total > MAX_XATTR_TOTAL:
raise HardeningError("host account file xattrs exceed the safe limit")
values.append((name, value))
return tuple(values)
def _restore_xattrs(descriptor: int, expected: tuple[tuple[str, bytes], ...]) -> None:
expected_names = {name for name, _value in expected}
for name, _value in _xattrs(descriptor):
if name not in expected_names:
os.removexattr(descriptor, name)
for name, value in expected:
os.setxattr(descriptor, name, value)
def read_regular(path: Path, maximum: int) -> FileSnapshot:
"""Read one bounded, non-symlink regular file and capture its metadata."""
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
raise HardeningError(f"unsafe regular file: {path.name}")
value = os.read(descriptor, maximum + 1)
attrs = _xattrs(descriptor)
after = os.fstat(descriptor)
finally:
os.close(descriptor)
if len(value) != metadata.st_size or _stat_identity(metadata) != _stat_identity(after):
raise HardeningError(f"short file read: {path.name}")
return FileSnapshot(
value=value,
device=metadata.st_dev,
inode=metadata.st_ino,
mode=stat.S_IMODE(metadata.st_mode),
uid=metadata.st_uid,
gid=metadata.st_gid,
size=metadata.st_size,
mtime_ns=metadata.st_mtime_ns,
ctime_ns=metadata.st_ctime_ns,
xattrs=attrs,
)
def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None:
"""Fail if an account file changed since planning began."""
current = read_regular(path, maximum)
if current != expected:
raise HardeningError(f"concurrent host account change detected: {path.name}")
def atomic_write(path: Path, value: bytes, metadata: FileSnapshot) -> None:
"""Atomically replace a file while retaining ACLs, labels, and xattrs."""
temporary = path.with_name(f".{path.name}.hermes-{os.getpid()}")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(temporary, flags, metadata.mode)
try:
if os.write(descriptor, value) != len(value):
raise HardeningError(f"short atomic write: {path.name}")
os.fchmod(descriptor, metadata.mode)
os.fchown(descriptor, metadata.uid, metadata.gid)
_restore_xattrs(descriptor, metadata.xattrs)
os.fsync(descriptor)
except Exception:
temporary.unlink(missing_ok=True)
raise
finally:
os.close(descriptor)
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(directory)
finally:
os.close(directory)
def backup_once(path: Path, snapshot: FileSnapshot, maximum: int) -> Path:
"""Create the first durable recovery copy without replacing an older one."""
backup = path.with_name(path.name + ".hermes-boundary-backup")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(backup, flags, snapshot.mode)
except FileExistsError:
read_regular(backup, maximum)
return backup
try:
if os.write(descriptor, snapshot.value) != len(snapshot.value):
raise HardeningError(f"short backup write: {path.name}")
os.fchmod(descriptor, snapshot.mode)
os.fchown(descriptor, snapshot.uid, snapshot.gid)
_restore_xattrs(descriptor, snapshot.xattrs)
os.fsync(descriptor)
except Exception:
backup.unlink(missing_ok=True)
raise
finally:
os.close(descriptor)
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(directory)
finally:
os.close(directory)
return backup
@contextmanager
def account_lock(host_etc: Path, *, expected_uid: int = 0) -> Iterator[None]:
"""Hold the standard shadow-utils account lock for the full transaction."""
path = host_etc / ".pwd.lock"
flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags, 0o600)
try:
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != expected_uid
or stat.S_IMODE(metadata.st_mode) != 0o600
):
raise HardeningError("unsafe standard account lock")
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
while True:
try:
fcntl.lockf(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError as exc:
if time.monotonic() >= deadline:
raise HardeningError("standard account lock is busy") from exc
time.sleep(0.05)
yield
finally:
try:
fcntl.lockf(descriptor, fcntl.LOCK_UN)
finally:
os.close(descriptor)