From 65262e639edd4fba4250074a6e7f59b21d964e2a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:37:54 +0200 Subject: [PATCH] fix(installer): keep resource data ownership on reinstall and upgrade install.sh ran chown -R 9999:root and chmod -R 700 on all of /data/coolify on every run. This changed bind-mounted data of services, applications, and databases, and reset the ownership that a non-root SSH user needs. Set owner and mode on /data/coolify and the resource directories only when the installer creates them. Change only source, ssh, and images recursively, because the coolify container uses them. Also wait until the Docker daemon accepts connections after a restart, because OpenRC returns before the daemon is ready. Fixes #11945 --- other/nightly/install.sh | 25 +++-- scripts/install.sh | 46 +++++--- .../InstallScriptDirectoryPermissionsTest.php | 105 ++++++++++++++++++ 3 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/InstallScriptDirectoryPermissionsTest.php diff --git a/other/nightly/install.sh b/other/nightly/install.sh index a0758e9228..513eac1c03 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -29,12 +29,24 @@ if [ $EUID != 0 ]; then exit 1 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} -mkdir -p /data/coolify/ssh/{keys,mux} -mkdir -p /data/coolify/proxy/dynamic +# Resource directories hold data whose owner and mode the resource images or a non-root SSH user need. +# Set their owner and mode only when they are created, never on an upgrade (#11945). +for COOLIFY_DIRECTORY in /data/coolify /data/coolify/{applications,databases,backups,services,proxy,proxy/dynamic,sentinel}; do + if [ ! -d "$COOLIFY_DIRECTORY" ]; then + mkdir -p "$COOLIFY_DIRECTORY" + chown 9999:root "$COOLIFY_DIRECTORY" + chmod 700 "$COOLIFY_DIRECTORY" + fi +done +mkdir -p /data/coolify/source /data/coolify/ssh/{keys,mux} -chown -R 9999:root /data/coolify -chmod -R 700 /data/coolify +# Coolify's own directories, used by the coolify container (UID 9999) +set_coolify_directory_permissions() { + chown -R 9999:root /data/coolify/{source,ssh} + chmod -R 700 /data/coolify/{source,ssh} +} + +set_coolify_directory_permissions INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" @@ -1071,8 +1083,7 @@ if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub fi -chown -R 9999:root /data/coolify -chmod -R 700 /data/coolify +set_coolify_directory_permissions log "SSH key check completed" step_done "$SSH_KEY_DETAIL" diff --git a/scripts/install.sh b/scripts/install.sh index 921a2940e4..b40db37408 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -29,13 +29,24 @@ if [ $EUID != 0 ]; then exit 1 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} -mkdir -p /data/coolify/images -mkdir -p /data/coolify/ssh/{keys,mux} -mkdir -p /data/coolify/proxy/dynamic +# Resource directories hold data whose owner and mode the resource images or a non-root SSH user need. +# Set their owner and mode only when they are created, never on an upgrade (#11945). +for COOLIFY_DIRECTORY in /data/coolify /data/coolify/{applications,databases,backups,services,proxy,proxy/dynamic,sentinel}; do + if [ ! -d "$COOLIFY_DIRECTORY" ]; then + mkdir -p "$COOLIFY_DIRECTORY" + chown 9999:root "$COOLIFY_DIRECTORY" + chmod 700 "$COOLIFY_DIRECTORY" + fi +done +mkdir -p /data/coolify/source /data/coolify/images /data/coolify/ssh/{keys,mux} -chown -R 9999:root /data/coolify -chmod -R 700 /data/coolify +# Coolify's own directories, used by the coolify container (UID 9999) +set_coolify_directory_permissions() { + chown -R 9999:root /data/coolify/{source,ssh,images} + chmod -R 700 /data/coolify/{source,ssh,images} +} + +set_coolify_directory_permissions INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" @@ -278,11 +289,21 @@ if [ -n "${DOCKER_ADDRESS_POOL_SIZE+x}" ]; then DOCKER_POOL_SIZE_PROVIDED=true fi +# OpenRC (Alpine) returns from "service docker start" before the daemon accepts connections +wait_for_docker_daemon() { + for _ in $(seq 1 30); do + if docker info >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + restart_docker_service() { # Check if systemctl is available if command -v systemctl >/dev/null 2>&1; then - systemctl restart docker - if [ $? -eq 0 ]; then + if systemctl restart docker && wait_for_docker_daemon; then echo " - Docker daemon restarted successfully" else echo " - Failed to restart Docker daemon" @@ -290,8 +311,7 @@ restart_docker_service() { fi # Check if service command is available elif command -v service >/dev/null 2>&1; then - service docker restart - if [ $? -eq 0 ]; then + if service docker restart && wait_for_docker_daemon; then echo " - Docker daemon restarted successfully" else echo " - Failed to restart Docker daemon" @@ -792,7 +812,8 @@ fi # Verify minimum Docker version MIN_DOCKER_VERSION=24 -INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) +wait_for_docker_daemon || true +INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1 || true) if [ -z "$INSTALLED_DOCKER_VERSION" ]; then warn "Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then @@ -1072,8 +1093,7 @@ if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub fi -chown -R 9999:root /data/coolify -chmod -R 700 /data/coolify +set_coolify_directory_permissions log "SSH key check completed" step_done "$SSH_KEY_DETAIL" diff --git a/tests/Unit/InstallScriptDirectoryPermissionsTest.php b/tests/Unit/InstallScriptDirectoryPermissionsTest.php new file mode 100644 index 0000000000..d9e079f365 --- /dev/null +++ b/tests/Unit/InstallScriptDirectoryPermissionsTest.php @@ -0,0 +1,105 @@ + the paths that chown received + */ +function runInstallScriptDirectorySetup(string $scriptPath, string $dataRoot): array +{ + $script = file_get_contents(dirname(__DIR__, 2).'/'.$scriptPath); + preg_match('/^# Resource directories.*?^set_coolify_directory_permissions$/ms', $script, $matches); + expect($matches)->not->toBeEmpty(); + + $setup = str_replace('/data/coolify', $dataRoot, $matches[0]); + $chownLog = "{$dataRoot}.chown"; + $process = proc_open( + ['bash', '-c', "set -e\nchown() { printf '%s\\n' \"\$@\" >> ".escapeshellarg($chownLog)."; }\n{$setup}"], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + $output = stream_get_contents($pipes[1]).stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + expect(proc_close($process))->toBe(0, $output); + + $chownedPaths = array_filter(file($chownLog, FILE_IGNORE_NEW_LINES), fn (string $argument) => str_starts_with($argument, '/')); + unlink($chownLog); + + return array_values(array_map(fn (string $path) => str_replace($dataRoot, '/data/coolify', $path), $chownedPaths)); +} + +function installScriptFileMode(string $path): string +{ + clearstatcache(true, $path); + + return substr(sprintf('%o', fileperms($path)), -4); +} + +$installScripts = [ + 'stable install' => ['scripts/install.sh', ['source', 'ssh', 'images']], + 'nightly install' => ['other/nightly/install.sh', ['source', 'ssh']], +]; +$resourceDirectories = ['applications', 'databases', 'backups', 'services', 'proxy', 'sentinel']; + +it('creates the data directories as uid 9999 with mode 700 on a fresh install', function (string $scriptPath, array $coolifyDirectories) use ($resourceDirectories) { + $dataRoot = sys_get_temp_dir().'/coolify-install-permissions-'.bin2hex(random_bytes(6)); + + try { + $chownedPaths = runInstallScriptDirectorySetup($scriptPath, $dataRoot); + + foreach (['', ...array_map(fn ($directory) => "/{$directory}", $resourceDirectories), '/proxy/dynamic'] as $directory) { + expect($chownedPaths)->toContain("/data/coolify{$directory}") + ->and(installScriptFileMode("{$dataRoot}{$directory}"))->toBe('0700'); + } + foreach ($coolifyDirectories as $directory) { + expect($chownedPaths)->toContain("/data/coolify/{$directory}") + ->and(installScriptFileMode("{$dataRoot}/{$directory}"))->toBe('0700'); + } + } finally { + exec('rm -rf '.escapeshellarg($dataRoot)); + } +})->with($installScripts); + +it('keeps existing resource directories and their content unchanged on an upgrade', function (string $scriptPath, array $coolifyDirectories) use ($resourceDirectories) { + $dataRoot = sys_get_temp_dir().'/coolify-install-permissions-'.bin2hex(random_bytes(6)); + + try { + // A non-root server user setup: the SSH user owns the tree with mode 750. + mkdir($dataRoot, 0750); + chmod($dataRoot, 0750); + foreach ([...$resourceDirectories, 'proxy/dynamic', ...$coolifyDirectories] as $directory) { + mkdir("{$dataRoot}/{$directory}/nested", 0755, true); + chmod("{$dataRoot}/{$directory}", 0750); + touch("{$dataRoot}/{$directory}/nested/file"); + chmod("{$dataRoot}/{$directory}/nested/file", 0644); + } + + $chownedPaths = runInstallScriptDirectorySetup($scriptPath, $dataRoot); + + expect($chownedPaths)->toBe(array_map(fn ($directory) => "/data/coolify/{$directory}", $coolifyDirectories)) + ->and(installScriptFileMode($dataRoot))->toBe('0750'); + foreach ([...$resourceDirectories, 'proxy/dynamic'] as $directory) { + expect(installScriptFileMode("{$dataRoot}/{$directory}"))->toBe('0750') + ->and(installScriptFileMode("{$dataRoot}/{$directory}/nested"))->toBe('0755') + ->and(installScriptFileMode("{$dataRoot}/{$directory}/nested/file"))->toBe('0644'); + } + foreach ($coolifyDirectories as $directory) { + expect(installScriptFileMode("{$dataRoot}/{$directory}/nested"))->toBe('0700') + ->and(installScriptFileMode("{$dataRoot}/{$directory}/nested/file"))->toBe('0700'); + } + } finally { + exec('rm -rf '.escapeshellarg($dataRoot)); + } +})->with($installScripts); + +it('does not change the whole data directory recursively', function (string $scriptPath) { + $script = file_get_contents(dirname(__DIR__, 2).'/'.$scriptPath); + + expect($script) + ->not->toContain("chown -R 9999:root /data/coolify\n") + ->not->toContain("chmod -R 700 /data/coolify\n") + ->and(substr_count($script, "\nset_coolify_directory_permissions\n"))->toBe(2); +})->with(array_map(fn (array $script) => $script[0], $installScripts));