184 lines
7.9 KiB
PHP
184 lines
7.9 KiB
PHP
<?php
|
|
|
|
use Symfony\Component\Process\Process;
|
|
|
|
/**
|
|
* The root-owned half of an update.
|
|
*
|
|
* An update runs as the service account, so everything on the host that belongs
|
|
* to root — installing a package, writing a unit — is out of its reach, and each
|
|
* such thing turns into "log into every server once and paste this".
|
|
*
|
|
* The tempting fix is a sudo grant on deploy/install-agent.sh. That is root, not
|
|
* a grant: the service account owns the checkout and can rewrite the script it
|
|
* would be allowed to run privileged. So the privileged part is written OUT of
|
|
* the checkout to a root-owned path, and the grant names one exact command line.
|
|
*
|
|
* These tests run the helper as bash actually runs it — the whole point is what
|
|
* it REFUSES, and a refusal is an exit code.
|
|
*/
|
|
function hostStepScript(): string
|
|
{
|
|
$installer = file_get_contents(base_path('deploy/install-agent.sh'));
|
|
|
|
// The quoted delimiter matters and is asserted separately below: an
|
|
// unquoted heredoc would expand the installer's own variables into a file
|
|
// that is supposed to be fixed at write time.
|
|
expect($installer)->toContain("cat > \"\$HOST_STEP\" <<'EOF'");
|
|
|
|
// betweenFirst, NOT between: `between` cuts at the LAST delimiter, and this
|
|
// installer has several here-documents after this one — so it handed back
|
|
// the helper plus everything down to the final EOF, and the extra `EOF`
|
|
// line then ran as a command. The helper was fine; the measurement was not.
|
|
$body = Str::betweenFirst($installer, "cat > \"\$HOST_STEP\" <<'EOF'\n", "\nEOF\n");
|
|
|
|
// The extraction is part of what these tests trust, so it is checked at
|
|
// both ends rather than only at the start.
|
|
expect($body)->toContain('CONTRACT=')
|
|
->and(rtrim($body))->toEndWith('esac')
|
|
->and($body)->not->toContain("\nEOF");
|
|
|
|
return $body;
|
|
}
|
|
|
|
function runHostStep(string $args, array $onPath = []): Process
|
|
{
|
|
$dir = sys_get_temp_dir().'/clupilot-host-step-'.bin2hex(random_bytes(6));
|
|
mkdir($dir.'/bin', 0755, true);
|
|
file_put_contents($dir.'/clupilot-host-step', hostStepScript());
|
|
chmod($dir.'/clupilot-host-step', 0755);
|
|
|
|
// A stub apt-get that fails loudly. Any test that does not expect an
|
|
// install will see it in the output rather than silently tolerating one.
|
|
file_put_contents($dir.'/bin/apt-get', "#!/bin/sh\necho 'APT-GET WAS CALLED' >&2\nexit 1\n");
|
|
chmod($dir.'/bin/apt-get', 0755);
|
|
|
|
foreach ($onPath as $name => $exit) {
|
|
file_put_contents($dir."/bin/{$name}", "#!/bin/sh\nexit {$exit}\n");
|
|
chmod($dir."/bin/{$name}", 0755);
|
|
}
|
|
|
|
$process = Process::fromShellCommandline(
|
|
'PATH='.escapeshellarg($dir.'/bin').':/usr/bin:/bin '
|
|
.escapeshellarg($dir.'/clupilot-host-step').' '.$args
|
|
);
|
|
$process->run();
|
|
|
|
exec('rm -rf '.escapeshellarg($dir));
|
|
|
|
return $process;
|
|
}
|
|
|
|
it('refuses a step it does not know by name', function () {
|
|
// The whole safety argument rests on this. sudoers permits one exact
|
|
// command line, and this is the second of the two locks: even reached some
|
|
// other way, the helper does nothing it was not built to do.
|
|
$process = runHostStep('install-whatever-i-like');
|
|
|
|
expect($process->getExitCode())->toBe(64)
|
|
->and($process->getErrorOutput())->toContain('unknown step')
|
|
->and($process->getErrorOutput())->not->toContain('APT-GET WAS CALLED');
|
|
});
|
|
|
|
it('refuses being run with no step at all', function () {
|
|
// `set -u` would otherwise end this as an unbound-variable error, which is
|
|
// the right outcome by accident and reads as a bug in the helper.
|
|
$process = runHostStep('');
|
|
|
|
expect($process->getExitCode())->toBe(64)
|
|
->and($process->getErrorOutput())->toContain('unknown step');
|
|
});
|
|
|
|
it('does not run a package manager when rsync is already there', function () {
|
|
$process = runHostStep('ensure-rsync', ['rsync' => 0]);
|
|
|
|
expect($process->getExitCode())->toBe(0)
|
|
->and($process->getErrorOutput())->not->toContain('APT-GET WAS CALLED');
|
|
});
|
|
|
|
it('says so rather than pretending, on a host with no apt-get', function () {
|
|
// Nothing here is Debian-only by design, but the install step is. Silence
|
|
// would leave an operator believing rsync is on a machine where it is not,
|
|
// and they would find out from a backup that collected nothing.
|
|
$dir = sys_get_temp_dir().'/clupilot-host-step-'.bin2hex(random_bytes(6));
|
|
mkdir($dir.'/bin', 0755, true);
|
|
file_put_contents($dir.'/clupilot-host-step', hostStepScript());
|
|
chmod($dir.'/clupilot-host-step', 0755);
|
|
|
|
// An empty PATH, and bash named outright — the helper's own `#!/usr/bin/env
|
|
// bash` would not find an interpreter otherwise, and that 127 looks exactly
|
|
// like the refusal being tested here.
|
|
$process = new Process(
|
|
[trim((string) shell_exec('command -v bash')), $dir.'/clupilot-host-step', 'ensure-rsync'],
|
|
null,
|
|
['PATH' => $dir.'/bin'],
|
|
);
|
|
$process->run();
|
|
exec('rm -rf '.escapeshellarg($dir));
|
|
|
|
expect($process->getExitCode())->toBe(3)
|
|
->and($process->getErrorOutput())->toContain('no apt-get');
|
|
});
|
|
|
|
it('reports the contract version the updater compares against', function () {
|
|
$process = runHostStep('contract');
|
|
|
|
expect($process->getExitCode())->toBe(0)
|
|
->and(trim($process->getOutput()))->toMatch('/^\d+$/');
|
|
|
|
// And the two halves agree. A helper written by an older installer is the
|
|
// failure mode this number exists for; if update.sh asked for a version the
|
|
// current installer never writes, every up-to-date server would be told to
|
|
// run the installer again forever.
|
|
$needs = Str::between(file_get_contents(base_path('deploy/update.sh')), 'HOST_STEP_NEEDS=', "\n");
|
|
|
|
expect((int) trim($process->getOutput()))->toBe((int) trim($needs));
|
|
});
|
|
|
|
it('grants one command line, not a script the service account can rewrite', function () {
|
|
$installer = file_get_contents(base_path('deploy/install-agent.sh'));
|
|
|
|
expect($installer)
|
|
// Root's, at a path under root's — the service account cannot edit what
|
|
// it is allowed to run. This is the entire safety argument.
|
|
->toContain('HOST_STEP=/usr/local/sbin/clupilot-host-step')
|
|
->toContain('chown root:root "$HOST_STEP"')
|
|
->toContain('chmod 0755 "$HOST_STEP"')
|
|
// Validated before it counts. A malformed fragment in /etc/sudoers.d
|
|
// breaks sudo for everyone on the machine, not only for this grant.
|
|
->toContain('visudo -cf /etc/sudoers.d/clupilot-host-step')
|
|
// Both halves must mean the same path.
|
|
->and(file_get_contents(base_path('deploy/update.sh')))
|
|
->toContain('HOST_STEP=/usr/local/sbin/clupilot-host-step');
|
|
|
|
// Every sudo grant this installer writes, read as sudoers reads it.
|
|
preg_match_all('/^\S+\s+ALL=\(root\)\s+NOPASSWD:\s*(.+)$/m', $installer, $m);
|
|
|
|
expect($m[1])->not->toBeEmpty();
|
|
|
|
foreach ($m[1] as $granted) {
|
|
// An argument, always. A grant on a bare path permits that path with
|
|
// ANY arguments — so every step ever added to the helper would be
|
|
// covered by a decision taken before it existed.
|
|
expect(trim($granted))->toContain(' ')
|
|
// And never anything inside the checkout, which the service account
|
|
// owns: it could rewrite the file and the grant would follow.
|
|
->not->toContain('$ROOT')
|
|
->not->toContain('deploy/');
|
|
}
|
|
|
|
expect($m[1])->toContain('$HOST_STEP ensure-rsync');
|
|
});
|
|
|
|
it('never lets a host package stop a deployment', function () {
|
|
// rsync is wanted by whoever collects the invoice archive, minutes or hours
|
|
// later. An update that died because a package manager was busy would be a
|
|
// far bigger problem than a backup waiting for the next run.
|
|
$update = file_get_contents(base_path('deploy/update.sh'));
|
|
|
|
$call = Str::between($update, 'if ! command -v rsync', 'phase maintenance_off');
|
|
|
|
expect($call)->toContain('sudo -n "$HOST_STEP" ensure-rsync >/dev/null 2>&1')
|
|
->and($call)->toContain('warn ');
|
|
});
|