fix: harden traffic analytics, terminal errors and Postgres restores

Traffic analytics:
- Reject enabling unless the server runs a Coolify-managed Traefik or
  Caddy proxy, and show the reason in the settings UI
- Save the proxy configuration before the setting so a failure leaves
  both unchanged; allow disabling after the proxy was removed
- Skip the proxy restart when the proxy is stopped and report that the
  config applies on next start
- Preserve the user's own Traefik --accesslog* flags and restore them
  when analytics is disabled
- Rotate the Traefik access log in the sidecar with BusyBox tools
  (copytruncate, 5 gzip rotations)
- Create the access log before starting Sentinel, which opens it once

Terminal:
- Detect and surface WebSocket connection rejections in the browser
  terminal, with shared helpers in terminal-connection.js and
  terminal-utils.js

Database import:
- Restore PostgreSQL backups in a single transaction; SQL replace
  restores go into a temporary database and swap in only on success,
  leaving the current database untouched on failure
This commit is contained in:
Andras Bacsai
2026-09-25 22:58:07 +02:00
parent 37bd776f70
commit 165d2f3dc8
24 changed files with 2028 additions and 117 deletions
@@ -8,25 +8,44 @@ use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Services\ProxyPortParser;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
use Throwable;
class ConfigureTrafficAnalytics
{
use AsAction;
public function handle(Server $server, bool $enable): void
private const STOPPED_PROXY_STATUSES = ['exited', 'stopped', 'stopping', 'dead'];
/**
* Turn traffic analytics on or off. The proxy configuration is saved before the setting,
* so a failure leaves both unchanged.
*
* @return bool Whether a proxy restart was queued to apply the new configuration.
*/
public function handle(Server $server, bool $enable): bool
{
$configuration = GetProxyConfiguration::run($server);
ProxyPortParser::fromConfiguration($configuration);
if ($enable && ($reason = $server->trafficAnalyticsUnsupportedReason()) !== null) {
throw new RuntimeException($reason);
}
$sentinelWasEnabled = (bool) $server->settings->is_sentinel_enabled;
// Disabling must still work after the proxy was removed, so there may be no proxy configuration to update.
$hasProxy = $server->hasTrafficAnalyticsProxy();
if ($hasProxy) {
$this->saveProxyConfiguration($server, $enable);
}
$server->settings->is_traffic_analytics_enabled = $enable;
$server->settings->save();
$server->refresh();
$configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration);
SaveProxyConfiguration::run($server, $configuration);
RestartProxyJob::dispatch($server);
// A proxy the user stopped stays stopped; its next start applies the saved configuration.
$restartProxy = $hasProxy && ! $this->proxyIsStopped($server);
if ($restartProxy) {
RestartProxyJob::dispatch($server);
}
// Recreate Sentinel so it picks up (enabling) or drops (disabling) the traffic env + proxy-log mount.
// Enabling analytics needs Sentinel running; when disabling, only restart if Sentinel was already
@@ -34,5 +53,35 @@ class ConfigureTrafficAnalytics
if ($enable || $sentinelWasEnabled) {
StartSentinel::run($server, restart: true);
}
return $restartProxy;
}
private function saveProxyConfiguration(Server $server, bool $enable): void
{
$previousEnabled = (bool) $server->settings->is_traffic_analytics_enabled;
$previousProxy = $server->proxy->all();
try {
$configuration = GetProxyConfiguration::run($server);
ProxyPortParser::fromConfiguration($configuration);
// The configuration is built from the in-memory setting; it is persisted only after the save succeeds.
$server->settings->is_traffic_analytics_enabled = $enable;
$configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration);
SaveProxyConfiguration::run($server, $configuration);
} catch (Throwable $exception) {
$server->settings->is_traffic_analytics_enabled = $previousEnabled;
$server->proxy = $previousProxy;
$server->save();
throw $exception;
}
}
private function proxyIsStopped(Server $server): bool
{
return (bool) $server->proxy->get('force_stop')
|| in_array($server->proxy->get('status'), self::STOPPED_PROXY_STATUSES, true);
}
}
+21
View File
@@ -44,6 +44,26 @@ class StartSentinel
return $env;
}
/**
* Sentinel opens the access log once at startup and never retries, so the file must exist
* before the container starts. `touch` keeps an existing log intact.
*
* @return array<int, string>
*/
public static function trafficLogPreparationCommands(Server $server): array
{
if (! $server->isTrafficAnalyticsEnabled() || ! $server->supportsTrafficAnalytics()) {
return [];
}
$directory = self::trafficLogDirectory($server);
return [
'mkdir -p '.escapeshellarg($directory),
'touch '.escapeshellarg($directory.'/access.log'),
];
}
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{
if ($server->isSwarm() || $server->isBuildServer()) {
@@ -96,6 +116,7 @@ class StartSentinel
instant_remote_process([
'docker rm -f coolify-sentinel || true',
"mkdir -p $mountDir",
...self::trafficLogPreparationCommands($server),
$dockerCommand,
"chown -R 9999:root $mountDir",
"chmod -R 700 $mountDir",
@@ -74,20 +74,18 @@ class TrafficAnalyticsSettings extends Component
{
try {
$this->authorize('update', $this->server);
if ($this->server->isSwarm() || $this->server->isBuildServer()) {
$this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.');
$enable = ! $this->server->isTrafficAnalyticsEnabled();
if ($enable && ($reason = $this->server->trafficAnalyticsUnsupportedReason()) !== null) {
$this->dispatch('error', $reason);
return;
}
$enable = ! $this->server->isTrafficAnalyticsEnabled();
ConfigureTrafficAnalytics::run($this->server, $enable);
$proxyRestarted = ConfigureTrafficAnalytics::run($this->server, $enable);
$this->server->refresh();
$this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled();
$this->dispatch('trafficAnalyticsStateChanged')->to(Analytics::class);
$this->dispatch('success', $enable
? 'Traffic analytics enabled. Restarting proxy and Sentinel.'
: 'Traffic analytics disabled. Restarting proxy and Sentinel.');
$this->dispatch('success', $this->toggleMessage($enable, $proxyRestarted));
auditLog($enable ? 'ui.server.traffic_analytics.enabled' : 'ui.server.traffic_analytics.disabled', $this->auditContext());
} catch (\Throwable $e) {
handleError($e, $this);
@@ -110,7 +108,24 @@ class TrafficAnalyticsSettings extends Component
public function render(): View
{
return view('livewire.server.traffic-analytics-settings');
return view('livewire.server.traffic-analytics-settings', [
'unsupportedReason' => $this->server->trafficAnalyticsUnsupportedReason(),
]);
}
private function toggleMessage(bool $enabled, bool $proxyRestarted): string
{
$state = $enabled ? 'enabled' : 'disabled';
if ($proxyRestarted) {
return "Traffic analytics {$state}. Restarting proxy and Sentinel.";
}
if ($this->server->hasTrafficAnalyticsProxy()) {
return "Traffic analytics {$state}. The proxy is stopped, so the new configuration applies the next time you start it.";
}
return "Traffic analytics {$state}.";
}
private function auditContext(array $context = []): array
+29
View File
@@ -1061,6 +1061,35 @@ $siteAddress {
return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false);
}
/**
* Traffic analytics reads the access log of a Coolify-managed Traefik or Caddy proxy.
*/
public function hasTrafficAnalyticsProxy(): bool
{
return in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true);
}
/**
* Why traffic analytics cannot be enabled on this server, or null when it can.
*/
public function trafficAnalyticsUnsupportedReason(): ?string
{
if ($this->isSwarm() || $this->isBuildServer()) {
return 'Traffic analytics is not supported on Swarm/Build servers.';
}
if (! $this->hasTrafficAnalyticsProxy()) {
return 'Traffic analytics needs the Traefik or Caddy proxy.';
}
return null;
}
public function supportsTrafficAnalytics(): bool
{
return $this->trafficAnalyticsUnsupportedReason() === null;
}
/**
* Caddy's `log_append` tags access-log lines with the app UUID for traffic analytics. It needs
* Caddy 2.8+, which caddy-docker-proxy ships from 2.9: the 2.8 image (the default before 2.13)
@@ -146,25 +146,42 @@ SH;
*/
private function postgresqlSingle(bool $replaceExisting): string
{
// Every restore runs in one transaction, so a failure part-way rolls back and leaves the
// current data as it was, also when --clean dropped objects first.
$clean = $replaceExisting ? ' --clean --if-exists' : '';
$sqlNotice = $replaceExisting ? <<<'SH'
$sqlRestore = $replaceExisting ? <<<'SH'
echo 'SQL backups cannot replace single objects. The database is recreated before the restore.'
echo "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" | psql -v db="$db" -U $POSTGRES_USER -d template1 >/dev/null || exit 1
dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$db" || exit 1
createdb -U $POSTGRES_USER "$db" || exit 1
SH : '';
echo 'SQL backups cannot replace single objects. The backup is restored into a new database first; the current database is replaced only when that restore succeeds.'
new=coolify_restore_new
old=coolify_restore_old
PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" || exit 1
createdb -U $POSTGRES_USER "$new" || exit 1
if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$new"; then
PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new"
fail 'The SQL restore failed. The current database was not changed.'
fi
PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1
if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U $POSTGRES_USER -d template1 >/dev/null; then
# Undo a half-done swap: the current database keeps its name and data.
printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U $POSTGRES_USER -d template1 >/dev/null 2>&1
PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new"
fail 'The current database is in use and could not be replaced. Nothing was changed.'
fi
PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1
SH : <<<'SH'
stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$db"
SH;
return <<<SH
db=\${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}
if [ "\$(stream | head -c 5)" = PGDMP ] || is_tar; then
stream | pg_restore --exit-on-error{$clean} -U \$POSTGRES_USER -d "\$db"
stream | pg_restore --exit-on-error --single-transaction{$clean} -U \$POSTGRES_USER -d "\$db"
elif ! is_text; then
fail 'Unsupported PostgreSQL backup format. Use a pg_dump archive (custom or tar format) or an SQL file.'
elif stream | head -c 4096 | grep -q 'PostgreSQL database cluster dump'; then
fail 'This backup contains all databases. Select "Backup contains all databases" to restore it.'
else{$sqlNotice}
stream | psql -v ON_ERROR_STOP=1 -U \$POSTGRES_USER -d "\$db"
else{$sqlRestore}
fi
SH;
}
+109 -13
View File
@@ -45,43 +45,139 @@ function applyTrafficAnalyticsToProxyConfiguration(Server $server, string $confi
return Yaml::dump($config, 12, 2);
}
/**
* Server proxy attribute that remembers the user's own `--accesslog*` Traefik flags while
* traffic analytics replaces them with the managed set, so disabling can restore them exactly.
*/
const TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY = 'traffic_analytics_user_accesslog_commands';
function isTraefikAccessLogCommand(mixed $command): bool
{
if (! is_string($command)) {
return false;
}
$flag = strtolower(explode('=', $command, 2)[0]);
return $flag === '--accesslog' || str_starts_with($flag, '--accesslog.');
}
/**
* Rotates the Traefik access log with BusyBox tools from the Alpine base image only (no package
* install, no network). Uses copytruncate semantics: Traefik keeps its file handle and Sentinel's
* tailer handles the truncation. Keeps at most 5 gzip-compressed rotations (access.log.1.gz..5.gz).
* The environment overrides exist for tests; the sidecar does not set them.
*/
function traefikAccessLogRotationScript(): string
{
return <<<'SH'
log="${TRAEFIK_ACCESS_LOG:-/traefik/access.log}"
max_bytes="${TRAEFIK_ACCESS_LOG_MAX_BYTES:-20971520}"
interval="${TRAEFIK_ACCESS_LOG_ROTATE_INTERVAL:-60}"
keep=5
while true; do
size=$(stat -c %s "$log" 2>/dev/null || echo 0)
if [ "$size" -gt "$max_bytes" ]; then
rm -f "$log.$keep.gz"
i=$((keep - 1))
while [ "$i" -ge 1 ]; do
if [ -f "$log.$i.gz" ]; then mv -f "$log.$i.gz" "$log.$((i + 1)).gz"; fi
i=$((i - 1))
done
if cp "$log" "$log.1"; then
: > "$log"
gzip -f "$log.1"
fi
fi
sleep "$interval"
done
SH;
}
/**
* Replace the user's own access log flags with the managed set while analytics is enabled, and
* restore them exactly when it is disabled. Flags that were never added by Coolify are kept.
*
* @param array<int, mixed> $commands
* @return array<int, mixed>
*/
function applyTraefikAccessLogCommands(Server $server, array $commands, bool $enabled): array
{
$managedCommands = traefikAccessLogCommands(true);
$storedUserCommands = $server->proxy->get(TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY);
$hasStoredUserCommands = is_array($storedUserCommands);
$userCommands = $hasStoredUserCommands ? array_values($storedUserCommands) : [];
// Managed flags are Coolify's when their user flags were remembered on enable, or (analytics
// enabled before flags were remembered) when the complete managed set is present. Otherwise a
// matching flag such as `--accesslog=true` belongs to the user and is kept.
$managedCommandsAddedByCoolify = $hasStoredUserCommands || array_diff($managedCommands, $commands) === [];
if ($managedCommandsAddedByCoolify) {
$commands = array_values(array_filter(
$commands,
fn (mixed $command): bool => ! in_array($command, $managedCommands, true)
));
}
if ($enabled) {
foreach ($commands as $command) {
if (isTraefikAccessLogCommand($command) && ! in_array($command, $userCommands, true)) {
$userCommands[] = $command;
}
}
$commands = [
...array_filter($commands, fn (mixed $command): bool => ! isTraefikAccessLogCommand($command)),
...$managedCommands,
];
if ($storedUserCommands !== $userCommands) {
$server->proxy->set(TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY, $userCommands);
$server->save();
}
} elseif ($hasStoredUserCommands) {
foreach ($userCommands as $command) {
if (! in_array($command, $commands, true)) {
$commands[] = $command;
}
}
$server->proxy->forget(TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY);
$server->save();
}
return array_values($commands);
}
function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config): array
{
$enabled = $server->isTrafficAnalyticsEnabled();
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
$managedCommands = traefikAccessLogCommands(true);
$commands = data_get($config, 'services.traefik.command', []);
if (! is_array($commands)) {
throw new RuntimeException('Traefik commands must be a YAML list.');
}
$commands = array_values(array_filter(
$commands,
fn (mixed $command): bool => ! in_array($command, $managedCommands, true)
));
if ($enabled) {
$commands = [...$commands, ...$managedCommands];
}
data_set($config, 'services.traefik.command', $commands);
data_set($config, 'services.traefik.command', applyTraefikAccessLogCommands($server, $commands, $enabled));
unset($config['services']['traefik-logrotate']);
if ($enabled && ! $server->isSwarm() && ! isDev()) {
$proxyPath = $server->proxyPath();
$config['services']['traefik-logrotate'] = [
'container_name' => 'coolify-proxy-logrotate',
'image' => 'alpine:3.20',
'image' => 'alpine:3.24',
'restart' => RESTART_MODE,
'network_mode' => 'none',
'volumes' => [
"{$proxyPath}:/traefik",
],
'labels' => [
'coolify.managed=true',
],
'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'',
// Docker Compose interpolates `$VAR`, so every `$` is escaped as `$$`.
'entrypoint' => ['/bin/sh', '-c', str_replace('$', '$$', traefikAccessLogRotationScript())],
];
}
} elseif ($server->proxyType() === ProxyTypes::CADDY->value) {
+43 -40
View File
@@ -4,6 +4,9 @@ import pty from 'node-pty';
import { parseCookie } from 'cookie';
import 'dotenv/config';
import {
TERMINAL_CLOSE_CODES,
authenticateTerminalUpgrade,
createTerminalUpgradeHandler,
extractHereDocContent,
extractSshArgs,
extractTargetHost,
@@ -11,6 +14,7 @@ import {
getTerminalProcessEnv,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
rejectTerminalSocket,
sanitizeSshArgs,
validateSshArgs,
} from './terminal-utils.js';
@@ -117,54 +121,44 @@ const getSessionCookie = (req) => {
}
}
const verifyClient = async (info, callback) => {
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req);
const verifyClient = async (req) => {
const sessionCookie = getSessionCookie(req);
const requestContext = {
remoteAddress: info.req.socket?.remoteAddress,
origin: info.origin,
sessionCookieName,
hasXsrfToken: Boolean(xsrfToken),
hasLaravelSession: Boolean(laravelSession),
remoteAddress: req.socket?.remoteAddress,
origin: req.headers.origin,
sessionCookieName: sessionCookie.sessionCookieName,
hasXsrfToken: Boolean(sessionCookie.xsrfToken),
hasLaravelSession: Boolean(sessionCookie.laravelSession),
};
logTerminal('log', 'Verifying websocket client.', requestContext);
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext);
return callback(false, 401, 'Unauthorized: Missing required tokens');
}
// Authenticate with Laravel backend
const result = await authenticateTerminalUpgrade(sessionCookie, postToCoolify);
try {
// Authenticate with Laravel backend
const response = await postToCoolify('/terminal/auth', {
'Cookie': `${sessionCookieName}=${laravelSession}`,
'X-XSRF-TOKEN': xsrfToken
});
if (response.status === 200) {
logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
callback(true);
} else {
logTerminal('warn', 'Websocket client authentication returned a non-success status.', {
...requestContext,
status: response.status,
});
callback(false, 401, 'Unauthorized: Invalid credentials');
}
} catch (error) {
if (result.authenticated) {
logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
} else if (result.error) {
logTerminal('error', 'Websocket client authentication failed.', {
...requestContext,
error: error.message,
responseStatus: error.response?.status,
responseData: error.response?.data,
error: result.error.message,
});
} else {
logTerminal('warn', 'Rejecting websocket client.', {
...requestContext,
reason: result.reason,
status: result.status,
closeCode: result.closeCode,
});
callback(false, 500, 'Internal Server Error');
}
return result;
};
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
// Upgrades are authenticated manually (instead of ws `verifyClient`) so a
// rejected browser receives a readable close code rather than an opaque 1006.
const wss = new WebSocketServer({ noServer: true, path: '/terminal/ws' });
server.on('upgrade', createTerminalUpgradeHandler({ wss, authenticate: verifyClient }));
const HEARTBEAT_INTERVAL_MS = 30000;
@@ -220,7 +214,7 @@ wss.on('connection', async (ws, req) => {
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
ws.close(401, 'Unauthorized: Missing required tokens');
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Missing required tokens');
return;
}
@@ -457,16 +451,25 @@ async function handleCommand(ws, command, userId) {
}, terminalSessionTimeout * 1000);
}
// The text message is kept for clients that predate the close code.
function rejectTerminalToken(userSession, message) {
logTerminal('warn', 'Closing websocket connection because the terminal token was rejected.', {
userId: userSession.userId,
reason: message,
});
rejectTerminalSocket(userSession.ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, message, { message });
}
async function handleTerminalToken(userSession, token) {
if (typeof token !== 'string' || !/^[a-zA-Z0-9]{64}$/.test(token)) {
userSession.ws.send('Unauthorized: Invalid terminal token');
rejectTerminalToken(userSession, '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');
rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected');
return;
}
@@ -476,7 +479,7 @@ async function handleTerminalToken(userSession, token) {
userId: userSession.userId,
error: error.message,
});
userSession.ws.send('Unauthorized: Terminal token was rejected');
rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected');
}
}
+121
View File
@@ -1,5 +1,126 @@
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
/**
* WebSocket close codes sent to the browser. Browsers cannot read the HTTP
* status of a rejected WebSocket handshake, so rejections are reported with
* application close codes (4000-4999) that the client can act on.
*/
export const TERMINAL_CLOSE_CODES = Object.freeze({
AUTH_REJECTED: 4401,
TOKEN_REJECTED: 4403,
AUTH_UNAVAILABLE: 1011,
});
export const TERMINAL_REJECTED_SOCKET_TERMINATE_MS = 2000;
/**
* Authenticates a terminal WebSocket upgrade with the browser's Laravel session
* cookie and XSRF token. Performs the same checks as the former verifyClient
* callback; only the way a rejection is reported to the client differs.
*
* @param {{ laravelSession?: string, xsrfToken?: string, sessionCookieName: string }} session
* @param {(path: string, headers: object) => Promise<{ status: number }>} postToCoolify
* @returns {Promise<{ authenticated: true } | { authenticated: false, closeCode: number, reason: string, status?: number, error?: Error }>}
*/
export async function authenticateTerminalUpgrade({ laravelSession, xsrfToken, sessionCookieName }, postToCoolify) {
if (!laravelSession || !xsrfToken) {
return {
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
reason: 'Unauthorized: Missing required tokens',
};
}
try {
const response = await postToCoolify('/terminal/auth', {
'Cookie': `${sessionCookieName}=${laravelSession}`,
'X-XSRF-TOKEN': xsrfToken,
});
if (response.status === 200) {
return { authenticated: true };
}
return {
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
reason: 'Unauthorized: Invalid credentials',
status: response.status,
};
} catch (error) {
return {
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE,
reason: 'Internal Server Error',
error,
};
}
}
/**
* Closes a WebSocket that must not be used, optionally sending a legacy text
* message first for clients that predate close codes. The socket is terminated
* if the peer does not complete the close handshake quickly.
*/
export function rejectTerminalSocket(ws, closeCode, reason, { message = null, terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS } = {}) {
const terminateTimer = setTimeout(() => ws.terminate(), terminateAfterMs);
terminateTimer.unref?.();
ws.once('close', () => clearTimeout(terminateTimer));
// Rejected sockets have no other listeners; an unhandled 'error' (e.g. a
// malformed frame from an unauthenticated client) would crash the server.
ws.on('error', () => ws.terminate());
if (message !== null) {
ws.send(message);
}
ws.close(closeCode, reason);
}
/**
* Builds the HTTP `upgrade` listener for the terminal WebSocket server.
* Unauthenticated clients never reach the `connection` event: they are upgraded
* only to receive a close frame with a machine-readable code, then dropped.
*/
export function createTerminalUpgradeHandler({ wss, authenticate, onRejected = () => {}, terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS }) {
return (req, socket, head) => {
if (!wss.shouldHandle(req)) {
// Let ws answer unknown paths exactly as it did with the `server` option.
wss.handleUpgrade(req, socket, head, (ws) => ws.terminate());
return;
}
// The client may disconnect while Coolify is checking the session.
const destroyOnError = () => socket.destroy();
socket.on('error', destroyOnError);
Promise.resolve()
.then(() => authenticate(req))
.catch((error) => ({
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE,
reason: 'Internal Server Error',
error,
}))
.then((result) => {
socket.removeListener('error', destroyOnError);
if (socket.destroyed) {
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (result?.authenticated === true) {
wss.emit('connection', ws, req);
return;
}
onRejected(result, req);
rejectTerminalSocket(ws, result.closeCode, result.reason, { terminateAfterMs });
});
});
};
}
const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
export function getTerminalProcessEnv(environment = process.env) {
@@ -2,12 +2,16 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
TERMINAL_CLOSE_CODES,
authenticateTerminalUpgrade,
createTerminalUpgradeHandler,
extractSshArgs,
extractTargetHost,
getTerminalProcessEnv,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
rejectTerminalSocket,
sanitizeSshArgs,
validateSshArgs,
} from './terminal-utils.js';
@@ -180,3 +184,277 @@ test('getTerminalSessionTimeout always enforces the maximum terminal session lif
assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
});
function createFakeSocket() {
const listeners = {};
return {
destroyed: false,
on(event, listener) {
(listeners[event] ??= []).push(listener);
},
removeListener(event, listener) {
listeners[event] = (listeners[event] ?? []).filter((candidate) => candidate !== listener);
},
emit(event, ...args) {
(listeners[event] ?? []).forEach((listener) => listener(...args));
},
destroy() {
this.destroyed = true;
},
listenerCount(event) {
return (listeners[event] ?? []).length;
},
};
}
function createFakeWebSocket() {
const closeListeners = [];
const errorListeners = [];
return {
events: [],
terminated: false,
on(event, listener) {
if (event === 'error') {
errorListeners.push(listener);
}
},
emitError(error) {
if (errorListeners.length === 0) {
throw error;
}
errorListeners.forEach((listener) => listener(error));
},
send(message) {
this.events.push(['send', message]);
},
close(code, reason) {
this.events.push(['close', code, reason]);
},
terminate() {
this.terminated = true;
},
once(event, listener) {
if (event === 'close') {
closeListeners.push(listener);
}
},
peerClosed() {
closeListeners.forEach((listener) => listener());
},
};
}
function createFakeWebSocketServer({ handles = true } = {}) {
return {
connections: [],
upgrades: [],
sockets: [],
shouldHandle() {
return handles;
},
handleUpgrade(req, socket, head, callback) {
this.upgrades.push(req);
if (!handles) {
return;
}
const ws = createFakeWebSocket();
this.sockets.push(ws);
callback(ws);
},
emit(event, ws, req) {
if (event === 'connection') {
this.connections.push([ws, req]);
}
},
};
}
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
test('authenticateTerminalUpgrade rejects missing session cookies with the auth close code without calling Coolify', async () => {
let requests = 0;
const postToCoolify = async () => {
requests++;
return { status: 200 };
};
for (const session of [
{ sessionCookieName: 'coolify_session', laravelSession: undefined, xsrfToken: 'xsrf' },
{ sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: undefined },
]) {
assert.deepEqual(await authenticateTerminalUpgrade(session, postToCoolify), {
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
reason: 'Unauthorized: Missing required tokens',
});
}
assert.equal(requests, 0);
assert.equal(TERMINAL_CLOSE_CODES.AUTH_REJECTED, 4401);
assert.equal(TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 4403);
});
test('authenticateTerminalUpgrade checks the Laravel session exactly like the previous verifyClient', async () => {
const requests = [];
const result = await authenticateTerminalUpgrade(
{ sessionCookieName: 'coolify_session', laravelSession: 'session-value', xsrfToken: 'xsrf-value' },
async (path, headers) => {
requests.push([path, headers]);
return { status: 200 };
},
);
assert.deepEqual(result, { authenticated: true });
assert.deepEqual(requests, [[
'/terminal/auth',
{ 'Cookie': 'coolify_session=session-value', 'X-XSRF-TOKEN': 'xsrf-value' },
]]);
});
test('authenticateTerminalUpgrade rejects every non-200 Coolify response', async () => {
for (const status of [201, 204, 302, 401, 403, 419, 500]) {
const result = await authenticateTerminalUpgrade(
{ sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' },
async () => ({ status }),
);
assert.equal(result.authenticated, false, `status ${status} must not authenticate`);
assert.equal(result.closeCode, TERMINAL_CLOSE_CODES.AUTH_REJECTED);
assert.equal(result.reason, 'Unauthorized: Invalid credentials');
}
});
test('authenticateTerminalUpgrade does not authenticate when Coolify is unreachable', async () => {
const result = await authenticateTerminalUpgrade(
{ sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' },
async () => {
throw new Error('connect ECONNREFUSED');
},
);
assert.equal(result.authenticated, false);
assert.equal(result.closeCode, TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE);
assert.equal(result.error.message, 'connect ECONNREFUSED');
});
test('rejectTerminalSocket sends the legacy message before closing with a readable code', () => {
const ws = createFakeWebSocket();
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 'Unauthorized: Terminal token was rejected', {
message: 'Unauthorized: Terminal token was rejected',
});
ws.peerClosed();
assert.deepEqual(ws.events, [
['send', 'Unauthorized: Terminal token was rejected'],
['close', 4403, 'Unauthorized: Terminal token was rejected'],
]);
});
test('rejectTerminalSocket terminates peers that ignore the close handshake', async () => {
const ws = createFakeWebSocket();
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Invalid credentials', { terminateAfterMs: 5 });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(ws.events, [['close', 4401, 'Unauthorized: Invalid credentials']]);
assert.equal(ws.terminated, true);
});
test('createTerminalUpgradeHandler only emits connection for authenticated upgrades', async () => {
const wss = createFakeWebSocketServer();
const req = { url: '/terminal/ws' };
const handler = createTerminalUpgradeHandler({ wss, authenticate: async () => ({ authenticated: true }) });
handler(req, createFakeSocket(), Buffer.alloc(0));
await flushPromises();
assert.equal(wss.connections.length, 1);
assert.equal(wss.connections[0][1], req);
assert.deepEqual(wss.sockets[0].events, []);
});
test('createTerminalUpgradeHandler closes rejected upgrades with the auth close code and never emits connection', async () => {
const wss = createFakeWebSocketServer();
const rejected = [];
const handler = createTerminalUpgradeHandler({
wss,
authenticate: async () => ({
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
reason: 'Unauthorized: Invalid credentials',
}),
onRejected: (result) => rejected.push(result.closeCode),
});
handler({ url: '/terminal/ws' }, createFakeSocket(), Buffer.alloc(0));
await flushPromises();
assert.equal(wss.connections.length, 0);
assert.deepEqual(rejected, [4401]);
assert.deepEqual(wss.sockets[0].events, [['close', 4401, 'Unauthorized: Invalid credentials']]);
});
test('createTerminalUpgradeHandler treats authentication errors and malformed results as rejections', async () => {
for (const authenticate of [
async () => {
throw new Error('boom');
},
async () => ({ authenticated: 'yes', closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, reason: 'Unauthorized' }),
]) {
const wss = createFakeWebSocketServer();
createTerminalUpgradeHandler({ wss, authenticate })({ url: '/terminal/ws' }, createFakeSocket(), Buffer.alloc(0));
await flushPromises();
assert.equal(wss.connections.length, 0);
assert.equal(wss.sockets[0].events[0][0], 'close');
assert.notEqual(wss.sockets[0].events[0][1], 1000);
}
});
test('createTerminalUpgradeHandler does not authenticate requests for other paths', async () => {
const wss = createFakeWebSocketServer({ handles: false });
let authenticated = 0;
createTerminalUpgradeHandler({ wss, authenticate: async () => ++authenticated })({ url: '/other' }, createFakeSocket(), Buffer.alloc(0));
await flushPromises();
assert.equal(authenticated, 0);
assert.equal(wss.upgrades.length, 1);
assert.equal(wss.connections.length, 0);
});
test('createTerminalUpgradeHandler drops sockets that disconnect while authentication is pending', async () => {
const wss = createFakeWebSocketServer();
const socket = createFakeSocket();
let finishAuthentication;
createTerminalUpgradeHandler({
wss,
authenticate: () => new Promise((resolve) => {
finishAuthentication = resolve;
}),
})({ url: '/terminal/ws' }, socket, Buffer.alloc(0));
socket.emit('error', new Error('ECONNRESET'));
await flushPromises();
finishAuthentication({ authenticated: true });
await flushPromises();
assert.equal(socket.destroyed, true);
assert.equal(socket.listenerCount('error'), 0);
assert.equal(wss.upgrades.length, 0);
assert.equal(wss.connections.length, 0);
});
test('rejectTerminalSocket handles socket errors so unauthenticated clients cannot crash the server', () => {
const ws = createFakeWebSocket();
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Invalid credentials');
assert.doesNotThrow(() => ws.emitError(new Error('Invalid WebSocket frame: RSV1 must be clear')));
assert.equal(ws.terminated, true);
ws.peerClosed();
});
+81
View File
@@ -0,0 +1,81 @@
/**
* 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.',
});
const AUTH_REJECTION_MESSAGES = new Set([
'Unauthorized: Missing required tokens',
'Unauthorized: Invalid terminal token',
'Unauthorized: Terminal token was rejected',
]);
export function isTerminalAuthRejectionCode(code) {
return code === TERMINAL_CLOSE_CODES.AUTH_REJECTED || code === TERMINAL_CLOSE_CODES.TOKEN_REJECTED;
}
/**
* Classifies a plain-text message from the terminal server.
*
* @returns {'auth-rejected'|'startup-rejected'|null}
*/
export function classifyTerminalServerMessage(data) {
if (typeof data !== 'string') {
return null;
}
if (AUTH_REJECTION_MESSAGES.has(data)) {
return 'auth-rejected';
}
if (data.startsWith('Unauthorized:') || data.startsWith('Invalid SSH command:')) {
return 'startup-rejected';
}
return null;
}
/**
* 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,
};
}
+89
View File
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TERMINAL_CLOSE_CODES,
TERMINAL_CONNECT_TIMEOUT_MS,
TERMINAL_CONNECTION_ERRORS,
TERMINAL_SESSION_START_TIMEOUT_MS,
classifyTerminalServerMessage,
isTerminalAuthRejectionCode,
resolveTerminalCloseOutcome,
} from './terminal-connection.js';
const closeOutcome = (overrides = {}) => resolveTerminalCloseOutcome({
code: 1006,
sessionPending: false,
reconnectAttempts: 0,
maxReconnectAttempts: 10,
...overrides,
});
test('terminal close codes match the codes sent by the terminal server', () => {
assert.equal(TERMINAL_CLOSE_CODES.AUTH_REJECTED, 4401);
assert.equal(TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 4403);
assert.equal(isTerminalAuthRejectionCode(4401), true);
assert.equal(isTerminalAuthRejectionCode(4403), true);
assert.equal(isTerminalAuthRejectionCode(1006), false);
assert.equal(isTerminalAuthRejectionCode(4000), false);
});
test('auth rejection closes stop reconnecting and show the reload message', () => {
for (const code of [4401, 4403]) {
for (const sessionPending of [true, false]) {
assert.deepEqual(closeOutcome({ code, sessionPending }), {
authRejected: true,
reconnect: false,
error: 'Terminal access was rejected. Reload the page and try again.',
});
}
}
});
test('a clean close neither reconnects nor reports an error', () => {
assert.deepEqual(closeOutcome({ code: 1000, sessionPending: true }), {
authRejected: false,
reconnect: false,
error: null,
});
});
test('a close before the session is ready leaves the connecting state with a generic error', () => {
for (const code of [1006, 1011, 1001, 4000]) {
assert.deepEqual(closeOutcome({ code, sessionPending: true }), {
authRejected: false,
reconnect: true,
error: TERMINAL_CONNECTION_ERRORS.connectionFailed,
});
}
});
test('an idle close reconnects silently until the retry limit, then reports an error', () => {
assert.deepEqual(closeOutcome({ reconnectAttempts: 9 }), {
authRejected: false,
reconnect: true,
error: null,
});
assert.deepEqual(closeOutcome({ reconnectAttempts: 10 }), {
authRejected: false,
reconnect: false,
error: TERMINAL_CONNECTION_ERRORS.connectionFailed,
});
});
test('server text messages are classified for backward compatible rejection handling', () => {
assert.equal(classifyTerminalServerMessage('Unauthorized: Invalid terminal token'), 'auth-rejected');
assert.equal(classifyTerminalServerMessage('Unauthorized: Terminal token was rejected'), 'auth-rejected');
assert.equal(classifyTerminalServerMessage('Unauthorized: Missing required tokens'), 'auth-rejected');
assert.equal(classifyTerminalServerMessage('Unauthorized: Target host 10.0.0.5 not in authorized list'), 'startup-rejected');
assert.equal(classifyTerminalServerMessage('Invalid SSH command: No target host found'), 'startup-rejected');
assert.equal(classifyTerminalServerMessage('pty-ready'), null);
assert.equal(classifyTerminalServerMessage('root@host:~# '), null);
assert.equal(classifyTerminalServerMessage(new Blob([])), null);
});
test('connection timeouts stay within a bounded window', () => {
assert.equal(TERMINAL_CONNECT_TIMEOUT_MS, 15000);
assert.ok(TERMINAL_SESSION_START_TIMEOUT_MS >= TERMINAL_CONNECT_TIMEOUT_MS);
assert.ok(TERMINAL_SESSION_START_TIMEOUT_MS <= 30000);
assert.equal(TERMINAL_CONNECTION_ERRORS.timeout, 'Timed out while connecting to the terminal. Reload the page and try again.');
});
+146 -13
View File
@@ -6,6 +6,13 @@ import {
TERMINAL_SESSION_WARNING_SECONDS,
formatTerminalSessionRemainingTime,
} from './terminal-session-timer.js';
import {
TERMINAL_CONNECT_TIMEOUT_MS,
TERMINAL_CONNECTION_ERRORS,
TERMINAL_SESSION_START_TIMEOUT_MS,
classifyTerminalServerMessage,
resolveTerminalCloseOutcome,
} from './terminal-connection.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugParameter = new URLSearchParams(window.location.search).get('terminal-debug');
@@ -178,6 +185,11 @@ export function initializeTerminalComponent() {
maxReconnectDelay: 30000,
connectionTimeout: 10000,
connectionTimeoutId: null,
// Shown instead of "connecting…" once a connection or session start failed.
connectionError: null,
// Set when the server rejected authentication; disables automatic reconnects.
authRejected: false,
sessionStartTimeoutId: null,
lastPingTime: null,
pingTimeout: 35000, // 5 seconds longer than ping interval
pingTimeoutId: null,
@@ -287,6 +299,13 @@ export function initializeTerminalComponent() {
this.setupTerminalEventListeners();
this.$wire.on('send-terminal-token', ([token]) => {
// A token requested before the rejection arrived must not reconnect;
// only a user-initiated start (terminal-starting) retries.
if (this.authRejected) {
logTerminal('warn', '[Terminal] Ignoring terminal token after authentication was rejected.');
return;
}
this.beginTerminalSessionStart();
this.sendCommandWhenReady({ terminalToken: token });
});
@@ -365,6 +384,7 @@ export function initializeTerminalComponent() {
clearTimeout(this.keyboardInsetSettleTimeout);
this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers();
this.clearSessionStartTimeout();
this.connectionState = 'disconnected';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
@@ -621,12 +641,15 @@ export function initializeTerminalComponent() {
this.socket = new WebSocket(url);
// Set connection timeout - increased for initial connection
const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
const timeoutMs = this.reconnectAttempts === 0 ? TERMINAL_CONNECT_TIMEOUT_MS : this.connectionTimeout;
this.connectionTimeoutId = setTimeout(() => {
if (this.connectionState === 'connecting') {
logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`);
if (this.isTerminalSessionPending()) {
this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.timeout);
}
// The resulting close event schedules the (bounded) reconnect.
this.socket.close();
this.handleConnectionError('Connection timeout');
}
}, timeoutMs);
@@ -659,6 +682,8 @@ export function initializeTerminalComponent() {
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
this.connectionError = null;
this.starting = true;
}
// (Re)start application-level keepalive on every successful connect.
@@ -679,7 +704,11 @@ export function initializeTerminalComponent() {
logTerminal('error', '[Terminal] WebSocket error:', error);
logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.handleConnectionError('WebSocket error occurred');
// Browsers always follow an error event with a close event, which
// schedules the reconnect. Only surface the failure here.
if (this.isTerminalSessionPending()) {
this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.connectionFailed);
}
},
handleSocketClose(event) {
@@ -687,10 +716,31 @@ export function initializeTerminalComponent() {
logTerminal('log', '[Terminal] Was clean close:', event.code === 1000);
logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
const outcome = resolveTerminalCloseOutcome({
code: event.code,
sessionPending: this.isTerminalSessionPending(),
reconnectAttempts: this.reconnectAttempts,
maxReconnectAttempts: this.maxReconnectAttempts,
});
this.connectionState = 'disconnected';
this.clearAllTimers();
this.resetTerminalSessionCountdown();
if (outcome.authRejected) {
// Reconnecting would be rejected again; wait for a reload or a new session request.
logTerminal('error', '[Terminal] Server rejected terminal authentication:', event.reason);
this.authRejected = true;
this.pendingCommand = null;
if (this.terminalActive) {
this.exitFullscreen();
this.terminalActive = false;
this.$wire.dispatch('terminalDisconnected');
}
this.failTerminalConnection(outcome.error, { override: true });
return;
}
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
// Don't show terminal reset message on first connection attempt
@@ -699,6 +749,9 @@ export function initializeTerminalComponent() {
this.message = '(connection closed)';
this.terminalActive = false;
}
if (outcome.error) {
this.failTerminalConnection(outcome.error);
}
this.scheduleReconnect();
}
},
@@ -707,21 +760,93 @@ export function initializeTerminalComponent() {
logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
this.connectionState = 'disconnected';
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
if (this.reconnectAttempts >= 2) {
this.$wire.dispatch('error', `Terminal connection error: ${reason}`);
if (this.isTerminalSessionPending() || this.reconnectAttempts >= 2) {
this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.connectionFailed);
}
this.scheduleReconnect();
},
/** 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.clearSessionStartTimeout();
this.sessionStartTimeoutId = setTimeout(() => {
this.sessionStartTimeoutId = null;
if (this.isTerminalSessionPending()) {
logTerminal('error', `[Terminal] Session did not start within ${TERMINAL_SESSION_START_TIMEOUT_MS}ms`);
// 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);
this.ensureWebSocketConnection();
},
clearSessionStartTimeout() {
if (this.sessionStartTimeoutId) {
clearTimeout(this.sessionStartTimeoutId);
this.sessionStartTimeoutId = null;
}
},
/** Reconnect immediately for a user-requested session if the socket is closed. */
ensureWebSocketConnection() {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
return;
}
if (this.reconnectInterval) {
clearTimeout(this.reconnectInterval);
this.reconnectInterval = null;
}
this.reconnectAttempts = 0;
this.initializeWebSocket();
},
reloadTerminalPage() {
window.location.reload();
},
scheduleReconnect() {
if (this.authRejected) {
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
logTerminal('error', '[Terminal] Max reconnection attempts reached');
this.message = '(connection failed - max retries exceeded)';
this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.connectionFailed);
return;
}
if (this.reconnectInterval) {
return; // A reconnect is already scheduled.
}
this.connectionState = 'reconnecting';
// Exponential backoff with jitter
@@ -733,6 +858,7 @@ export function initializeTerminalComponent() {
logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
this.reconnectInterval = setTimeout(() => {
this.reconnectInterval = null;
this.reconnectAttempts++;
this.initializeWebSocket();
}, delay);
@@ -769,6 +895,8 @@ export function initializeTerminalComponent() {
if (event.data === 'pty-ready') {
this.starting = false;
this.connectionError = null;
this.clearSessionStartTimeout();
if (!this.term._initialized) {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
@@ -809,6 +937,7 @@ export function initializeTerminalComponent() {
this.$wire.dispatch('terminalConnected');
} else if (event.data === 'unprocessable') {
this.starting = false;
this.clearSessionStartTimeout();
if (this.term) this.term.reset();
this.terminalActive = false;
this.resetTerminalSessionCountdown();
@@ -827,14 +956,18 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
) {
} else if (classifyTerminalServerMessage(event.data) !== null) {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.pendingCommand = null;
this.terminalActive = false;
this.resetTerminalSessionCountdown();
// Newer servers follow an auth rejection with a 4401/4403 close.
this.failTerminalConnection(
classifyTerminalServerMessage(event.data) === 'auth-rejected'
? TERMINAL_CONNECTION_ERRORS.authRejected
: event.data,
{ override: true },
);
} else {
try {
this.pendingWrites++;
@@ -985,7 +1118,7 @@ export function initializeTerminalComponent() {
keepAlive() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
} else if (this.connectionState === 'disconnected' && !this.authRejected) {
// Attempt to reconnect if we're disconnected
this.initializeWebSocket();
}
@@ -1025,7 +1158,7 @@ export function initializeTerminalComponent() {
// ignore — close handler will run on its own
}
}, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected' && !this.authRejected) {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;
this.initializeWebSocket();
@@ -3,7 +3,7 @@
@endphp
<div id="terminal-container" x-data="terminalData()" data-auto-start="{{ $autoStart ? 'true' : 'false' }}"
x-on:terminal-starting.window="starting = true; setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')"
x-on:terminal-starting.window="beginTerminalSessionStart(); setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')"
x-on:terminal-theme-change.window="setTerminalTheme($event.detail.theme)"
@class([
'group/terminal relative h-full min-h-0 bg-transparent' => $isApplicationConsole,
@@ -48,7 +48,13 @@
@if ($isApplicationConsole)
<div x-show="!terminalActive" x-cloak
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-transparent">
<div class="terminal-loading-label flex items-center gap-2">
<div x-show="connectionError" x-cloak data-terminal-connection-error role="alert"
class="terminal-loading-label pointer-events-auto flex max-w-md flex-col items-center gap-3 px-4 text-center">
<span x-text="connectionError"></span>
<button type="button" x-on:click="reloadTerminalPage()"
class="cursor-pointer rounded-md border border-current/30 px-3 py-1 text-xs font-medium transition-colors hover:bg-current/10">Reload page</button>
</div>
<div x-show="!connectionError" class="terminal-loading-label flex items-center gap-2">
<svg x-show="starting || connectionState === 'connecting' || connectionState === 'reconnecting'"
class="size-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor"
@@ -61,6 +67,11 @@
</div>
</div>
@else
<div x-show="!terminalActive && connectionError" x-cloak data-terminal-connection-error role="alert"
class="mb-2 flex shrink-0 items-center gap-3 rounded-sm border border-red-500/40 bg-red-950/80 px-3 py-2 text-sm text-red-200">
<span x-text="connectionError"></span>
<button type="button" class="underline" x-on:click="reloadTerminalPage()">Reload page</button>
</div>
<div x-show="terminalActive" x-cloak class="mb-2 flex shrink-0 justify-start">
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
:class="terminalSessionTimerClass()" x-text="terminalSessionRemainingLabel()">
@@ -56,10 +56,15 @@
</div>
@else
<x-empty size="sm" title="Traffic analytics is disabled"
description="Enable traffic analytics to collect proxy access logs and geolocate visitor traffic."
:description="$unsupportedReason ?? 'Enable traffic analytics to collect proxy access logs and geolocate visitor traffic.'"
icon-name="dashboard">
<x-slot:contents>
<div class="flex items-center gap-3">
@if ($unsupportedReason)
<x-forms.button disabled :tooltip="$unsupportedReason">
Enable traffic analytics
</x-forms.button>
@else
<x-loading wire:loading.flex wire:target="toggleTrafficAnalytics"
text="Restarting Sentinel and proxy..." compact />
<x-modal-confirmation title="Enable traffic analytics?"
@@ -72,6 +77,7 @@
step2ButtonText="Enable traffic analytics" isHighlightedButton
:disabled="! auth()->user()->can('update', $server)"
:authDisabled="! auth()->user()->can('update', $server)" />
@endif
</div>
</x-slot:contents>
</x-empty>
@@ -143,7 +143,7 @@ it('shows connection progress in the terminal body instead of the header', funct
->not->toContain('wire:loading.flex wire:target="selected_container,connectToContainer"')
->not->toContain('wire:loading.flex wire:target="selected_uuid,connectToContainer"')
->and($terminalView)
->toContain("x-on:terminal-starting.window=\"starting = true; setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')\"")
->toContain("x-on:terminal-starting.window=\"beginTerminalSessionStart(); setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')\"")
->toContain('data-auto-start="{{ $autoStart ? \'true\' : \'false\' }}"')
->toContain("starting ? 'connecting…'")
->and($terminalClient)
@@ -578,3 +578,43 @@ it('fits the terminal with FitAddon and keeps xterm within the host after resize
->toContain('scrollback: 5000')
->not->toContain('Math.floor(height / charSize.height) - 1');
});
it('reports terminal authentication rejections with readable WebSocket close codes', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-terminal/terminal-server.js'));
$terminalUtils = file_get_contents(base_path('docker/coolify-terminal/terminal-utils.js'));
expect($terminalUtils)
->toContain('AUTH_REJECTED: 4401')
->toContain('TOKEN_REJECTED: 4403')
->and($terminalServer)
->toContain("new WebSocketServer({ noServer: true, path: '/terminal/ws' })")
->toContain("server.on('upgrade', createTerminalUpgradeHandler({ wss, authenticate: verifyClient }))")
->toContain("rejectTerminalToken(userSession, 'Unauthorized: Invalid terminal token')")
->toContain("rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected')")
->toContain("typeof token !== 'string' || !/^[a-zA-Z0-9]{64}$/.test(token)")
->toContain("response.status !== 200 || typeof response.data?.command !== 'string'")
->not->toContain('verifyClient: verifyClient')
->not->toContain('ws.close(401');
});
it('leaves the terminal connecting state when the connection is rejected, lost, or times out', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalClient)
->toContain("from './terminal-connection.js'")
->toContain('connectionError: null')
->toContain('const outcome = resolveTerminalCloseOutcome({')
->toContain('this.authRejected = true;')
->toContain('if (this.authRejected) {')
->toContain('Ignoring terminal token after authentication was rejected.')
->toContain('failTerminalConnection(TERMINAL_CONNECTION_ERRORS.timeout)')
->toContain('failTerminalConnection(TERMINAL_CONNECTION_ERRORS.connectionFailed)')
->toContain('TERMINAL_SESSION_START_TIMEOUT_MS')
->toContain('this.beginTerminalSessionStart();')
->and($terminalView)
->toContain('data-terminal-connection-error')
->toContain('x-text="connectionError"')
->toContain('x-on:click="reloadTerminalPage()"')
->toContain('x-show="!connectionError" class="terminal-loading-label');
});
@@ -58,3 +58,129 @@ it('disables analytics', function () {
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
});
function trafficAnalyticsProxyServer(object $test, string $proxyType, array $proxy = [], bool $analyticsEnabled = false): Server
{
$server = Server::factory()->create(['team_id' => $test->team->id]);
$server->proxy->set('type', $proxyType);
foreach ($proxy as $key => $value) {
$server->proxy->set($key, $value);
}
$server->save();
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
$server->settings->save();
return $server->fresh();
}
it('rejects enabling analytics when the server has no traefik or caddy proxy', function (string $proxyType) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->never();
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->never();
$server = trafficAnalyticsProxyServer($this, $proxyType);
expect(fn () => ConfigureTrafficAnalytics::run($server, true))
->toThrow(RuntimeException::class, 'Traffic analytics needs the Traefik or Caddy proxy.');
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse()
->and($server->isTrafficAnalyticsEnabled())->toBeFalse()
->and($server->fresh()->proxy->get('last_saved_proxy_configuration'))->toBeNull();
Queue::assertNotPushed(RestartProxyJob::class);
})->with(['NONE', 'NGINX']);
it('lets a server without a usable proxy turn analytics off', function () {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->once();
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->never();
$server = trafficAnalyticsProxyServer($this, 'NONE', analyticsEnabled: true);
$server->settings->is_sentinel_enabled = true;
$server->settings->save();
expect(ConfigureTrafficAnalytics::run($server, false))->toBeFalse();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
Queue::assertNotPushed(RestartProxyJob::class);
});
it('keeps the analytics setting and saved proxy configuration unchanged when saving the configuration fails', function (bool $enable) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->never();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn("services:\n traefik:\n command: []\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturnUsing(function (Server $server, string $configuration) {
$server->proxy->last_saved_settings = 'new-hash';
$server->proxy->last_saved_proxy_configuration = $configuration;
$server->save();
throw new RuntimeException('SSH connection failed.');
});
$server = trafficAnalyticsProxyServer($this, 'TRAEFIK', [
'status' => 'running',
'last_saved_settings' => 'old-hash',
'last_saved_proxy_configuration' => 'old-configuration',
], analyticsEnabled: ! $enable);
expect(fn () => ConfigureTrafficAnalytics::run($server, $enable))
->toThrow(RuntimeException::class, 'SSH connection failed.');
$fresh = $server->fresh();
expect($fresh->isTrafficAnalyticsEnabled())->toBe(! $enable)
->and($server->isTrafficAnalyticsEnabled())->toBe(! $enable)
->and($fresh->proxy->get('last_saved_settings'))->toBe('old-hash')
->and($fresh->proxy->get('last_saved_proxy_configuration'))->toBe('old-configuration');
Queue::assertNotPushed(RestartProxyJob::class);
})->with(['enabling' => true, 'disabling' => false]);
it('keeps the analytics setting unchanged when the proxy configuration is not a mapping', function () {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->never();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn("services:\n traefik:\n command: '--api'\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->never();
$server = trafficAnalyticsProxyServer($this, 'TRAEFIK', ['status' => 'running']);
expect(fn () => ConfigureTrafficAnalytics::run($server, true))->toThrow(Exception::class);
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse()
->and($server->isTrafficAnalyticsEnabled())->toBeFalse();
Queue::assertNotPushed(RestartProxyJob::class);
});
it('saves the configuration without starting a proxy the user stopped', function (array $proxy) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->once();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn("services:\n traefik:\n command: []\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once()->withArgs(
fn (Server $server, string $configuration): bool => str_contains($configuration, '--accesslog=true')
);
$server = trafficAnalyticsProxyServer($this, 'TRAEFIK', $proxy);
expect(ConfigureTrafficAnalytics::run($server, true))->toBeFalse();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue()
->and((bool) $server->fresh()->proxy->get('force_stop'))->toBe((bool) ($proxy['force_stop'] ?? false));
Queue::assertNotPushed(RestartProxyJob::class);
})->with([
'force stopped' => [['status' => 'running', 'force_stop' => true]],
'exited' => [['status' => 'exited']],
'stopped' => [['status' => 'stopped']],
]);
it('restarts a running proxy to apply the configuration', function (string $proxyType) {
Queue::fake();
StartSentinel::partialMock()->shouldReceive('handle')->once();
GetProxyConfiguration::partialMock()->shouldReceive('handle')->once()->andReturn($proxyType === 'CADDY'
? "services:\n caddy:\n volumes: []\n"
: "services:\n traefik:\n command: []\n");
SaveProxyConfiguration::partialMock()->shouldReceive('handle')->once();
$server = trafficAnalyticsProxyServer($this, $proxyType, ['status' => 'running', 'force_stop' => false]);
expect(ConfigureTrafficAnalytics::run($server, true))->toBeTrue();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
Queue::assertPushed(RestartProxyJob::class);
})->with(['TRAEFIK', 'CADDY']);
@@ -1,10 +1,17 @@
<?php
use App\Actions\Server\StartSentinel;
use App\Enums\ServerRole;
use App\Events\SentinelRestarted;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
@@ -85,3 +92,106 @@ it('injects the maxmind license key only when geoip is enabled', function () {
$env = StartSentinel::sentinelTrafficEnvironment($server->fresh());
expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('secret-maxmind-key');
});
function sentinelTrafficServer(object $test, ?string $proxyType, bool $analyticsEnabled): Server
{
$server = Server::factory()->create([
'team_id' => $test->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $test->team->id])->id,
]);
$server->proxy->set('type', $proxyType);
$server->save();
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
$server->settings->sentinel_custom_url = 'https://coolify.example.com';
$server->settings->save();
return $server->fresh();
}
/**
* Runs StartSentinel against a faked SSH process and returns the remote script it sent.
*/
function runStartSentinelAndCaptureScript(Server $server): string
{
if (! InstanceSettings::query()->whereKey(0)->exists()) {
InstanceSettings::forceCreate(['id' => 0]);
}
config(['constants.ssh.mux_enabled' => false]);
Event::fake([SentinelRestarted::class]);
$scripts = [];
Process::fake(function ($process) use (&$scripts) {
$scripts[] = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return Process::result(output: '');
});
StartSentinel::run($server, latestVersion: '1.0.1');
return collect($scripts)->first(fn (string $script): bool => str_contains($script, 'docker run -d'));
}
it('creates the access log before starting sentinel with traffic analytics', function (string $proxyType, string $directory) {
$server = sentinelTrafficServer($this, $proxyType, analyticsEnabled: true);
expect(StartSentinel::trafficLogPreparationCommands($server))->toBe([
'mkdir -p '.escapeshellarg($directory),
'touch '.escapeshellarg($directory.'/access.log'),
]);
$script = runStartSentinelAndCaptureScript($server);
$touch = 'touch '.escapeshellarg($directory.'/access.log');
expect($script)->toContain('mkdir -p '.escapeshellarg($directory))
->toContain($touch)
->not->toContain('> '.$directory.'/access.log')
->and(strpos($script, 'mkdir -p '.escapeshellarg($directory)))->toBeLessThan(strpos($script, $touch))
->and(strpos($script, $touch))->toBeLessThan(strpos($script, 'docker run -d'));
})->with([
'traefik' => ['TRAEFIK', '/data/coolify/proxy'],
'caddy' => ['CADDY', '/data/coolify/proxy/caddy'],
]);
it('does not touch the access log when traffic analytics cannot run', function (?string $proxyType, bool $analyticsEnabled) {
$server = sentinelTrafficServer($this, $proxyType, $analyticsEnabled);
expect(StartSentinel::trafficLogPreparationCommands($server))->toBe([]);
$script = runStartSentinelAndCaptureScript($server);
expect($script)->toContain('docker run -d')
->not->toContain('touch ');
})->with([
'analytics disabled' => ['TRAEFIK', false],
'proxy none' => ['NONE', true],
]);
it('does not touch the access log on swarm or build servers', function (string $setting, mixed $value) {
$server = sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: true);
$server->settings->{$setting} = $value;
$server->settings->save();
expect(StartSentinel::trafficLogPreparationCommands($server->fresh()))->toBe([]);
})->with([
'swarm' => ['is_swarm_manager', true],
'build' => ['server_role', ServerRole::BUILD],
]);
it('keeps the access log commands valid for non-root servers', function () {
$server = sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: true);
$server->user = 'ubuntu';
$commands = parseCommandsByLineForSudo(collect(StartSentinel::trafficLogPreparationCommands($server)), $server);
expect($commands)->toBe([
"sudo mkdir -p '/data/coolify/proxy'",
"sudo touch '/data/coolify/proxy/access.log'",
]);
$script = tempnam(sys_get_temp_dir(), 'sentinel-traffic');
file_put_contents($script, implode("\n", $commands)."\n");
$syntax = SymfonyProcess::fromShellCommandline('bash -n '.escapeshellarg($script));
$syntax->run();
unlink($script);
expect($syntax->isSuccessful())->toBeTrue($syntax->getErrorOutput());
});
@@ -1,6 +1,7 @@
<?php
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Enums\ServerRole;
use App\Livewire\Analytics;
use App\Livewire\Server\TrafficAnalyticsSettings;
use App\Models\InstanceSettings;
@@ -24,9 +25,13 @@ it('toggles traffic analytics via the sentinel settings component', function ()
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->once()->andReturnUsing(function ($server, $enable) {
$server->settings->is_traffic_analytics_enabled = $enable;
$server->settings->save();
return true;
});
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
// New servers default analytics on; start from the disabled state to exercise enabling.
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
@@ -43,6 +48,8 @@ it('toggles traffic analytics via the sentinel settings component', function ()
it('warns about the application interruption before enabling traffic analytics', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
@@ -65,6 +72,8 @@ it('does not enable traffic analytics on a swarm server', function () {
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_swarm_manager = true;
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
@@ -73,6 +82,7 @@ it('does not enable traffic analytics on a swarm server', function () {
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->call('toggleTrafficAnalytics')
->assertDispatched('error', 'Traffic analytics is not supported on Swarm/Build servers.')
->assertHasNoErrors();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
@@ -117,7 +127,9 @@ it('does not enable traffic analytics on a build server', function () {
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings->is_build_server = true;
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->server_role = ServerRole::BUILD;
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
@@ -125,7 +137,103 @@ it('does not enable traffic analytics on a build server', function () {
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->call('toggleTrafficAnalytics')
->assertDispatched('error', 'Traffic analytics is not supported on Swarm/Build servers.')
->assertHasNoErrors();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
});
it('disables the analytics toggle and explains why when the server has no traefik or caddy proxy', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'NONE');
$server->save();
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
$html = Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->assertSee('Traffic analytics needs the Traefik or Caddy proxy.')
->assertDontSee('Enable traffic analytics?')
->assertDontSeeHtml('submitAction')
->html();
expect($html)->toMatch('/<button disabled[^>]*>(?:\s|<!--.*?-->)*Enable traffic analytics\s*<\/button>/s');
});
it('rejects enabling analytics from the component when the server has no traefik or caddy proxy', function () {
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'NONE');
$server->save();
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->call('toggleTrafficAnalytics')
->assertDispatched('error', 'Traffic analytics needs the Traefik or Caddy proxy.')
->assertSet('isTrafficAnalyticsEnabled', false);
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
});
it('lets analytics be disabled on a server whose proxy was removed', function () {
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->once()->andReturnUsing(function ($server, $enable) {
$server->settings->is_traffic_analytics_enabled = $enable;
$server->settings->save();
return false;
});
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'NONE');
$server->save();
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->assertDontSee('Traffic analytics needs the Traefik or Caddy proxy.')
->call('toggleTrafficAnalytics')
->assertDispatched('success', 'Traffic analytics disabled.')
->assertSet('isTrafficAnalyticsEnabled', false);
});
it('tells the user a stopped proxy picks up the change on its next start', function () {
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->once()->andReturnUsing(function ($server, $enable) {
$server->settings->is_traffic_analytics_enabled = $enable;
$server->settings->save();
return false;
});
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->proxy->set('status', 'exited');
$server->proxy->set('force_stop', true);
$server->save();
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->call('toggleTrafficAnalytics')
->assertDispatched('success', 'Traffic analytics enabled. The proxy is stopped, so the new configuration applies the next time you start it.');
});
it('does not let a team member toggle traffic analytics', function () {
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->assertForbidden();
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
});
@@ -0,0 +1,160 @@
<?php
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process as LaravelProcess;
use Symfony\Component\Process\Process;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
// generateDefaultProxyConfiguration() persists the config over SSH; the rotation script itself
// runs through Symfony Process below, which the Laravel fake does not intercept.
LaravelProcess::fake();
$user = User::factory()->create();
$team = $user->teams()->first();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$entrypoint = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()))['services']['traefik-logrotate']['entrypoint'];
// Docker Compose turns `$$` into a literal `$` before the container runs the script.
$this->script = str_replace('$$', '$', $entrypoint[2]);
$this->directory = sys_get_temp_dir().'/coolify-logrotate-'.bin2hex(random_bytes(6));
File::ensureDirectoryExists($this->directory);
$this->log = $this->directory.'/access.log';
});
afterEach(function () {
File::deleteDirectory($this->directory);
});
/**
* @return array<int, string>
*/
function rotationScriptShells(): array
{
$shells = ['/bin/sh'];
foreach (['/usr/bin/busybox', '/bin/busybox'] as $busybox) {
if (is_executable($busybox)) {
$shells[] = $busybox;
break;
}
}
return $shells;
}
/**
* Run the sidecar loop until $isDone() holds (or a timeout), then stop it.
*
* @param array<string, string> $env
*/
function runRotationScript(string $shell, string $script, string $log, callable $isDone, array $env = [], float $timeout = 20.0): void
{
$command = str_ends_with($shell, 'busybox') ? [$shell, 'sh', '-c', $script] : [$shell, '-c', $script];
$process = new Process($command, null, ['TRAEFIK_ACCESS_LOG' => $log, ...$env]);
$process->start();
$deadline = microtime(true) + $timeout;
while (microtime(true) < $deadline && $process->isRunning()) {
clearstatcache();
if ($isDone()) {
break;
}
usleep(50_000);
}
$running = $process->isRunning();
$process->stop(0);
expect($running)->toBeTrue('The rotation loop exited: '.$process->getErrorOutput());
}
/**
* @return array<int, string>
*/
function rotatedAccessLogs(string $directory): array
{
return collect(File::files($directory))
->map(fn (SplFileInfo $file): string => $file->getFilename())
->filter(fn (string $name): bool => str_starts_with($name, 'access.log.'))
->sort()
->values()
->all();
}
it('rotates an access log larger than 20 MiB, truncates it in place and keeps at most 5 rotations', function (string $shell) {
$line = '{"ClientHost":"203.0.113.10","RequestPath":"/","DownstreamStatus":200}'."\n";
$content = str_repeat($line, intdiv(21 * 1024 * 1024, strlen($line)) + 1);
File::put($this->log, $content);
$inode = fileinode($this->log);
foreach (range(1, 5) as $index) {
File::put("{$this->log}.{$index}.gz", gzencode("rotation {$index}\n"));
}
runRotationScript($shell, $this->script, $this->log, fn (): bool => filesize($this->log) === 0
&& ! file_exists("{$this->log}.1")
&& file_exists("{$this->log}.1.gz"));
clearstatcache();
expect(filesize($this->log))->toBe(0)
->and(fileinode($this->log))->toBe($inode)
->and(rotatedAccessLogs($this->directory))->toBe([
'access.log.1.gz',
'access.log.2.gz',
'access.log.3.gz',
'access.log.4.gz',
'access.log.5.gz',
])
->and(gzdecode(File::get("{$this->log}.1.gz")) === $content)->toBeTrue()
->and(gzdecode(File::get("{$this->log}.2.gz")))->toBe("rotation 1\n")
->and(gzdecode(File::get("{$this->log}.5.gz")))->toBe("rotation 4\n");
})->with(rotationScriptShells());
it('shifts rotations on every run and never keeps more than 5', function (string $shell) {
foreach (range(1, 7) as $run) {
File::put($this->log, str_repeat("run {$run}\n", 64));
runRotationScript(
$shell,
$this->script,
$this->log,
fn (): bool => filesize($this->log) === 0 && ! file_exists("{$this->log}.1") && file_exists("{$this->log}.1.gz"),
['TRAEFIK_ACCESS_LOG_MAX_BYTES' => '100'],
);
expect(count(rotatedAccessLogs($this->directory)))->toBe(min($run, 5));
}
expect(gzdecode(File::get("{$this->log}.1.gz")))->toStartWith('run 7')
->and(gzdecode(File::get("{$this->log}.5.gz")))->toStartWith('run 3')
->and(file_exists("{$this->log}.6.gz"))->toBeFalse();
})->with(rotationScriptShells());
it('leaves an access log under the size limit and a missing access log alone', function (string $shell) {
File::put($this->log, str_repeat("small\n", 1000));
runRotationScript($shell, $this->script, $this->log, fn (): bool => false, timeout: 1.0);
expect(filesize($this->log))->toBe(6000)
->and(rotatedAccessLogs($this->directory))->toBe([]);
File::delete($this->log);
runRotationScript($shell, $this->script, $this->log, fn (): bool => false, timeout: 1.0);
expect(rotatedAccessLogs($this->directory))->toBe([]);
})->with(rotationScriptShells());
@@ -95,7 +95,7 @@ it('does not add a traefik-logrotate sidecar when traffic analytics is disabled'
expect($config['services'])->not->toHaveKey('traefik-logrotate');
});
it('adds a traefik-logrotate sidecar with copytruncate and the proxy mount when enabled', function () {
it('adds a traefik-logrotate sidecar with the proxy mount when enabled', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
@@ -105,16 +105,52 @@ it('adds a traefik-logrotate sidecar with copytruncate and the proxy mount when
$server = $server->fresh();
$yaml = generateDefaultProxyConfiguration($server);
expect($yaml)->toContain('traefik-logrotate')
->toContain('copytruncate');
expect($yaml)->toContain('traefik-logrotate');
$config = Yaml::parse($yaml);
$sidecar = $config['services']['traefik-logrotate'];
expect($sidecar['container_name'])->toBe('coolify-proxy-logrotate');
expect($sidecar['image'])->toBe('alpine:3.20');
expect($sidecar['image'])->toBe('alpine:3.24');
expect($sidecar['network_mode'])->toBe('none');
expect($sidecar['volumes'])->toContain($server->proxyPath().':/traefik');
expect($sidecar['labels'])->toContain('coolify.managed=true');
expect($sidecar['entrypoint'])->toContain('copytruncate');
expect($sidecar['entrypoint'])->toContain('logrotate');
expect($sidecar['entrypoint'])->toHaveCount(3)
->and($sidecar['entrypoint'][0])->toBe('/bin/sh')
->and($sidecar['entrypoint'][1])->toBe('-c');
});
it('rotates the access log with busybox tools only, without installing packages', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$yaml = generateDefaultProxyConfiguration($server->fresh());
$script = Yaml::parse($yaml)['services']['traefik-logrotate']['entrypoint'][2];
expect($yaml)->not->toContain('apk add')
->not->toContain('apk ')
->not->toContain('logrotate -s');
expect($script)->toContain('while true; do')
->toContain('stat -c %s')
->toContain('20971520')
->toContain('keep=5')
->toContain('gzip -f')
->toContain(': > "$$log"');
});
it('escapes every dollar sign in the rotation script from docker compose interpolation', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$script = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()))['services']['traefik-logrotate']['entrypoint'][2];
expect(str_replace('$$', '', $script))->not->toContain('$')
->and(str_replace('$$', '$', $script))->toBe(traefikAccessLogRotationScript());
});
@@ -0,0 +1,201 @@
<?php
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
Process::fake();
$user = User::factory()->create();
$team = $user->teams()->first();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$this->server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
$this->server->proxy->set('type', 'TRAEFIK');
$this->server->save();
});
/**
* Toggle analytics like ConfigureTrafficAnalytics does and run the given Traefik commands through the helper.
*
* @param array<int, string> $commands
* @return array<int, string>
*/
function applyTrafficAnalyticsToCommands(Server $server, bool $enabled, array $commands): array
{
$server->settings->is_traffic_analytics_enabled = $enabled;
$server->settings->save();
$server->refresh();
$configuration = Yaml::dump(['services' => ['traefik' => ['command' => $commands]]], 12, 2);
$result = applyTrafficAnalyticsToProxyConfiguration($server, $configuration);
$server->refresh();
return Yaml::parse($result)['services']['traefik']['command'];
}
/**
* @param array<int, string> $commands
* @return array<int, string>
*/
function accessLogFlagsIn(array $commands): array
{
return array_values(array_filter(
$commands,
fn (string $command): bool => $command === '--accesslog' || str_starts_with($command, '--accesslog=') || str_starts_with($command, '--accesslog.')
));
}
it('restores a user --accesslog=true after enabling and disabling analytics', function () {
$userCommands = ['--providers.docker=true', '--accesslog=true'];
$enabled = applyTrafficAnalyticsToCommands($this->server, true, $userCommands);
expect(accessLogFlagsIn($enabled))->toBe(traefikAccessLogCommands(true))
->and(array_count_values($enabled)['--accesslog=true'])->toBe(1);
$disabled = applyTrafficAnalyticsToCommands($this->server, false, $enabled);
expect($disabled)->toBe($userCommands)
->and($this->server->proxy->get('traffic_analytics_user_accesslog_commands'))->toBeNull();
});
it('replaces a conflicting user access log format while enabled and restores it when disabled', function () {
$userCommands = ['--providers.docker=true', '--accesslog=true', '--accesslog.format=common'];
$enabled = applyTrafficAnalyticsToCommands($this->server, true, $userCommands);
expect($enabled)->not->toContain('--accesslog.format=common')
->and(array_values(array_filter($enabled, fn (string $command): bool => str_starts_with($command, '--accesslog.format='))))
->toBe(['--accesslog.format=json']);
$disabled = applyTrafficAnalyticsToCommands($this->server, false, $enabled);
expect($disabled)->toBe($userCommands);
});
it('remembers user access log filters while enabled and restores them exactly when disabled', function () {
$userCommands = [
'--providers.docker=true',
'--accesslog=true',
'--accesslog.filters.statuscodes=400-599',
'--accesslog.filters.minduration=10ms',
'--accesslog.bufferingsize=100',
];
$enabled = applyTrafficAnalyticsToCommands($this->server, true, $userCommands);
expect(accessLogFlagsIn($enabled))->toBe(traefikAccessLogCommands(true))
->and($this->server->proxy->get('traffic_analytics_user_accesslog_commands'))->toBe([
'--accesslog=true',
'--accesslog.filters.statuscodes=400-599',
'--accesslog.filters.minduration=10ms',
'--accesslog.bufferingsize=100',
]);
$disabled = applyTrafficAnalyticsToCommands($this->server, false, $enabled);
expect($disabled)->toBe($userCommands);
});
it('is idempotent when analytics is enabled twice', function () {
$userCommands = ['--providers.docker=true', '--accesslog=true', '--accesslog.format=common'];
$first = applyTrafficAnalyticsToCommands($this->server, true, $userCommands);
$second = applyTrafficAnalyticsToCommands($this->server, true, $first);
expect($second)->toBe($first)
->and($this->server->proxy->get('traffic_analytics_user_accesslog_commands'))->toBe(['--accesslog=true', '--accesslog.format=common']);
$disabled = applyTrafficAnalyticsToCommands($this->server, false, $second);
expect($disabled)->toBe($userCommands);
});
it('is idempotent when analytics is disabled twice', function () {
$userCommands = ['--providers.docker=true', '--accesslog=true'];
$enabled = applyTrafficAnalyticsToCommands($this->server, true, $userCommands);
$disabled = applyTrafficAnalyticsToCommands($this->server, false, $enabled);
$disabledAgain = applyTrafficAnalyticsToCommands($this->server, false, $disabled);
expect($disabledAgain)->toBe($userCommands);
});
it('keeps a user --accesslog=true when analytics was never enabled', function () {
$userCommands = ['--providers.docker=true', '--accesslog=true', '--accesslog.format=common'];
expect(applyTrafficAnalyticsToCommands($this->server, false, $userCommands))->toBe($userCommands);
});
it('removes only the managed flags when disabling analytics that was enabled before flags were remembered', function () {
$legacyEnabled = [
'--providers.docker=true',
'--accesslog.bufferingsize=100',
...traefikAccessLogCommands(true),
];
$disabled = applyTrafficAnalyticsToCommands($this->server, false, $legacyEnabled);
expect($disabled)->toBe(['--providers.docker=true', '--accesslog.bufferingsize=100']);
});
it('remembers only user flags when re-enabling analytics that was enabled before flags were remembered', function () {
$legacyEnabled = [
'--providers.docker=true',
'--accesslog.bufferingsize=100',
...traefikAccessLogCommands(true),
];
$enabled = applyTrafficAnalyticsToCommands($this->server, true, $legacyEnabled);
expect(accessLogFlagsIn($enabled))->toBe(traefikAccessLogCommands(true))
->and($this->server->proxy->get('traffic_analytics_user_accesslog_commands'))->toBe(['--accesslog.bufferingsize=100']);
$disabled = applyTrafficAnalyticsToCommands($this->server, false, $enabled);
expect($disabled)->toBe(['--providers.docker=true', '--accesslog.bufferingsize=100']);
});
it('keeps a custom --accesslog=true when resetting to the default configuration with analytics off', function () {
$this->server->settings->is_traffic_analytics_enabled = false;
$this->server->settings->save();
$existing = Yaml::dump(['services' => ['traefik' => ['command' => [
'--providers.docker=true',
'--accesslog=true',
'--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20',
]]]], 12, 2);
$server = $this->server->fresh();
$customCommands = extractCustomProxyCommands($server, $existing);
$commands = Yaml::parse(generateDefaultProxyConfiguration($server, $customCommands))['services']['traefik']['command'];
expect($commands)->toContain('--accesslog=true')
->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20')
->not->toContain('--accesslog.format=json')
->not->toContain('--accesslog.filepath=/traefik/access.log');
});
it('keeps the remembered user flags when resetting to the default configuration with analytics on', function () {
$userCommands = ['--providers.docker=true', '--accesslog=true', '--accesslog.format=common'];
$enabled = applyTrafficAnalyticsToCommands($this->server, true, $userCommands);
$server = $this->server->fresh();
$existing = Yaml::dump(['services' => ['traefik' => ['command' => $enabled]]], 12, 2);
$commands = Yaml::parse(generateDefaultProxyConfiguration($server, extractCustomProxyCommands($server, $existing)))['services']['traefik']['command'];
expect(accessLogFlagsIn($commands))->toBe(traefikAccessLogCommands(true))
->and($server->fresh()->proxy->get('traffic_analytics_user_accesslog_commands'))->toBe(['--accesslog=true', '--accesslog.format=common']);
$disabled = applyTrafficAnalyticsToCommands($server, false, $commands);
expect(accessLogFlagsIn($disabled))->toBe(['--accesslog=true', '--accesslog.format=common']);
});
@@ -0,0 +1,68 @@
// Minimal stand-in for the terminal WebSocket server used by browser tests.
// Built on Node core only (no `ws`), it reproduces how the real server rejects
// authentication so the browser client can be tested end to end.
//
// Usage: node terminal-websocket-rejection-server.mjs <mode>
// close-on-upgrade: complete the handshake, then close with 4401 (session rejected)
// reject-token: on the first client frame, send the legacy text message and close with 4403
//
// Prints the listening port on stdout. GET /stats returns {"upgrades": n}.
import { createHash } from 'node:crypto';
import http from 'node:http';
const mode = process.argv[2] ?? 'close-on-upgrade';
let upgrades = 0;
function frame(opcode, payload) {
if (payload.length > 125) {
throw new Error('Fixture only supports short frames.');
}
return Buffer.concat([Buffer.from([0x80 | opcode, payload.length]), payload]);
}
function closeFrame(code, reason) {
const payload = Buffer.alloc(2 + Buffer.byteLength(reason));
payload.writeUInt16BE(code, 0);
payload.write(reason, 2);
return frame(0x8, payload);
}
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify({ upgrades }));
});
server.on('upgrade', (req, socket) => {
upgrades++;
socket.on('error', () => socket.destroy());
const accept = createHash('sha1')
.update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest('base64');
socket.write([
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
'',
'',
].join('\r\n'));
if (mode === 'close-on-upgrade') {
socket.end(closeFrame(4401, 'Unauthorized: Invalid credentials'));
return;
}
socket.once('data', () => {
const message = 'Unauthorized: Terminal token was rejected';
socket.write(frame(0x1, Buffer.from(message)));
socket.end(closeFrame(4403, message));
});
});
server.listen(0, '127.0.0.1', () => {
process.stdout.write(`${server.address().port}\n`);
});
@@ -285,26 +285,31 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu
expect($run['exit'])->toBe(0, $run['stderr']);
restoreScriptExpectCalls($run['calls'], $expectedCalls);
})->with([
'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error -U postgres -d app']]]],
'custom archive replacing existing objects' => ['pg-custom', true, [['pg_restore', 'PGDMP', ['--exit-on-error --clean --if-exists -U postgres -d app']]]],
'gzip custom archive' => ['pg-custom-gz', false, [['pg_restore', 'PGDMP', ['--exit-on-error -U postgres -d app']]]],
'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['pg_restore', 'PGDMP', ['--exit-on-error --clean --if-exists -U postgres -d app']]]],
'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error -U postgres -d app']]]],
'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error -U postgres -d app']]]],
'plain SQL' => ['pg-sql', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 -U postgres -d app']]]],
'gzip SQL' => ['pg-sql-gz', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 -U postgres -d app']]]],
// SQL cannot replace single objects, so replacing recreates the target database first.
'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'custom archive replacing existing objects' => ['pg-custom', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --clean --if-exists -U postgres -d app']]]],
'gzip custom archive' => ['pg-custom-gz', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --clean --if-exists -U postgres -d app']]]],
'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'plain SQL' => ['pg-sql', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
'gzip SQL' => ['pg-sql-gz', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
// SQL cannot replace single objects: it restores into a new database, and only a
// successful restore replaces the current one, so a failure changes nothing.
'plain SQL replacing existing objects' => ['pg-sql', true, [
['psql', 'SELEC', ['-v db=app -U postgres -d template1']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists app']],
['createdb', '', ['-U postgres app']],
['psql', '-- Po', ['-v ON_ERROR_STOP=1 -U postgres -d app']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_new']],
['createdb', '', ['-U postgres coolify_restore_new']],
['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d coolify_restore_new']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_old']],
['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -v db=app -v old=coolify_restore_old -v new=coolify_restore_new -U postgres -d template1']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_old']],
]],
'gzip SQL replacing existing objects' => ['pg-sql-gz', true, [
['psql', 'SELEC', ['-v db=app -U postgres -d template1']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists app']],
['createdb', '', ['-U postgres app']],
['psql', '-- Po', ['-v ON_ERROR_STOP=1 -U postgres -d app']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_new']],
['createdb', '', ['-U postgres coolify_restore_new']],
['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d coolify_restore_new']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_old']],
['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -v db=app -v old=coolify_restore_old -v new=coolify_restore_new -U postgres -d template1']],
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_old']],
]],
]);
@@ -0,0 +1,108 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Process\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
$stack = seedBrowserResourceStack();
$stack['server']->settings->update(['is_terminal_enabled' => true]);
$this->serverUuid = $stack['server']->uuid;
Cache::flush();
$this->terminalServer = null;
});
afterEach(function () {
$this->terminalServer?->stop(0);
});
/**
* Start the fake terminal WebSocket server and point the terminal client at it.
*/
function startFakeTerminalServer(string $mode): array
{
$process = new Process(['node', base_path('tests/Fixtures/terminal-websocket-rejection-server.mjs'), $mode]);
$process->start();
$process->waitUntil(fn (string $type, string $output) => str_contains($output, "\n"));
$port = (int) trim($process->getOutput());
config()->set('constants.terminal.protocol', 'ws');
config()->set('constants.terminal.host', '127.0.0.1');
config()->set('constants.terminal.port', (string) $port);
return [$process, $port];
}
function fakeTerminalServerUpgrades(int $port): int
{
return json_decode(file_get_contents("http://127.0.0.1:{$port}/stats"), true)['upgrades'];
}
/**
* The browser plugin only boots for tests whose closure calls `visit(` as a standalone expression.
*/
function openServerTerminal(mixed $loginPage, string $serverUuid): mixed
{
return $loginPage
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Dashboard')
->navigate("/server/{$serverUuid}/terminal");
}
it('shows an auth rejection instead of connecting forever and does not reconnect', function () {
[$this->terminalServer, $port] = startFakeTerminalServer('close-on-upgrade');
$loginPage = visit('/login');
$page = openServerTerminal($loginPage, $this->serverUuid)
->assertSee('Terminal access was rejected. Reload the page and try again.')
->assertSee('Reload page')
->assertDontSee('connecting…');
// Auth rejections must not start the exponential reconnect loop.
$page->wait(4);
expect(fakeTerminalServerUpgrades($port))->toBe(1);
$page->assertSee('Terminal access was rejected. Reload the page and try again.')
->screenshot(filename: 'terminal-auth-rejected');
});
it('shows an auth rejection when the terminal token is rejected', function () {
[$this->terminalServer, $port] = startFakeTerminalServer('reject-token');
$loginPage = visit('/login');
$page = openServerTerminal($loginPage, $this->serverUuid)
->assertSee('Terminal access was rejected. Reload the page and try again.')
->assertDontSee('connecting…');
$page->wait(4);
expect(fakeTerminalServerUpgrades($port))->toBe(1);
$page->screenshot(filename: 'terminal-token-rejected');
});
it('shows a connection error when the terminal server is unreachable', function () {
// Reserve a free port, then release it so nothing is listening there.
$socket = stream_socket_server('tcp://127.0.0.1:0');
$port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1);
fclose($socket);
config()->set('constants.terminal.protocol', 'ws');
config()->set('constants.terminal.host', '127.0.0.1');
config()->set('constants.terminal.port', (string) $port);
$loginPage = visit('/login');
openServerTerminal($loginPage, $this->serverUuid)
->assertSee('Could not connect to the terminal server. Reload the page and try again.')
->assertDontSee('connecting…')
->screenshot(filename: 'terminal-connection-failed');
});