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));