then reaches the database through the SSH server), * poll() (every frame) watches the process. The ssh process forwards the bytes by itself, polled or not. * * $tunnel = new SshTunnel(['sshHost' => 'bastion.example', 'sshUser' => 'me', 'sshAuth' => 'agent', 'host' => '10.0.0.5', 'port' => 3306]); * $tunnel->start()->then(fn (int $port) => new MysqlConnection(['host' => '127.0.0.1', 'port' => $port] + ...)); * * sshAuth: 'agent' (the session's key, ssh-agent: SSH_AUTH_SOCK), 'key' (sshKey: a private key file, sshPassphrase if it * has one), 'password' (sshPassword, given to ssh through SSH_ASKPASS), 'auto' (what ssh does by itself: ~/.ssh/config, * the default keys, the agent). Host keys: accepted the first time and remembered (known_hosts of the app, then the * user's ~/.ssh/known_hosts); a changed one is refused. */ final class SshTunnel { public const AUTHS = ['agent' => 'Session key (ssh-agent)', 'key' => 'Key file', 'password' => 'Password', 'auto' => 'SSH config / default keys']; /** new, connecting, ready, closed */ public string $state = 'new'; public int $localPort = 0; public ?string $error = null; /** called when the tunnel drops after it was up: fn (string $why) */ public ?\Closure $onClose = null; /** @var resource|null */ private $process = null; /** @var resource|null */ private $stderr = null; private string $log = ''; private float $deadline = 0; private float $nextProbe = 0; private ?Promise $starting = null; private ?string $askpass = null; /** * @param array $config sshHost, sshPort, sshUser, sshAuth, sshKey, sshPassphrase, sshPassword; host and * port: the database server as the SSH server sees it * @param string $knownHosts the app's known_hosts file */ public function __construct(public readonly array $config, private string $knownHosts = '', private string $ssh = 'ssh') { } /** The ssh command line (without the secrets: they go through the environment). @return list */ public function command(int $localPort): array { $c = $this->config; $auth = (string)($c['sshAuth'] ?? 'agent'); $target = (string)($c['host'] ?? '127.0.0.1'); $target = $target === 'localhost' ? '127.0.0.1' : $target; $target = str_contains($target, ':') ? "[$target]" : $target; $args = [$this->ssh, '-N', '-T', '-L', "127.0.0.1:$localPort:$target:" . (int)($c['port'] ?? 3306), '-p', (string)((int)($c['sshPort'] ?? 22) ?: 22), '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=3', '-o', 'ConnectTimeout=' . (int)($c['sshTimeout'] ?? 15), '-o', 'StrictHostKeyChecking=accept-new']; $home = getenv('HOME') ?: ''; if ($this->knownHosts !== '') { $files = [$this->knownHosts]; if ($home !== '' && is_file("$home/.ssh/known_hosts")) { $files[] = "$home/.ssh/known_hosts"; } $args = [...$args, '-o', 'UserKnownHostsFile=' . implode(' ', array_map(fn (string $f) => str_replace(' ', '\ ', $f), $files))]; } switch ($auth) { case 'agent': $args = [...$args, '-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no', '-o', 'BatchMode=yes']; break; case 'key': $args = [...$args, '-i', (string)($c['sshKey'] ?? ''), '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none', '-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no', '-o', 'NumberOfPasswordPrompts=1']; if ((string)($c['sshPassphrase'] ?? '') === '') { $args = [...$args, '-o', 'BatchMode=yes']; } break; case 'password': $args = [...$args, '-o', 'PreferredAuthentications=password,keyboard-interactive', '-o', 'PubkeyAuthentication=no', '-o', 'IdentityAgent=none', '-o', 'NumberOfPasswordPrompts=1']; break; default: $args = [...$args, '-o', 'BatchMode=yes']; } $user = trim((string)($c['sshUser'] ?? '')); $args[] = ($user !== '' ? $user . '@' : '') . trim((string)($c['sshHost'] ?? '')); return $args; } /** A free port of this computer (asked to the system). */ public static function freePort(): int { $server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $error); if ($server === false) { throw new MysqlError("No free local port for the SSH tunnel: $error"); } $name = (string)stream_socket_get_name($server, false); fclose($server); return (int)substr($name, strrpos($name, ':') + 1); } public function start(): Promise { if ($this->starting !== null) { return $this->starting; } $this->starting = new Promise(); if (trim((string)($this->config['sshHost'] ?? '')) === '') { $this->fail('SSH: a host, please'); return $this->starting; } if (($this->config['sshAuth'] ?? '') === 'key' && !is_file((string)($this->config['sshKey'] ?? ''))) { $this->fail("SSH: no key file at '" . ($this->config['sshKey'] ?? '') . "'"); return $this->starting; } if (!function_exists('proc_open')) { $this->fail('SSH: proc_open is not available'); return $this->starting; } try { $this->localPort = self::freePort(); } catch (MysqlError $e) { $this->fail($e->getMessage()); return $this->starting; } if ($this->knownHosts !== '' && !is_dir(dirname($this->knownHosts))) { @mkdir(dirname($this->knownHosts), 0700, true); } $env = $this->environment(); $null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; $pipes = []; $process = @proc_open($this->command($this->localPort), [0 => ['file', $null, 'r'], 1 => ['file', $null, 'w'], 2 => ['pipe', 'w']], $pipes, null, $env); if (!is_resource($process)) { $this->fail("SSH: can't start '$this->ssh' (is the ssh client installed?)"); return $this->starting; } $this->process = $process; $this->stderr = $pipes[2]; stream_set_blocking($this->stderr, false); $this->state = 'connecting'; $this->deadline = microtime(true) + (int)($this->config['sshTimeout'] ?? 15) + 5; $this->nextProbe = microtime(true) + 0.15; return $this->starting; } /** @return array the process's environment: ours, plus the secret and SSH_ASKPASS when one is needed */ private function environment(): array { $env = array_filter(getenv(), 'is_string'); $secret = match ($this->config['sshAuth'] ?? '') { 'password' => (string)($this->config['sshPassword'] ?? ''), 'key' => (string)($this->config['sshPassphrase'] ?? ''), default => '', }; $env += self::passwdWrapper(); if ($secret !== '') { $this->askpass = $this->askpassScript(); $env['JUMPHPSQL_SSH_SECRET'] = $secret; $env['SSH_ASKPASS'] = $this->askpass; $env['SSH_ASKPASS_REQUIRE'] = 'force'; $env['DISPLAY'] = $env['DISPLAY'] ?? ':0'; } return $env; } /** * ssh finds the user's home (~/.ssh) and name in the passwd database: in a container run with another uid (bin/jumphp) * there is none and ssh stops ("No user exists for uid"). nss_wrapper then gives it one, home = $HOME. * * @return array */ public static function passwdWrapper(): array { if (!function_exists('posix_getpwuid') || posix_getpwuid(posix_geteuid()) !== false) { return []; } $library = ''; foreach (glob('/usr/lib/*/libnss_wrapper.so') ?: [] as $file) { $library = $file; } if ($library === '') { return []; } $dir = rtrim(sys_get_temp_dir(), '/') . '/jumphpsql-nss-' . posix_geteuid(); @mkdir($dir, 0700, true); $uid = posix_geteuid(); $gid = posix_getegid(); $home = getenv('HOME') ?: sys_get_temp_dir(); file_put_contents("$dir/passwd", "root:x:0:0:root:/root:/bin/sh\nuser:x:$uid:$gid:user:$home:/bin/sh\n"); file_put_contents("$dir/group", "root:x:0:\nuser:x:$gid:\n"); return ['LD_PRELOAD' => $library, 'NSS_WRAPPER_PASSWD' => "$dir/passwd", 'NSS_WRAPPER_GROUP' => "$dir/group"]; } /** A tiny program that prints the secret ssh asks for (it reads it from its environment: never written to a file). */ private function askpassScript(): string { $windows = PHP_OS_FAMILY === 'Windows'; $file = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'jumphpsql-askpass-' . getmypid() . ($windows ? '.cmd' : '.sh'); file_put_contents($file, $windows ? "@echo off\r\necho %JUMPHPSQL_SSH_SECRET%\r\n" : "#!/bin/sh\nprintf '%s\\n' \"\$JUMPHPSQL_SSH_SECRET\"\n"); @chmod($file, 0700); return $file; } public function poll(): void { if ($this->process === null) { return; } $this->readLog(); $status = proc_get_status($this->process); if (!$status['running']) { $this->readLog(); $this->fail($this->reason($status['exitcode'])); return; } if ($this->state === 'connecting') { if (microtime(true) > $this->deadline) { $this->fail('SSH: timed out' . ($this->log !== '' ? ' — ' . $this->lastLine() : '')); return; } if (microtime(true) >= $this->nextProbe) { $this->nextProbe = microtime(true) + 0.15; // the forwarded port listens once ssh is logged in (and ExitOnForwardFailure kept it alive) $probe = @stream_socket_client("tcp://127.0.0.1:$this->localPort", $errno, $error, 0.05); if ($probe !== false) { fclose($probe); $this->state = 'ready'; $this->forgetAskpass(); $this->starting?->resolve($this->localPort); } } } } private function readLog(): void { while ($this->stderr !== null && ($chunk = @fread($this->stderr, 8192)) !== false && $chunk !== '') { $this->log = substr($this->log . $chunk, -4000); } } private function lastLine(): string { $lines = array_values(array_filter(array_map('trim', explode("\n", $this->log)), fn (string $l) => $l !== '' && !str_starts_with($l, 'Warning: Permanently added'))); return (string)end($lines); } private function reason(int $code): string { $line = $this->lastLine(); if ($code === 127 || str_contains($line, 'not found')) { return "SSH: the ssh client was not found ('$this->ssh')"; } return 'SSH: ' . ($line !== '' ? $line : "ssh stopped (exit code $code)"); } /** Why ssh could not forward a connection (the server refused it, the database is unreachable from it), or ''. */ public function forwardError(): string { $this->readLog(); return preg_match('/channel \d+: open failed: (.+)/', $this->log, $m) ? 'SSH: forwarding refused or failed: ' . trim($m[1]) : ''; } /** What ssh said (its errors and warnings). */ public function log(): string { return $this->log; } public function isReady(): bool { return $this->state === 'ready'; } public function close(): void { $this->stop('SSH tunnel closed', false); } private function fail(string $why): void { $this->error = $why; $this->stop($why, true); } private function stop(string $why, bool $failed): void { $wasReady = $this->state === 'ready'; if ($this->process !== null) { $status = proc_get_status($this->process); if ($status['running']) { proc_terminate($this->process); } if ($this->stderr !== null) { fclose($this->stderr); } proc_close($this->process); } $this->process = $this->stderr = null; $this->state = 'closed'; $this->forgetAskpass(); if ($this->starting !== null && !$this->starting->isDone()) { $this->starting->reject(new MysqlError($why)); } if ($wasReady && $failed && $this->onClose !== null) { ($this->onClose)($why); } } private function forgetAskpass(): void { if ($this->askpass !== null) { @unlink($this->askpass); $this->askpass = null; } } public function __destruct() { $this->close(); } }