mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 09:20:54 -04:00
Improve terminal and realtime session handling (#11942)
This commit is contained in:
@@ -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')]
|
||||
|
||||
@@ -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"
|
||||
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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user