Fix ISO tool checks and fail promptly on installer refusal
This commit is contained in:
+30
-7
@@ -423,6 +423,15 @@ def parse_frame(data, token):
|
|||||||
return int(end.group(1)), output
|
return int(end.group(1)), output
|
||||||
|
|
||||||
|
|
||||||
|
def installer_command(token):
|
||||||
|
# A literal control-byte return frame cannot be forged by terminal echo.
|
||||||
|
# Keep the installer on this controlling TTY for getpass, then mark every
|
||||||
|
# shell return, including an unexpected status zero without completion.
|
||||||
|
return ('sudo -n hydra-install --disk /dev/vda --user hydratest --execute '
|
||||||
|
'--confirm-erase /dev/vda; rc=$?; '
|
||||||
|
f"printf '\\036{token}:INSTALL_EXIT:%s\\037' \"$rc\"")
|
||||||
|
|
||||||
|
|
||||||
class Console:
|
class Console:
|
||||||
def __init__(self, connection, logfile, passwords):
|
def __init__(self, connection, logfile, passwords):
|
||||||
self.sock = connection
|
self.sock = connection
|
||||||
@@ -472,10 +481,17 @@ class Console:
|
|||||||
raise SmokeError('Console exceeded bounded capture size')
|
raise SmokeError('Console exceeded bounded capture size')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def expect(self, pattern, timeout):
|
def expect(self, pattern, timeout, failure_pattern=None):
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
while True:
|
while True:
|
||||||
match = re.search(pattern, self.buffer)
|
match = re.search(pattern, self.buffer)
|
||||||
|
failure = re.search(failure_pattern, self.buffer) if failure_pattern else None
|
||||||
|
if failure and (not match or failure.start() < match.start()):
|
||||||
|
evidence = failure.group()
|
||||||
|
for password in self.passwords:
|
||||||
|
evidence = evidence.replace(password, b'[REDACTED]')
|
||||||
|
raise SmokeError('Installer exited before expected console state: ' +
|
||||||
|
evidence.decode(errors='replace'))
|
||||||
if match:
|
if match:
|
||||||
self.buffer = self.buffer[match.end():]
|
self.buffer = self.buffer[match.end():]
|
||||||
return match
|
return match
|
||||||
@@ -484,6 +500,11 @@ class Console:
|
|||||||
raise SmokeError('Timed out waiting for guest console state')
|
raise SmokeError('Timed out waiting for guest console state')
|
||||||
self.read(min(remaining, 1))
|
self.read(min(remaining, 1))
|
||||||
|
|
||||||
|
def expect_installer(self, pattern, timeout, token):
|
||||||
|
failure = (rb'hydra-install: [^\r\n]*[\r\n]|\x1e' +
|
||||||
|
re.escape(token.encode()) + rb':INSTALL_EXIT:[0-9]+\x1f')
|
||||||
|
return self.expect(pattern, timeout, failure_pattern=failure)
|
||||||
|
|
||||||
def command(self, command, timeout=60):
|
def command(self, command, timeout=60):
|
||||||
token = secrets.token_hex(12)
|
token = secrets.token_hex(12)
|
||||||
self.buffer = b''
|
self.buffer = b''
|
||||||
@@ -579,7 +600,7 @@ test -f /etc/hydra/live-mode/active
|
|||||||
findmnt -n -o FSTYPE / | grep -qx overlay
|
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 remote-viewer foot labwc; do command -v "$tool"; done
|
||||||
for service in NetworkManager hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done
|
for service in NetworkManager hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -627,7 +648,7 @@ ip -4 route show default | grep '^default '
|
|||||||
nft list table ip hydra_nat
|
nft list table ip hydra_nat
|
||||||
nmcli -t -f STATE general | grep -E '^connected'
|
nmcli -t -f STATE general | grep -E '^connected'
|
||||||
getent ahostsv4 gentoo.org
|
getent ahostsv4 gentoo.org
|
||||||
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 remote-viewer foot labwc; do command -v "$tool"; done
|
||||||
hydra --help
|
hydra --help
|
||||||
qemu-system-x86_64 --version
|
qemu-system-x86_64 --version
|
||||||
qemu-img --version
|
qemu-img --version
|
||||||
@@ -789,12 +810,14 @@ def main(argv=None):
|
|||||||
console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout)
|
console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout)
|
||||||
checkpoint('live_boot', console.command(LIVE_CHECK, 90))
|
checkpoint('live_boot', console.command(LIVE_CHECK, 90))
|
||||||
console.buffer = b''
|
console.buffer = b''
|
||||||
console.send('sudo -n hydra-install --disk /dev/vda --user hydratest --execute --confirm-erase /dev/vda\n')
|
installer_token = secrets.token_hex(12)
|
||||||
console.expect(rb'Installed account password: ', 120)
|
console.send(installer_command(installer_token) + '\n')
|
||||||
|
console.expect_installer(rb'Installed account password: ', 120, installer_token)
|
||||||
console.send(password + '\n')
|
console.send(password + '\n')
|
||||||
console.expect(rb'Repeat password: ', 30)
|
console.expect_installer(rb'Repeat password: ', 30, installer_token)
|
||||||
console.send(password + '\n')
|
console.send(password + '\n')
|
||||||
console.expect(rb'Installation completed\. Diagnostic log: [^\r\n]+', args.install_timeout)
|
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+',
|
||||||
|
args.install_timeout, installer_token)
|
||||||
checkpoint('real_installer', b'Installer completed via guest controlling TTY')
|
checkpoint('real_installer', b'Installer completed via guest controlling TTY')
|
||||||
console.command("sudo -n mkdir -p /mnt/hydra-validation && sudo -n mount /dev/vda2 /mnt/hydra-validation && printf %s " +
|
console.command("sudo -n mkdir -p /mnt/hydra-validation && sudo -n mount /dev/vda2 /mnt/hydra-validation && printf %s " +
|
||||||
shlex.quote(sentinel) + " | sudo -n tee /mnt/hydra-validation/var/lib/hydra-acceptance-sentinel >/dev/null && sudo -n umount /mnt/hydra-validation", 60)
|
shlex.quote(sentinel) + " | sudo -n tee /mnt/hydra-validation/var/lib/hydra-acceptance-sentinel >/dev/null && sudo -n umount /mnt/hydra-validation", 60)
|
||||||
|
|||||||
+62
-2
@@ -101,6 +101,57 @@ class SmokeTests(unittest.TestCase):
|
|||||||
with self.assertRaises(self.mod.SmokeError):
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
console.command('true', timeout=0.05)
|
console.command('true', timeout=0.05)
|
||||||
|
|
||||||
|
def installer_console(self):
|
||||||
|
one, two = socket.socketpair()
|
||||||
|
self.addCleanup(two.close)
|
||||||
|
console = self.mod.Console(one, self.root / 'installer.log', ['test-secret'])
|
||||||
|
self.addCleanup(console.close)
|
||||||
|
return console, two
|
||||||
|
|
||||||
|
def test_captured_installer_refusal_fails_before_password_timeout(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
guest.sendall(b'\x1b]3008;type=command\x1b\\hydra-install: Command failed (1): '
|
||||||
|
b'findmnt --json --target /LiveOS/squashfs.img '
|
||||||
|
b'--output SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS: \r\n')
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, 'Installer.*findmnt'):
|
||||||
|
console.expect_installer(rb'Installed account password: ', 0.05, 'testtoken')
|
||||||
|
|
||||||
|
def test_installer_shell_return_without_completion_is_failure_even_zero(self):
|
||||||
|
for status in (0, 1, 127):
|
||||||
|
with self.subTest(status=status):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
guest.sendall(b'\x1etesttoken:INSTALL_EXIT:' + str(status).encode() + b'\x1f')
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, 'Installer.*INSTALL_EXIT'):
|
||||||
|
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+',
|
||||||
|
0.05, 'testtoken')
|
||||||
|
|
||||||
|
def test_installer_echo_cannot_fake_exit_and_complete_prompt_still_required(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
guest.sendall(self.mod.installer_command('testtoken').encode() + b'\r\n'
|
||||||
|
b'Installed account password: ')
|
||||||
|
self.assertEqual(console.expect_installer(rb'Installed account password: ',
|
||||||
|
0.05, 'testtoken').group(), b'Installed account password: ')
|
||||||
|
guest.sendall(b'Installation completed. Diagnostic')
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, 'Timed out'):
|
||||||
|
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+',
|
||||||
|
0.05, 'testtoken')
|
||||||
|
guest.sendall(b' log: /var/log/hydra-install.log\r\n')
|
||||||
|
self.assertIn(b'/var/log/hydra-install.log', console.expect_installer(
|
||||||
|
rb'Installation completed\. Diagnostic log: [^\r\n]+', 0.05, 'testtoken').group())
|
||||||
|
|
||||||
|
def test_installer_wrapper_marks_actual_shell_return_and_redacts_error(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
fake_sudo = self.root / 'sudo'
|
||||||
|
fake_sudo.write_text('#!/bin/sh\nprintf "hydra-install: test-secret refused\\n"\nexit 1\n')
|
||||||
|
fake_sudo.chmod(0o700)
|
||||||
|
result = subprocess.run(['/bin/sh', '-c', self.mod.installer_command('testtoken')],
|
||||||
|
env={'PATH': str(self.root)}, capture_output=True, check=True)
|
||||||
|
self.assertIn(b'\x1etesttoken:INSTALL_EXIT:1\x1f', result.stdout)
|
||||||
|
guest.sendall(result.stdout)
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, r'Installer.*\[REDACTED\] refused') as caught:
|
||||||
|
console.expect_installer(rb'Repeat password: ', 0.05, 'testtoken')
|
||||||
|
self.assertNotIn('test-secret', str(caught.exception))
|
||||||
|
|
||||||
def test_frame_real_shell_roundtrip_does_not_accept_sentinel_in_echo(self):
|
def test_frame_real_shell_roundtrip_does_not_accept_sentinel_in_echo(self):
|
||||||
one, two = socket.socketpair()
|
one, two = socket.socketpair()
|
||||||
self.addCleanup(one.close)
|
self.addCleanup(one.close)
|
||||||
@@ -207,14 +258,19 @@ else: print('fixture tool')
|
|||||||
dispatcher.chmod(0o755)
|
dispatcher.chmod(0o755)
|
||||||
for command in ('id', 'findmnt', 'systemctl', 'getent', 'ip', 'nft',
|
for command in ('id', 'findmnt', 'systemctl', 'getent', 'ip', 'nft',
|
||||||
'nmcli', 'sudo', 'hydra', 'hydra-install',
|
'nmcli', 'sudo', 'hydra', 'hydra-install',
|
||||||
'qemu-system-x86_64', 'qemu-img', 'virt-viewer', 'foot', 'labwc'):
|
'qemu-system-x86_64', 'qemu-img', 'remote-viewer', 'foot', 'labwc'):
|
||||||
path = commands / command
|
path = commands / command
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
path.symlink_to(dispatcher)
|
path.symlink_to(dispatcher)
|
||||||
probe = self.mod.LIVE_CHECK if live else self.mod.INSTALLED_CHECK
|
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'):
|
for path in ('/etc/', '/usr/share', '/proc/cmdline', '/sys/firmware/efi', '/run/initramfs/live/LiveOS/squashfs.img'):
|
||||||
probe = probe.replace(path, str(root) + path)
|
probe = probe.replace(path, str(root) + path)
|
||||||
env = dict(os.environ, PATH=str(commands) + ':' + os.environ['PATH'],
|
import shutil
|
||||||
|
for command in ('python3', 'sh', 'grep', 'find', 'cut', 'readlink', 'base64'):
|
||||||
|
target = shutil.which(command)
|
||||||
|
self.assertIsNotNone(target)
|
||||||
|
(commands / command).symlink_to(target) if not (commands / command).exists() else None
|
||||||
|
env = dict(os.environ, PATH=str(commands),
|
||||||
SMOKE_CASE=case, SMOKE_LIVE='1' if live else '0')
|
SMOKE_CASE=case, SMOKE_LIVE='1' if live else '0')
|
||||||
result = subprocess.run(['/bin/sh', '-c', self.mod.frame_command(probe, 'probecheck')],
|
result = subprocess.run(['/bin/sh', '-c', self.mod.frame_command(probe, 'probecheck')],
|
||||||
env=env, capture_output=True, timeout=10)
|
env=env, capture_output=True, timeout=10)
|
||||||
@@ -500,3 +556,7 @@ else: sys.exit(1)
|
|||||||
self.assertTrue(self.mod.no_open_logins(context))
|
self.assertTrue(self.mod.no_open_logins(context))
|
||||||
with self.assertRaises(self.mod.SmokeError):
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
self.mod.capture_logout_snapshot(context)
|
self.mod.capture_logout_snapshot(context)
|
||||||
|
|
||||||
|
def test_guest_tools_accept_gentoo_remote_viewer_without_virt_viewer(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean'), 0)
|
||||||
|
self.assertEqual(self.probe_fixture('clean', live=True), 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user