198 lines
9.3 KiB
Python
198 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Synthetic idmapped data views inside owned cgroups/private mount namespaces."""
|
|
import errno
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts'))
|
|
from c01_children import Children, drop_credentials, process_group_path
|
|
from c01_views import PrivateMounts, PinnedSource, identity, pin_directory, require_private_backing, swap_map
|
|
import c01_views
|
|
|
|
assert os.geteuid() == 0 and os.environ.get('ARCHY_C01_VIEWS_FIXTURE') == '1'
|
|
assert Path(process_group_path(os.getpid())).parent.name.startswith('opencode-c01-views-')
|
|
for directory in ('/run', '/var/lib/archipelago'):
|
|
rows = [line.split() for line in Path('/proc/self/mountinfo').read_text().splitlines()]
|
|
assert any(row[4] == directory and row[row.index('-') + 1] == 'tmpfs' for row in rows)
|
|
|
|
OLD, MANAGER, RUNTIME = 1000, 60001, 60002
|
|
root = Path('/run/c01-view-fixture'); root.mkdir(mode=0o700)
|
|
backing = root / 'backing'; backing.mkdir(mode=0o700); os.chown(backing, OLD, OLD)
|
|
private = backing / 'identity'; private.mkdir(mode=0o700); os.chown(private, OLD, OLD)
|
|
key = private / 'key'; key.write_bytes(b'synthetic-private-identity'); key.chmod(0o600); os.chown(key, OLD, OLD)
|
|
work = root / 'work'; work.mkdir(mode=0o700); os.chown(work, OLD, OLD)
|
|
workfile = work / 'file'; workfile.write_bytes(b'synthetic-work'); workfile.chmod(0o600); os.chown(workfile, OLD, OLD)
|
|
data_fd = require_private_backing(backing)
|
|
work_fd = pin_directory(work)
|
|
race = root / 'race'; race.mkdir(mode=0o700); os.chown(race, OLD, OLD)
|
|
(race / 'original').write_bytes(b'pinned-source')
|
|
race_fd = pin_directory(race)
|
|
removed = root / 'removed'; removed.mkdir(mode=0o700); os.chown(removed, OLD, OLD)
|
|
removed_fd = pin_directory(removed)
|
|
data_source = PinnedSource.capture(data_fd, backing)
|
|
work_source = PinnedSource.capture(work_fd, work)
|
|
race_source = PinnedSource.capture(race_fd, race)
|
|
removed_source = PinnedSource.capture(removed_fd, removed)
|
|
before = {name: identity(fd) for name, fd in (('data', data_fd), ('work', work_fd))}
|
|
key_before = (key.stat().st_ino, key.stat().st_uid, key.stat().st_gid, key.stat().st_mode)
|
|
work_flags = os.statvfs(work).f_flag
|
|
namespace = os.stat('/proc/self/ns/mnt').st_ino
|
|
targets = Path('/var/lib/archipelago')
|
|
for name in ('manager', 'runtime-work', 'manager-work', 'readonly-work', 'covered', 'pinned-race', 'refused-replacement'):
|
|
(targets / name).mkdir(mode=0o755)
|
|
(targets / 'covered/original').write_bytes(b'original')
|
|
os.symlink(targets / 'manager/identity/key', work / 'private-escape')
|
|
|
|
|
|
def denied(path):
|
|
try:
|
|
path.read_bytes()
|
|
except PermissionError:
|
|
return
|
|
raise AssertionError('runtime reached private identity')
|
|
|
|
|
|
def as_user(uid, action):
|
|
parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
try:
|
|
parent.close()
|
|
drop_credentials(uid, uid, os.getppid())
|
|
action()
|
|
child.send(b'passed')
|
|
os._exit(0)
|
|
except BaseException:
|
|
traceback.print_exc()
|
|
os._exit(111)
|
|
child.close(); parent.settimeout(3)
|
|
try:
|
|
assert parent.recv(16) == b'passed'
|
|
assert os.waitpid(pid, 0)[1] == 0
|
|
finally:
|
|
parent.close()
|
|
|
|
|
|
owner = Children()
|
|
try:
|
|
def prepare_views(control):
|
|
view = PrivateMounts()
|
|
view.cover(targets / 'covered')
|
|
assert not (targets / 'covered/original').exists()
|
|
(targets / 'covered/new').write_bytes(b'private-namespace')
|
|
view.idmapped_directory(data_source, targets / 'manager', OLD, MANAGER)
|
|
view.idmapped_directory(work_source, targets / 'runtime-work', OLD, RUNTIME)
|
|
view.idmapped_directory(work_source, targets / 'manager-work', OLD, MANAGER)
|
|
view.idmapped_directory(work_source, targets / 'readonly-work', OLD, RUNTIME, readonly=True)
|
|
race.rename(root / 'original-race')
|
|
race.mkdir(mode=0o700); (race / 'bait').write_bytes(b'replacement')
|
|
try:
|
|
view.idmapped_directory(race_source, targets / 'pinned-race', OLD, MANAGER)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('renamed source or replacement accepted under stale locator')
|
|
assert not (targets / 'pinned-race/original').exists()
|
|
assert not (targets / 'pinned-race/bait').exists()
|
|
removed.rmdir(); removed.mkdir(mode=0o700)
|
|
try:
|
|
view.idmapped_directory(removed_source, targets / 'refused-replacement', OLD, MANAGER)
|
|
except (FileNotFoundError, ValueError):
|
|
pass
|
|
else:
|
|
raise AssertionError('unlinked source silently adopted its replacement')
|
|
assert (targets / 'refused-replacement').stat().st_uid == 0
|
|
|
|
def runtime():
|
|
assert (targets / 'runtime-work/file').read_bytes() == b'synthetic-work'
|
|
denied(targets / 'manager/identity/key')
|
|
denied(targets / 'runtime-work/private-escape')
|
|
denied(key)
|
|
(targets / 'runtime-work/from-runtime').write_bytes(b'runtime')
|
|
try:
|
|
(targets / 'readonly-work/refused').write_bytes(b'no')
|
|
except OSError as error:
|
|
assert error.errno == errno.EROFS
|
|
else:
|
|
raise AssertionError('read-only view allowed write')
|
|
try:
|
|
view.cover(targets / 'manager')
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('view setup permitted after credential drop')
|
|
code = ('import os,pathlib;assert os.geteuid()==0;'
|
|
'assert pathlib.Path("/var/lib/archipelago/runtime-work/file").read_bytes()==b"synthetic-work"\n'
|
|
'try:pathlib.Path("/var/lib/archipelago/manager/identity/key").read_bytes()\n'
|
|
'except PermissionError:print("USERNS_PRIVATE_DENIED")\n'
|
|
'else:raise AssertionError("namespace root read private key")')
|
|
nested = c01_views.subprocess.run(['/usr/bin/unshare', '--user', '--map-root-user',
|
|
'/usr/bin/python3', '-I', '-c', code], capture_output=True, timeout=5,
|
|
env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C'})
|
|
assert nested.returncode == 0 and nested.stdout.strip() == b'USERNS_PRIVATE_DENIED'
|
|
as_user(RUNTIME, runtime)
|
|
|
|
def manager():
|
|
assert (targets / 'manager/identity/key').read_bytes() == b'synthetic-private-identity'
|
|
assert (targets / 'manager-work/from-runtime').read_bytes() == b'runtime'
|
|
(targets / 'manager-work/from-manager').write_bytes(b'manager')
|
|
as_user(MANAGER, manager)
|
|
assert identity(data_fd) == before['data'] and identity(work_fd) == before['work']
|
|
control.send(b'views-passed')
|
|
|
|
def prepare(control):
|
|
original = c01_views.subprocess.run
|
|
def observed_run(*args, **kwargs):
|
|
if os.environ.get('ARCHY_C01_VIEW_DIAGNOSTICS') == '1':
|
|
kwargs['env'] = {**kwargs['env'], 'LIBMOUNT_DEBUG': 'all'}
|
|
result = original(*args, **kwargs)
|
|
if result.returncode:
|
|
print(result.stderr.decode(errors='replace'), file=sys.stderr, flush=True)
|
|
return result
|
|
try:
|
|
with patch.object(c01_views.subprocess, 'run', observed_run):
|
|
prepare_views(control)
|
|
except BaseException:
|
|
traceback.print_exc()
|
|
raise
|
|
|
|
record = owner.spawn('application', prepare, keep=(data_fd, work_fd, race_fd, removed_fd))
|
|
record.channel.settimeout(15); assert record.channel.recv(32) == b'views-passed'
|
|
deadline = time.monotonic() + 5
|
|
while owner.children:
|
|
assert owner.poll() == []
|
|
assert time.monotonic() < deadline
|
|
time.sleep(0.005)
|
|
assert owner.results['application'] == 0
|
|
owner.close()
|
|
assert os.stat('/proc/self/ns/mnt').st_ino == namespace
|
|
assert (targets / 'covered/original').read_bytes() == b'original'
|
|
assert not (targets / 'covered/new').exists()
|
|
assert (targets / 'manager').stat().st_uid == 0
|
|
assert identity(data_fd) == before['data'] and identity(work_fd) == before['work']
|
|
assert (key.stat().st_ino, key.stat().st_uid, key.stat().st_gid, key.stat().st_mode) == key_before
|
|
assert key.read_bytes() == b'synthetic-private-identity' and workfile.read_bytes() == b'synthetic-work'
|
|
assert os.statvfs(work).f_flag == work_flags
|
|
assert (work / 'from-runtime').stat().st_uid == OLD and (work / 'from-manager').stat().st_uid == OLD
|
|
assert not (work / 'refused').exists()
|
|
assert swap_map(MANAGER, OLD) == swap_map(OLD, MANAGER)
|
|
print(json.dumps({'passed':True,'private_identity_separated':True,'shared_work_preserves_host_owner':True,
|
|
'read_only_enforced':True,'parent_namespace_and_mount_flags_unchanged':True,
|
|
'existing_bytes_modes_owners_inodes_preserved':True,'runtime_user_namespace_root_private_read_denied':True,
|
|
'renamed_source_locator_refused':True,'unlinked_source_replacement_refused':True,'installed_acceptance':False}))
|
|
finally:
|
|
owner.request_stop()
|
|
deadline = time.monotonic() + 5
|
|
while owner.children:
|
|
owner.poll()
|
|
assert time.monotonic() < deadline, 'view fixture retained a child'
|
|
time.sleep(0.005)
|
|
for fd in (data_fd, work_fd, race_fd, removed_fd):
|
|
os.close(fd)
|