Files
ladill-hosting/app/Console/Commands/RunHostingTerminalWorkerCommand.php
T
isaaccladandClaude Opus 4.8 2a1228b40e
Deploy Ladill Hosting / deploy (push) Successful in 45s
Jail the control-panel terminal to the customer's home (chroot)
SECURITY: the browser terminal ran an unconfined login shell as the account's
system user, so customers could browse the whole host (/var/www, other tenants,
/etc). Run it inside a jailkit chroot instead: a vetted, sudo-scoped launcher
(/usr/local/sbin/ladill-jailsh) enters a private mount namespace, bind-mounts
ONLY that user's home into /home/jail, and chroots as the user. They can no
longer see anything outside their own home. Provisioning committed in
deployment/ (setup-terminal-jail.sh + ladill-jailsh) so it is reproducible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:41:18 +00:00

242 lines
9.5 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Services\Hosting\BrowserTerminalSessionManager;
use App\Services\Hosting\Providers\SharedNodeProvider;
use App\Services\Hosting\Terminal\LocalPtyShellSession;
use App\Services\Hosting\Terminal\RemoteSshShellSession;
use App\Services\Hosting\Terminal\ShellSession;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RunHostingTerminalWorkerCommand extends Command
{
protected $signature = 'hosting:terminal-worker {session}';
protected $description = 'Run a live browser terminal worker for a hosting account session.';
public function handle(
BrowserTerminalSessionManager $sessionManager,
SharedNodeProvider $provider,
): int {
$sessionId = (string) $this->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.');
}
$remoteCommand = "runuser -u {$account->username} -- bash -lc ".escapeshellarg($launchCommand);
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): string
{
$launcher = (string) config('hosting.terminal_jail_launcher', '/usr/local/sbin/ladill-jailsh');
$rel = $relativePath !== '' ? $relativePath : '/public_html';
return implode(' ', [
'sudo', '-n',
escapeshellarg($launcher),
escapeshellarg($account->username),
escapeshellarg($rel),
]);
}
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);
}
}