diff --git a/docker-compose.yml b/docker-compose.yml index b0d1b69d9e..42a3d9e1ce 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,13 @@ services: environment: # Custom PGDATA per recommendations from official Docker page - PGDATA=/var/lib/postgresql/data/pgdata - - POSTGRES_PASSWORD=pleaseChange # default postgres password that should be changed for security. + # The postgres database will only pull from this value on the first + # startup of OED. If a change is desired after this, one must + # run the following command in the docker web terminal: + # "npm run changePostgresPassword -- postgrespassword oedpassword" + # Replace "postgrespassword" with your desired postgres user password + # and "oedpassword" with your desired oed user password. + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-pleaseChange} volumes: - ./postgres-data:/var/lib/postgresql/data/pgdata healthcheck: @@ -29,15 +35,15 @@ services: web: # Configuration variables for the app. environment: - - OED_PRODUCTION=no + - OED_PRODUCTION=no # Set this value to yes or no, other values will result in a configuration error - OED_SERVER_PORT=3000 - OED_DB_USER=oed - OED_DB_DATABASE=oed - OED_DB_TEST_DATABASE=oed_testing - - OED_DB_PASSWORD=opened + - OED_DB_PASSWORD=${OED_DB_PASSWORD:-opened} # See comment regarding POSTGRES_PASSWORD above, same situation - OED_DB_HOST=database # Docker will set this hostname - OED_DB_PORT=5432 - - OED_TOKEN_SECRET=? + - OED_TOKEN_SECRET=${OED_TOKEN_SECRET:-?} #Automatically generated when OED is run in production - OED_LOG_FILE=log.txt - OED_MAIL_METHOD=none # Method of sending mail. Supports "secure-smtp", "none". Case insensitive. - OED_MAIL_SMTP=smtp.example.com # Edit this @@ -47,6 +53,7 @@ services: - 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 + - PASSWORD_VAULT=no # Set to yes to load DB passwords from Infisical # 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. @@ -54,6 +61,7 @@ services: # If in a subdirectory, set it here # - OED_SUBDIR=/subdir/ # Set the correct build environment. + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-pleaseChange} build: context: ./ dockerfile: ./containers/web/Dockerfile @@ -111,7 +119,3 @@ services: /bin/sh -c " rm -f /tmp/.X99-lock && Xvfb :99 -screen 0 1024x768x16" - read_only: true - security_opt: - - no-new-privileges:true - diff --git a/package.json b/package.json old mode 100644 new mode 100755 index 29a440be8a..daa523235a --- 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()'", + "changePostgresPassword": "node ./src/server/util/changePostgresPass.js" }, "nodemonConfig": { "watch": [ diff --git a/src/scripts/installOED.sh b/src/scripts/installOED.sh index 1bef1b7a5b..a68cfb2090 100755 --- a/src/scripts/installOED.sh +++ b/src/scripts/installOED.sh @@ -58,6 +58,19 @@ if [ -f ".env" ]; then source .env fi +# Creating a centralized variable to keep track of the type of installation. +INSTALL_MODE="production" + +if [ "$production" = "yes" ] || [ "$OED_PRODUCTION" = "yes" ]; then + INSTALL_MODE="production" +elif [ "$production" = "no" ] || [ "$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 + # Skip the install if the node_modules were installed before the package files. # The two package files packageFile="package.json" @@ -147,7 +160,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 @@ -176,7 +189,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 @@ -186,13 +199,63 @@ printf "%s\n" "OED install finished" # Start OED if [ "$dostart" == "yes" ]; then - if [ "$production" == "yes" ] || [ "$OED_PRODUCTION" == "yes" ]; then + if [ "$INSTALL_MODE" = "production" ]; then printf "%s\n" "Starting OED in production mode" + # 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: %s\n" "$OED_TOKEN_SECRET" + printf "\nMake sure to save or change this value" + 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 + # If the user is in production and their postgres password has been left default, generating a random one + if [ -z "$POSTGRES_PASSWORD" ] || [ "$POSTGRES_PASSWORD" = "pleaseChange" ]; then + printf "\nNo valid PostgreSQL password detected. Generating a secure random password...\n" + node ./src/server/util/changePostgresPass.js "" "" install + fi 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..c8580c034c 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -5,17 +5,21 @@ const path = require('path'); const fs = require('fs'); const dotenv = require('dotenv'); +const loadInfisicalSecrets = require('./util/loadInfisicalSecrets'); // Try to load the .env file 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"); } +// Optional Infisical password vault support. If PASSWORD_VAULT is enabled, override the +// database password environment variables from Infisical secrets +loadInfisicalSecrets(); const config = {}; diff --git a/src/server/util/changePostgresPass.js b/src/server/util/changePostgresPass.js new file mode 100755 index 0000000000..2090beeea5 --- /dev/null +++ b/src/server/util/changePostgresPass.js @@ -0,0 +1,202 @@ +/* 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 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; + +// Load whichever .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')); +} + +// 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, "''"); +} + +// Prompt the user for an explicit yes/no confirmation +function promptConfirmation(promptText) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise((resolve) => { + rl.question(`${promptText} `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'yes'); + }); + }); +} + +// 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('.env updated with new 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 changePasswords() { + const fileEnv = parseEnvFile(ENV_PATH); + + // Determine context: 'install' (automatic, no restart needed) or 'manual' (script, restart needed) + const isManual = process.argv[4] !== 'install'; + + // Warn if manual invocation that all OED users will lose access until restart + if (isManual) { + 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 promptConfirmation('Do you want to continue? Type yes to proceed:'); + if (!confirmed) { + console.error('Aborting password change. No changes were made.'); + process.exit(0); + } + } + + // 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; + } + if (fileEnv.OED_DB_PASSWORD) { + process.env.OED_DB_PASSWORD = fileEnv.OED_DB_PASSWORD; + } + + // If arguments are included, treat them as the new passwords + const currentPostgresPassword = fileEnv.POSTGRES_PASSWORD || process.env.POSTGRES_PASSWORD || 'pleaseChange'; + const newPostgresPassword = process.argv[2] || generatePassword(); + const newOedPassword = process.argv[3] || generatePassword(); + + 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(); + + const sqlPostgres = `ALTER USER postgres WITH PASSWORD '${escapePassword(newPostgresPassword)}'`; + await client.query(sqlPostgres); + + const sqlOED = `ALTER USER oed WITH PASSWORD '${escapePassword(newOedPassword)}'`; + await client.query(sqlOED); + + await client.end(); + + updateEnvFile(newPostgresPassword, newOedPassword); + + console.log('********************************************************************************'); + console.log('Generated a secure PostgreSQL and OED password and applied them successfully.'); + console.log('The passwords have been stored in ".env" for reference.'); + 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.'); + } + + process.exit(1); + } + } +} + +if (require.main === module) { + changePasswords(); +} + +module.exports = { changePasswords }; \ No newline at end of file diff --git a/src/server/util/loadInfisicalSecrets.js b/src/server/util/loadInfisicalSecrets.js new file mode 100644 index 0000000000..71af29c931 --- /dev/null +++ b/src/server/util/loadInfisicalSecrets.js @@ -0,0 +1,57 @@ +/* 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 childProcess = require('child_process'); +const path = require('path'); + +function loadInfisicalSecretsSync() { + // Keep vault support opt-in so default setups continue to use local .env values. + const vaultEnabled = /^(yes)$/i.test(process.env.PASSWORD_VAULT || 'no'); + if (!vaultEnabled) { + return; + } + + // Ensure @infisical/sdk is installed (it is an optional dependency not bundled by default) + try { + require.resolve('@infisical/sdk'); + } catch (e) { + console.error('PASSWORD_VAULT is enabled but @infisical/sdk is not installed. Run: npm install @infisical/sdk'); + process.exit(1); + } + + const scriptPath = path.resolve(__dirname, 'loadInfisicalSecretsChild.js'); + try { + // Fetch secrets in a child process so this call remains synchronous for startup. + const output = childProcess.execFileSync( + process.execPath, + [scriptPath], + { + env: process.env, + encoding: 'utf8', + timeout: 15000, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + + let secretValues; + try { + secretValues = JSON.parse(output); + } catch (parseErr) { + throw new Error(`Failed to parse Infisical response: ${parseErr.message}`); + } + + // Only overwrite password variables when a secret is returned from Infisical. + if (secretValues.POSTGRES_PASSWORD) { + process.env.POSTGRES_PASSWORD = secretValues.POSTGRES_PASSWORD; + } + if (secretValues.OED_DB_PASSWORD) { + process.env.OED_DB_PASSWORD = secretValues.OED_DB_PASSWORD; + } + } catch (err) { + console.error(`Infisical secret loading failed: ${err.message}`); + process.exit(1); + } +} + +module.exports = loadInfisicalSecretsSync; diff --git a/src/server/util/loadInfisicalSecretsChild.js b/src/server/util/loadInfisicalSecretsChild.js new file mode 100644 index 0000000000..ffb37208fc --- /dev/null +++ b/src/server/util/loadInfisicalSecretsChild.js @@ -0,0 +1,65 @@ +/* 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 { InfisicalSDK } = require('@infisical/sdk'); + +const env = process.env; +// Required Infisical configuration values for vault access +// Need to be set in .env for Infisical support to work +const clientId = env.INFISICAL_CLIENT_ID; +const clientSecret = env.INFISICAL_CLIENT_SECRET; +const siteUrl = env.INFISICAL_SITE_URL || 'https://app.infisical.com'; +const projectId = env.INFISICAL_PROJECT_ID; +const environment = env.INFISICAL_ENVIRONMENT || 'dev'; +const secretPath = env.INFISICAL_PATH || '/'; + +if (!clientId || !clientSecret) { + console.error('PASSWORD_VAULT is enabled but INFISICAL_CLIENT_ID or INFISICAL_CLIENT_SECRET is not set in the .env file.'); + process.exit(1); +} + +if (!projectId) { + console.error('PASSWORD_VAULT is enabled but INFISICAL_PROJECT_ID is not set in the .env file.'); + process.exit(1); +} + +(async () => { + try { + const client = new InfisicalSDK({ siteUrl }); + + // Authenticate using machine identity credentials to get secrets from vault + await client.auth().universalAuth.login({ clientId, clientSecret }); + + async function loadSecret(name) { + try { + const result = await client.secrets().getSecret({ + environment, + projectId, + secretName: name, + secretPath, + type: 'shared' + }); + return result?.secretValue; + } catch (err) { + // Missing secrets are treated as optional overrides + return undefined; + } + } + + const [postgresPassword, oedDbPassword] = await Promise.all([ + loadSecret('POSTGRES_PASSWORD'), + loadSecret('OED_DB_PASSWORD') + ]); + + const payload = { + POSTGRES_PASSWORD: postgresPassword, + OED_DB_PASSWORD: oedDbPassword + }; + process.stdout.write(JSON.stringify(payload)); + process.exit(0); + } catch (err) { + console.error('Infisical vault loading error:', err.message); + process.exit(1); + } +})();