argument('session'); $shell = null; $status = 'closed'; $error = null; $eventOffset = 0; try { $metadata = $sessionManager->readWorkerMetadata($sessionId); $account = HostingAccount::query() ->with('node') ->find($metadata['account_id'] ?? null); if (! $account || ! $account->node) { throw new \RuntimeException('Hosting account or node was not found for this terminal session.'); } $shell = $this->makeShell($account, $metadata, $provider); $shell->boot(); $metadata['status'] = 'running'; $metadata['error'] = null; $sessionManager->writeWorkerMetadata($sessionId, $metadata); $status = 'closed'; while (true) { $metadata = $sessionManager->readWorkerMetadata($sessionId); if ($sessionManager->isSessionIdle($metadata)) { $sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal disconnected after inactivity]\r\n"); break; } foreach ($sessionManager->readWorkerEvents($sessionId, $eventOffset) as $event) { $type = (string) ($event['type'] ?? ''); if ($type === 'input') { $shell->write((string) ($event['data'] ?? '')); continue; } if ($type === 'resize') { $shell->resize((int) ($event['cols'] ?? 120), (int) ($event['rows'] ?? 30)); continue; } if ($type === 'close') { $sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal closed]\r\n"); break 2; } } $output = $shell->read(); if ($output !== '') { $sessionManager->appendWorkerOutput($sessionId, $output); } if (! $shell->isAlive()) { break; } usleep(50000); } } catch (\Throwable $e) { $status = 'failed'; $error = $e->getMessage(); Log::error('Hosting terminal worker failed.', [ 'session_id' => $sessionId, 'exception' => get_class($e), 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ]); $sessionManager->appendWorkerOutput( $sessionId, sprintf( "\r\n[Terminal error] %s (%s:%d)\r\n", $error, basename($e->getFile()), $e->getLine() ) ); } finally { if ($shell instanceof ShellSession) { $shell->close(); } try { $metadata = $sessionManager->readWorkerMetadata($sessionId); $metadata['status'] = $status; $metadata['error'] = $error; $metadata['exit_code'] = $status === 'failed' ? 1 : 0; $metadata['closed_at'] = now()->toIso8601String(); $sessionManager->writeWorkerMetadata($sessionId, $metadata); } catch (\Throwable) { // Ignore metadata write failures during shutdown. } } return $status === 'failed' ? self::FAILURE : self::SUCCESS; } private function makeShell(HostingAccount $account, array $metadata, SharedNodeProvider $provider): ShellSession { $relativePath = $this->normalizeRelativePath((string) ($metadata['relative_path'] ?? '/public_html')); $cols = max(40, min((int) ($metadata['cols'] ?? 120), 240)); $rows = max(10, min((int) ($metadata['rows'] ?? 30), 80)); $launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows); if ($this->shouldUseLocalExecution($account->node)) { // Confine the customer to a jailkit chroot (only their own home is // visible) via the vetted, sudo-scoped launcher. SECURITY: never run // an unjailed login shell here — it exposes the whole host filesystem. return new LocalPtyShellSession($this->buildJailedLaunchCommand($account, $relativePath)); } $ssh = $provider->connect($account->node); if (! $ssh) { throw new \RuntimeException('Unable to establish an SSH connection for the browser terminal.'); } // SECURITY: jail the remote shell too. provider->connect() logs in as // root, so the launcher runs without sudo and chroots the customer into // the node's jail (only their own home visible) — never an unjailed shell. $remoteCommand = $this->buildJailedLaunchCommand($account, $relativePath, false); return new RemoteSshShellSession($ssh, $remoteCommand, $cols, $rows); } /** * Local jailed shell: run the customer inside the jailkit chroot * (config('hosting.terminal_jail_launcher')) as their own user. The launcher * derives uid/gid, binds only that user's home into the jail, and chroots — * so the customer can never see the host filesystem (/var/www, other homes, * /etc, secrets). Invoked via sudo -n, scoped in /etc/sudoers.d/ladill-jailsh * because the terminal worker runs as www-data. */ private function buildJailedLaunchCommand(HostingAccount $account, string $relativePath, bool $sudo = true): string { $launcher = (string) config('hosting.terminal_jail_launcher', '/usr/local/sbin/ladill-jailsh'); $rel = $relativePath !== '' ? $relativePath : '/public_html'; $parts = []; if ($sudo) { $parts[] = 'sudo'; $parts[] = '-n'; } $parts[] = escapeshellarg($launcher); $parts[] = escapeshellarg($account->username); $parts[] = escapeshellarg($rel); return implode(' ', $parts); } private function buildUserShellBootstrap(HostingAccount $account, string $relativePath, int $cols, int $rows): string { $homeDirectory = "/home/{$account->username}"; $workingDirectory = $relativePath === '/' ? $homeDirectory : $homeDirectory.$relativePath; return implode('; ', [ 'export HOME='.escapeshellarg($homeDirectory), 'export USER='.escapeshellarg($account->username), 'export LOGNAME='.escapeshellarg($account->username), 'export SHELL=/bin/bash', 'export TERM=xterm-256color', 'export COLORTERM=truecolor', 'export COLUMNS='.(int) $cols, 'export LINES='.(int) $rows, 'export PS1='.escapeshellarg($account->username.'@ladill:\w\$ '), 'cd '.escapeshellarg($workingDirectory).' 2>/dev/null || cd '.escapeshellarg($homeDirectory), 'stty rows '.(int) $rows.' cols '.(int) $cols.' echo 2>/dev/null || true', 'exec /bin/bash --noprofile --norc -i', ]); } private function buildLocalLaunchCommand(HostingAccount $account, string $launchCommand): string { // For local development, fall back to running as current user if user switching isn't available $devFallback = app()->environment('local') ? 'exec bash -lc "$LAUNCH_CMD";' : 'echo '.escapeshellarg('Local hosting commands require passwordless sudo, runuser access, or running the app as the hosting account user.').'; exit 1;'; return implode(' ', [ 'TARGET_USER='.escapeshellarg($account->username).';', 'LAUNCH_CMD='.escapeshellarg($launchCommand).';', 'if [ "$(id -un)" = "$TARGET_USER" ]; then exec bash -lc "$LAUNCH_CMD"; fi;', 'if command -v sudo >/dev/null 2>&1 && sudo -n -u "$TARGET_USER" -- true >/dev/null 2>&1; then exec sudo -n -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;', 'if command -v runuser >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then exec runuser -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;', $devFallback, ]); } private function normalizeRelativePath(string $path): string { $path = '/'.ltrim(trim($path), '/'); $path = preg_replace('#/+#', '/', $path) ?: '/public_html'; $segments = []; foreach (explode('/', $path) as $segment) { if ($segment === '' || $segment === '.') { continue; } if ($segment === '..') { array_pop($segments); continue; } $segments[] = $segment; } $normalized = '/'.implode('/', $segments); return $normalized === '/' ? '/' : $normalized; } private function shouldUseLocalExecution(HostingNode $node): bool { return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost') && blank($node->ssh_private_key); } }