mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co>
This commit is contained in:
co-authored by
Claude Opus 5.5
peaklabs-dev
Aditya Tripathi
parent
b7dc2e435b
commit
033f0c84da
@@ -4,6 +4,9 @@ import pty from 'node-pty';
|
||||
import 'dotenv/config';
|
||||
import {
|
||||
TERMINAL_CLOSE_CODES,
|
||||
TERMINAL_IDLE_TIMEOUT_SECONDS,
|
||||
TERMINAL_SESSION_CHECK_INTERVAL_MS,
|
||||
TERMINAL_SESSION_CHECK_MAX_FAILURES,
|
||||
authenticateTerminalUpgrade,
|
||||
createTerminalUpgradeHandler,
|
||||
extractHereDocContent,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
isAuthorizedTargetHost,
|
||||
rejectTerminalSocket,
|
||||
sanitizeSshArgs,
|
||||
terminalSessionCheckResult,
|
||||
validateSshArgs,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
@@ -55,6 +59,9 @@ async function postToCoolify(path, headers, body = null) {
|
||||
});
|
||||
});
|
||||
|
||||
request.setTimeout(10_000, () => {
|
||||
request.destroy(new Error('Request to Coolify timed out'));
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end(requestBody);
|
||||
});
|
||||
@@ -259,21 +266,26 @@ wss.on('close', () => clearInterval(heartbeat));
|
||||
|
||||
const messageHandlers = {
|
||||
message: (session, data) => {
|
||||
if (typeof data !== 'string') {
|
||||
return;
|
||||
}
|
||||
session.lastActivityAt = Date.now();
|
||||
session.ptyProcess.write(data);
|
||||
},
|
||||
resize: (session, { cols, rows }) => {
|
||||
cols = cols > 0 ? cols : 80;
|
||||
rows = rows > 0 ? rows : 30;
|
||||
session.ptyProcess.resize(cols, rows)
|
||||
resize: (session, size) => {
|
||||
const cols = terminalDimension(size?.cols, 80);
|
||||
const rows = terminalDimension(size?.rows, 30);
|
||||
session.ptyProcess.resize(cols, rows);
|
||||
},
|
||||
pause: (session) => session.ptyProcess.pause(),
|
||||
resume: (session) => session.ptyProcess.resume(),
|
||||
ping: (session) => session.ws.send('pong'),
|
||||
checkActive: (session, data) => {
|
||||
if (data === 'force' && session.isActive) {
|
||||
session.endReason = 'replaced';
|
||||
killPtyProcess(session.userId);
|
||||
} else {
|
||||
session.ws.send(session.isActive);
|
||||
session.ws.send(String(session.isActive));
|
||||
}
|
||||
},
|
||||
terminalToken: (session, token) => handleTerminalToken(session, token)
|
||||
@@ -289,10 +301,17 @@ function handleMessage(userSession, message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
logTerminal('warn', 'Ignoring websocket message because it is not a JSON object.', {
|
||||
userId: userSession.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(parsed).forEach(([key, value]) => {
|
||||
const handler = messageHandlers[key];
|
||||
const handler = Object.hasOwn(messageHandlers, key) ? messageHandlers[key] : null;
|
||||
if (handler && (userSession.isActive || key === 'checkActive' || key === 'terminalToken' || key === 'ping')) {
|
||||
handler(userSession, value);
|
||||
runMessageHandler(handler, userSession, key, value);
|
||||
} else if (!handler) {
|
||||
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
|
||||
userId: userSession.userId,
|
||||
@@ -307,6 +326,28 @@ function handleMessage(userSession, message) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one handler. A handler that throws must not stop the process, because one
|
||||
* process serves the terminals of all users.
|
||||
*/
|
||||
function runMessageHandler(handler, userSession, key, value) {
|
||||
const logFailure = (error) => logTerminal('error', 'Websocket message handler failed.', {
|
||||
userId: userSession.userId,
|
||||
key,
|
||||
error: error?.message ?? error,
|
||||
});
|
||||
|
||||
try {
|
||||
Promise.resolve(handler(userSession, value)).catch(logFailure);
|
||||
} catch (error) {
|
||||
logFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
function terminalDimension(value, fallback) {
|
||||
return Number.isInteger(value) && value > 0 && value <= 1000 ? value : fallback;
|
||||
}
|
||||
|
||||
function parseMessage(message) {
|
||||
try {
|
||||
return JSON.parse(message);
|
||||
@@ -318,9 +359,15 @@ function parseMessage(message) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommand(ws, command, userId) {
|
||||
/**
|
||||
* Starts the PTY for a redeemed terminal session.
|
||||
*
|
||||
* @returns {Promise<boolean>} false when the command was rejected and no PTY started
|
||||
*/
|
||||
async function handleCommand(ws, command, userId, terminalSessionId, reportEnd) {
|
||||
const userSession = userSessions.get(userId);
|
||||
if (userSession && userSession.isActive) {
|
||||
userSession.endReason = 'replaced';
|
||||
const result = await killPtyProcess(userId);
|
||||
if (!result) {
|
||||
logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', {
|
||||
@@ -328,7 +375,7 @@ async function handleCommand(ws, command, userId) {
|
||||
});
|
||||
// if terminal is still active, even after we tried to kill it, dont continue and show error
|
||||
ws.send('unprocessable');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +407,7 @@ async function handleCommand(ws, command, userId) {
|
||||
sshArgs,
|
||||
});
|
||||
ws.send('Invalid SSH command: No target host found');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate target host against authorized IPs
|
||||
@@ -371,7 +418,7 @@ async function handleCommand(ws, command, userId) {
|
||||
authorizedIPs: userSession.authorizedIPs,
|
||||
});
|
||||
ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateSshArgs(sshArgs, userSession.authorizedIPs)) {
|
||||
@@ -380,7 +427,7 @@ async function handleCommand(ws, command, userId) {
|
||||
targetHost,
|
||||
});
|
||||
ws.send('Invalid SSH command: Unsupported SSH arguments');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const sanitizedSshArgs = sanitizeSshArgs(sshArgs);
|
||||
|
||||
@@ -405,14 +452,21 @@ async function handleCommand(ws, command, userId) {
|
||||
|
||||
userSession.ptyProcess = ptyProcess;
|
||||
userSession.isActive = true;
|
||||
userSession.endReason = null;
|
||||
userSession.lastActivityAt = Date.now();
|
||||
const sessionWatch = watchTerminalSession(userSession, ptyProcess, terminalSessionId);
|
||||
// Attach sessions share the container's main process, so they must be detached, not killed.
|
||||
userSession.isAttach = isAttachCommand(hereDocContent);
|
||||
|
||||
ws.send('pty-ready');
|
||||
|
||||
// PTY output goes in binary frames. Text frames are only control messages of this server,
|
||||
// so output of the remote shell cannot look like one.
|
||||
ptyProcess.onData((data) => {
|
||||
if (userSession.ptyProcess !== ptyProcess) return;
|
||||
ws.send(data);
|
||||
// Output counts as activity, so commands like `docker logs -f` are not ended as idle.
|
||||
userSession.lastActivityAt = Date.now();
|
||||
ws.send(Buffer.from(data, 'utf8'));
|
||||
});
|
||||
|
||||
// when parent closes
|
||||
@@ -423,13 +477,18 @@ async function handleCommand(ws, command, userId) {
|
||||
signal,
|
||||
});
|
||||
|
||||
clearInterval(sessionWatch);
|
||||
|
||||
// A previous PTY can exit late (e.g. an attach session detaching during a mode
|
||||
// switch) after its replacement is already spawned. Only mutate the shared session
|
||||
// state when this exit belongs to the session's current PTY.
|
||||
if (userSession.ptyProcess !== ptyProcess) {
|
||||
reportEnd('replaced');
|
||||
return;
|
||||
}
|
||||
|
||||
reportEnd(userSession.endReason ?? 'exited');
|
||||
|
||||
ws.send('pty-exited');
|
||||
userSession.isActive = false;
|
||||
|
||||
@@ -440,8 +499,101 @@ async function handleCommand(ws, command, userId) {
|
||||
});
|
||||
|
||||
userSession.terminalSessionTimer = setTimeout(async () => {
|
||||
await killPtyProcess(userId);
|
||||
await endTerminalSession(userSession, ptyProcess, 'max_duration', `it reached the maximum duration of ${terminalSessionTimeout / 3600} hours.`);
|
||||
}, terminalSessionTimeout * 1000);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the PTY when it has no input and no output for too long, or when Coolify no longer allows it:
|
||||
* after a logout, a lost admin role, a removal from the team, or when terminal access
|
||||
* was turned off on the server.
|
||||
*/
|
||||
function watchTerminalSession(userSession, ptyProcess, terminalSessionId) {
|
||||
let failedChecks = 0;
|
||||
let checking = false;
|
||||
|
||||
return setInterval(async () => {
|
||||
if (userSession.ptyProcess !== ptyProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (TERMINAL_IDLE_TIMEOUT_SECONDS > 0 && Date.now() - userSession.lastActivityAt >= TERMINAL_IDLE_TIMEOUT_SECONDS * 1000) {
|
||||
await endTerminalSession(userSession, ptyProcess, 'idle', `no input or output for ${TERMINAL_IDLE_TIMEOUT_SECONDS / 60} minutes.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return;
|
||||
}
|
||||
|
||||
checking = true;
|
||||
let response = null;
|
||||
try {
|
||||
// Accept JSON so a logged-out session gets a 401 instead of a redirect to the login page.
|
||||
response = await postToCoolify('/terminal/session/check', { ...userSession.authHeaders, Accept: 'application/json' }, { session_id: terminalSessionId });
|
||||
} catch (error) {
|
||||
logTerminal('error', 'Failed to check terminal session.', {
|
||||
userId: userSession.userId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
checking = false;
|
||||
|
||||
const result = terminalSessionCheckResult(response);
|
||||
if (result === 'active') {
|
||||
failedChecks = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === 'revoked') {
|
||||
await endTerminalSession(userSession, ptyProcess, 'revoked', 'access to this terminal was revoked.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Coolify is unavailable (for example during an upgrade) or gave an unexpected answer. Fail closed after a few minutes.
|
||||
logTerminal('warn', 'Terminal session check failed.', {
|
||||
userId: userSession.userId,
|
||||
status: response?.status,
|
||||
});
|
||||
failedChecks++;
|
||||
if (failedChecks >= TERMINAL_SESSION_CHECK_MAX_FAILURES) {
|
||||
await endTerminalSession(userSession, ptyProcess, 'revoked', 'Coolify could not verify access to this terminal.');
|
||||
}
|
||||
}, TERMINAL_SESSION_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function endTerminalSession(userSession, ptyProcess, reason, notice) {
|
||||
if (!userSession.isActive || userSession.ptyProcess !== ptyProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
logTerminal('log', 'Ending terminal session.', {
|
||||
userId: userSession.userId,
|
||||
reason,
|
||||
});
|
||||
userSession.endReason = reason;
|
||||
if (userSession.ws.readyState === userSession.ws.OPEN) {
|
||||
userSession.ws.send(`Terminal session ended: ${notice}`);
|
||||
}
|
||||
await killPtyProcess(userSession.userId);
|
||||
}
|
||||
|
||||
// Sent without the browser cookie: the session id authenticates it, and the user may have logged out.
|
||||
function reportTerminalSessionEnd(terminalSessionId, reason) {
|
||||
postToCoolify('/terminal/session/end', {}, { session_id: terminalSessionId, reason })
|
||||
.then((response) => {
|
||||
if (response.status !== 204) {
|
||||
throw createHttpError(response);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logTerminal('error', 'Failed to report the end of a terminal session.', {
|
||||
reason,
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rejectTerminalToken(userSession, message) {
|
||||
@@ -458,15 +610,28 @@ async function handleTerminalToken(userSession, token) {
|
||||
return;
|
||||
}
|
||||
|
||||
let terminalSessionId;
|
||||
let endReported = false;
|
||||
const reportEnd = (reason) => {
|
||||
if (typeof terminalSessionId !== 'string' || endReported) return;
|
||||
endReported = true;
|
||||
reportTerminalSessionEnd(terminalSessionId, reason);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await postToCoolify('/terminal/session', userSession.authHeaders, { token });
|
||||
if (response.status !== 200 || typeof response.data?.command !== 'string') {
|
||||
terminalSessionId = response.data?.session_id;
|
||||
if (response.status !== 200 || typeof response.data?.command !== 'string' || typeof terminalSessionId !== 'string') {
|
||||
rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
await handleCommand(userSession.ws, [response.data.command], userSession.userId);
|
||||
const started = await handleCommand(userSession.ws, [response.data.command], userSession.userId, terminalSessionId, reportEnd);
|
||||
if (!started) {
|
||||
reportEnd('rejected');
|
||||
}
|
||||
} catch (error) {
|
||||
reportEnd('rejected');
|
||||
logTerminal('error', 'Failed to redeem terminal token.', {
|
||||
userId: userSession.userId,
|
||||
error: error.message,
|
||||
@@ -480,6 +645,7 @@ async function handleError(err, userId) {
|
||||
userId,
|
||||
error: err?.message ?? err,
|
||||
});
|
||||
markDisconnected(userId);
|
||||
await killPtyProcess(userId);
|
||||
}
|
||||
|
||||
@@ -487,10 +653,18 @@ async function handleClose(userId) {
|
||||
logTerminal('log', 'Cleaning up terminal websocket session.', {
|
||||
userId,
|
||||
});
|
||||
markDisconnected(userId);
|
||||
await killPtyProcess(userId);
|
||||
userSessions.delete(userId);
|
||||
}
|
||||
|
||||
function markDisconnected(userId) {
|
||||
const session = userSessions.get(userId);
|
||||
if (session?.isActive) {
|
||||
session.endReason = 'disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
async function killPtyProcess(userId) {
|
||||
const session = userSessions.get(userId);
|
||||
if (!session?.ptyProcess) return false;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import * as terminalUtils from './terminal-utils.js';
|
||||
|
||||
const source = readFileSync(new URL('./terminal-server.js', import.meta.url), 'utf8');
|
||||
const handler = source.slice(source.indexOf('async function handleTerminalToken('), source.indexOf('async function handleError('));
|
||||
const commandHandler = source.slice(source.indexOf('async function handleCommand('), source.indexOf('function watchTerminalSession('));
|
||||
const sessionWatcher = source.slice(source.indexOf('function watchTerminalSession('), source.indexOf('async function endTerminalSession('));
|
||||
|
||||
for (const exitDuringStartup of [false, true]) {
|
||||
test(`startup failure reports one end (exit during startup: ${exitDuringStartup})`, async () => {
|
||||
const reports = [];
|
||||
let exit;
|
||||
const context = vm.createContext({
|
||||
postToCoolify: async () => ({ status: 200, data: { command: 'ssh', session_id: 'session' } }),
|
||||
handleCommand: async (_ws, _command, _userId, _id, reportEnd) => {
|
||||
exit = () => reportEnd('exited');
|
||||
if (exitDuringStartup) exit();
|
||||
throw new Error('Startup failed');
|
||||
},
|
||||
reportTerminalSessionEnd: (id, reason) => reports.push({ id, reason }),
|
||||
logTerminal() {},
|
||||
rejectTerminalToken() {},
|
||||
});
|
||||
vm.runInContext(handler, context);
|
||||
await context.handleTerminalToken({ ws: {}, userId: 'user' }, 'a'.repeat(64));
|
||||
if (!exitDuringStartup) exit();
|
||||
assert.deepEqual(reports, [{ id: 'session', reason: exitDuringStartup ? 'exited' : 'rejected' }]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the session watcher with the given check responses, one per interval tick.
|
||||
* Returns the end reasons, in order.
|
||||
*/
|
||||
async function runSessionChecks(responses) {
|
||||
const ends = [];
|
||||
let tick;
|
||||
const ptyProcess = {};
|
||||
const userSession = { userId: 'user', ptyProcess, lastActivityAt: Date.now(), authHeaders: {} };
|
||||
const context = vm.createContext({
|
||||
...terminalUtils,
|
||||
Date,
|
||||
setInterval: (callback) => { tick = callback; return 1; },
|
||||
postToCoolify: async () => {
|
||||
const response = responses.shift();
|
||||
if (response instanceof Error) throw response;
|
||||
return response;
|
||||
},
|
||||
endTerminalSession: async (_session, _pty, reason) => ends.push(reason),
|
||||
logTerminal() {},
|
||||
});
|
||||
vm.runInContext(sessionWatcher, context);
|
||||
context.watchTerminalSession(userSession, ptyProcess, 'session');
|
||||
while (responses.length > 0) {
|
||||
await tick();
|
||||
}
|
||||
|
||||
return ends;
|
||||
}
|
||||
|
||||
for (const status of [401, 403, 404, 419]) {
|
||||
test(`a session check with status ${status} revokes the session at once`, async () => {
|
||||
assert.deepEqual(await runSessionChecks([{ status, data: null }]), ['revoked']);
|
||||
});
|
||||
}
|
||||
|
||||
test('a redirect from a session check counts as a failed check, not a revocation', async () => {
|
||||
const redirect = { status: 302, data: null };
|
||||
assert.deepEqual(await runSessionChecks([redirect, redirect]), []);
|
||||
assert.deepEqual(await runSessionChecks([redirect, redirect, redirect]), ['revoked']);
|
||||
});
|
||||
|
||||
test('an active check resets the failed checks', async () => {
|
||||
const failed = { status: 302, data: null };
|
||||
const active = { status: 200, data: { active: true } };
|
||||
assert.deepEqual(await runSessionChecks([failed, failed, active, failed, new Error('down')]), []);
|
||||
});
|
||||
|
||||
test('a 200 response without the active flag is a failed check', async () => {
|
||||
const unexpected = { status: 200, data: '<html></html>' };
|
||||
assert.deepEqual(await runSessionChecks([unexpected, unexpected, unexpected]), ['revoked']);
|
||||
});
|
||||
|
||||
test('pty output is sent as binary frames, so it cannot look like a control message', async () => {
|
||||
const sent = [];
|
||||
let emitData;
|
||||
const ptyProcess = { onData: (callback) => { emitData = callback; }, onExit() {} };
|
||||
const userSession = { userId: 'user', isActive: false, authorizedIPs: ['10.0.0.5'], terminalSessionTimer: null };
|
||||
const context = vm.createContext({
|
||||
...terminalUtils,
|
||||
process,
|
||||
Buffer,
|
||||
userSessions: new Map([['user', userSession]]),
|
||||
pty: { spawn: () => ptyProcess },
|
||||
watchTerminalSession: () => 1,
|
||||
setTimeout: () => 1,
|
||||
logTerminal() {},
|
||||
});
|
||||
vm.runInContext(commandHandler, context);
|
||||
const command = "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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p '22' -- 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc";
|
||||
|
||||
const started = await context.handleCommand({ send: (data) => sent.push(data) }, [command], 'user', 'session', () => {});
|
||||
assert.equal(started, true, JSON.stringify(sent));
|
||||
emitData('Terminal session ended: spoofed');
|
||||
|
||||
assert.deepEqual(sent.slice(0, 1), ['pty-ready']);
|
||||
assert.ok(Buffer.isBuffer(sent[1]));
|
||||
assert.equal(sent[1].toString('utf8'), 'Terminal session ended: spoofed');
|
||||
});
|
||||
|
||||
/**
|
||||
* Starts a PTY with a fake clock and returns controls to emit output, send input, and run a watcher tick.
|
||||
*/
|
||||
async function startWatchedSession() {
|
||||
let now = 0;
|
||||
let tick;
|
||||
let emitData;
|
||||
const ends = [];
|
||||
const ptyProcess = { onData: (callback) => { emitData = callback; }, onExit() {}, write() {} };
|
||||
const userSession = { userId: 'user', isActive: false, authorizedIPs: ['10.0.0.5'], terminalSessionTimer: null, authHeaders: {} };
|
||||
const context = vm.createContext({
|
||||
...terminalUtils,
|
||||
process,
|
||||
Buffer,
|
||||
Date: { now: () => now },
|
||||
userSessions: new Map([['user', userSession]]),
|
||||
pty: { spawn: () => ptyProcess },
|
||||
setInterval: (callback) => { tick = callback; return 1; },
|
||||
setTimeout: () => 1,
|
||||
postToCoolify: async () => ({ status: 200, data: { active: true } }),
|
||||
endTerminalSession: async (_session, _pty, reason) => ends.push(reason),
|
||||
logTerminal() {},
|
||||
});
|
||||
vm.runInContext(`${commandHandler}\n${sessionWatcher}\n${source.slice(source.indexOf('const messageHandlers = {'), source.indexOf('function handleMessage('))}\nthis.messageHandlers = messageHandlers;`, context);
|
||||
const command = "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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p '22' -- 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc";
|
||||
assert.equal(await context.handleCommand({ send() {} }, [command], 'user', 'session', () => {}), true);
|
||||
|
||||
return {
|
||||
ends,
|
||||
at: async (minutes, action) => {
|
||||
now = minutes * 60 * 1000;
|
||||
await action?.();
|
||||
},
|
||||
output: () => emitData('tail -f line\r\n'),
|
||||
input: () => context.messageHandlers.message(userSession, 'x'),
|
||||
tick: () => tick(),
|
||||
};
|
||||
}
|
||||
|
||||
test('pty output keeps a session without input alive', async () => {
|
||||
const session = await startWatchedSession();
|
||||
await session.at(25, session.output);
|
||||
await session.at(50, session.output);
|
||||
await session.at(70, session.tick);
|
||||
|
||||
assert.deepEqual(session.ends, []);
|
||||
});
|
||||
|
||||
test('input keeps a session without output alive', async () => {
|
||||
const session = await startWatchedSession();
|
||||
await session.at(25, session.input);
|
||||
await session.at(50, session.tick);
|
||||
|
||||
assert.deepEqual(session.ends, []);
|
||||
});
|
||||
|
||||
test('a session with no input and no output is not ended as idle by default', async () => {
|
||||
const session = await startWatchedSession();
|
||||
await session.at(10, session.output);
|
||||
await session.at(7 * 60, session.tick);
|
||||
|
||||
assert.deepEqual(session.ends, []);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const source = readFileSync(new URL('./terminal-server.js', import.meta.url), 'utf8');
|
||||
const extract = (name, next, globals) => vm.runInNewContext(`${source.slice(source.indexOf(name), source.lastIndexOf('\n', source.indexOf(next)))}; ${name.match(/function (\w+)/)[1]}`, globals);
|
||||
test('requests destroy and reject on timeout', async () => {
|
||||
let expire, fail;
|
||||
const post = extract('async function postToCoolify', 'function parseResponseData', {
|
||||
process, Buffer, http: { request: () => ({
|
||||
setTimeout(ms, callback) { assert.equal(ms, 10000); expire = callback; },
|
||||
on(event, callback) { if (event === 'error') fail = callback; },
|
||||
end() {}, destroy(error) { fail(error); },
|
||||
}) },
|
||||
});
|
||||
const pending = post('/check', {});
|
||||
expire();
|
||||
await assert.rejects(pending, /timed out/);
|
||||
});
|
||||
test('idle check continues during pending access request', async () => {
|
||||
let tick, now = 0, ended = false;
|
||||
const watch = extract('function watchTerminalSession', 'function endTerminalSession', {
|
||||
setInterval(callback) { tick = callback; }, Date: { now: () => now },
|
||||
TERMINAL_IDLE_TIMEOUT_SECONDS: 60, TERMINAL_SESSION_CHECK_INTERVAL_MS: 1000,
|
||||
postToCoolify: () => new Promise(() => {}),
|
||||
endTerminalSession: async () => { ended = true; },
|
||||
});
|
||||
const pty = {};
|
||||
watch({ ptyProcess: pty, lastActivityAt: 0 }, pty, 'id');
|
||||
tick(); now = 60000; await tick();
|
||||
assert.equal(ended, true);
|
||||
});
|
||||
@@ -2,6 +2,36 @@ import { parseCookie } from 'cookie';
|
||||
|
||||
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
|
||||
|
||||
/**
|
||||
* A PTY with no input and no output for this long is ended. 0 turns the idle limit off, so a
|
||||
* long command without output (for example a restore) keeps running until the maximum duration.
|
||||
*/
|
||||
export const TERMINAL_IDLE_TIMEOUT_SECONDS = 0;
|
||||
|
||||
/** How often each live PTY checks with Coolify that the user may still use it. */
|
||||
export const TERMINAL_SESSION_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
/** Consecutive failed checks (Coolify unreachable, 5xx, or an unexpected answer) that end a PTY. */
|
||||
export const TERMINAL_SESSION_CHECK_MAX_FAILURES = 3;
|
||||
|
||||
/** Session check statuses with which Coolify says that the session is no longer allowed. */
|
||||
const TERMINAL_SESSION_REVOKED_STATUSES = new Set([401, 403, 404, 419]);
|
||||
|
||||
/**
|
||||
* Reads the answer of Coolify to a session check. Only an explicit rejection revokes the
|
||||
* session at once. Anything else, for example a redirect from a page gate, is a failed check.
|
||||
*
|
||||
* @param {{ status: number, data: unknown } | null} response null when the request failed
|
||||
* @returns {'active' | 'revoked' | 'failed'}
|
||||
*/
|
||||
export function terminalSessionCheckResult(response) {
|
||||
if (response?.status === 200 && response.data?.active === true) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
return TERMINAL_SESSION_REVOKED_STATUSES.has(response?.status) ? 'revoked' : 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close codes sent to the browser. Browsers cannot read the HTTP
|
||||
* status of a rejected WebSocket handshake, so rejections are reported with
|
||||
@@ -243,13 +273,8 @@ export function extractSshArgs(commandString) {
|
||||
}
|
||||
|
||||
sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg));
|
||||
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
|
||||
|
||||
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
|
||||
sshArgs.push('-o', 'RequestTTY=yes');
|
||||
}
|
||||
|
||||
return sshArgs;
|
||||
return sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
|
||||
}
|
||||
|
||||
export function extractHereDocContent(commandString) {
|
||||
@@ -321,6 +346,9 @@ const REQUIRED_SSH_OPTIONS = new Set([
|
||||
'StrictHostKeyChecking',
|
||||
'UserKnownHostsFile',
|
||||
'PasswordAuthentication',
|
||||
'IdentitiesOnly',
|
||||
'ClearAllForwardings',
|
||||
'ForwardAgent',
|
||||
'ConnectTimeout',
|
||||
'ServerAliveInterval',
|
||||
'RequestTTY',
|
||||
@@ -329,9 +357,10 @@ const REQUIRED_SSH_OPTIONS = new Set([
|
||||
|
||||
function isAllowedSshOption(name, value) {
|
||||
const fixedOptions = {
|
||||
StrictHostKeyChecking: 'no',
|
||||
UserKnownHostsFile: '/dev/null',
|
||||
PasswordAuthentication: 'no',
|
||||
IdentitiesOnly: 'yes',
|
||||
ClearAllForwardings: 'yes',
|
||||
ForwardAgent: 'no',
|
||||
LogLevel: 'ERROR',
|
||||
ControlMaster: 'auto',
|
||||
ProxyCommand: 'cloudflared access ssh --hostname %h',
|
||||
@@ -341,6 +370,18 @@ function isAllowedSshOption(name, value) {
|
||||
return value === fixedOptions[name];
|
||||
}
|
||||
|
||||
if (name === 'StrictHostKeyChecking') {
|
||||
return value === 'yes' || value === 'no';
|
||||
}
|
||||
|
||||
if (name === 'UserKnownHostsFile') {
|
||||
return value === '/dev/null' || /^\/var\/www\/html\/storage\/app\/ssh\/known_hosts\/known_hosts_[a-zA-Z0-9_-]+$/.test(value);
|
||||
}
|
||||
|
||||
if (name === 'HostKeyAlias') {
|
||||
return /^coolify-[a-zA-Z0-9_-]+$/.test(value);
|
||||
}
|
||||
|
||||
if (name === 'RequestTTY') {
|
||||
return value === 'yes' || value === 'no';
|
||||
}
|
||||
@@ -364,6 +405,8 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
|
||||
const seenOptions = new Set();
|
||||
let hasIdentityFile = false;
|
||||
let hasPort = false;
|
||||
let hasConfigFile = false;
|
||||
let hasEndOfOptions = false;
|
||||
let targetHost = null;
|
||||
|
||||
for (let index = 0; index < sshArgs.length; index++) {
|
||||
@@ -373,6 +416,18 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ssh reads every argument after `--` as the destination or the remote command.
|
||||
if (hasEndOfOptions) {
|
||||
if (targetHost !== null || !/^[a-zA-Z0-9_.][a-zA-Z0-9._-]*@[^@]+$/.test(argument)) {
|
||||
return false;
|
||||
}
|
||||
targetHost = extractTargetHost([argument]);
|
||||
if (!/^(?:[a-zA-Z0-9]|\[)/.test(targetHost ?? '')) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argument === '-i') {
|
||||
const identityFile = sshArgs[++index];
|
||||
if (hasIdentityFile || !/^\/var\/www\/html\/storage\/app\/ssh\/keys\/ssh_key@[a-zA-Z0-9_-]+$/.test(identityFile ?? '')) {
|
||||
@@ -391,6 +446,19 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argument === '-F') {
|
||||
if (hasConfigFile || sshArgs[++index] !== '/dev/null') {
|
||||
return false;
|
||||
}
|
||||
hasConfigFile = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argument === '--') {
|
||||
hasEndOfOptions = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argument === '-o') {
|
||||
const option = sshArgs[++index];
|
||||
const separator = option?.indexOf('=') ?? -1;
|
||||
@@ -407,14 +475,6 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -425,6 +485,7 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
|
||||
|
||||
return hasIdentityFile
|
||||
&& hasPort
|
||||
&& hasConfigFile
|
||||
&& targetHost !== null
|
||||
&& hasRequiredOptions
|
||||
&& hasCompleteMultiplexingOptions
|
||||
|
||||
@@ -127,7 +127,7 @@ test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () =>
|
||||
|
||||
test('validateSshArgs accepts the SSH arguments generated by Coolify', () => {
|
||||
const sshArgs = 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"
|
||||
"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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p '22' -- 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
|
||||
@@ -143,7 +143,7 @@ test('validateSshArgs rejects an injected ProxyCommand', () => {
|
||||
|
||||
test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => {
|
||||
const validArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -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@example.com 'bash -se' << \\$abc\necho hi\nabc"
|
||||
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@example.com 'bash -se' << \\$abc\necho hi\nabc"
|
||||
);
|
||||
const maliciousArgs = [...validArgs];
|
||||
maliciousArgs[1] = 'ProxyCommand=cloudflared access ssh --hostname %h; id';
|
||||
@@ -154,7 +154,7 @@ test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => {
|
||||
|
||||
test('validateSshArgs rejects unknown SSH options and key paths', () => {
|
||||
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"
|
||||
"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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false);
|
||||
@@ -168,18 +168,42 @@ test('validateSshArgs rejects unknown SSH options and key paths', () => {
|
||||
|
||||
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"
|
||||
"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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -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);
|
||||
const endOfOptions = baseArgs.indexOf('--');
|
||||
assert.equal(validateSshArgs([...baseArgs.slice(0, endOfOptions), '-p', '22', ...baseArgs.slice(endOfOptions)], ['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 requires one destination directly after the end of options', () => {
|
||||
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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
||||
);
|
||||
const endOfOptions = baseArgs.indexOf('--');
|
||||
const configFile = baseArgs.indexOf('-F');
|
||||
|
||||
assert.equal(validateSshArgs(baseArgs, ['10.0.0.5']), true);
|
||||
assert.equal(validateSshArgs(baseArgs.filter((arg) => arg !== '--'), ['10.0.0.5']), false);
|
||||
assert.equal(validateSshArgs([...baseArgs.slice(0, configFile), ...baseArgs.slice(configFile + 2)], ['10.0.0.5']), false);
|
||||
assert.equal(validateSshArgs([...baseArgs.slice(0, endOfOptions), '--', ...baseArgs.slice(endOfOptions)], ['10.0.0.5']), false);
|
||||
assert.equal(validateSshArgs([...baseArgs, 'id'], ['10.0.0.5']), false);
|
||||
assert.equal(validateSshArgs(baseArgs.slice(0, -1), ['10.0.0.5']), false);
|
||||
});
|
||||
|
||||
test('extractSshArgs adds no argument after the destination', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.deepEqual(sshArgs, ['-p', '22', '--', 'root@10.0.0.5']);
|
||||
});
|
||||
|
||||
test('validateSshArgs rejects a destination that begins with an option prefix', () => {
|
||||
const sshArgs = 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 -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
||||
"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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false);
|
||||
@@ -187,7 +211,7 @@ test('validateSshArgs rejects a destination that begins with an option prefix',
|
||||
|
||||
test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -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"
|
||||
"timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -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 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
|
||||
@@ -204,12 +228,21 @@ test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', ()
|
||||
'ConnectTimeout=10',
|
||||
'-o',
|
||||
'ServerAliveInterval=20',
|
||||
'-F',
|
||||
'/dev/null',
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-o',
|
||||
'ClearAllForwardings=yes',
|
||||
'-o',
|
||||
'ForwardAgent=no',
|
||||
'-o',
|
||||
'RequestTTY=yes',
|
||||
'-o',
|
||||
'LogLevel=ERROR',
|
||||
'-p',
|
||||
'22',
|
||||
'--',
|
||||
'root@10.0.0.5',
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user