Files
coolify/resources/js/terminal-connection.js
T
Andras BacsaiandClaude Opus 5.5 3a4ea3b4d6 fix(terminal): forward the session cookie instead of guessing its name
The terminal server built the Laravel session cookie name from APP_NAME
with its own regex and ignored SESSION_COOKIE. When the name differed
from Laravel's, every connection closed with "Missing required tokens".
Forward the browser Cookie header so Laravel reads its own session.

Also remove the text rejection messages kept for terminal clients from
before close codes; the server and client now ship in the same image.

Closes #10549

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:05:35 +01:00

146 lines
5.5 KiB
JavaScript

/**
* Close codes sent by the terminal WebSocket server (docker/coolify-terminal).
* 4401: the browser session was rejected; 4403: the terminal token was rejected.
*/
export const TERMINAL_CLOSE_CODES = Object.freeze({
NORMAL: 1000,
AUTH_REJECTED: 4401,
TOKEN_REJECTED: 4403,
});
/** Maximum time to wait for the WebSocket to open on the first attempt. */
export const TERMINAL_CONNECT_TIMEOUT_MS = 15000;
/** Maximum time to wait for the server's `pty-ready` after a session was requested. */
export const TERMINAL_SESSION_START_TIMEOUT_MS = 20000;
export const TERMINAL_CONNECTION_ERRORS = Object.freeze({
authRejected: 'Terminal access was rejected. Reload the page and try again.',
connectionFailed: 'Could not connect to the terminal server. Reload the page and try again.',
timeout: 'Timed out while connecting to the terminal. Reload the page and try again.',
});
export function isTerminalAuthRejectionCode(code) {
return code === TERMINAL_CLOSE_CODES.AUTH_REJECTED || code === TERMINAL_CLOSE_CODES.TOKEN_REJECTED;
}
/**
* Whether a plain-text message from the terminal server rejects a session start,
* for example a target host that is not authorized or an invalid SSH command.
* Authentication rejections arrive as close codes instead.
*/
export function isTerminalStartupRejection(data) {
return typeof data === 'string' && (data.startsWith('Unauthorized:') || data.startsWith('Invalid SSH command:'));
}
/**
* Decides how the terminal reacts to a WebSocket close event.
*
* - Auth rejections never reconnect: a new socket would be rejected again.
* - A clean close (1000) is intentional and needs no action.
* - Any other close reconnects with backoff until the attempt limit, and
* reports an error when a session was waiting to start or retries ran out.
*
* @param {{ code: number, sessionPending: boolean, reconnectAttempts: number, maxReconnectAttempts: number }} state
* @returns {{ authRejected: boolean, reconnect: boolean, error: string|null }}
*/
export function resolveTerminalCloseOutcome({ code, sessionPending, reconnectAttempts, maxReconnectAttempts }) {
if (isTerminalAuthRejectionCode(code)) {
return { authRejected: true, reconnect: false, error: TERMINAL_CONNECTION_ERRORS.authRejected };
}
if (code === TERMINAL_CLOSE_CODES.NORMAL) {
return { authRejected: false, reconnect: false, error: null };
}
const reconnect = reconnectAttempts < maxReconnectAttempts;
return {
authRejected: false,
reconnect,
error: sessionPending || !reconnect ? TERMINAL_CONNECTION_ERRORS.connectionFailed : null,
};
}
/**
* Session-start state for the terminal Alpine component (`terminalData()` spreads it in).
*
* The host provides `starting`, `terminalActive`, `connectionError`, `authRejected`,
* `pendingCommand`, `sessionStartTimeoutId`, `$wire`, and `ensureWebSocketConnection()`.
* Only one session-start timer exists at a time: every start clears the previous timer.
*/
export const terminalSessionStartMethods = {
/** A session was requested and the UI shows "connecting…" until `pty-ready`. */
isTerminalSessionPending() {
return this.starting && !this.terminalActive;
},
/**
* Leave the "connecting" state and show why. The first reason wins unless
* `override` is set (auth rejections replace generic connection errors).
*/
failTerminalConnection(message, { override = false } = {}) {
this.starting = false;
this.clearSessionStartTimeout();
if (this.connectionError === message || (this.connectionError && !override)) {
return;
}
this.connectionError = message;
this.$wire.dispatch('error', message);
},
/** Called whenever a new terminal session is requested (target chosen or token issued). */
beginTerminalSessionStart() {
this.starting = true;
this.connectionError = null;
this.authRejected = false;
this.armTerminalSessionStartTimeout();
this.ensureWebSocketConnection();
},
/** Show the timeout error if no session is ready in time. Replaces an earlier timer. */
armTerminalSessionStartTimeout() {
this.clearSessionStartTimeout();
this.sessionStartTimeoutId = setTimeout(() => {
this.sessionStartTimeoutId = null;
if (this.isTerminalSessionPending()) {
// Single-use tokens must not be sent after the user was told to retry.
this.pendingCommand = null;
this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.timeout);
}
}, TERMINAL_SESSION_START_TIMEOUT_MS);
},
clearSessionStartTimeout() {
if (this.sessionStartTimeoutId) {
clearTimeout(this.sessionStartTimeoutId);
this.sessionStartTimeoutId = null;
}
},
/** The server reported `pty-ready`. */
completeTerminalSessionStart() {
this.starting = false;
this.connectionError = null;
this.clearSessionStartTimeout();
},
/** Coolify refused to issue a terminal token (`terminal-session-failed`). */
failTerminalSessionStart(message) {
this.pendingCommand = null;
this.failTerminalConnection(message || TERMINAL_CONNECTION_ERRORS.connectionFailed, { override: true });
},
/** No container was auto-selected (`terminal-auto-start-cancelled`); wait for the user. */
cancelTerminalAutoStart() {
if (this.terminalActive || this.pendingCommand) {
return;
}
this.starting = false;
this.clearSessionStartTimeout();
},
};