EN

jumphpsql

by davidnurdin · Demos and experiments
not rated yet
▶ Open 💬 Feedback Source 🕘 Versions Edit GitHub
apps/jumphpsql / tests / SshTunnelTest.php · commit 1d1d550
<?phpnamespace Tests;use App\Db\Endpoint;use App\Db\MysqlConnection;use App\Db\SshTunnel;use PHPUnit\Framework\TestCase;final class SshTunnelTest extends TestCase{    private ?Endpoint $endpoint = null;    private string $knownHosts = '';    protected function setUp(): void    {        $this->knownHosts = sys_get_temp_dir() . '/jumphpsql-known-hosts-' . getmypid() . '-' . mt_rand();    }    protected function tearDown(): void    {        $this->endpoint?->close();        @unlink($this->knownHosts);    }    /** @return array<string, mixed> */    private function ssh(array $more = []): array    {        $config = TestServer::sshConfig();        if ($config === null) {            self::markTestSkipped('No SSH test server on 2299 or no ssh client (see tests/TestServer.php)');        }        return $more + $config;    }    /** Opens the endpoint, frame after frame, as the window does: the connection, or the error. */    private function open(array $config): MysqlConnection|\Throwable    {        $this->endpoint = new Endpoint($config, $this->knownHosts);        $outcome = null;        $this->endpoint->open()->then(function (MysqlConnection $db) use (&$outcome): void {            $outcome = $db;        }, function (\Throwable $e) use (&$outcome): void {            $outcome = $e;        });        $end = microtime(true) + 25;        while ($outcome === null && microtime(true) < $end) {            $this->endpoint->poll();            usleep(5000);        }        self::assertNotNull($outcome, 'the tunnel answered in time');        return $outcome;    }    private function query(MysqlConnection $db, string $sql): string    {        return (string)$db->wait($db->first($sql))->rows[0][0];    }    public function testTheCommandHasNoSecretAndFitsTheAuthentication(): void    {        $base = ['sshHost' => 'bastion', 'sshUser' => 'me', 'host' => 'db.internal', 'port' => 3307, 'sshPassword' => 'pw', 'sshPassphrase' => 'pp'];        $agent = (new SshTunnel($base + ['sshAuth' => 'agent']))->command(40000);        self::assertSame(['ssh', '-N', '-T', '-L', '127.0.0.1:40000:db.internal:3307', '-p', '22'], array_slice($agent, 0, 7));        self::assertContains('BatchMode=yes', $agent);        self::assertSame('me@bastion', end($agent));        $key = (new SshTunnel($base + ['sshAuth' => 'key', 'sshKey' => '/k']))->command(1);        self::assertContains('/k', $key);        self::assertContains('IdentitiesOnly=yes', $key);        self::assertNotContains('BatchMode=yes', $key, 'a passphrase may be asked');        $password = (new SshTunnel($base + ['sshAuth' => 'password']))->command(1);        self::assertContains('PubkeyAuthentication=no', $password);        foreach ([$agent, $key, $password] as $command) {            self::assertNotContains('pw', $command);            self::assertNotContains('pp', $command);        }    }    public function testAPasswordOpensTheTunnelToTheDatabase(): void    {        $db = $this->open($this->ssh());        self::assertInstanceOf(MysqlConnection::class, $db, $db instanceof \Throwable ? $db->getMessage() : '');        self::assertSame('1', $this->query($db, 'SELECT 1'));        self::assertSame('127.0.0.1', $this->endpoint->mysqlConfig()['host'], 'another connection goes through the tunnel');        self::assertFileExists($this->knownHosts, 'the host key is remembered');    }    public function testAWrongPasswordIsRefused(): void    {        $error = $this->open($this->ssh(['sshPassword' => 'wrong']));        self::assertInstanceOf(\Throwable::class, $error);        self::assertStringContainsString('Permission denied', $error->getMessage());    }    public function testAKeyFileOpensTheTunnel(): void    {        $dir = TestServer::sshKeys();        $db = $this->open($this->ssh(['sshAuth' => 'key', 'sshKey' => "$dir/key"]));        self::assertInstanceOf(MysqlConnection::class, $db, $db instanceof \Throwable ? $db->getMessage() : '');        self::assertSame('2', $this->query($db, 'SELECT 1 + 1'));    }    public function testAKeyWithAPassphraseOpensTheTunnel(): void    {        $dir = TestServer::sshKeys();        $db = $this->open($this->ssh(['sshAuth' => 'key', 'sshKey' => "$dir/keyp", 'sshPassphrase' => 'secret phrase']));        self::assertInstanceOf(MysqlConnection::class, $db, $db instanceof \Throwable ? $db->getMessage() : '');        $this->endpoint->close();        $error = $this->open($this->ssh(['sshAuth' => 'key', 'sshKey' => "$dir/keyp", 'sshPassphrase' => '']));        self::assertInstanceOf(\Throwable::class, $error, 'without its passphrase the key is refused');    }    public function testTheSessionKeyOfTheAgentOpensTheTunnel(): void    {        $config = $this->ssh(['sshAuth' => 'agent']);        $dir = TestServer::sshKeys();        $socket = sys_get_temp_dir() . '/jumphpsql-agent-' . getmypid() . '.sock';        @unlink($socket);        $env = '';        foreach (SshTunnel::passwdWrapper() as $name => $value) {            $env .= $name . '=' . escapeshellarg($value) . ' ';        }        $out = (string)shell_exec($env . 'ssh-agent -a ' . escapeshellarg($socket) . ' 2>&1');        self::assertMatchesRegularExpression('/SSH_AGENT_PID=(\d+)/', $out);        preg_match('/SSH_AGENT_PID=(\d+)/', $out, $m);        $before = getenv('SSH_AUTH_SOCK');        putenv("SSH_AUTH_SOCK=$socket");        try {            shell_exec($env . 'ssh-add ' . escapeshellarg("$dir/key") . ' 2>&1');            $db = $this->open($config);            self::assertInstanceOf(MysqlConnection::class, $db, $db instanceof \Throwable ? $db->getMessage() : '');        } finally {            putenv($before === false ? 'SSH_AUTH_SOCK' : "SSH_AUTH_SOCK=$before");            posix_kill((int)$m[1], SIGTERM);            @unlink($socket);        }    }    public function testAnUnknownSshHostFailsWithItsReason(): void    {        $error = $this->open($this->ssh(['sshHost' => 'no-such-host.invalid']));        self::assertInstanceOf(\Throwable::class, $error);        self::assertStringStartsWith('SSH: ', $error->getMessage());    }    public function testTheWindowGoesThroughTheTunnelWhenItDrops(): void    {        $db = $this->open($this->ssh());        self::assertInstanceOf(MysqlConnection::class, $db, $db instanceof \Throwable ? $db->getMessage() : '');        $why = null;        $this->endpoint->onClose = function (string $reason) use (&$why): void {            $why = $reason;        };        // the ssh process killed: the endpoint says so        $tunnel = $this->endpoint->tunnel;        $process = (new \ReflectionProperty($tunnel, 'process'))->getValue($tunnel);        proc_terminate($process, SIGKILL);        $end = microtime(true) + 5;        while ($why === null && microtime(true) < $end) {            $this->endpoint->poll();            usleep(5000);        }        self::assertNotNull($why);    }}