diff --git a/other/nightly/install.sh b/other/nightly/install.sh index f6975ef619..842e0e769d 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -929,7 +929,8 @@ update_env_var() { # If variable "key=" exists but has no value, update the value of the existing line if grep -q "^${key}=$" "$ENV_FILE"; then - sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + # awk reads the value from the environment, so characters such as | & \ in it stay literal + KEY="$key" VALUE="$value" awk '$0 == ENVIRON["KEY"] "=" { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"] } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" echo " - Updated value of ${key} as the current value was empty" # If variable "key=" doesn't exist, append it to the file with value elif ! grep -q "^${key}=" "$ENV_FILE"; then @@ -938,6 +939,23 @@ update_env_var() { fi } +# Quote a user-supplied value so that Docker Compose and phpdotenv both read it back unchanged +# (spaces, #, $ and quotes otherwise break parsing or change the value). +quote_env_value() { + local value="$1" + case "$value" in + *\'*) + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//\$/\\\$} + printf '"%s"' "$value" + ;; + *) + printf "'%s'" "$value" + ;; + esac +} + update_env_var "APP_ID" "$(openssl rand -hex 16)" update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)" # update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist @@ -951,9 +969,9 @@ update_env_var "PUSHER_BACKEND_PORT" "6001" # Add default root user credentials from environment variables if [ -n "$ROOT_USERNAME" ] && [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then echo " - Setting predefined root user credentials from environment" - update_env_var "ROOT_USERNAME" "$ROOT_USERNAME" - update_env_var "ROOT_USER_EMAIL" "$ROOT_USER_EMAIL" - update_env_var "ROOT_USER_PASSWORD" "$ROOT_USER_PASSWORD" + update_env_var "ROOT_USERNAME" "$(quote_env_value "$ROOT_USERNAME")" + update_env_var "ROOT_USER_EMAIL" "$(quote_env_value "$ROOT_USER_EMAIL")" + update_env_var "ROOT_USER_PASSWORD" "$(quote_env_value "$ROOT_USER_PASSWORD")" fi if [ -n "${REGISTRY_URL+x}" ]; then diff --git a/scripts/install.sh b/scripts/install.sh index 93fef122e2..25ab62a754 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -929,7 +929,8 @@ update_env_var() { # If variable "key=" exists but has no value, update the value of the existing line if grep -q "^${key}=$" "$ENV_FILE"; then - sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + # awk reads the value from the environment, so characters such as | & \ in it stay literal + KEY="$key" VALUE="$value" awk '$0 == ENVIRON["KEY"] "=" { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"] } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" echo " - Updated value of ${key} as the current value was empty" # If variable "key=" doesn't exist, append it to the file with value elif ! grep -q "^${key}=" "$ENV_FILE"; then @@ -938,6 +939,23 @@ update_env_var() { fi } +# Quote a user-supplied value so that Docker Compose and phpdotenv both read it back unchanged +# (spaces, #, $ and quotes otherwise break parsing or change the value). +quote_env_value() { + local value="$1" + case "$value" in + *\'*) + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//\$/\\\$} + printf '"%s"' "$value" + ;; + *) + printf "'%s'" "$value" + ;; + esac +} + update_env_var "APP_ID" "$(openssl rand -hex 16)" update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)" # update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist @@ -951,9 +969,9 @@ update_env_var "PUSHER_BACKEND_PORT" "6001" # Add default root user credentials from environment variables if [ -n "$ROOT_USERNAME" ] && [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then echo " - Setting predefined root user credentials from environment" - update_env_var "ROOT_USERNAME" "$ROOT_USERNAME" - update_env_var "ROOT_USER_EMAIL" "$ROOT_USER_EMAIL" - update_env_var "ROOT_USER_PASSWORD" "$ROOT_USER_PASSWORD" + update_env_var "ROOT_USERNAME" "$(quote_env_value "$ROOT_USERNAME")" + update_env_var "ROOT_USER_EMAIL" "$(quote_env_value "$ROOT_USER_EMAIL")" + update_env_var "ROOT_USER_PASSWORD" "$(quote_env_value "$ROOT_USER_PASSWORD")" fi if [ -n "${REGISTRY_URL+x}" ]; then diff --git a/tests/Unit/InstallScriptRootUserEnvTest.php b/tests/Unit/InstallScriptRootUserEnvTest.php new file mode 100644 index 0000000000..aab987d738 --- /dev/null +++ b/tests/Unit/InstallScriptRootUserEnvTest.php @@ -0,0 +1,69 @@ + $credentials + * @return array + */ +function writeRootUserCredentialsWithInstallScript(string $scriptPath, array $credentials, string $initialEnv): array +{ + $script = file_get_contents(dirname(__DIR__, 2).'/'.$scriptPath); + preg_match('/^update_env_var\(\) \{.*?^\}$/ms', $script, $updateEnvVar); + preg_match('/^quote_env_value\(\) \{.*?^\}$/ms', $script, $quoteEnvValue); + expect($updateEnvVar)->not->toBeEmpty() + ->and($quoteEnvValue)->not->toBeEmpty(); + + $envFile = tempnam(sys_get_temp_dir(), 'coolify-install-env-'); + file_put_contents($envFile, $initialEnv); + + $commands = array_map( + fn (string $key, string $value) => "update_env_var {$key} \"\$(quote_env_value ".escapeshellarg($value).')"', + array_keys($credentials), + $credentials, + ); + $process = proc_open( + ['bash', '-c', implode("\n", ['set -e', 'ENV_FILE='.escapeshellarg($envFile), $updateEnvVar[0], $quoteEnvValue[0], ...$commands])], + [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); + + $contents = file_get_contents($envFile); + unlink($envFile); + + return Dotenv::parse($contents); +} + +$installScripts = ['stable install' => 'scripts/install.sh', 'nightly install' => 'other/nightly/install.sh']; + +$credentials = [ + 'username with a space' => ['ROOT_USERNAME', 'Coolify Lab'], + 'email' => ['ROOT_USER_EMAIL', 'admin@example.com'], + 'password with symbols' => ['ROOT_USER_PASSWORD', 'P@ss w0rd#1|&\\$HOME'], + 'password with quotes' => ['ROOT_USER_PASSWORD', 'it\'s "Quoted" \\ $HOME #1'], +]; + +it('writes root user credentials that parse back unchanged into an empty variable', function (string $scriptPath, string $key, string $value) { + $env = writeRootUserCredentialsWithInstallScript($scriptPath, [$key => $value], "APP_NAME=Coolify\n{$key}=\nAPP_ENV=production\n"); + + expect($env)->toBe(['APP_NAME' => 'Coolify', $key => $value, 'APP_ENV' => 'production']); +})->with($installScripts)->with($credentials); + +it('writes root user credentials that parse back unchanged into a missing variable', function (string $scriptPath, string $key, string $value) { + $env = writeRootUserCredentialsWithInstallScript($scriptPath, [$key => $value], "APP_NAME=Coolify\n"); + + expect($env)->toBe(['APP_NAME' => 'Coolify', $key => $value]); +})->with($installScripts)->with($credentials); + +it('keeps root user credentials that are already set', function (string $scriptPath) { + $env = writeRootUserCredentialsWithInstallScript($scriptPath, ['ROOT_USERNAME' => 'New Name'], "ROOT_USERNAME='Old Name'\n"); + + expect($env)->toBe(['ROOT_USERNAME' => 'Old Name']); +})->with($installScripts);