mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 09:45:58 -04:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user