mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
1 parent
8a0d21e6f2
commit
a5564320ab
14 files changed
+323
-70
No files matched your search
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Shared;
|
namespace App\Livewire\Project\Shared;
|
||||||
|
|
||||||
use App\Helpers\SshMultiplexingHelper;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Services\TerminalSessionService;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
@@ -37,7 +37,7 @@ class Terminal extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[On('send-terminal-command')]
|
#[On('send-terminal-command')]
|
||||||
public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
|
public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService)
|
||||||
{
|
{
|
||||||
$this->authorize('canAccessTerminal');
|
$this->authorize('canAccessTerminal');
|
||||||
|
|
||||||
@@ -65,44 +65,10 @@ class Terminal extends Component
|
|||||||
if (! $this->hasShell) {
|
if (! $this->hasShell) {
|
||||||
return;
|
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
|
$token = $terminalSessionService->issue(auth()->user(), $server, $isContainer ? $identifier : null);
|
||||||
// a better solution would be to remove websocket on NodeJS and work with something like
|
$this->dispatch('send-terminal-token', $token);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[On('terminalConnected')]
|
#[On('terminalConnected')]
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
AUTORUN_ENABLED: false
|
AUTORUN_ENABLED: false
|
||||||
PUSHER_HOST: "${PUSHER_HOST:-coolify}"
|
PUSHER_HOST: "${PUSHER_HOST:-}"
|
||||||
PUSHER_PORT: "${PUSHER_PORT:-6001}"
|
PUSHER_PORT: "${PUSHER_PORT:-6001}"
|
||||||
PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}"
|
PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}"
|
||||||
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
|
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ services:
|
|||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
REDIS_PASSWORD: "${REDIS_PASSWORD:-null}"
|
REDIS_PASSWORD: "${REDIS_PASSWORD:-null}"
|
||||||
COOLIFY_CONTAINER_ROLE: all
|
COOLIFY_CONTAINER_ROLE: all
|
||||||
PUSHER_HOST: coolify
|
PUSHER_HOST: "${PUSHER_HOST:-}"
|
||||||
PUSHER_PORT: 6001
|
PUSHER_PORT: 6001
|
||||||
PUSHER_BACKEND_PORT: 6001
|
PUSHER_BACKEND_PORT: 6001
|
||||||
PUSHER_SCHEME: http
|
PUSHER_SCHEME: http
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTORUN_ENABLED: false
|
AUTORUN_ENABLED: false
|
||||||
COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}"
|
COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}"
|
||||||
PUSHER_HOST: "${PUSHER_HOST:-coolify}"
|
PUSHER_HOST: "${PUSHER_HOST:-}"
|
||||||
PUSHER_PORT: "${PUSHER_PORT:-6001}"
|
PUSHER_PORT: "${PUSHER_PORT:-6001}"
|
||||||
PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}"
|
PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}"
|
||||||
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
|
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
|
||||||
|
|||||||
@@ -15,14 +15,21 @@ import {
|
|||||||
validateSshArgs,
|
validateSshArgs,
|
||||||
} from './terminal-utils.js';
|
} from './terminal-utils.js';
|
||||||
|
|
||||||
async function postToCoolify(path, headers) {
|
async function postToCoolify(path, headers, body = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestBody = body === null ? '' : JSON.stringify(body);
|
||||||
const request = http.request({
|
const request = http.request({
|
||||||
hostname: process.env.TERMINAL_AUTH_HOST || '127.0.0.1',
|
hostname: process.env.TERMINAL_AUTH_HOST || '127.0.0.1',
|
||||||
port: 8080,
|
port: 8080,
|
||||||
path,
|
path,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(body === null ? {} : {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(requestBody),
|
||||||
|
}),
|
||||||
|
},
|
||||||
}, (response) => {
|
}, (response) => {
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|
||||||
@@ -43,7 +50,7 @@ async function postToCoolify(path, headers) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
request.on('error', reject);
|
request.on('error', reject);
|
||||||
request.end();
|
request.end(requestBody);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +174,7 @@ wss.on('connection', async (ws, req) => {
|
|||||||
|
|
||||||
const userId = generateUserId();
|
const userId = generateUserId();
|
||||||
ws.userId = userId;
|
ws.userId = userId;
|
||||||
|
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
||||||
const userSession = {
|
const userSession = {
|
||||||
ws,
|
ws,
|
||||||
userId,
|
userId,
|
||||||
@@ -176,8 +184,11 @@ wss.on('connection', async (ws, req) => {
|
|||||||
authReady: false,
|
authReady: false,
|
||||||
pendingMessages: [],
|
pendingMessages: [],
|
||||||
terminalSessionTimer: null,
|
terminalSessionTimer: null,
|
||||||
|
authHeaders: {
|
||||||
|
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||||
|
'X-XSRF-TOKEN': xsrfToken,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
|
||||||
const connectionContext = {
|
const connectionContext = {
|
||||||
userId,
|
userId,
|
||||||
remoteAddress: req.socket?.remoteAddress,
|
remoteAddress: req.socket?.remoteAddress,
|
||||||
@@ -289,7 +300,7 @@ const messageHandlers = {
|
|||||||
session.ws.send(session.isActive);
|
session.ws.send(session.isActive);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
command: (session, data) => handleCommand(session.ws, data, session.userId)
|
terminalToken: (session, token) => handleTerminalToken(session, token)
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleMessage(userSession, message) {
|
function handleMessage(userSession, message) {
|
||||||
@@ -304,7 +315,7 @@ function handleMessage(userSession, message) {
|
|||||||
|
|
||||||
Object.entries(parsed).forEach(([key, value]) => {
|
Object.entries(parsed).forEach(([key, value]) => {
|
||||||
const handler = messageHandlers[key];
|
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);
|
handler(userSession, value);
|
||||||
} else if (!handler) {
|
} else if (!handler) {
|
||||||
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
|
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
|
||||||
@@ -446,6 +457,29 @@ async function handleCommand(ws, command, userId) {
|
|||||||
}, terminalSessionTimeout * 1000);
|
}, 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) {
|
async function handleError(err, userId) {
|
||||||
logTerminal('error', 'WebSocket error.', {
|
logTerminal('error', 'WebSocket error.', {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
|
|||||||
|
|
||||||
if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) {
|
if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) {
|
||||||
targetHost = extractTargetHost([argument]);
|
targetHost = extractTargetHost([argument]);
|
||||||
|
if (!/^(?:[a-zA-Z0-9]|\[)/.test(targetHost ?? '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
continue;
|
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(['-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', '/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', () => {
|
test('validateSshArgs rejects a destination that begins with an option prefix', () => {
|
||||||
|
|||||||
@@ -185,10 +185,6 @@ export function initializeTerminalComponent() {
|
|||||||
maxHeartbeatMisses: 3,
|
maxHeartbeatMisses: 3,
|
||||||
// Command buffering for race condition prevention
|
// Command buffering for race condition prevention
|
||||||
pendingCommand: null,
|
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
|
// Resize handling
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
resizeTimeout: null,
|
resizeTimeout: null,
|
||||||
@@ -290,8 +286,8 @@ export function initializeTerminalComponent() {
|
|||||||
|
|
||||||
this.setupTerminalEventListeners();
|
this.setupTerminalEventListeners();
|
||||||
|
|
||||||
this.$wire.on('send-back-command', (command) => {
|
this.$wire.on('send-terminal-token', ([token]) => {
|
||||||
this.sendCommandWhenReady({ command: command });
|
this.sendCommandWhenReady({ terminalToken: token });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.$wire.on('terminal-should-focus', () => {
|
this.$wire.on('terminal-should-focus', () => {
|
||||||
@@ -658,15 +654,11 @@ export function initializeTerminalComponent() {
|
|||||||
this.connectionTimeoutId = null;
|
this.connectionTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush any buffered command from before WebSocket was ready, otherwise
|
// Flush a token that was issued before the WebSocket was ready. Issued
|
||||||
// replay the last command so a transient reconnect respawns the PTY
|
// tokens are single-use and must never be replayed after a reconnect.
|
||||||
// automatically without requiring the user to click Connect again.
|
|
||||||
if (this.pendingCommand) {
|
if (this.pendingCommand) {
|
||||||
this.sendMessage(this.pendingCommand);
|
this.sendMessage(this.pendingCommand);
|
||||||
this.pendingCommand = null;
|
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.
|
// (Re)start application-level keepalive on every successful connect.
|
||||||
@@ -749,9 +741,6 @@ export function initializeTerminalComponent() {
|
|||||||
sendMessage(message) {
|
sendMessage(message) {
|
||||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||||
this.socket.send(JSON.stringify(message));
|
this.socket.send(JSON.stringify(message));
|
||||||
if (message && message.command) {
|
|
||||||
this.lastSentCommand = message;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
|
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
|
||||||
}
|
}
|
||||||
@@ -822,7 +811,6 @@ export function initializeTerminalComponent() {
|
|||||||
this.starting = false;
|
this.starting = false;
|
||||||
if (this.term) this.term.reset();
|
if (this.term) this.term.reset();
|
||||||
this.terminalActive = false;
|
this.terminalActive = false;
|
||||||
this.lastSentCommand = null;
|
|
||||||
this.resetTerminalSessionCountdown();
|
this.resetTerminalSessionCountdown();
|
||||||
this.message = '(sorry, something went wrong, please try again)';
|
this.message = '(sorry, something went wrong, please try again)';
|
||||||
|
|
||||||
@@ -836,7 +824,6 @@ export function initializeTerminalComponent() {
|
|||||||
this.resetTerminalSessionCountdown();
|
this.resetTerminalSessionCountdown();
|
||||||
this.term.reset();
|
this.term.reset();
|
||||||
this.commandBuffer = '';
|
this.commandBuffer = '';
|
||||||
this.lastSentCommand = null;
|
|
||||||
|
|
||||||
// Notify parent component that terminal disconnected
|
// Notify parent component that terminal disconnected
|
||||||
this.$wire.dispatch('terminalDisconnected');
|
this.$wire.dispatch('terminalDisconnected');
|
||||||
|
|||||||
@@ -289,7 +289,7 @@
|
|||||||
wsHost: "{{ config('constants.pusher.host') }}" || window.location.hostname,
|
wsHost: "{{ config('constants.pusher.host') }}" || window.location.hostname,
|
||||||
wsPort: "{{ getRealtime() }}",
|
wsPort: "{{ getRealtime() }}",
|
||||||
wssPort: "{{ getRealtime() }}",
|
wssPort: "{{ getRealtime() }}",
|
||||||
forceTLS: false,
|
forceTLS: window.location.protocol === 'https:',
|
||||||
encrypted: true,
|
encrypted: true,
|
||||||
enableStats: false,
|
enableStats: false,
|
||||||
enableLogging: true,
|
enableLogging: true,
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ use App\Models\ScheduledVolumeBackupExecution;
|
|||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
|
use App\Services\TerminalSessionService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||||
|
|
||||||
@@ -252,6 +254,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
return response()->json(['ipAddresses' => []], 401);
|
return response()->json(['ipAddresses' => []], 401);
|
||||||
})->name('terminal.auth.ips')->middleware('can.access.terminal');
|
})->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::prefix('invitations')->group(function () {
|
||||||
Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show');
|
Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show');
|
||||||
Route::post('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
|
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;');
|
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'));
|
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||||
|
|
||||||
expect($terminalClient)
|
expect($terminalClient)
|
||||||
->toContain('lastSentCommand')
|
->toContain('terminalToken')
|
||||||
->toContain('Replaying last command after reconnect.')
|
->toContain("this.\$wire.on('send-terminal-token', ([token]) =>")
|
||||||
->toContain('this.lastSentCommand = null;');
|
->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 () {
|
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}');
|
->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 () {
|
it('proxies Reverb and terminal websocket traffic to the Coolify app container', function () {
|
||||||
$serverModel = file_get_contents(app_path('Models/Server.php'));
|
$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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user