diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php index b47f03d0b6..287df678a1 100644 --- a/app/Actions/Server/ConfigureTrafficAnalytics.php +++ b/app/Actions/Server/ConfigureTrafficAnalytics.php @@ -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); } } diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 3857d8c4d3..57db2d3779 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -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 + */ + 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", diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index b0d80f00c0..ce7837dcc9 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -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 diff --git a/app/Models/Server.php b/app/Models/Server.php index 4f081b1a88..9b5bc54145 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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) diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php index ffb28ba6fd..e1de4d153c 100644 --- a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -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 <</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 $commands + * @return array + */ +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) { diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index a955247dca..394cf5277d 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -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'); } } diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index cc6d6fefd0..a1207b028a 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -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) { diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index bc2525ded8..fd6297fad4 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -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(); +}); diff --git a/resources/js/terminal-connection.js b/resources/js/terminal-connection.js new file mode 100644 index 0000000000..27fab80e61 --- /dev/null +++ b/resources/js/terminal-connection.js @@ -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, + }; +} diff --git a/resources/js/terminal-connection.test.js b/resources/js/terminal-connection.test.js new file mode 100644 index 0000000000..2f0827283f --- /dev/null +++ b/resources/js/terminal-connection.test.js @@ -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.'); +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index e643a72510..b58b3e59c3 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -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(); diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index b4a0961a0f..8c5bd50767 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -3,7 +3,7 @@ @endphp
$isApplicationConsole, @@ -48,7 +48,13 @@ @if ($isApplicationConsole)
-
+ +
@else +
diff --git a/resources/views/livewire/server/traffic-analytics-settings.blade.php b/resources/views/livewire/server/traffic-analytics-settings.blade.php index 8031654046..83ab167ec5 100644 --- a/resources/views/livewire/server/traffic-analytics-settings.blade.php +++ b/resources/views/livewire/server/traffic-analytics-settings.blade.php @@ -56,10 +56,15 @@
@else
+ @if ($unsupportedReason) + + Enable traffic analytics + + @else + @endif
diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 4c4872f0d5..e44135e0c2 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -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'); +}); diff --git a/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php index 54ac92fdaf..765f4aa5dc 100644 --- a/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ConfigureTrafficAnalyticsTest.php @@ -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']); diff --git a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php index b8856f4c67..1588a7c430 100644 --- a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php @@ -1,10 +1,17 @@ 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()); +}); diff --git a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php index 63864aada1..fca6e5a981 100644 --- a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php @@ -1,6 +1,7 @@ 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('/