fix: improve terminal and realtime session handling

Commit creation was blocked because the shared Git metadata is read-only in this session. All 14 changes remain staged and ready to commit.
This commit is contained in:
Andras Bacsai
2026-09-22 20:17:14 +02:00
parent 8a0d21e6f2
commit a5564320ab
14 changed files with 323 additions and 70 deletions
+5 -39
View File
@@ -2,8 +2,8 @@
namespace App\Livewire\Project\Shared;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\TerminalSessionService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
@@ -37,7 +37,7 @@ class Terminal extends Component
}
#[On('send-terminal-command')]
public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService)
{
$this->authorize('canAccessTerminal');
@@ -65,44 +65,10 @@ class Terminal extends Component
if (! $this->hasShell) {
return;
}
// Escape the identifier for shell usage
$escapedIdentifier = escapeshellarg($identifier);
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
// Add sudo for non-root users to access Docker socket
$dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'";
if ($server->isNonRoot()) {
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand(
$server,
$dockerCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand(
$server,
$shellCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
}
// ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here
// a better solution would be to remove websocket on NodeJS and work with something like
// 1. Laravel Pusher/Echo connection (not possible without a sdk)
// 2. Ratchet / Revolt / ReactPHP / Event Loop (possible but hard to implement and huge dependencies)
// 3. Just found out about this https://github.com/sirn-se/websocket-php, perhaps it can be used
// 4. Follow-up discussions here:
// - https://github.com/coollabsio/coolify/issues/2298
// - https://github.com/coollabsio/coolify/discussions/3362
$this->dispatch('send-back-command', $command);
$token = $terminalSessionService->issue(auth()->user(), $server, $isContainer ? $identifier : null);
$this->dispatch('send-terminal-token', $token);
}
#[On('terminalConnected')]
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Services;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Models\User;
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class TerminalSessionService
{
private const TOKEN_TTL_SECONDS = 60;
public function issue(User $user, Server $server, ?string $container = null): string
{
$token = Str::random(64);
Cache::put($this->cacheKey($token), [
'user_id' => $user->id,
'team_id' => $user->currentTeam()->id,
'server_uuid' => $server->uuid,
'container' => $container,
], now()->addSeconds(self::TOKEN_TTL_SECONDS));
return $token;
}
public function redeem(User $user, string $token): string
{
$payload = Cache::lock($this->cacheKey($token).':lock', 5)
->get(fn () => Cache::pull($this->cacheKey($token)));
if (! is_array($payload)
|| data_get($payload, 'user_id') !== $user->id
|| data_get($payload, 'team_id') !== $user->currentTeam()->id
|| ! is_string(data_get($payload, 'server_uuid'))
|| ! array_key_exists('container', $payload)) {
throw new AccessDeniedHttpException('Invalid or expired terminal token.');
}
$server = Server::ownedByCurrentTeam()
->whereUuid(data_get($payload, 'server_uuid'))
->with('privateKey', 'settings')
->firstOrFail();
if (! $server->isTerminalEnabled()
|| $server->isForceDisabled()
|| ! $server->privateKey
|| $server->privateKey->team_id !== $user->currentTeam()->id) {
throw new AccessDeniedHttpException('Terminal target is not authorized.');
}
$command = $this->shellCommand();
$container = $payload['container'];
if ($container !== null) {
if (! is_string($container)
|| ! ValidationPatterns::isValidContainerName($container)
|| getContainerStatus($server, $container) !== 'running') {
throw new AccessDeniedHttpException('Terminal container is not authorized.');
}
$dockerCommand = 'docker exec -it '.escapeshellarg($container).' sh -c '.escapeshellarg($command);
$command = $server->isNonRoot() ? "sudo {$dockerCommand}" : $dockerCommand;
}
return SshMultiplexingHelper::generateSshCommand(
$server,
$command,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
}
private function shellCommand(): string
{
return 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
}
private function cacheKey(string $token): string
{
return "terminal-session:{$token}";
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ services:
- "host.docker.internal:host-gateway"
environment:
AUTORUN_ENABLED: false
PUSHER_HOST: "${PUSHER_HOST:-coolify}"
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-6001}"
PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
+1 -1
View File
@@ -45,7 +45,7 @@ services:
REDIS_PORT: 6379
REDIS_PASSWORD: "${REDIS_PASSWORD:-null}"
COOLIFY_CONTAINER_ROLE: all
PUSHER_HOST: coolify
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: 6001
PUSHER_BACKEND_PORT: 6001
PUSHER_SCHEME: http
+1 -1
View File
@@ -17,7 +17,7 @@ services:
environment:
AUTORUN_ENABLED: false
COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}"
PUSHER_HOST: "${PUSHER_HOST:-coolify}"
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-6001}"
PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
+40 -6
View File
@@ -15,14 +15,21 @@ import {
validateSshArgs,
} from './terminal-utils.js';
async function postToCoolify(path, headers) {
async function postToCoolify(path, headers, body = null) {
return new Promise((resolve, reject) => {
const requestBody = body === null ? '' : JSON.stringify(body);
const request = http.request({
hostname: process.env.TERMINAL_AUTH_HOST || '127.0.0.1',
port: 8080,
path,
method: 'POST',
headers,
headers: {
...headers,
...(body === null ? {} : {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
}),
},
}, (response) => {
let responseText = '';
@@ -43,7 +50,7 @@ async function postToCoolify(path, headers) {
});
request.on('error', reject);
request.end();
request.end(requestBody);
});
}
@@ -167,6 +174,7 @@ wss.on('connection', async (ws, req) => {
const userId = generateUserId();
ws.userId = userId;
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const userSession = {
ws,
userId,
@@ -176,8 +184,11 @@ wss.on('connection', async (ws, req) => {
authReady: false,
pendingMessages: [],
terminalSessionTimer: null,
authHeaders: {
'Cookie': `${sessionCookieName}=${laravelSession}`,
'X-XSRF-TOKEN': xsrfToken,
},
};
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
remoteAddress: req.socket?.remoteAddress,
@@ -289,7 +300,7 @@ const messageHandlers = {
session.ws.send(session.isActive);
}
},
command: (session, data) => handleCommand(session.ws, data, session.userId)
terminalToken: (session, token) => handleTerminalToken(session, token)
};
function handleMessage(userSession, message) {
@@ -304,7 +315,7 @@ function handleMessage(userSession, message) {
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
if (handler && (userSession.isActive || key === 'checkActive' || key === 'terminalToken' || key === 'ping')) {
handler(userSession, value);
} else if (!handler) {
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
@@ -446,6 +457,29 @@ async function handleCommand(ws, command, userId) {
}, terminalSessionTimeout * 1000);
}
async function handleTerminalToken(userSession, token) {
if (typeof token !== 'string' || !/^[a-zA-Z0-9]{64}$/.test(token)) {
userSession.ws.send('Unauthorized: Invalid terminal token');
return;
}
try {
const response = await postToCoolify('/terminal/session', userSession.authHeaders, { token });
if (response.status !== 200 || typeof response.data?.command !== 'string') {
userSession.ws.send('Unauthorized: Terminal token was rejected');
return;
}
await handleCommand(userSession.ws, [response.data.command], userSession.userId);
} catch (error) {
logTerminal('error', 'Failed to redeem terminal token.', {
userId: userSession.userId,
error: error.message,
});
userSession.ws.send('Unauthorized: Terminal token was rejected');
}
}
async function handleError(err, userId) {
logTerminal('error', 'WebSocket error.', {
userId,
@@ -232,6 +232,9 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) {
targetHost = extractTargetHost([argument]);
if (!/^(?:[a-zA-Z0-9]|\[)/.test(targetHost ?? '')) {
return false;
}
continue;
}
@@ -119,6 +119,22 @@ test('validateSshArgs rejects unknown SSH options and key paths', () => {
assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false);
assert.equal(validateSshArgs(['-i', '/tmp/attacker-key', ...baseArgs.slice(2)], ['10.0.0.5']), false);
assert.equal(validateSshArgs(['-i', '/var/www/html/storage/app/ssh/keys/../ssh_key@victim', ...baseArgs.slice(2)], ['10.0.0.5']), false);
assert.equal(validateSshArgs(['-o', 'Include=/tmp/config', ...baseArgs], ['10.0.0.5']), false);
assert.equal(validateSshArgs(['-o', 'IdentityFile=/tmp/key', ...baseArgs], ['10.0.0.5']), false);
assert.equal(validateSshArgs(['-o', 'PermitLocalCommand=yes', ...baseArgs], ['10.0.0.5']), false);
assert.equal(validateSshArgs(['-o', 'LocalCommand=id', ...baseArgs], ['10.0.0.5']), false);
});
test('validateSshArgs rejects repeated flags and option-like connection values', () => {
const baseArgs = extractSshArgs(
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
);
assert.equal(validateSshArgs(['-i', baseArgs[1], ...baseArgs], ['10.0.0.5']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-p', '22', 'root@10.0.0.5'], ['10.0.0.5']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), 'root@-oProxyCommand=id'], ['-oproxycommand=id']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-root@10.0.0.5'], ['10.0.0.5']), false);
});
test('validateSshArgs rejects a destination that begins with an option prefix', () => {
+4 -17
View File
@@ -185,10 +185,6 @@ export function initializeTerminalComponent() {
maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
// Last successfully sent SSH command — replayed after a transient reconnect
// so the PTY respawns automatically. Cleared on intentional terminations
// (pty-exited, unprocessable).
lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
@@ -290,8 +286,8 @@ export function initializeTerminalComponent() {
this.setupTerminalEventListeners();
this.$wire.on('send-back-command', (command) => {
this.sendCommandWhenReady({ command: command });
this.$wire.on('send-terminal-token', ([token]) => {
this.sendCommandWhenReady({ terminalToken: token });
});
this.$wire.on('terminal-should-focus', () => {
@@ -658,15 +654,11 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null;
}
// Flush any buffered command from before WebSocket was ready, otherwise
// replay the last command so a transient reconnect respawns the PTY
// automatically without requiring the user to click Connect again.
// Flush a token that was issued before the WebSocket was ready. Issued
// tokens are single-use and must never be replayed after a reconnect.
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
} else if (this.lastSentCommand) {
logTerminal('log', '[Terminal] Replaying last command after reconnect.');
this.sendMessage(this.lastSentCommand);
}
// (Re)start application-level keepalive on every successful connect.
@@ -749,9 +741,6 @@ export function initializeTerminalComponent() {
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
if (message && message.command) {
this.lastSentCommand = message;
}
} else {
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
}
@@ -822,7 +811,6 @@ export function initializeTerminalComponent() {
this.starting = false;
if (this.term) this.term.reset();
this.terminalActive = false;
this.lastSentCommand = null;
this.resetTerminalSessionCountdown();
this.message = '(sorry, something went wrong, please try again)';
@@ -836,7 +824,6 @@ export function initializeTerminalComponent() {
this.resetTerminalSessionCountdown();
this.term.reset();
this.commandBuffer = '';
this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
+1 -1
View File
@@ -289,7 +289,7 @@
wsHost: "{{ config('constants.pusher.host') }}" || window.location.hostname,
wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}",
forceTLS: false,
forceTLS: window.location.protocol === 'https:',
encrypted: true,
enableStats: false,
enableLogging: true,
+10
View File
@@ -111,6 +111,8 @@ use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Providers\RouteServiceProvider;
use App\Services\TerminalSessionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
@@ -252,6 +254,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
return response()->json(['ipAddresses' => []], 401);
})->name('terminal.auth.ips')->middleware('can.access.terminal');
Route::post('/terminal/session', function (Request $request, TerminalSessionService $terminalSessionService) {
$request->validate(['token' => ['required', 'string', 'size:64']]);
return response()->json([
'command' => $terminalSessionService->redeem($request->user(), $request->string('token')->toString()),
]);
})->name('terminal.session')->middleware('can.access.terminal');
Route::prefix('invitations')->group(function () {
Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show');
Route::post('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
@@ -297,13 +297,15 @@ it('exits fullscreen when the terminal process exits', function () {
this.terminalActive = false;');
});
it('replays the last command on reconnect so the PTY respawns automatically', function () {
it('does not replay single-use terminal tokens after reconnect', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('lastSentCommand')
->toContain('Replaying last command after reconnect.')
->toContain('this.lastSentCommand = null;');
->toContain('terminalToken')
->toContain("this.\$wire.on('send-terminal-token', ([token]) =>")
->toContain('tokens are single-use and must never be replayed')
->not->toContain('lastSentCommand')
->not->toContain('Replaying last command after reconnect.');
});
it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () {
@@ -148,6 +148,20 @@ it('keeps the internal Reverb listen port separate from the public Pusher port',
->toContain('exec php artisan reverb:start --host=0.0.0.0 --port=${PUSHER_BACKEND_PORT:-6001}');
});
it('uses the browser host and page TLS mode for development Reverb connections', function () {
$baseLayout = file_get_contents(resource_path('views/layouts/base.blade.php'));
expect(file_get_contents(base_path('docker-compose.dev.yml')))
->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"')
->and(file_get_contents(base_path('docker-compose-maxio.dev.yml')))
->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"')
->and(file_get_contents(base_path('docker-compose.dev-multi.yml')))
->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"')
->and($baseLayout)
->toContain("forceTLS: window.location.protocol === 'https:'")
->not->toContain('forceTLS: false');
});
it('proxies Reverb and terminal websocket traffic to the Coolify app container', function () {
$serverModel = file_get_contents(app_path('Models/Server.php'));
@@ -0,0 +1,133 @@
<?php
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\TerminalSessionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
Cache::clear();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
function terminalTokenPayload(User $user, Team $team, Server $server, ?string $container = null): array
{
return [
'user_id' => $user->id,
'team_id' => $team->id,
'server_uuid' => $server->uuid,
'container' => $container,
];
}
it('issues an opaque terminal token without exposing connection data', function () {
$server = Server::factory()->make([
'uuid' => 'server-uuid',
'ip' => '192.0.2.10',
'team_id' => $this->team->id,
]);
$token = app(TerminalSessionService::class)->issue($this->user, $server);
expect($token)->toHaveLength(64)
->not->toContain($server->uuid)
->not->toContain($server->ip);
});
it('rejects expired and replayed terminal tokens', function () {
$service = app(TerminalSessionService::class);
expect(fn () => $service->redeem($this->user, str_repeat('a', 64)))
->toThrow(AccessDeniedHttpException::class);
Cache::put('terminal-session:'.str_repeat('b', 64), ['invalid' => true], now()->addMinute());
expect(fn () => $service->redeem($this->user, str_repeat('b', 64)))
->toThrow(AccessDeniedHttpException::class)
->and(Cache::has('terminal-session:'.str_repeat('b', 64)))->toBeFalse();
});
it('rejects a cross-team server after token issue', function () {
$otherTeam = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $otherTeam->id]);
$token = str_repeat('c', 64);
Cache::put("terminal-session:{$token}", terminalTokenPayload($this->user, $this->team, $server), now()->addMinute());
$this->postJson('/terminal/session', ['token' => $token])->assertNotFound();
});
it('rejects a server that references another teams private key', function () {
$otherTeam = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $otherTeam->id]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
]);
$token = str_repeat('d', 64);
Cache::put("terminal-session:{$token}", terminalTokenPayload($this->user, $this->team, $server), now()->addMinute());
$this->postJson('/terminal/session', ['token' => $token])->assertForbidden();
});
it('constructs fixed ssh arguments from the authorized server record', function () {
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$server = Server::factory()->create([
'ip' => '192.0.2.25',
'user' => 'root',
'port' => 2222,
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
]);
$token = app(TerminalSessionService::class)->issue($this->user, $server);
$response = $this->postJson('/terminal/session', ['token' => $token]);
$response->assertSuccessful();
expect($response->json('command'))
->toContain("ssh_key@{$privateKey->uuid}")
->toContain("'2222'")
->toContain("'root'@'192.0.2.25'")
->not->toContain('ProxyCommand=');
$this->postJson('/terminal/session', ['token' => $token])->assertForbidden();
});
it('rejects option-like and traversal container identifiers before command construction', function (string $container) {
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
]);
$token = str_repeat('g', 63).random_int(0, 9);
Cache::put("terminal-session:{$token}", terminalTokenPayload($this->user, $this->team, $server, $container), now()->addMinute());
$this->postJson('/terminal/session', ['token' => $token])->assertForbidden();
})->with(['-oProxyCommand=id', '../other-container', 'container name']);
it('denies members from redeeming terminal tokens', function () {
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$this->postJson('/terminal/session', ['token' => str_repeat('e', 64)])->assertForbidden();
});
it('does not accept ssh arguments or identity paths from the client', function () {
$this->postJson('/terminal/session', [
'token' => str_repeat('f', 64),
'sshArgs' => ['-o', 'ProxyCommand=id'],
'identityFile' => '../../other-team-key',
'target' => '-oProxyCommand=id',
])->assertForbidden();
});