Make ISO acceptance probes fail on forbidden state
This commit is contained in:
+31
-7
@@ -260,19 +260,43 @@ findmnt -n -o FSTYPE / | grep -qx overlay
|
|||||||
test -f /run/initramfs/live/LiveOS/squashfs.img
|
test -f /run/initramfs/live/LiveOS/squashfs.img
|
||||||
sudo -n true
|
sudo -n true
|
||||||
for tool in hydra hydra-install qemu-system-x86_64 qemu-img virt-viewer foot labwc; do command -v "$tool"; done
|
for tool in hydra hydra-install qemu-system-x86_64 qemu-img virt-viewer foot labwc; do command -v "$tool"; done
|
||||||
systemctl is-active NetworkManager hydra-network hydra-dnsmasq
|
for service in NetworkManager hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done
|
||||||
'''
|
'''
|
||||||
|
|
||||||
INSTALLED_CHECK = r'''set -eu
|
INSTALLED_CHECK = r'''set -eu
|
||||||
|
assert_no_match() {
|
||||||
|
if grep "$@"; then
|
||||||
|
printf 'Forbidden live configuration remains\n' >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
[ "$status" -eq 1 ] || exit "$status"
|
||||||
|
fi
|
||||||
|
}
|
||||||
[ "$(findmnt -n -o FSTYPE /)" = ext4 ]
|
[ "$(findmnt -n -o FSTYPE /)" = ext4 ]
|
||||||
[ "$(readlink -f "$(findmnt -n -o SOURCE /)")" = /dev/vda2 ]
|
[ "$(readlink -f "$(findmnt -n -o SOURCE /)")" = /dev/vda2 ]
|
||||||
! grep -Eq 'root=live:|rd\.live' /proc/cmdline
|
assert_no_match -Eq 'root=live:|rd\.live' /proc/cmdline
|
||||||
! findmnt -n -o FSTYPE / | grep -Eq 'overlay|squashfs'
|
|
||||||
test ! -e /etc/hydra/live-mode
|
test ! -e /etc/hydra/live-mode
|
||||||
! getent passwd hydra-live
|
test ! -L /etc/hydra/live-mode
|
||||||
! find /etc/systemd/system /etc/sudoers.d -name 90-hydra-live.conf -o -name 90-hydra-live | grep .
|
if getent passwd hydra-live; then
|
||||||
|
printf 'Live account remains\n' >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
[ "$status" -eq 2 ] || exit "$status"
|
||||||
|
fi
|
||||||
|
remaining=$(find /etc/systemd/system /etc/sudoers.d \( -name 90-hydra-live.conf -o -name 90-hydra-live \) -print)
|
||||||
|
[ -z "$remaining" ]
|
||||||
test ! -e /etc/greetd/live.toml
|
test ! -e /etc/greetd/live.toml
|
||||||
! grep -R -E -- '--autologin|NOPASSWD' /etc/systemd/system/getty@*.service.d /etc/systemd/system/serial-getty@*.service.d /etc/sudoers.d 2>/dev/null
|
test ! -L /etc/greetd/live.toml
|
||||||
|
for directory in /etc/systemd/system/getty@*.service.d /etc/systemd/system/serial-getty@*.service.d /etc/sudoers.d; do
|
||||||
|
if [ -d "$directory" ]; then
|
||||||
|
assert_no_match -R -E -- '--autologin|NOPASSWD' "$directory"
|
||||||
|
elif [ -e "$directory" ] || [ -L "$directory" ]; then
|
||||||
|
printf 'Unexpected non-directory configuration path\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
getent shadow root | cut -d: -f2 | grep -Eq '^[!*]'
|
getent shadow root | cut -d: -f2 | grep -Eq '^[!*]'
|
||||||
getent shadow hydratest | cut -d: -f2 | grep -Eq '^\$'
|
getent shadow hydratest | cut -d: -f2 | grep -Eq '^\$'
|
||||||
for service in NetworkManager greetd hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done
|
for service in NetworkManager greetd hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done
|
||||||
@@ -329,7 +353,7 @@ try:
|
|||||||
port = 43117
|
port = 43117
|
||||||
server_code = "import socket; s=socket.socket(); s.settimeout(15); s.bind(('10.203.0.250',43117)); s.listen(1); c,a=s.accept(); c.sendall(b'HYDRA_LAN_TO_VM_OK'); c.close(); s.close()"
|
server_code = "import socket; s=socket.socket(); s.settimeout(15); s.bind(('10.203.0.250',43117)); s.listen(1); c,a=s.accept(); c.sendall(b'HYDRA_LAN_TO_VM_OK'); c.close(); s.close()"
|
||||||
server = subprocess.Popen(['ip', 'netns', 'exec', guest, 'python3', '-c', server_code], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
server = subprocess.Popen(['ip', 'netns', 'exec', guest, 'python3', '-c', server_code], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
client_code = "import socket,time; deadline=time.monotonic()+10\nwhile True:\n try:\n s=socket.create_connection(('10.203.0.250',43117),1); break\n except OSError:\n if time.monotonic()>deadline: raise\n time.sleep(.1)\nassert s.recv(100)==b'HYDRA_LAN_TO_VM_OK'; s.close(); print('PASS: routed new LAN-to-VM TCP connection on arbitrary port 43117')"
|
client_code = "import socket,time; deadline=time.monotonic()+10\nwhile True:\n try:\n s=socket.create_connection(('10.203.0.250',43117),1); break\n except OSError:\n if time.monotonic()>deadline: raise\n time.sleep(.1)\nexpected=b'HYDRA_LAN_TO_VM_OK'; received=b''\nwhile len(received)<len(expected):\n remaining=deadline-time.monotonic()\n if remaining<=0: raise TimeoutError('TCP marker deadline exceeded')\n s.settimeout(remaining)\n part=s.recv(len(expected)-len(received))\n if not part: raise RuntimeError('Truncated TCP marker')\n received+=part\nassert received==expected; s.close(); print('PASS: routed new LAN-to-VM TCP connection on arbitrary port 43117')"
|
||||||
print(run('ip', 'netns', 'exec', lan, 'python3', '-c', client_code), end='')
|
print(run('ip', 'netns', 'exec', lan, 'python3', '-c', client_code), end='')
|
||||||
assert server.wait(timeout=10) == 0
|
assert server.wait(timeout=10) == 0
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -141,3 +141,159 @@ class SmokeTests(unittest.TestCase):
|
|||||||
with self.assertRaises(self.mod.SmokeError):
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
self.mod.qemu_argv('qemu', self.root, comma, self.code,
|
self.mod.qemu_argv('qemu', self.root, comma, self.code,
|
||||||
self.variables, None, 2048)
|
self.variables, None, 2048)
|
||||||
|
|
||||||
|
def probe_fixture(self, case, live=False):
|
||||||
|
# Run the production shell against ordinary scratch files. Commands that
|
||||||
|
# query guest state are boundary fixtures; grep/find/test are real tools.
|
||||||
|
import os
|
||||||
|
root = self.root / 'guest'
|
||||||
|
for directory in ('etc/hydra', 'etc/greetd', 'etc/sudoers.d',
|
||||||
|
'etc/systemd/system/getty@tty2.service.d',
|
||||||
|
'etc/systemd/system/serial-getty@ttyS0.service.d',
|
||||||
|
'usr/share/firmware', 'proc', 'sys/firmware/efi',
|
||||||
|
'run/initramfs/live/LiveOS'):
|
||||||
|
(root / directory).mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / 'etc/greetd/config.toml').write_text('normal authentication')
|
||||||
|
(root / 'usr/share/firmware/OVMF_CODE.fd').write_text('firmware')
|
||||||
|
(root / 'proc/cmdline').write_text('root=UUID=disk ro' if case != 'live-cmdline' else 'root=live:CDLABEL=HYDRA_OS rd.live.image')
|
||||||
|
if live or case == 'live-marker':
|
||||||
|
(root / 'etc/hydra/live-mode').mkdir(exist_ok=True)
|
||||||
|
(root / 'etc/hydra/live-mode/active').touch()
|
||||||
|
if live:
|
||||||
|
(root / 'run/initramfs/live/LiveOS/squashfs.img').touch()
|
||||||
|
files = {
|
||||||
|
'live-dropin': ('etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf', 'live'),
|
||||||
|
'live-sudoers': ('etc/sudoers.d/90-hydra-live', 'live'),
|
||||||
|
'autologin': ('etc/systemd/system/serial-getty@ttyS0.service.d/other.conf', 'ExecStart=agetty --autologin hydratest'),
|
||||||
|
'nopasswd': ('etc/sudoers.d/other', 'hydratest ALL=(ALL) NOPASSWD: ALL'),
|
||||||
|
'live-greetd': ('etc/greetd/live.toml', 'live'),
|
||||||
|
}
|
||||||
|
if case in files:
|
||||||
|
path, text = files[case]
|
||||||
|
(root / path).write_text(text)
|
||||||
|
if case == 'config-read-error':
|
||||||
|
unreadable = root / 'etc/sudoers.d/unreadable'
|
||||||
|
unreadable.write_text('configuration')
|
||||||
|
unreadable.chmod(0)
|
||||||
|
if case == 'cmdline-read-error':
|
||||||
|
(root / 'proc/cmdline').unlink()
|
||||||
|
commands = self.root / 'fixture-bin'
|
||||||
|
commands.mkdir(exist_ok=True)
|
||||||
|
dispatcher = commands / 'fixture'
|
||||||
|
dispatcher.write_text('''#!/usr/bin/env python3
|
||||||
|
import os, pathlib, sys
|
||||||
|
name = pathlib.Path(sys.argv[0]).name
|
||||||
|
args = sys.argv[1:]
|
||||||
|
case = os.environ['SMOKE_CASE']
|
||||||
|
if name == 'id': print('hydra-live')
|
||||||
|
elif name == 'findmnt': print('/dev/vda2' if 'SOURCE' in args else ('overlay' if os.environ['SMOKE_LIVE']=='1' else 'ext4'))
|
||||||
|
elif name == 'systemctl':
|
||||||
|
if args[0] == 'is-active':
|
||||||
|
states = [unit != case for unit in args[1:]]
|
||||||
|
print('\\n'.join('active' if state else 'failed' for state in states))
|
||||||
|
sys.exit(0 if any(states) else 3)
|
||||||
|
print('success' if 'Result' in args else '0')
|
||||||
|
elif name == 'getent':
|
||||||
|
if args[:2] == ['passwd','hydra-live']:
|
||||||
|
if case == 'account-lookup-error': sys.exit(3)
|
||||||
|
if case != 'live-account': sys.exit(2)
|
||||||
|
print('hydra-live:x:1000:1000::/home/hydra-live:/bin/bash')
|
||||||
|
elif args[0] == 'shadow': print(args[1] + ':' + ('!' if args[1]=='root' else '$6$hash') + ':1::::::')
|
||||||
|
else: print('127.0.0.1 STREAM example')
|
||||||
|
elif name == 'ip': print('10.203.0.1/24' if 'addr' in args else 'default via 10.0.2.2')
|
||||||
|
elif name == 'nmcli': print('connected')
|
||||||
|
else: print('fixture tool')
|
||||||
|
''')
|
||||||
|
dispatcher.chmod(0o755)
|
||||||
|
for command in ('id', 'findmnt', 'systemctl', 'getent', 'ip', 'nft',
|
||||||
|
'nmcli', 'sudo', 'hydra', 'hydra-install',
|
||||||
|
'qemu-system-x86_64', 'qemu-img', 'virt-viewer', 'foot', 'labwc'):
|
||||||
|
path = commands / command
|
||||||
|
if not path.exists():
|
||||||
|
path.symlink_to(dispatcher)
|
||||||
|
probe = self.mod.LIVE_CHECK if live else self.mod.INSTALLED_CHECK
|
||||||
|
for path in ('/etc/', '/usr/share', '/proc/cmdline', '/sys/firmware/efi', '/run/initramfs/live/LiveOS/squashfs.img'):
|
||||||
|
probe = probe.replace(path, str(root) + path)
|
||||||
|
env = dict(os.environ, PATH=str(commands) + ':' + os.environ['PATH'],
|
||||||
|
SMOKE_CASE=case, SMOKE_LIVE='1' if live else '0')
|
||||||
|
result = subprocess.run(['/bin/sh', '-c', self.mod.frame_command(probe, 'probecheck')],
|
||||||
|
env=env, capture_output=True, timeout=10)
|
||||||
|
parsed = self.mod.parse_frame(result.stdout, 'probecheck')
|
||||||
|
self.assertIsNotNone(parsed, result.stderr)
|
||||||
|
return parsed[0]
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_each_retained_live_artifact(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean'), 0)
|
||||||
|
for case in ('live-cmdline', 'live-account', 'live-dropin', 'live-sudoers',
|
||||||
|
'autologin', 'nopasswd', 'live-marker', 'live-greetd'):
|
||||||
|
with self.subTest(case=case):
|
||||||
|
# Give each negative case an independent clean filesystem.
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.root / 'guest')
|
||||||
|
self.assertNotEqual(self.probe_fixture(case), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_does_not_treat_account_query_error_as_absence(self):
|
||||||
|
self.assertNotEqual(self.probe_fixture('account-lookup-error'), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_unreadable_configuration(self):
|
||||||
|
self.assertNotEqual(self.probe_fixture('config-read-error'), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_unreadable_boot_arguments(self):
|
||||||
|
self.assertNotEqual(self.probe_fixture('cmdline-read-error'), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_each_failed_required_service(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean'), 0)
|
||||||
|
for service in ('NetworkManager', 'greetd', 'hydra-network', 'hydra-dnsmasq'):
|
||||||
|
with self.subTest(service=service):
|
||||||
|
self.assertNotEqual(self.probe_fixture(service), 0)
|
||||||
|
|
||||||
|
def test_live_probe_rejects_each_failed_required_service(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean', live=True), 0)
|
||||||
|
for service in ('NetworkManager', 'hydra-network', 'hydra-dnsmasq'):
|
||||||
|
with self.subTest(service=service):
|
||||||
|
self.assertNotEqual(self.probe_fixture(service, live=True), 0)
|
||||||
|
|
||||||
|
def tcp_probe(self, fragments):
|
||||||
|
# Execute the exact Python client embedded in the guest network probe,
|
||||||
|
# redirecting only its address to a fresh loopback socket.
|
||||||
|
import ast
|
||||||
|
import time
|
||||||
|
source = self.mod.NETWORK_CHECK.split("<<'NETWORK'\n", 1)[1].rsplit('\nNETWORK\n', 1)[0]
|
||||||
|
assignments = (node for node in ast.walk(ast.parse(source)) if isinstance(node, ast.Assign))
|
||||||
|
value = next(node.value for node in assignments
|
||||||
|
if any(isinstance(target, ast.Name) and target.id == 'client_code' for target in node.targets))
|
||||||
|
client = ast.parse(ast.literal_eval(value))
|
||||||
|
listener = socket.socket()
|
||||||
|
self.addCleanup(listener.close)
|
||||||
|
listener.bind(('127.0.0.1', 0))
|
||||||
|
listener.listen(1)
|
||||||
|
listener.settimeout(3)
|
||||||
|
host, port = listener.getsockname()
|
||||||
|
class Redirect(ast.NodeTransformer):
|
||||||
|
def visit_Tuple(self, node):
|
||||||
|
if isinstance(node.elts[0], ast.Constant) and node.elts[0].value == '10.203.0.250':
|
||||||
|
return ast.Tuple(elts=[ast.Constant(host), ast.Constant(port)], ctx=ast.Load())
|
||||||
|
return self.generic_visit(node)
|
||||||
|
client = ast.fix_missing_locations(Redirect().visit(client))
|
||||||
|
def server():
|
||||||
|
connection, _ = listener.accept()
|
||||||
|
with connection:
|
||||||
|
for fragment in fragments:
|
||||||
|
try:
|
||||||
|
connection.sendall(fragment)
|
||||||
|
except BrokenPipeError:
|
||||||
|
break
|
||||||
|
time.sleep(.05)
|
||||||
|
thread = threading.Thread(target=server)
|
||||||
|
thread.start()
|
||||||
|
result = subprocess.run(['python3', '-c', ast.unparse(client)], capture_output=True, timeout=5)
|
||||||
|
thread.join(timeout=3)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_tcp_probe_accepts_complete_marker_across_fragmented_reads(self):
|
||||||
|
result = self.tcp_probe([b'HYDRA_', b'LAN_TO_', b'VM_OK'])
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_tcp_probe_rejects_truncated_marker(self):
|
||||||
|
self.assertNotEqual(self.tcp_probe([b'HYDRA_LAN_TO_']).returncode, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user