diff --git a/containers/database/Dockerfile b/containers/database/Dockerfile index ec446505dd..97dd22d52d 100644 --- a/containers/database/Dockerfile +++ b/containers/database/Dockerfile @@ -8,7 +8,16 @@ # Use a pinned version FROM postgres:15.3 +# Sent from the build for the database in the main docker-compose.yml file. +ARG OED_DB_PASSWORD + # All SQL files in the build context # get copied into the container and # run on init. COPY *.sql /docker-entrypoint-initdb.d/ +# Edit the initialization SQL file to used the passed OED database password. +# The one copied always has a password of opened. +# The edited password should correspond to the same named environment variable in +# the OED web container so they will match when the software tries to log in +# it will be successful. +RUN sed -i "s/opened/${OED_DB_PASSWORD}/" /docker-entrypoint-initdb.d/init.sql diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 9dd20e22a2..553de51a59 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -11,26 +11,24 @@ # or an appropriate variant to cause the values in this file to override # the ones in the default docker file. # ***************************************************************** - -version: "3.8" services: database: # The next lines enable access to the PostgreSQL server from the host machine. # It can be very valuable for debugging the database via tools such as PGAdmin. # They can be commented out to close that port but it will stop that type of debugging. ports: - - "5432:5432" + - "5432:5432" web: environment: # Set this value to yes or no, other values wil result in a configuration error. # Unless you are testing a production setup, this is normally no. - - OED_PRODUCTION=no + OED_PRODUCTION: no # This is to let the install know that the second, developer docker file was used. # It is mostly as a safety check and during the transition to warn developers to # transition to the new system. It should NOT be changed. # The value is not set in the production config file to avoid someone wanting to # set it there and that is not desired. - - OED_DOCKER_CONFIG_DEV=yes + OED_DOCKER_CONFIG_DEV: yes ports: # If you are experiencing port conflicts with 3000 then you can modify this to # use another port. For example, to switch to port xxxx you would do: @@ -44,19 +42,21 @@ services: "./src/scripts/installOED.sh", # This allows for starting OED with special values for a developer. # See developer docs for details. + # If environment variable install_args is not set then it becomes blank without warning user. "${install_args:-}" ] # Cypress testing service # This is used for UI testing and normally only creates this Docker container # if you specially start up UI testing. + # TODO This is not longer working. It is unclear why. cypress: image: cypress/included profiles: - ui-testing environment: - - CYPRESS_BASE_URL=http://web:3000 - - DISPLAY=:99 + CYPRESS_BASE_URL: http://web:3000 + DISPLAY: 99 working_dir: /usr/src/app depends_on: web: diff --git a/docker-compose.yml b/docker-compose.yml index bcb86c351d..b6114e1542 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,24 +12,65 @@ # with a modified command. OED sites should NEVER do this. # ***************************************************************** -version: "3.8" +x-common-variables: &common-db-web-variables + # The OED install process will not allow this password and it will + # be replaced with a random, secure password if not changed here. + # That is fine as many sites do not need regular access to the + # database so a password they created is not important. OED will + # provide the password created for future use. + # If the password is changed then it is important to make it secure to + # avoid unauthorized access. Note the double quotes ("") around the password + # are not part of the password. + # OED STRONGLY recommends that if this is a site/production build that the passwords + # are strong and be at least 12 characters for the database and this is enforced. + # This value should NEVER be changed after the first time OED is started. + # Doing it will cause OED not to know the current password and it will stop + # working. One should use the changePostgresPasswords command provided by OED + # and described in the documentation. + # Obviously this should never be an empty string but if that is done then the OED install will fail. + # For developers: This value must match the one used in src/server/util/changePostgresPass.js + # to work properly for security checks. + POSTGRES_PASSWORD: "pleaseChange" + # Default oed user postgres password that should be changed for security. + # The OED install process will not allow this password and it will + # be replaced with a random, secure password if not changed here. + # That is fine as many sites do not need regular access to the + # database so a password they created is not important. OED will + # provide the password created for future use. + # If the password is changed then it is important to make it secure to + # avoid unauthorized access. Note the double quotes ("") around the password + # are not part of the password. + # OED STRONGLY recommends that if this is a site/production build that the passwords + # are strong and be at least 12 characters for the database and this is enforced. + # This value should NEVER be changed after the first time OED is started. + # Doing it will cause OED not to know the current password and it will stop + # working. One should use the changePostgresPasswords command provided by OED + # and described in the documentation. + # Obviously this should never be an empty string but if that is done then the OED install will fail. + # For developers: This value must match the one used in src/server/util/changePostgresPass.js + # to work properly for security checks. + # TODO Hope to fix, but for now this MUST match the same key value below. + OED_DB_PASSWORD: "opened" + services: # Database service. It's PostgreSQL, see the Dockerfile in ./database. database: environment: - # Default postgres password that should be changed for security. - # The OED install process will not allow this password and it will - # be replaced with a random, secure password if not changed here. - # That is fine as many sites do not need regular access to the - # database so a password they created is not important. OED will - # provide the password created for future use. - # If the password is changed then it is important to make it secure to - # avoid unauthorized access. - - POSTGRES_PASSWORD=pleaseChange + # Includes the environment variables needed by database & web containers. + <<: *common-db-web-variables + # Normally there is no reason to the next value. Changing them may break OED. # Custom PGDATA per recommendations from official Docker page - - PGDATA=/var/lib/postgresql/data/pgdata + PGDATA: /var/lib/postgresql/data/pgdata # Location of database Docker configuration and startup files. - build: ./containers/database/ + build: + context: ./containers/database/ + args: + # TODO The hope was this would get the environment variable and pass it to + # the database build. However, it is not working. + # oedPassword: ${OED_DB_PASSWORD} + # TODO This is a fix (hopefully temporary) to pass the needed value. + # TODO Hope to fix, but for now this MUST match the same key value above. + OED_DB_PASSWORD: "opened" volumes: # This maps the proper Docker database directory back to the local file # system. It is critical that the changes actually be written back to @@ -39,7 +80,7 @@ services: # Tries to make sure postgres is running properly. For example, the DB can be # running but not ready to accept connections. healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: [ "CMD-SHELL", "pg_isready -U postgres" ] interval: 10s timeout: 10s retries: 3 @@ -47,15 +88,8 @@ services: web: # Configuration variables for the app. environment: - # Default oed user postgres password that should be changed for security. - # The OED install process will not allow this password and it will - # be replaced with a random, secure password if not changed here. - # That is fine as many sites do not need regular access to the - # database so a password they created is not important. OED will - # provide the password created for future use. - # If the password is changed then it is important to make it secure to - # avoid unauthorized access. - - OED_DB_PASSWORD=opened + # Includes the environment variables needed by database & web containers. + <<: *common-db-web-variables # Default web token that should be changed for security. # The OED install process will not allow this token and it will # be replaced with a random, secure token if not changed. @@ -63,51 +97,51 @@ services: # OED will provide the token created for future use. # If the taken is changed here then it is important to make it secure to # avoid unauthorized access. - - OED_TOKEN_SECRET=? + OED_TOKEN_SECRET: "?" # The OED_MAIL_... values are set to enable OED to send email about issues. - - OED_MAIL_METHOD=none # Method of sending mail. Supports "secure-smtp", "none". Case insensitive. - - OED_MAIL_SMTP=smtp.example.com # Edit this - - OED_MAIL_SMTP_PORT=465 # Edit this - - OED_MAIL_IDENT=someone@example.com # The user email that is used for sending emails (SMTP) - - OED_MAIL_CREDENTIAL=credential # Set the email password for sending email here - - OED_MAIL_FROM=mydomain@example.com # The email address that the email will come from - - OED_MAIL_TO=someone@example.com # Set the destination address here for where to send emails - - OED_MAIL_ORG=My Organization Name # Org name for mail that is included in the subject + OED_MAIL_METHOD: none # Method of sending mail. Supports "secure-smtp", "none". Case insensitive. + OED_MAIL_SMTP: smtp.example.com # Edit this + OED_MAIL_SMTP_PORT: 465 # Edit this + OED_MAIL_IDENT: someone@example.com # The user email that is used for sending emails (SMTP) + OED_MAIL_CREDENTIAL: credential # Set the email password for sending email here + OED_MAIL_FROM: mydomain@example.com # The email address that the email will come from + OED_MAIL_TO: someone@example.com # Set the destination address here for where to send emails + OED_MAIL_ORG: My Organization Name # Org name for mail that is included in the subject # Changing this value does not impact what OED displays. # What it will change is the date/time stamp on logs, notes and change dates that place the current date/time. # It can also impact the interpretation of readings sent to OED such as Unix timestamps. - - TZ=Etc/UTC # Set the timezone of the Docker container where OED runs the web services. + TZ: Etc/UTC # Set the timezone of the Docker container where OED runs the web services. # OED_PRODUCTION should NEVER be changed here. If this is for an OED site then # it is VERY important that it be yes for security reasons. If you are doing # development then you should be changing the other docker file as per the help # page for developers. - # - OED_PRODUCTION=yes + # OED_PRODUCTION: yes # TODO THIS VALUE IS TEMPORARILY BEING SET TO no DURING THE TRANSITION TO THE NEW SETUP WHERE # THERE IS A SEPARATE DOCKER CONFIGURATION FILE FOR DEVELOPERS. THIS ALLOWS DEVELOPERS # TO CONTINUE TO USE THE CURRENT COMMAND TO START UP OED. THE PLAN IS TO REMOVE THIS # AFTER THE PHASE IN PERIOD. - - OED_PRODUCTION=no + OED_PRODUCTION: no # See the ports mapping below to change the port used on your system. # Normally OED_SERVER_PORT is not modified. - - OED_SERVER_PORT=3000 + OED_SERVER_PORT: 3000 # Normally these users are fine and there should be no reason to change the next # three items. - - OED_DB_USER=oed - - OED_DB_DATABASE=oed + OED_DB_USER: oed + OED_DB_DATABASE: oed # This user is used when the standard OED testing is run. It is left in this Docker # config file in case sites want to run the test suite. - - OED_DB_TEST_DATABASE=oed_testing + OED_DB_TEST_DATABASE: oed_testing # Docker will set this hostname. - - OED_DB_HOST=database + OED_DB_HOST: database # This is the standard postgres port. At a site this should not cause issues as it # is only used within the Docker container. - - OED_DB_PORT=5432 + OED_DB_PORT: 5432 # This is the file where OED log messages are stored. It is now a backup location for # messages since all should be logged to the database and available via an OED web page. - - OED_LOG_FILE=log.txt + OED_LOG_FILE: log.txt # If in a subdirectory, set it here - # - OED_SUBDIR=/subdir/ - # Set the correct build environment. + # OED_SUBDIR: /subdir/ + # Set the correct build environment. build: context: ./ dockerfile: ./containers/web/Dockerfile @@ -128,18 +162,13 @@ services: depends_on: # - database database: - # We need the database and it has to be ready for work (see healthcheck above). - condition: service_healthy + # We need the database and it has to be ready for work (see healthcheck above). + condition: service_healthy # As with the DB above, this tries to ensure all is okay. healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000"] + test: [ "CMD", "curl", "-f", "http://localhost:3000" ] interval: 10s timeout: 5s retries: 5 # Lets docker compose up work right - # If environment variable install_args is not set then it becomes blank without warning user. - command: - [ - "bash", - "./src/scripts/installOED.sh", - ] + command: [ "bash", "./src/scripts/installOED.sh" ] diff --git a/package.json b/package.json old mode 100644 new mode 100755 index 29a440be8a..1de1846736 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "generateTestingData": "node -e 'require(\"./src/server/data/automatedTestingData\").generateTestingData()'", "testData": "node -e 'require(\"./src/server/data/automatedTestingData.js\").insertSpecialUnitsConversionsMetersGroups()'", "webData": "node -e 'require(\"./src/server/data/websiteData.js\").insertWebsiteData()'", - "addLogMsg": "node -e 'require(\"./src/server/services/addLogMsg.js\").addLogMsgToDB()'" + "addLogMsg": "node -e 'require(\"./src/server/services/addLogMsg.js\").addLogMsgToDB()'", + "changePostgresPasswords": "node -e 'require(\"./src/server/util/changePostgresPasswords.js\").changePostgresPasswords()'" }, "nodemonConfig": { "watch": [ diff --git a/src/scripts/installOED.sh b/src/scripts/installOED.sh index e6c6de3228..9936fd25a6 100755 --- a/src/scripts/installOED.sh +++ b/src/scripts/installOED.sh @@ -63,13 +63,20 @@ INSTALL_MODE="production" if [ "$production" = "yes" ] || [ "$OED_PRODUCTION" = "yes" ]; then INSTALL_MODE="production" -elif [ "$production" = "no" ] || [ "$OED_PRODUCTION" = "no" ]; then +elif [ "$OED_PRODUCTION" = "no" ]; then INSTALL_MODE="development" else printf "\nFailure: Invalid or missing environment configuration." printf "\nSet OED_PRODUCTION to 'yes' for production or 'no' for development." exit 10 fi +# TODO This does not work as it is not resetting the environment variable see in OED. +# It isn't common and only for developers but should be fixed at some point. +# if [ "$production" = "yes" ]; then + # The production switch was set so it likely overrode the environment variable. + # To make sure OED knows what is going on, reset the environment variable. + # OED_PRODUCTION="yes" +# fi # Warn installer of unusual or incorrect installation settings. # OED_DOCKER_CONFIG_DEV should either be yes or not set in the config files. @@ -180,7 +187,7 @@ else # Create a user set -e - if [ "$production" == "no" ] && [ ! "$OED_PRODUCTION" == "yes" ]; then + if [ "$INSTALL_MODE" = "development" ]; then npm run createUser -- $usernameTest password createuserTest_code=$? # this second username uses an email: test@example.com and we will remove this eventually @@ -209,7 +216,7 @@ else fi # Build webpack if needed -if [ "$production" == "yes" ] || [ "$OED_PRODUCTION" == "yes" ]; then +if [ "$INSTALL_MODE" = "production" ]; then npm run webpack:build elif [ "$dostart" == "no" ]; then npm run webpack @@ -219,13 +226,61 @@ printf "%s\n" "OED install finished" # Start OED if [ "$dostart" == "yes" ]; then - if [ "$production" == "yes" ] || [ "$OED_PRODUCTION" == "yes" ]; then - printf "%s\n" "Starting OED in production mode" + if [ "$INSTALL_MODE" = "production" ]; then + # Checking if the user has set a mail method and left one of the mailing environment variables default, warning if so + if [ -z "$OED_MAIL_METHOD" ] || [ "$OED_MAIL_METHOD" != "none" ]; then + if [ "$OED_MAIL_SMTP" = "smtp.example.com" ] || \ + [ "$OED_MAIL_SMTP_PORT" = "465" ] || \ + [ "$OED_MAIL_IDENT" = "someone@example.com" ] || \ + [ "$OED_MAIL_CREDENTIAL" = "credential" ] || \ + [ "$OED_MAIL_FROM" = "mydomain@example.com" ] || \ + [ "$OED_MAIL_TO" = "someone@example.com" ] || \ + [ "$OED_MAIL_ORG" = "My Organization Name" ]; then + printf "\n********************************************************************************\n" + printf "* WARNING: You have set your mail method but one or more of the mail environment variables are still set to the default value!*\n" + printf "********************************************************************************\n\n" + fi + fi + # If the user is in production and their token secret has been left default, generating a random one + if [ -z "$OED_TOKEN_SECRET" ] || [ "$OED_TOKEN_SECRET" = "?" ]; then + printf "\nNo valid OED_TOKEN_SECRET detected. Generating a secure random secret...\n" + + # Generate 32 bytes of random data and convert to 64-character hex + OED_TOKEN_SECRET=$(openssl rand -hex 32) + export OED_TOKEN_SECRET + + printf "\n********************************************************************************\n" + printf "Generated OED_TOKEN_SECRET applied successfully.\n" + printf "Generated OED_TOKEN_SECRET has been stored in ".env" for reference.\n" + printf "********************************************************************************\n\n" + + # Save to .env for future runs + if [ -f ".env" ]; then + if grep -q "^OED_TOKEN_SECRET=" .env; then + sed -i "s/^OED_TOKEN_SECRET=.*/OED_TOKEN_SECRET=$OED_TOKEN_SECRET/" .env + else + echo "OED_TOKEN_SECRET=$OED_TOKEN_SECRET" >> .env + fi + else + echo "OED_TOKEN_SECRET=$OED_TOKEN_SECRET" > .env + fi + fi + # Check out the DB passwords to see if need to be changed. + npm run changePostgresPasswords $POSTGRES_PASSWORD $OED_DB_PASSWORD install "$INSTALL_MODE" + # Get OED running in production mode. + printf "%s\n" "Starting OED in production mode." npm run start else - printf "%s\n" "Starting OED in development mode" + # Warning the user if they've left their token or postgres password default, we don't randomly generate it in dev mode + if [ -z "$OED_TOKEN_SECRET" ] || [ "$OED_TOKEN_SECRET" = "?" ]; then + printf "Warning: you are using OED in development mode with the default OED_TOKEN_SECRET set in docker-compose.yml. If this is not intentional, please update it there.\n" + fi + if [ -z "$POSTGRES_PASSWORD" ] || [ "$POSTGRES_PASSWORD" = "pleaseChange" ]; then + printf "* Warning: you are using OED in development mode with the default PostgreSQL password set in docker-compose.yml. If this is not intentional, please update it there. *\n" printf "********************************************************************************\n\n" + fi + printf "%s\n" "Starting OED in development mode." ./src/scripts/devstart.sh fi else - printf "%s\n" "Not starting OED due to --nostart" + printf "%s\n" "Not starting OED due to --nostart." fi diff --git a/src/server/config.js b/src/server/config.js index ef72048242..dcc90e0e73 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -10,7 +10,7 @@ const dotenv = require('dotenv'); const envPath = path.join(__dirname, '..', '..', '.env'); try { fs.accessSync(envPath); - dotenv.config({ path: envPath }); + dotenv.config({ path: envPath, override: true }); } catch (err) { // TODO: Check if valid env variables are actually loaded despite the lack of a file, only log if they are not // console.log("Couldn't load a .env file"); diff --git a/src/server/util/changePostgresPasswords.js b/src/server/util/changePostgresPasswords.js new file mode 100755 index 0000000000..cddcdec23f --- /dev/null +++ b/src/server/util/changePostgresPasswords.js @@ -0,0 +1,350 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Client } = require('pg'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const dotenv = require('dotenv'); +const { ask } = require('../services/utils'); + +const ROOT_ENV_PATH = path.resolve(__dirname, '..', '..', '..', '.env'); +const CWD_ENV_PATH = path.resolve(process.cwd(), '.env'); +const ENV_PATH = fs.existsSync(ROOT_ENV_PATH) ? ROOT_ENV_PATH : CWD_ENV_PATH; +// The minimum length password that is considered acceptable. +const MIN_PASSWORD_LENGTH = 12; +// Default postgres password - must match value in the unedited docker-compose.yml file. +const DEFAULT_POSTGRES_PASSWORD = 'pleaseChange'; +// Default OED database password - must match value in the unedited docker-compose.yml file. +const DEFAULT_OED_DB__PASSWORD = 'opened'; + +// Load whenever .env file is available and override existing process vars +// so repeated invocations in one session can pick up the newest credentials. +if (fs.existsSync(ENV_PATH)) { + dotenv.config({ path: ENV_PATH, override: true }); +} + +// Parse the shared .env file directly so we can prefer the most recent values +// when npm or Docker still has a stale environment variable. +function parseEnvFile(envPath) { + if (!fs.existsSync(envPath)) { + return {}; + } + + return dotenv.parse(fs.readFileSync(envPath, 'utf8')); +} + +/** + * If the password would change, checks the proposedPassword and replaces if not acceptable. + * @param currentPassword The current password before this possible change (string) + * @param proposedPassword The password that was sent to be used (string) + * @param defaultPassword The default password that cannot be used (string) + * @param whatPassword Describes what password is being checked (string) + * @param productionInstall If this was called from an OED version running production. (boolean) + * @returns If the password should be changed (updatePassword) and the password that is acceptable to use (usePassword). + * If the proposedPassword is not okay then it is replaced with a secure one in usePassword. + * updatePassword is only false if the currentPassword and proposedPassword are the same and true otherwise. ({ updatePassword, usePassword }) + */ +function acceptablePassword(currentPassword, proposedPassword, defaultPassword, whatPassword, productionInstall) { + // True if should replace the proposed password with a secure one. + let replacePassword; + // True if the password should be updated. Only false if same as current one so make it true here. + let updatePassword = true; + if (productionInstall && proposedPassword === defaultPassword) { + // Cannot use the default password under any circumstance if in production. + console.log(''); + console.log('The new Postgres password for ' + whatPassword + ' is the default one.'); + console.log('As a result, it will be replace with a secure password.'); + console.log(''); + replacePassword = true; + } else if (productionInstall && proposedPassword.length < MIN_PASSWORD_LENGTH) { + // TODO OED is planning to implement password quality checks. When that is available, the above + // if should be switched to that since length isn't the best security. + // The password is not secure enough if in production mode. + console.log(''); + console.log('The new Postgres password for ' + whatPassword + ' that would be used '); + console.log('is not secure because it is shorter than the minimum length of ' + MIN_PASSWORD_LENGTH + '.'); + console.log('As a result, it will be replaced with a secure password.'); + console.log(''); + replacePassword = true; + } else if (currentPassword === proposedPassword) { + // Password is the same so will not change. + console.log('The new Postgres password for ' + whatPassword + ' is the same as the current one so not being changed.'); + replacePassword = false; + updatePassword = false; + } else { + // It seems acceptable so do not need to update the provided password. + replacePassword = false; + } + + // The password that should be used. + let usePassword; + if (replacePassword) { + // Use a secure one since current needs to be replaced. + usePassword = generatePassword(); + } else { + // Proposed one is okay. Note it will not be used if not being replaced. + usePassword = proposedPassword; + } + + return { updatePassword, usePassword }; +} + +// Generate a secure random password +function generatePassword() { + return crypto.randomBytes(32).toString('base64'); +} + +// Escape single quotes in password for SQL +function escapePassword(password) { + return password.replace(/'/g, "''"); +} + +// Update .env with PostgreSQL and OED user passwords +function updateEnvFile(postgresPassword, oedPassword) { + let env = ''; + + // Reading current .env contents if it exists + if (fs.existsSync(ENV_PATH)) { + env = fs.readFileSync(ENV_PATH, 'utf8'); + } + + // Updating the postgres password if it exists, else appending it instead + if (/^POSTGRES_PASSWORD=/m.test(env)) { + env = env.replace(/^POSTGRES_PASSWORD=.*/m, `POSTGRES_PASSWORD=${postgresPassword}`); + } else { + env = env.trimEnd() + `\nPOSTGRES_PASSWORD=${postgresPassword}\n`; + } + + // Updating the oed user password if it exists, else appending it instead + if (/^OED_DB_PASSWORD=/m.test(env)) { + env = env.replace(/^OED_DB_PASSWORD=.*/m, `OED_DB_PASSWORD=${oedPassword}`); + } else { + env = env.trimEnd() + `\nOED_DB_PASSWORD=${oedPassword}\n`; + } + + // Writing new passwords to .env or creating it if it doesn't exist yet, only allowing the current user to read and write + fs.writeFileSync(ENV_PATH, env, { mode: 0o600 }); + console.log('\n.env updated with new/current PostgreSQL and OED passwords'); +} + +// Pause execution +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Detect retryable Postgres errors: +// 'tuple concurrently updated': row was modified by another transaction +// 40001 (serialization_failure): concurrent transaction conflict +// 55P03 (lock_not_available): couldn't acquire lock, may be freed soon +function shouldRetryError(error) { + return ( + error.message.includes('tuple concurrently updated') || + (error.code && (error.code === '40001' || error.code === '55P03')) + ); +} + +// Change the database passwords for both the default postgres user and the OED user. +// If this is done after the initial setup, OED must be restarted to get a connection with the server. +async function changePostgresPasswords() { + // Input/desired new passwords for postgres & OED DB user. + let proposedPostgresPassword, proposedOedDbPassword; + // If should set a new password for each type: true if should. + // This is used to stop when in development and empty password and also set again if not based on passwords given. + let updatePostgresPassword = true, updateOedDbPassword = true; + // Holds new DB passwords that OED will actually use. + let newPostgresPassword, newOedDbPassword; + // If this is a script then false. + let isManual; + if (process.argv.length > 1) { + // There are some args so use them as the input. + // The proposed, new postgres password or empty. + proposedPostgresPassword = process.argv[1] || ''; + // The proposed, new OED database password or empty. + proposedOedDbPassword = process.argv[2] || ''; + // Determine context: 'install' (automatic, no restart needed) or 'manual' (script, restart needed). + // Note if empty then will be manual which is needed since this argument is not normally present in that case. + isManual = process.argv[3] !== 'install'; + } else { + // Get values by prompting since no args. + // This is manual since no args. + isManual = true; + // Input from user. + // TODO Should all user input be sanitized? + // TODO Might be good to add a parameter to ask for boolean questions to standardize the way input since used in several places in code. + // Get Postgres password. + proposedPostgresPassword = await ask('Enter new Postgres password (blank if want unchanged): '); + // Get OED DB password. + proposedOedDbPassword = await ask('Enter new OED database password (blank if want unchanged): '); + } + + const fileEnv = parseEnvFile(ENV_PATH); + // Is this a production install. First get from the environment variable. + // Note that when a developer manually sets the --production flag when starting OED, + // OED does not receive the updated environment variable so it does not know OED + // is in production mode. As a result, this does not work after the initial install. + // This isn't a big deal since it is unusual and only for developers but there is a note + // in the install file to fix it at some point. + let productionInstall = process.env.OED_PRODUCTION === 'yes'; + if (!isManual && process.argv[4] === 'production') { + // This is from an OED install so the extra variable exists. It might be that the install had + // the production switch set so need to use that instead of the environment variable value. + productionInstall = true; + } + + // If the new password is empty then don't update to leave current password. + if (proposedPostgresPassword.length === 0) { + updatePostgresPassword = false; + console.log('The new Postgres password was empty so it is not being changed.'); + } + if (proposedOedDbPassword.length === 0) { + updateOedDbPassword = false; + console.log('The new OED database password was empty so it is not being changed.'); + } + + // Warn if manual invocation that all OED users will lose access until restart. + // Only do if updating at least one password. + if (isManual && (updatePostgresPassword || updateOedDbPassword)) { + console.error(''); + console.error('WARNING: This will change database passwords immediately.'); + console.error('All currently logged-in users will experience disconnections.'); + console.error('OED will not work for anyone until the server is restarted.'); + console.error(''); + + const confirmed = await ask('Do you want to continue? If so, enter yes or anything else to skip: '); + if (!confirmed) { + console.error('Aborting password change. No changes were made.'); + process.exit(0); + } + } + + // TODO why is this needed given it has the currentPostgresPassword setting below with ||? + // Prefer the most recent passwords from the .env file over process.env which may be outdated + if (fileEnv.POSTGRES_PASSWORD) { + process.env.POSTGRES_PASSWORD = fileEnv.POSTGRES_PASSWORD; + } + // TODO is this used??? + if (fileEnv.OED_DB_PASSWORD) { + process.env.OED_DB_PASSWORD = fileEnv.OED_DB_PASSWORD; + } + + // This should only be needed if that password is being updated. + // However, it is used when you write to the .env so always set. + // Also, the currentPostgresPassword is needed to make any change to connect to DB. + // If no value for .env nor process env then set to default password so it is updated. + const currentPostgresPassword = fileEnv.POSTGRES_PASSWORD || process.env.POSTGRES_PASSWORD || DEFAULT_POSTGRES_PASSWORD; + // If no value for .env nor process env then set to default password so it is updated. + const currentOedDbPassword = fileEnv.OED_DB_PASSWORD || process.env.OED_DB_PASSWORD || DEFAULT_OED_DB__PASSWORD; + if (updatePostgresPassword) { + // For Postgres password. Only do if not stopped above. + + // Check and replace password as needed for Postgres. + // It can be empty as it will be replaced in that case. + // Note this changes updatePostgresPassword from original value above. + ({ updatePassword: updatePostgresPassword, usePassword: newPostgresPassword } = + acceptablePassword(currentPostgresPassword, proposedPostgresPassword, DEFAULT_POSTGRES_PASSWORD, 'Postgres', productionInstall)); + } else { + // Below it writes the new Postgres password to .env so set it to the current one so that works. + newPostgresPassword = currentPostgresPassword; + } + + if (updateOedDbPassword) { + // For OED DB password. Only do if not stopped above. + + // Check and replace password as needed for OED DB user. + // It can be empty as it will be replaced in that case. + // Note this changes updateOedDbPassword from original value above. + ({ updatePassword: updateOedDbPassword, usePassword: newOedDbPassword } = + acceptablePassword(currentOedDbPassword, proposedOedDbPassword, DEFAULT_OED_DB__PASSWORD, 'OED DB', productionInstall)); + } else { + // Below it writes the new OED DB password to .env so set it to the current one so that works. + newOedDbPassword = currentOedDbPassword; + } + + // Only try to update if one of the passwords changed. + if (updatePostgresPassword || updateOedDbPassword) { + const clientConfig = { + host: process.env.OED_DB_HOST || 'database', + port: parseInt(process.env.OED_DB_PORT || '5432', 10), + user: 'postgres', + password: currentPostgresPassword, + database: 'postgres', + connectionTimeoutMillis: 10000 + }; + + // Try the password update multiple times to handle transient lock error + for (let attempt = 1; attempt <= 3; attempt += 1) { + const client = new Client(clientConfig); + + try { + await client.connect(); + + // TODO Maybe needs to be a transaction because if you change the postgres password but retry + // for OED then it cannot log in again. Note sure if will happen or not. + // Note if this is a retry then this might have been set a previous time. + // It is easier to just set again. + if (updatePostgresPassword) { + // Postgres password needs replacing. + const sqlPostgres = `ALTER USER postgres WITH PASSWORD '${escapePassword(newPostgresPassword)}'`; + await client.query(sqlPostgres); + } + if (updateOedDbPassword) { + // OED DB password needs replacing. + const sqlOED = `ALTER USER oed WITH PASSWORD '${escapePassword(newOedDbPassword)}'`; + await client.query(sqlOED); + } + + await client.end(); + + // This writes both passwords even if it one was not changed to make sure both are + // stored in the .env to be consistent. Not a big deal to write even if not changed. + updateEnvFile(newPostgresPassword, newOedDbPassword); + + console.log('********************************************************************************'); + if (updatePostgresPassword) { + console.log('PostgreSQL password changed and applied successfully.'); + } + if (updateOedDbPassword) { + console.log('OED database password changed and applied successfully.'); + } + console.log('The passwords have been stored in ".env" for reference including unchanged ones.'); + if (isManual) { + console.log(''); + console.log('CRITICAL: OED is now disconnected for all users.'); + console.log('You must restart OED immediately for it to function.'); + console.log('All active user sessions will be terminated.'); + } else { + console.log('(Installation mode: restart not required)'); + } + console.log('********************************************************************************\n'); + + process.exit(0); + } catch (error) { + await client.end().catch(() => { }); + + if (shouldRetryError(error) && attempt < 3) { + console.error(`Transient Postgres error on attempt ${attempt}: ${error.message}`); + console.error('Retrying password update...'); + await sleep(1000 * attempt); + continue; + } + + console.error('Error changing PostgreSQL or OED password:', error.message); + + if (error.message.includes('password authentication')) { + console.error('Authentication failed: default password may already be changed or password used is incorrect.'); + } + + console.log('Stopping password change(s). Due to error it is unknown if any were changed.'); + process.exit(1); + } + } + } + // Should only get here if no passwords were change. + console.log('\nNo passwords changed given input.'); + process.exit(0); +} + +module.exports = { changePostgresPasswords };