diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index f38564aef..092f89b17 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -95,44 +95,55 @@ jobs: sleep 10 done + - name: Export all secrets and environment variables + run: | + cd ./${{ github.event.repository.name }} + + SECRETS_JSON_WITH_NEWLINES=$(cat<> .env.${{ github.event.inputs.environment }} + done < <( + jq -r ' + to_entries | + map( + select(.value | test("\n") | not) | + "\(.key)=\"\(.value)\"" + ) | + .[]' <<< "$SECRETS_JSON_WITH_NEWLINES" + ) + + VARS_JSON_WITH_NEWLINES=$(cat<> .env.${{ github.event.inputs.environment }} + done < <( + jq -r ' + to_entries | + map( + select(.value | test("\n") | not) | + "\(.key)=\"\(.value)\"" + ) | + .[]' <<< "$VARS_JSON_WITH_NEWLINES" + ) + - name: Deploy to ${{ github.event.inputs.environment }} - env: - DOMAIN: ${{ vars.DOMAIN }} - REPLICAS: ${{ vars.REPLICAS }} - NOTIFICATION_TRANSPORT: ${{ vars.NOTIFICATION_TRANSPORT }} - SMTP_PORT: ${{ secrets.SMTP_PORT }} - SMTP_HOST: ${{ secrets.SMTP_HOST }} - SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} - SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} - SMTP_SECURE: ${{ secrets.SMTP_SECURE }} - ALERT_EMAIL: ${{ secrets.ALERT_EMAIL }} - DOCKERHUB_ACCOUNT: ${{ secrets.DOCKERHUB_ACCOUNT }} - DOCKERHUB_REPO: ${{ secrets.DOCKERHUB_REPO }} - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} - KIBANA_USERNAME: ${{ secrets.KIBANA_USERNAME }} - KIBANA_PASSWORD: ${{ secrets.KIBANA_PASSWORD }} - MONGODB_ADMIN_USER: ${{ secrets.MONGODB_ADMIN_USER }} - MONGODB_ADMIN_PASSWORD: ${{ secrets.MONGODB_ADMIN_PASSWORD }} - ELASTICSEARCH_SUPERUSER_PASSWORD: ${{ secrets.ELASTICSEARCH_SUPERUSER_PASSWORD }} - MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} - MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} - INFOBIP_SENDER_ID: ${{ secrets.INFOBIP_SENDER_ID }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - INFOBIP_GATEWAY_ENDPOINT: ${{ secrets.INFOBIP_GATEWAY_ENDPOINT }} - INFOBIP_API_KEY: ${{ secrets.INFOBIP_API_KEY }} - SENDER_EMAIL_ADDRESS: ${{ secrets.SENDER_EMAIL_ADDRESS }} - SUPER_USER_PASSWORD: ${{ secrets.SUPER_USER_PASSWORD }} - CONTENT_SECURITY_POLICY_WILDCARD: ${{ vars.CONTENT_SECURITY_POLICY_WILDCARD }} - SSH_ARGS: ${{ vars.SSH_ARGS }} run: | cd ./${{ github.event.repository.name }} yarn deploy \ --clear_data=no \ --environment=${{ github.event.inputs.environment }} \ - --host=${{ env.DOMAIN }} \ + --host=${{ vars.DOMAIN }} \ --ssh_host=${{ secrets.SSH_HOST }} \ --ssh_user=${{ secrets.SSH_USER }} \ --version=${{ github.event.inputs.core-image-tag }} \ --country_config_version=${{ github.event.inputs.countryconfig-image-tag }} \ - --replicas=${{ env.REPLICAS }} + --replicas=${{ vars.REPLICAS }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a1c3df56e..e87557f5f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -97,85 +97,65 @@ jobs: sleep 10 done + - name: Export all secrets and environment variables + run: | + cd ./${{ github.event.repository.name }} + + SECRETS_JSON_WITH_NEWLINES=$(cat<> .env.${{ github.event.inputs.environment }} + done < <( + jq -r ' + to_entries | + map( + select(.value | test("\n") | not) | + "\(.key)=\"\(.value)\"" + ) | + .[]' <<< "$SECRETS_JSON_WITH_NEWLINES" + ) + + VARS_JSON_WITH_NEWLINES=$(cat<> .env.${{ github.event.inputs.environment }} + done < <( + jq -r ' + to_entries | + map( + select(.value | test("\n") | not) | + "\(.key)=\"\(.value)\"" + ) | + .[]' <<< "$VARS_JSON_WITH_NEWLINES" + ) + - name: Deploy to ${{ github.event.inputs.environment }} id: deploy continue-on-error: ${{ github.event.inputs.debug == true }} - env: - DOMAIN: ${{ vars.DOMAIN }} - REPLICAS: ${{ vars.REPLICAS }} - NOTIFICATION_TRANSPORT: ${{ vars.NOTIFICATION_TRANSPORT }} - SMTP_PORT: ${{ secrets.SMTP_PORT }} - SMTP_HOST: ${{ secrets.SMTP_HOST }} - SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} - SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} - SMTP_SECURE: ${{ secrets.SMTP_SECURE }} - ALERT_EMAIL: ${{ secrets.ALERT_EMAIL }} - DOCKERHUB_ACCOUNT: ${{ secrets.DOCKERHUB_ACCOUNT }} - DOCKERHUB_REPO: ${{ secrets.DOCKERHUB_REPO }} - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} - KIBANA_USERNAME: ${{ secrets.KIBANA_USERNAME }} - KIBANA_PASSWORD: ${{ secrets.KIBANA_PASSWORD }} - MONGODB_ADMIN_USER: ${{ secrets.MONGODB_ADMIN_USER }} - MONGODB_ADMIN_PASSWORD: ${{ secrets.MONGODB_ADMIN_PASSWORD }} - ELASTICSEARCH_SUPERUSER_PASSWORD: ${{ secrets.ELASTICSEARCH_SUPERUSER_PASSWORD }} - MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} - MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} - INFOBIP_SENDER_ID: ${{ secrets.INFOBIP_SENDER_ID }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - INFOBIP_GATEWAY_ENDPOINT: ${{ secrets.INFOBIP_GATEWAY_ENDPOINT }} - INFOBIP_API_KEY: ${{ secrets.INFOBIP_API_KEY }} - WIREGUARD_ADMIN_PASSWORD: ${{ secrets.WIREGUARD_ADMIN_PASSWORD }} - SENDER_EMAIL_ADDRESS: ${{ secrets.SENDER_EMAIL_ADDRESS }} - SUPER_USER_PASSWORD: ${{ secrets.SUPER_USER_PASSWORD }} - SSH_ARGS: ${{ vars.SSH_ARGS }} - CONTENT_SECURITY_POLICY_WILDCARD: ${{ vars.CONTENT_SECURITY_POLICY_WILDCARD }} run: | cd ./${{ github.event.repository.name }} yarn deploy \ --clear_data=no \ --environment=${{ github.event.inputs.environment }} \ - --host=${{ env.DOMAIN }} \ + --host=${{ vars.DOMAIN }} \ --ssh_host=${{ secrets.SSH_HOST }} \ --ssh_user=${{ secrets.SSH_USER }} \ --version=${{ github.event.inputs.core-image-tag }} \ --country_config_version=${{ github.event.inputs.countryconfig-image-tag }} \ - --replicas=${{ env.REPLICAS }} + --replicas=${{ vars.REPLICAS }} - name: Setup tmate session uses: mxschmitt/action-tmate@v3 if: ${{ github.event.inputs.debug == true }} - env: - DOMAIN: ${{ vars.DOMAIN }} - REPLICAS: ${{ vars.REPLICAS }} - NOTIFICATION_TRANSPORT: ${{ vars.NOTIFICATION_TRANSPORT }} - SMTP_PORT: ${{ secrets.SMTP_PORT }} - SMTP_HOST: ${{ secrets.SMTP_HOST }} - SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} - SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} - SMTP_SECURE: ${{ secrets.SMTP_SECURE }} - ALERT_EMAIL: ${{ secrets.ALERT_EMAIL }} - DOCKERHUB_ACCOUNT: ${{ secrets.DOCKERHUB_ACCOUNT }} - DOCKERHUB_REPO: ${{ secrets.DOCKERHUB_REPO }} - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} - KIBANA_USERNAME: ${{ secrets.KIBANA_USERNAME }} - KIBANA_PASSWORD: ${{ secrets.KIBANA_PASSWORD }} - MONGODB_ADMIN_USER: ${{ secrets.MONGODB_ADMIN_USER }} - MONGODB_ADMIN_PASSWORD: ${{ secrets.MONGODB_ADMIN_PASSWORD }} - ELASTICSEARCH_SUPERUSER_PASSWORD: ${{ secrets.ELASTICSEARCH_SUPERUSER_PASSWORD }} - MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} - MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - WIREGUARD_ADMIN_PASSWORD: ${{ secrets.WIREGUARD_ADMIN_PASSWORD }} - INFOBIP_SENDER_ID: ${{ secrets.INFOBIP_SENDER_ID }} - INFOBIP_GATEWAY_ENDPOINT: ${{ secrets.INFOBIP_GATEWAY_ENDPOINT }} - INFOBIP_API_KEY: ${{ secrets.INFOBIP_API_KEY }} - SENDER_EMAIL_ADDRESS: ${{ secrets.SENDER_EMAIL_ADDRESS }} - SUPER_USER_PASSWORD: ${{ secrets.SUPER_USER_PASSWORD }} - SSH_KEY: ${{ secrets.SSH_KEY }} - SSH_ARGS: ${{ vars.SSH_ARGS }} - CONTENT_SECURITY_POLICY_WILDCARD: ${{ vars.CONTENT_SECURITY_POLICY_WILDCARD }} + reset: needs: deploy if: ${{ github.event.inputs.reset == 'true' && needs.deploy.outputs.outcome == 'success' }} diff --git a/.github/workflows/provision.yml b/.github/workflows/provision.yml index 7b904a911..353eaa83e 100644 --- a/.github/workflows/provision.yml +++ b/.github/workflows/provision.yml @@ -1,6 +1,9 @@ name: Provision environment run-name: Provision ${{ github.event.inputs.environment }} on: + push: + branches: + - release-v1.4.0 workflow_dispatch: inputs: environment: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ef9f576..c19f90e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -## [1.4.0](https://github.com/opencrvs/opencrvs-countryconfig/compare/v1.3.2...v1.4.0) (TBD) +## [1.4.0](https://github.com/opencrvs/opencrvs-countryconfig/compare/v1.3.3...v1.4.0) (TBD) + +- Adds examples of configuring HTTP-01, DNS-01 and manual HTTPS certificates By default development & QA uses HTTP-01 and others DNS-01. +- All secrets & variables defined in Github Secrets are now passed automatically to the deployment script +- Make VPN_HOST_ADDRESS variable required for staging and production installations. This is to verify deployments are not publicly accessible on public internet. + +### Bug fixes + +See [Releases](https://github.com/opencrvs/opencrvs-countryconfig/releases) for release notes of older releases. + +## [1.3.3](https://github.com/opencrvs/opencrvs-countryconfig/compare/v1.3.2...v1.3.3) (TBD) ### Breaking changes diff --git a/infrastructure/ci/github.ts b/infrastructure/ci/github.ts index 336bba611..b16b10462 100644 --- a/infrastructure/ci/github.ts +++ b/infrastructure/ci/github.ts @@ -55,18 +55,76 @@ export async function getRepositoryId( return response.data.id } -export async function createSecret( +async function getRepositoryPublicKey( + octokit: Octokit, + owner: string, + repo: string +): Promise { + const res = await octokit.request( + 'GET /repos/{owner}/{repo}/actions/secrets/public-key', + { + owner: owner, + repo: repo + } + ) + + return res.data +} + +async function getPublicKey( + octokit: Octokit, + environment: string, + ORGANISATION: string, + REPOSITORY_NAME: string +): Promise { + const repositoryId = await getRepositoryId( + octokit, + ORGANISATION, + REPOSITORY_NAME + ) + + await octokit.request( + `PUT /repos/${ORGANISATION}/${REPOSITORY_NAME}/environments/${environment}`, + { + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + } + ) + + const res = await octokit.request( + `GET /repositories/${repositoryId}/environments/${environment}/secrets/public-key`, + { + owner: ORGANISATION, + repo: REPOSITORY_NAME, + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + } + ) + + return res.data +} + +export async function createEnvironmentSecret( octokit: Octokit, repositoryId: number, environment: string, - key: string, - keyId: string, name: string, - secret: string + secret: string, + organisationName: string, + repositoryName: string ): Promise { //Check if libsodium is ready and then proceed. await sodium.ready + const { key, key_id } = await getPublicKey( + octokit, + environment, + organisationName, + repositoryName + ) + // Convert Secret & Base64 key to Uint8Array. const binkey = sodium.from_base64(key, sodium.base64_variants.ORIGINAL) const binsec = sodium.from_string(secret) @@ -87,7 +145,7 @@ export async function createSecret( environment_name: environment, secret_name: name, encrypted_value: encryptedValue, - key_id: keyId, + key_id, headers: { 'X-GitHub-Api-Version': '2022-11-28' } @@ -95,44 +153,81 @@ export async function createSecret( ) } -export async function getPublicKey( +export async function createRepositorySecret( octokit: Octokit, - environment: string, - ORGANISATION: string, - REPOSITORY_NAME: string -): Promise { - const repositoryId = await getRepositoryId( + repositoryId: number, + name: string, + secret: string, + organisationName: string, + repositoryName: string +): Promise { + //Check if libsodium is ready and then proceed. + await sodium.ready + const { key, key_id } = await getRepositoryPublicKey( octokit, - ORGANISATION, - REPOSITORY_NAME + organisationName, + repositoryName + ) + + // Convert Secret & Base64 key to Uint8Array. + const binkey = sodium.from_base64(key, sodium.base64_variants.ORIGINAL) + const binsec = sodium.from_string(secret) + + //Encrypt the secret using LibSodium + const encBytes = sodium.crypto_box_seal(binsec, binkey) + + // Convert encrypted Uint8Array to Base64 + const encryptedValue = sodium.to_base64( + encBytes, + sodium.base64_variants.ORIGINAL ) await octokit.request( - `PUT /repos/${ORGANISATION}/${REPOSITORY_NAME}/environments/${environment}`, + `PUT /repositories/${repositoryId}/actions/secrets/${name}`, { + encrypted_value: encryptedValue, + key_id, headers: { 'X-GitHub-Api-Version': '2022-11-28' } } ) +} - const res = await octokit.request( - `GET /repositories/${repositoryId}/environments/${environment}/secrets/public-key`, - { - owner: ORGANISATION, - repo: REPOSITORY_NAME, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' +export async function createEnvironment( + octokit: Octokit, + environment: string, + ORGANISATION: string, + REPOSITORY_NAME: string +): Promise { + try { + await octokit.request( + `PUT /repos/${ORGANISATION}/${REPOSITORY_NAME}/environments/${environment}`, + { + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } } - } - ) + ) + return true + } catch (err) { + throw new Error('Cannot create environment') + } +} - return res.data +export type Secret = { + type: 'SECRET' + name: string + scope: 'ENVIRONMENT' | 'REPOSITORY' +} +export type Variable = { + type: 'VARIABLE' + name: string + value: string + scope: 'ENVIRONMENT' | 'REPOSITORY' } -export type Secret = { type: 'SECRET'; name: string } -export type Variable = { type: 'VARIABLE'; name: string; value: string } -export async function listRepoSecrets( +export async function listEnvironmentSecrets( octokit: Octokit, owner: string, repositoryId: number, @@ -146,10 +241,33 @@ export async function listRepoSecrets( environment_name: environmentName } ) - return response.data.secrets.map((secret) => ({ ...secret, type: 'SECRET' })) + return response.data.secrets.map((secret) => ({ + ...secret, + type: 'SECRET', + scope: 'ENVIRONMENT' + })) } -export async function listRepoVariables( +export async function listRepositorySecrets( + octokit: Octokit, + owner: string, + repositoryName: string +): Promise { + const response = await octokit.request( + 'GET /repos/{owner}/{repo}/actions/secrets', + { + owner: owner, + repo: repositoryName + } + ) + return response.data.secrets.map((secret) => ({ + ...secret, + type: 'SECRET', + scope: 'REPOSITORY' + })) +} + +export async function listEnvironmentVariables( octokit: Octokit, repositoryId: number, environmentName: string @@ -166,8 +284,11 @@ export async function listRepoVariables( } ) - return response.data.variables.map((variable) => ({ - ...variable, - type: 'VARIABLE' - })) + return response.data.variables + .map((variable) => ({ + ...variable, + type: 'VARIABLE' as const, + scope: 'ENVIRONMENT' as const + })) + .filter((variable) => variable.name !== 'ACTIONS_RUNNER_DEBUG') } diff --git a/infrastructure/ci/setup-environment.ts b/infrastructure/ci/setup-environment.ts index 4dde74fbc..a1d016cce 100644 --- a/infrastructure/ci/setup-environment.ts +++ b/infrastructure/ci/setup-environment.ts @@ -1,18 +1,21 @@ import { Octokit } from '@octokit/core' import { PromptObject } from 'prompts' import prompts from 'prompts' +import minimist from 'minimist' import kleur from 'kleur' import { Variable, Secret, - getPublicKey, getRepositoryId, - listRepoSecrets, - listRepoVariables, - createSecret, + listEnvironmentSecrets, + listEnvironmentVariables, + createRepositorySecret, + createEnvironmentSecret, createVariable, - updateVariable + updateVariable, + createEnvironment, + listRepositorySecrets } from './github' import editor from '@inquirer/editor' @@ -24,11 +27,17 @@ const notEmpty = (value: string | number) => type Question = PromptObject & { name: T valueType?: 'SECRET' | 'VARIABLE' + scope: 'ENVIRONMENT' | 'REPOSITORY' valueLabel?: string } +type QuestionDescriptor = Omit, 'type'> & { + type: 'disabled' | PromptObject['type'] +} + type SecretAnswer = { type: 'SECRET' + scope: 'ENVIRONMENT' | 'REPOSITORY' name: string value: string didExist: Secret | undefined @@ -47,6 +56,8 @@ function questionToPrompt({ valueType, // eslint-disable-next-line no-unused-vars valueLabel, + // eslint-disable-next-line no-unused-vars + scope, ...promptOptions }: Question): PromptObject { return promptOptions @@ -55,16 +66,30 @@ function questionToPrompt({ // eslint-disable-next-line no-console const log = console.log -const ALL_QUESTIONS: Array> = [] +const ALL_QUESTIONS: Array> = [] const ALL_ANSWERS: Array> = [] +const { environment } = minimist(process.argv.slice(2)) + +if (!environment || typeof environment !== 'string') { + console.error('Please specify an environment name with --environment=') + process.exit(1) +} + +// Read users .env file based on the environment name they gave above, e.g. .env.production +require('dotenv').config({ + path: `${process.cwd()}/.env.${environment}` +}) + function findExistingValue( name: string, type: T, + scope: 'ENVIRONMENT' | 'REPOSITORY', existingValues: Array ) { return existingValues.find( - (value) => value.name === name && value.type === type + (value) => + value.name === name && value.type === type && value.scope === scope ) as | (T extends 'SECRET' ? Secret : T extends 'VARIABLE' ? Variable : never) | undefined @@ -89,6 +114,7 @@ async function promptAndStoreAnswer( const existingVariable = findExistingValue( questionWithVariableLabel.valueLabel, 'VARIABLE', + questionWithVariableLabel.scope, existingValues ) if (existingVariable) { @@ -96,6 +122,7 @@ async function promptAndStoreAnswer( { name: 'overWrite' + questionWithVariableLabel.name, type: 'confirm' as const, + scope: questionWithVariableLabel.scope, message: `${kleur.yellow( `Variable ${kleur.cyan( existingVariable.name @@ -116,6 +143,7 @@ async function promptAndStoreAnswer( const existingSecret = findExistingValue( questionWithVariableLabel.valueLabel, 'SECRET', + questionWithVariableLabel.scope, existingValues ) @@ -124,8 +152,13 @@ async function promptAndStoreAnswer( { name: 'overWrite' + questionWithVariableLabel.name, type: 'confirm' as const, + scope: questionWithVariableLabel.scope, message: `${kleur.yellow( - `Secret ${kleur.cyan( + `${ + existingSecret.scope === 'REPOSITORY' + ? 'Repository secret' + : 'Secret' + } ${kleur.cyan( existingSecret.name )} already exists in Github. Do you want to update it?` )}` @@ -140,16 +173,29 @@ async function promptAndStoreAnswer( } return questionWithVariableLabel }) - - ALL_QUESTIONS.push(...questions) - const result = await prompts(processedQuestions.map(questionToPrompt), { + const foo = processedQuestions.map(questionToPrompt) + const result = await prompts(foo, { onCancel: () => { process.exit(1) } }) ALL_ANSWERS.push(result) storeSecrets(environment, getAnswers(existingValues)) - return result + + const existingValuesForQuestions = questions + // Only variables can have previous values we can use + .filter((question) => question.valueType === 'VARIABLE') + .map((question) => [ + question.name, + findExistingValue( + question.valueLabel!, + 'VARIABLE', + question.scope, + existingValues + )?.value + ]) + + return { ...Object.fromEntries(existingValuesForQuestions), ...result } } function generateLongPassword() { @@ -164,22 +210,549 @@ function generateLongPassword() { function storeSecrets(environment: string, answers: Answers) { writeFileSync( `.env.${environment}`, - answers.map((update) => `${update.name}=${update.value}`).join('\n') + answers.map((update) => `${update.name}="${update.value}"`).join('\n') ) } -;(async () => { - const { environment, type } = await prompts( - [ +const githubQuestions = [ + { + name: 'githubOrganisation', + type: 'text' as const, + message: 'What is the name of your Github organisation?', + validate: notEmpty, + initial: process.env.GITHUB_ORGANISATION, + scope: 'REPOSITORY' as const + }, + { + name: 'githubRepository', + type: 'text' as const, + message: 'What is your Github repository?', + validate: notEmpty, + initial: process.env.GITHUB_REPOSITORY, + scope: 'REPOSITORY' as const + }, + { + name: 'githubToken', + type: 'text' as const, + message: 'What is your Github token?', + validate: notEmpty, + initial: process.env.GITHUB_TOKEN, + scope: 'REPOSITORY' as const + } +] + +const dockerhubQuestions = [ + { + name: 'dockerhubOrganisation', + type: 'text' as const, + message: 'What is the name of your Docker Hub organisation?', + valueType: 'SECRET' as const, + valueLabel: 'DOCKERHUB_ACCOUNT', + validate: notEmpty, + initial: process.env.DOCKER_ORGANISATION, + scope: 'REPOSITORY' as const + }, + { + name: 'dockerhubRepository', + type: 'text' as const, + message: 'What is the name of your private Docker Hub repository?', + valueType: 'SECRET' as const, + valueLabel: 'DOCKERHUB_REPO', + validate: notEmpty, + initial: process.env.DOCKER_REPO, + scope: 'REPOSITORY' as const + }, + { + name: 'dockerhubUsername', + type: 'text' as const, + message: + 'What is the Docker Hub username the the target server should be using?', + valueType: 'SECRET' as const, + valueLabel: 'DOCKER_USERNAME', + validate: notEmpty, + initial: process.env.DOCKER_USERNAME, + scope: 'REPOSITORY' as const + }, + { + name: 'dockerhubToken', + type: 'text' as const, + message: 'What is the token of this Docker Hub account?', + valueType: 'SECRET' as const, + valueLabel: 'DOCKER_TOKEN', + validate: notEmpty, + initial: process.env.DOCKER_TOKEN, + scope: 'REPOSITORY' as const + } +] +const sshQuestions = [ + { + name: 'sshHost', + type: 'text' as const, + message: + 'What is the target server IP address? Note: For "production" environment server clusters of (2, 3 or 5 replicas) this is always the IP address for just 1 manager server', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SSH_HOST', + initial: process.env.SSH_HOST, + scope: 'ENVIRONMENT' as const + }, + { + name: 'sshUser', + type: 'text' as const, + message: 'What is the SSH login user to be used for provisioning?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SSH_USER', + initial: process.env.SSH_USER || 'provision', + scope: 'ENVIRONMENT' as const + }, + { + name: 'sshArgs', + type: 'text' as const, + message: + 'Specify any additional SSH arguments to be used when connecting to the target machine. For example, if you need to connect via a jump server, you can specify the jump server here.', + valueType: 'VARIABLE' as const, + valueLabel: 'SSH_ARGS', + format: (value: string) => value.trim(), + initial: process.env.SSH_ARGS, + scope: 'ENVIRONMENT' as const + } +] + +const sshKeyQuestions = [ + { + name: 'sshKey', + type: 'text' as const, + message: `Paste the SSH private key for SSH_USER here:`, + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SSH_KEY', + initial: process.env.SSH_KEY, + scope: 'ENVIRONMENT' as const + } +] + +const infrastructureQuestions = [ + { + name: 'diskSpace', + type: 'text' as const, + message: `What is the amount of diskspace that should be dedicated to OpenCRVS data and will become the size of an encrypted cryptfs data directory. + \n${kleur.red('DO NOT USE ALL DISKSPACE FOR OPENCRVS!')} + \nLeave at least 50g available for OS use.`, + valueType: 'VARIABLE' as const, + validate: notEmpty, + valueLabel: 'DISK_SPACE', + initial: process.env.DISK_SPACE || '200g', + scope: 'ENVIRONMENT' as const + }, + { + name: 'domain', + type: 'text' as const, + message: 'What is the web domain applied after all subdomains in URLs?', + valueType: 'VARIABLE' as const, + validate: notEmpty, + valueLabel: 'DOMAIN', + initial: process.env.DOMAIN, + scope: 'ENVIRONMENT' as const + }, + { + name: 'replicas', + type: 'number' as const, + message: + 'What is the number of replicas? EDIT: This should be 1 for qa, staging and backup environments. For "production" environment server clusters of (2, 3 or 5 replicas), set to 2, 3 or 5 as appropriate.', + valueType: 'VARIABLE' as const, + validate: notEmpty, + valueLabel: 'REPLICAS', + initial: process.env.REPLICAS ? parseInt(process.env.REPLICAS, 10) : 1, + scope: 'ENVIRONMENT' as const + } +] + +const databaseAndMonitoringQuestions = [ + { + name: 'kibanaUsername', + type: 'text' as const, + message: 'Input the username for logging in to Kibana', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'KIBANA_USERNAME', + initial: process.env.KIBANA_USERNAME || 'opencrvs-admin', + scope: 'ENVIRONMENT' as const + }, + { + name: 'kibanaPassword', + type: 'text' as const, + message: 'Input the password for logging in to Kibana', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'KIBANA_PASSWORD', + initial: process.env.KIBANA_PASSWORD || generateLongPassword(), + scope: 'ENVIRONMENT' as const + }, + { + name: 'elasticsearchSuperuserPassword', + type: 'text' as const, + message: 'Input the password for the Elasticsearch superuser', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'ELASTICSEARCH_SUPERUSER_PASSWORD', + initial: + process.env.ELASTICSEARCH_SUPERUSER_PASSWORD || generateLongPassword(), + scope: 'ENVIRONMENT' as const + }, + { + name: 'minioRootUser', + type: 'text' as const, + message: 'Input the username for the Minio root user', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'MINIO_ROOT_USER', + initial: process.env.MINIO_ROOT_USER || generateLongPassword(), + scope: 'ENVIRONMENT' as const + }, + { + name: 'minioRootPassword', + type: 'text' as const, + message: 'Input the password for the Minio root user', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'MINIO_ROOT_PASSWORD', + initial: process.env.MINIO_ROOT_PASSWORD || generateLongPassword(), + scope: 'ENVIRONMENT' as const + }, + { + name: 'mongodbAdminUser', + type: 'text' as const, + message: 'Input the username for the MongoDB admin user', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'MONGODB_ADMIN_USER', + initial: process.env.MONGODB_ADMIN_USER || generateLongPassword(), + scope: 'ENVIRONMENT' as const + }, + { + name: 'mongodbAdminPassword', + type: 'text' as const, + message: 'Input the password for the MongoDB admin user', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'MONGODB_ADMIN_PASSWORD', + initial: process.env.MONGODB_ADMIN_PASSWORD || generateLongPassword(), + scope: 'ENVIRONMENT' as const + }, + { + name: 'superUserPassword', + type: 'text' as const, + message: 'Input the password for the OpenCRVS super user', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SUPER_USER_PASSWORD', + initial: process.env.SUPER_USER_PASSWORD || generateLongPassword(), + scope: 'ENVIRONMENT' as const + }, + { + name: 'encryptionKey', + type: 'text' as const, + message: 'Input the password for the disk encryption key', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'ENCRYPTION_KEY', + initial: process.env.ENCRYPTION_KEY || generateLongPassword(), + scope: 'ENVIRONMENT' as const + } +] + +const notificationTransportQuestions = [ + { + name: 'notificationTransport', + type: 'select' as const, + message: 'Notification transport for 2FA, informant and user messaging', + choices: [ { - name: 'environment', - type: 'text' as const, - message: 'What is the environment name?', - validate: notEmpty + title: 'Email (with SMTP details)', + value: 'email' }, + { + title: 'SMS (Infobip)', + value: 'sms' + } + ], + valueLabel: 'NOTIFICATION_TRANSPORT', + valueType: 'VARIABLE' as const, + scope: 'ENVIRONMENT' as const, + initial: process.env.NOTIFICATION_TRANSPORT + } +] + +const smsQuestions = [ + { + name: 'infobipApiKey', + type: 'text' as const, + message: 'What is your Infobip API key?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'INFOBIP_API_KEY', + initial: process.env.INFOBIP_API_KEY, + scope: 'ENVIRONMENT' as const + }, + { + name: 'infobipGatewayEndpoint', + type: 'text' as const, + message: 'What is your Infobip gateway endpoint?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'INFOBIP_GATEWAY_ENDPOINT', + initial: process.env.INFOBIP_GATEWAY_ENDPOINT, + scope: 'ENVIRONMENT' as const + }, + { + name: 'infobipSenderId', + type: 'text' as const, + message: 'What is your Infobip sender ID?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'INFOBIP_SENDER_ID', + initial: process.env.INFOBIP_SENDER_ID, + scope: 'ENVIRONMENT' as const + } +] + +const emailQuestions = [ + { + name: 'smtpHost', + type: 'text' as const, + message: 'What is your SMTP host?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SMTP_HOST', + initial: process.env.SMTP_HOST, + scope: 'ENVIRONMENT' as const + }, + { + name: 'smtpUsername', + type: 'text' as const, + message: 'What is your SMTP username?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SMTP_USERNAME', + initial: process.env.SMTP_USERNAME, + scope: 'ENVIRONMENT' as const + }, + { + name: 'smtpPassword', + type: 'text' as const, + message: 'What is your SMTP password?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SMTP_PASSWORD', + initial: process.env.SMTP_PASSWORD, + scope: 'ENVIRONMENT' as const + }, + { + name: 'smtpPort', + type: 'text' as const, + message: 'What is your SMTP port?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SMTP_PORT', + initial: process.env.SMTP_PORT, + scope: 'ENVIRONMENT' as const + }, + { + name: 'smtpSecure', + type: 'select' as const, + message: 'Is the SMTP connection made securely using TLS?', + choices: [ + { + title: 'True', + value: 'true' + }, + { + title: 'False', + value: 'false' + } + ], + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SMTP_SECURE', + initial: process.env.SMTP_SECURE, + scope: 'ENVIRONMENT' as const + }, + { + name: 'senderEmailAddress', + type: 'text' as const, + message: 'What is your sender email address?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SENDER_EMAIL_ADDRESS', + initial: process.env.SENDER_EMAIL_ADDRESS, + scope: 'ENVIRONMENT' as const + }, + { + name: 'alertEmail', + type: 'text' as const, + message: + 'What is the email address to receive alert emails or a Slack channel email link?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'ALERT_EMAIL', + initial: process.env.ALERT_EMAIL, + scope: 'ENVIRONMENT' as const + } +] + +const backupQuestions = [ + { + name: 'backupHost', + type: 'text' as const, + message: 'What is your backup host IP address?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'BACKUP_HOST', + initial: process.env.BACKUP_HOST, + scope: 'ENVIRONMENT' as const + }, + { + name: 'backupSshUser', + type: 'text' as const, + message: + 'What user should application servers use to login to the backup server?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'BACKUP_SSH_USER', + initial: process.env.BACKUP_SSH_USER, + scope: 'ENVIRONMENT' as const + }, + { + name: 'backupDirectory', + type: 'text' as const, + message: + 'What is the full path to a directory on your backup server where encrypted backups will be stored?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'BACKUP_DIRECTORY', + initial: process.env.BACKUP_DIRECTORY, + scope: 'ENVIRONMENT' as const + }, + { + name: 'backupEncryptionPassprase', + type: 'text' as const, + message: 'Input a long random passphrase to be used for encrypting backups', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'BACKUP_ENCRYPTION_PASSPHRASE', + initial: process.env.BACKUP_ENCRYPTION_PASSPHRASE || generateLongPassword(), + scope: 'ENVIRONMENT' as const + } +] +const vpnQuestions = [ + { + name: 'vpnHostAddress', + type: 'text' as const, + message: `Please enter the IP address users logged in to the VPN will use`, + initial: process.env.VPN_HOST_ADDRESS || '', + validate: notEmpty, + valueType: 'VARIABLE' as const, + valueLabel: 'VPN_HOST_ADDRESS', + scope: 'ENVIRONMENT' as const + }, + { + name: 'vpnAdminPassword', + type: 'text' as const, + message: `Admin password for Wireguard UI`, + initial: generateLongPassword(), + valueType: 'VARIABLE' as const, + valueLabel: 'VPN_ADMIN_PASSWORD', + scope: 'ENVIRONMENT' as const + } +] + +const sentryQuestions = [ + { + name: 'sentryDsn', + type: 'text' as const, + message: 'What is your Sentry DSN?', + valueType: 'SECRET' as const, + validate: notEmpty, + valueLabel: 'SENTRY_DSN', + initial: process.env.SENTRY_DSN, + scope: 'ENVIRONMENT' as const + } +] + +const derivedVariables = [ + { + valueType: 'VARIABLE', + name: 'ACTIVATE_USERS', + type: 'disabled', + valueLabel: 'ACTIVATE_USERS', + scope: 'ENVIRONMENT' + }, + { + valueType: 'VARIABLE', + name: 'AUTH_HOST', + type: 'disabled', + valueLabel: 'AUTH_HOST', + scope: 'ENVIRONMENT' + }, + { + valueType: 'VARIABLE', + name: 'COUNTRY_CONFIG_HOST', + type: 'disabled', + valueLabel: 'COUNTRY_CONFIG_HOST', + scope: 'ENVIRONMENT' + }, + { + valueType: 'VARIABLE', + name: 'GATEWAY_HOST', + type: 'disabled', + valueLabel: 'GATEWAY_HOST', + scope: 'ENVIRONMENT' + }, + { + valueType: 'VARIABLE', + name: 'CONTENT_SECURITY_POLICY_WILDCARD', + type: 'disabled', + valueLabel: 'CONTENT_SECURITY_POLICY_WILDCARD', + scope: 'ENVIRONMENT' + }, + { + valueType: 'VARIABLE', + name: 'CLIENT_APP_URL', + type: 'disabled', + valueLabel: 'CLIENT_APP_URL', + scope: 'ENVIRONMENT' + }, + { + valueType: 'VARIABLE', + name: 'LOGIN_URL', + type: 'disabled', + valueLabel: 'LOGIN_URL', + scope: 'ENVIRONMENT' + } +] as const + +ALL_QUESTIONS.push( + ...dockerhubQuestions, + ...sshQuestions, + ...sshKeyQuestions, + ...infrastructureQuestions, + ...databaseAndMonitoringQuestions, + ...notificationTransportQuestions, + ...smsQuestions, + ...emailQuestions, + ...backupQuestions, + ...vpnQuestions, + ...sentryQuestions, + ...derivedVariables +) +;(async () => { + const { type } = await prompts( + [ { name: 'type', type: 'select' as const, + scope: 'ENVIRONMENT' as const, message: 'Purpose for the environment?', choices: [ { @@ -197,37 +770,8 @@ function storeSecrets(environment: string, answers: Answers) { ].map(questionToPrompt) ) - // Read users .env file based on the environment name they gave above, e.g. .env.production - require('dotenv').config({ - path: `${process.cwd()}/.env.${environment}` - }) - log('\n', kleur.bold().underline('Github')) - const githubQuestions = [ - { - name: 'githubOrganisation', - type: 'text', - message: 'What is the name of your Github organisation?', - validate: notEmpty, - initial: process.env.GITHUB_ORGANISATION - }, - { - name: 'githubRepository', - type: 'text', - message: 'What is your Github repository?', - validate: notEmpty, - initial: process.env.GITHUB_REPOSITORY - }, - { - name: 'githubToken', - type: 'text', - message: 'What is your Github token?', - validate: notEmpty, - initial: process.env.GITHUB_TOKEN - } - ] as const - const { githubOrganisation, githubRepository, githubToken } = await prompts( githubQuestions.map(questionToPrompt), { @@ -241,42 +785,55 @@ function storeSecrets(environment: string, answers: Answers) { auth: githubToken }) - const { key, key_id } = await getPublicKey( + await createEnvironment( octokit, environment, githubOrganisation, githubRepository ) + const repositoryId = await getRepositoryId( octokit, githubOrganisation, githubRepository ) - const existingVariables = await listRepoVariables( + const existingRepositoryVariable = await listRepositorySecrets( + octokit, + githubOrganisation, + githubRepository + ) + const existingEnvironmentVariables = await listEnvironmentVariables( octokit, repositoryId, environment ) - const existingSecrets = await listRepoSecrets( + const existingEnvironmentSecrets = await listEnvironmentSecrets( octokit, githubOrganisation, repositoryId, environment ) - const existingValues = [...existingVariables, ...existingSecrets] + const existingValues = [ + ...existingEnvironmentVariables, + ...existingRepositoryVariable, + ...existingEnvironmentSecrets + ] - if (existingVariables.length > 0 || existingSecrets.length > 0) { + if ( + existingEnvironmentVariables.length > 0 || + existingEnvironmentSecrets.length > 0 + ) { log( '\nEnvironment with the name', environment, 'already exists in Github.\n', 'Found', - existingVariables.length, + existingEnvironmentVariables.length, 'existing variables and', - existingSecrets.length, + existingEnvironmentSecrets.length, 'secrets' ) } else { @@ -285,98 +842,13 @@ function storeSecrets(environment: string, answers: Answers) { log('\n', kleur.bold().underline('Docker Hub')) - const dockerhubQuestions = [ - { - name: 'dockerhubOrganisation', - type: 'text' as const, - message: 'What is the name of your Docker Hub organisation?', - valueType: 'SECRET' as const, - valueLabel: 'DOCKERHUB_ACCOUNT', - validate: notEmpty, - initial: process.env.DOCKER_ORGANISATION - }, - { - name: 'dockerhubRepository', - type: 'text' as const, - message: 'What is the name of your private Docker Hub repository?', - valueType: 'SECRET' as const, - valueLabel: 'DOCKERHUB_REPO', - validate: notEmpty, - initial: process.env.DOCKER_REPO - }, - { - name: 'dockerhubUsername', - type: 'text' as const, - message: - 'What is the Docker Hub username the the target server should be using?', - valueType: 'SECRET' as const, - valueLabel: 'DOCKER_USERNAME', - validate: notEmpty, - initial: process.env.DOCKER_USERNAME - }, - { - name: 'dockerhubToken', - type: 'text' as const, - message: 'What is the token of this Docker Hub account?', - valueType: 'SECRET' as const, - valueLabel: 'DOCKER_TOKEN', - validate: notEmpty, - initial: process.env.DOCKER_TOKEN - } - ] - await promptAndStoreAnswer(environment, dockerhubQuestions, existingValues) - const sshQuestions = [ - { - name: 'sshHost', - type: 'text' as const, - message: - 'What is the target server IP address? Note: For "production" environment server clusters of (2, 3 or 5 replicas) this is always the IP address for just 1 manager server', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SSH_HOST', - initial: process.env.SSH_HOST - }, - { - name: 'sshUser', - type: 'text' as const, - message: 'What is the SSH login user to be used for provisioning?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SSH_USER', - initial: process.env.SSH_USER || 'provision' - }, - { - name: 'sshArgs', - type: 'text' as const, - message: - 'Specify any additional SSH arguments to be used when connecting to the target machine. For example, if you need to connect via a jump server, you can specify the jump server here.', - valueType: 'VARIABLE' as const, - valueLabel: 'SSH_ARGS', - format: (value: string) => value.trim(), - initial: process.env.SSH_ARGS - } - ] log('\n', kleur.bold().underline('SSH')) - const { sshUser } = await promptAndStoreAnswer( - environment, - sshQuestions, - existingValues - ) - - ALL_QUESTIONS.push({ - name: 'sshKey', - type: 'text' as const, - message: `Paste the SSH private key for ${sshUser} here:`, - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SSH_KEY', - initial: process.env.SSH_KEY - }) + await promptAndStoreAnswer(environment, sshQuestions, existingValues) const SSH_KEY_EXISTS = existingValues.find( - (value) => value.name === 'SSH_KEY' + (value) => value.name === 'SSH_KEY' && value.scope === 'ENVIRONMENT' ) if (!SSH_KEY_EXISTS) { @@ -384,42 +856,10 @@ function storeSecrets(environment: string, answers: Answers) { message: `Paste the SSH private key for ${kleur.cyan('SSH_USER')} here:` }) - ALL_ANSWERS.push({ sshKey }) + const formattedSSHKey = sshKey.endsWith('\n') ? sshKey : sshKey + '\n' + ALL_ANSWERS.push({ sshKey: formattedSSHKey }) } - const infrastructureQuestions = [ - { - name: 'diskSpace', - type: 'text' as const, - message: `What is the amount of diskspace that should be dedicated to OpenCRVS data and will become the size of an encrypted cryptfs data directory. - \n${kleur.red('DO NOT USE ALL DISKSPACE FOR OPENCRVS!')} - \nLeave at least 50g available for OS use.`, - valueType: 'VARIABLE' as const, - validate: notEmpty, - valueLabel: 'DISK_SPACE', - initial: process.env.DISK_SPACE || '200g' - }, - { - name: 'domain', - type: 'text' as const, - message: 'What is the web domain applied after all subdomains in URLs?', - valueType: 'VARIABLE' as const, - validate: notEmpty, - valueLabel: 'DOMAIN', - initial: process.env.DOMAIN - }, - { - name: 'replicas', - type: 'number' as const, - message: - 'What is the number of replicas? EDIT: This should be 1 for qa, staging and backup environments. For "production" environment server clusters of (2, 3 or 5 replicas), set to 2, 3 or 5 as appropriate.', - valueType: 'VARIABLE' as const, - validate: notEmpty, - valueLabel: 'REPLICAS', - initial: process.env.REPLICAS ? parseInt(process.env.REPLICAS, 10) : 1 - } - ] - log('\n', kleur.bold().underline('Server setup')) await promptAndStoreAnswer( environment, @@ -431,81 +871,7 @@ function storeSecrets(environment: string, answers: Answers) { await promptAndStoreAnswer( environment, - [ - { - name: 'kibanaUsername', - type: 'text' as const, - message: 'Input the username for logging in to Kibana', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'KIBANA_USERNAME', - initial: process.env.KIBANA_USERNAME || 'opencrvs-admin' - }, - { - name: 'kibanaPassword', - type: 'text' as const, - message: 'Input the password for logging in to Kibana', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'KIBANA_PASSWORD', - initial: process.env.KIBANA_PASSWORD || generateLongPassword() - }, - { - name: 'elasticsearchSuperuserPassword', - type: 'text' as const, - message: 'Input the password for the Elasticsearch superuser', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'ELASTICSEARCH_SUPERUSER_PASSWORD', - initial: - process.env.ELASTICSEARCH_SUPERUSER_PASSWORD || generateLongPassword() - }, - { - name: 'minioRootUser', - type: 'text' as const, - message: 'Input the username for the Minio root user', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'MINIO_ROOT_USER', - initial: process.env.MINIO_ROOT_USER || generateLongPassword() - }, - { - name: 'minioRootPassword', - type: 'text' as const, - message: 'Input the password for the Minio root user', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'MINIO_ROOT_PASSWORD', - initial: process.env.MINIO_ROOT_PASSWORD || generateLongPassword() - }, - { - name: 'mongodbAdminUser', - type: 'text' as const, - message: 'Input the username for the MongoDB admin user', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'MONGODB_ADMIN_USER', - initial: process.env.MONGODB_ADMIN_USER || generateLongPassword() - }, - { - name: 'mongodbAdminPassword', - type: 'text' as const, - message: 'Input the password for the MongoDB admin user', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'MONGODB_ADMIN_PASSWORD', - initial: process.env.MONGODB_ADMIN_PASSWORD || generateLongPassword() - }, - { - name: 'superUserPassword', - type: 'text' as const, - message: 'Input the password for the OpenCRVS super user', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SUPER_USER_PASSWORD', - initial: process.env.SUPER_USER_PASSWORD || generateLongPassword() - } - ], + databaseAndMonitoringQuestions, existingValues ) log('\n', kleur.bold().underline('Sentry')) @@ -515,72 +881,17 @@ function storeSecrets(environment: string, answers: Answers) { name: 'useSentry', type: 'confirm' as const, message: 'Do you want to use Sentry?', + scope: 'ENVIRONMENT' as const, initial: Boolean(process.env.SENTRY_DNS) } ].map(questionToPrompt) ) if (useSentry) { - await promptAndStoreAnswer( - environment, - [ - { - name: 'sentryDsn', - type: 'text', - message: 'What is your Sentry DSN?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SENTRY_DSN', - initial: process.env.SENTRY_DSN - } - ], - existingValues - ) + await promptAndStoreAnswer(environment, sentryQuestions, existingValues) } if (['production', 'staging'].includes(type)) { - const backupQuestions = [ - { - name: 'backupHost', - type: 'text' as const, - message: 'What is your backup host IP address?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'BACKUP_HOST', - initial: process.env.BACKUP_HOST - }, - { - name: 'backupSshUser', - type: 'text' as const, - message: - 'What user should application servers use to login to the backup server?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'BACKUP_SSH_USER', - initial: process.env.BACKUP_SSH_USER - }, - { - name: 'backupDirectory', - type: 'text' as const, - message: - 'What is the full path to a directory on your backup server where encrypted backups will be stored?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'BACKUP_DIRECTORY', - initial: process.env.BACKUP_DIRECTORY - }, - { - name: 'backupEncryptionPassprase', - type: 'text' as const, - message: - 'Input a long random passphrase to be used for encrypting backups', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'BACKUP_ENCRYPTION_PASSPHRASE', - initial: - process.env.BACKUP_ENCRYPTION_PASSPHRASE || generateLongPassword() - } - ] log('\n', kleur.bold().underline('Backups')) await promptAndStoreAnswer(environment, backupQuestions, existingValues) @@ -591,147 +902,26 @@ function storeSecrets(environment: string, answers: Answers) { name: 'vpnEnabled', type: 'confirm' as const, message: `Do you want to setup a VPN (Wireguard) to connect to your ${type} environment?`, + scope: 'ENVIRONMENT' as const, initial: true } ].map(questionToPrompt) ) if (vpnEnabled) { - const vpnQuestions = [ - { - name: 'vpnHostAddress', - type: 'text' as const, - message: `Please enter the IP address users logged in to the VPN will use`, - initial: true, - valueType: 'VARIABLE' as const, - valueLabel: 'VPN_HOST_ADDRESS' - }, - { - name: 'vpnAdminPassword', - type: 'text' as const, - message: `Admin password for Wireguard UI`, - initial: generateLongPassword(), - valueType: 'VARIABLE' as const, - valueLabel: 'VPN_ADMIN_PASSWORD' - } - ] - await promptAndStoreAnswer(environment, vpnQuestions, existingValues) } } - const smsQuestions = [ - { - name: 'infobipApiKey', - type: 'text' as const, - message: 'What is your Infobip API key?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'INFOBIP_API_KEY', - initial: process.env.INFOBIP_API_KEY - }, - { - name: 'infobipGatewayEndpoint', - type: 'text' as const, - message: 'What is your Infobip gateway endpoint?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'INFOBIP_GATEWAY_ENDPOINT', - initial: process.env.INFOBIP_GATEWAY_ENDPOINT - }, - { - name: 'infobipSenderId', - type: 'text' as const, - message: 'What is your Infobip sender ID?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'INFOBIP_SENDER_ID', - initial: process.env.INFOBIP_SENDER_ID - } - ] - - const emailQuestions = [ - { - name: 'smtpHost', - type: 'text' as const, - message: 'What is your SMTP host?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SMTP_HOST', - initial: process.env.SMTP_HOST - }, - { - name: 'smtpUsername', - type: 'text' as const, - message: 'What is your SMTP username?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SMTP_USERNAME', - initial: process.env.SMTP_USERNAME - }, - { - name: 'smtpPassword', - type: 'text' as const, - message: 'What is your SMTP password?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SMTP_PASSWORD', - initial: process.env.SMTP_PASSWORD - }, - { - name: 'smtpPort', - type: 'text' as const, - message: 'What is your SMTP port?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SMTP_PORT', - initial: process.env.SMTP_PORT - }, - { - name: 'senderEmailAddress', - type: 'text' as const, - message: 'What is your sender email address?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'SENDER_EMAIL_ADDRESS', - initial: process.env.SENDER_EMAIL_ADDRESS - }, - { - name: 'alertEmail', - type: 'text' as const, - message: - 'What is the email address to receive alert emails or a Slack channel email link?', - valueType: 'SECRET' as const, - validate: notEmpty, - valueLabel: 'ALERT_EMAIL', - initial: process.env.ALERT_EMAIL - } - ] - log('\n', kleur.bold().underline('SMTP')) await promptAndStoreAnswer(environment, emailQuestions, existingValues) log('\n', kleur.bold().underline('Notification')) - const { notificationTransport } = await prompts( - [ - { - name: 'notificationTransport', - type: 'select' as const, - message: 'Notification transport for 2FA, informant and user messaging', - choices: [ - { - title: 'Email (with SMTP details)', - value: 'email' - }, - { - title: 'SMS (Infobip)', - value: 'sms' - } - ], - valueLabel: 'NOTIFICATION_TRANSPORT', - valueType: 'VARIABLE' as const - } - ].map(questionToPrompt) + + const { notificationTransport } = await promptAndStoreAnswer( + environment, + notificationTransportQuestions, + existingValues ) if (notificationTransport.includes('sms')) { @@ -742,8 +932,6 @@ function storeSecrets(environment: string, answers: Answers) { return { ...acc, ...answer } }) - const updates: Answers = getAnswers(existingValues) - /* * Variables the user doesn't need to set manually */ @@ -758,79 +946,121 @@ function storeSecrets(environment: string, answers: Answers) { type: 'VARIABLE' as const, name: 'ACTIVATE_USERS', value: ['production', 'staging'].includes(environment) ? 'false' : 'true', - didExist: findExistingValue('ACTIVATE_USERS', 'VARIABLE', existingValues) + didExist: findExistingValue( + 'ACTIVATE_USERS', + 'VARIABLE', + 'ENVIRONMENT', + existingValues + ), + scope: 'ENVIRONMENT' as const }, { type: 'VARIABLE' as const, name: 'AUTH_HOST', value: answerOrExisting( allAnswers.domain, - findExistingValue('DOMAIN', 'VARIABLE', existingValues), + findExistingValue('DOMAIN', 'VARIABLE', 'ENVIRONMENT', existingValues), (val) => `https://auth.${val}` ), - didExist: findExistingValue('AUTH_HOST', 'VARIABLE', existingValues) + didExist: findExistingValue( + 'AUTH_HOST', + 'VARIABLE', + 'ENVIRONMENT', + existingValues + ), + scope: 'ENVIRONMENT' as const }, { type: 'VARIABLE' as const, name: 'COUNTRY_CONFIG_HOST', value: answerOrExisting( allAnswers.domain, - findExistingValue('DOMAIN', 'VARIABLE', existingValues), + findExistingValue('DOMAIN', 'VARIABLE', 'ENVIRONMENT', existingValues), (val) => `https://countryconfig.${val}` ), didExist: findExistingValue( 'COUNTRY_CONFIG_HOST', 'VARIABLE', + 'ENVIRONMENT', existingValues - ) + ), + scope: 'ENVIRONMENT' as const }, { type: 'VARIABLE' as const, name: 'GATEWAY_HOST', value: answerOrExisting( allAnswers.domain, - findExistingValue('DOMAIN', 'VARIABLE', existingValues), + findExistingValue('DOMAIN', 'VARIABLE', 'ENVIRONMENT', existingValues), (val) => `https://gateway.${val}` ), - didExist: findExistingValue('GATEWAY_HOST', 'VARIABLE', existingValues) + didExist: findExistingValue( + 'GATEWAY_HOST', + 'VARIABLE', + 'ENVIRONMENT', + existingValues + ), + scope: 'ENVIRONMENT' as const }, { type: 'VARIABLE' as const, name: 'CONTENT_SECURITY_POLICY_WILDCARD', value: answerOrExisting( allAnswers.domain, - findExistingValue('DOMAIN', 'VARIABLE', existingValues), + findExistingValue('DOMAIN', 'VARIABLE', 'ENVIRONMENT', existingValues), (val) => `*.${val}` ), didExist: findExistingValue( 'CONTENT_SECURITY_POLICY_WILDCARD', 'VARIABLE', + 'ENVIRONMENT', existingValues - ) + ), + scope: 'ENVIRONMENT' as const }, { type: 'VARIABLE' as const, name: 'CLIENT_APP_URL', value: answerOrExisting( allAnswers.domain, - findExistingValue('DOMAIN', 'VARIABLE', existingValues), + findExistingValue('DOMAIN', 'VARIABLE', 'ENVIRONMENT', existingValues), (val) => `https://register.${val}` ), - didExist: findExistingValue('CLIENT_APP_URL', 'VARIABLE', existingValues) + didExist: findExistingValue( + 'CLIENT_APP_URL', + 'VARIABLE', + 'ENVIRONMENT', + existingValues + ), + scope: 'ENVIRONMENT' as const }, { type: 'VARIABLE' as const, name: 'LOGIN_URL', value: answerOrExisting( allAnswers.domain, - findExistingValue('DOMAIN', 'VARIABLE', existingValues), + findExistingValue('DOMAIN', 'VARIABLE', 'ENVIRONMENT', existingValues), (val) => `https://login.${val}` ), - didExist: findExistingValue('LOGIN_URL', 'VARIABLE', existingValues) + didExist: findExistingValue( + 'LOGIN_URL', + 'VARIABLE', + 'ENVIRONMENT', + existingValues + ), + scope: 'ENVIRONMENT' as const } ] - updates.push(...derivedUpdates.filter((variable) => Boolean(variable.value))) + const updates: Answers = getAnswers(existingValues) + .concat(...derivedUpdates) + .filter( + (variable) => + Boolean(variable.value) && + // Only update values that changed + (variable.type !== 'VARIABLE' || + variable.value !== variable.didExist?.value) + ) /* * List out all updates to the variables and confirm with the user @@ -853,6 +1083,15 @@ function storeSecrets(environment: string, answers: Answers) { update.type === 'VARIABLE' && Boolean(update.didExist) ) + const unknownVariables = existingValues.filter((value) => { + return !ALL_QUESTIONS.find( + (question) => + question.valueLabel === value.name && + question.valueType === value.type && + question.scope === value.scope + ) + }) + log('') if (newSecrets.length > 0) { @@ -864,6 +1103,7 @@ function storeSecrets(environment: string, answers: Answers) { newSecrets.forEach((secret) => { log(secret.name, '=', secret.value) }) + log('') } if (updatedSecrets.length > 0) { log( @@ -874,6 +1114,7 @@ function storeSecrets(environment: string, answers: Answers) { updatedSecrets.forEach((secret) => { log(secret.name, '=', secret.value) }) + log('') } if (newVariables.length > 0) { log( @@ -884,6 +1125,7 @@ function storeSecrets(environment: string, answers: Answers) { newVariables.forEach((variable) => { log(variable.name, '=', variable.value) }) + log('') } if (updatedVariables.length > 0) { log( @@ -891,6 +1133,7 @@ function storeSecrets(environment: string, answers: Answers) { `The following variables will be updated in Github for environment ${environment}:` ) ) + updatedVariables.forEach((variable) => { log( variable.name, @@ -899,6 +1142,50 @@ function storeSecrets(environment: string, answers: Answers) { `(was ${variable.didExist?.value})` ) }) + log('') + } + + if (unknownVariables.length > 0) { + log( + kleur.yellow( + `The following unknown variables/secrets were stored in Github are not managed by this script:` + ) + ) + log('') + log(kleur.blue(`Repository:`)) + + unknownVariables + .filter(({ scope }) => scope === 'REPOSITORY') + .forEach((variable) => { + log(kleur.cyan(variable.type) + ':', variable.name) + }) + + log('') + log(kleur.blue(`Environment:`)) + + unknownVariables + .filter(({ scope }) => scope === 'ENVIRONMENT') + .forEach((variable) => { + log(kleur.cyan(variable.type) + ':', variable.name) + }) + + log('') + log( + kleur.yellow( + `These variables will not be updated by this script. If you want to update them, you will need to do so manually.` + ) + ) + log('') + } + + if ( + ([] as Array) + .concat(newSecrets) + .concat(updatedSecrets) + .concat(newVariables) + .concat(updatedVariables).length === 0 + ) { + process.exit(0) } const { confirm } = await prompts([ @@ -916,29 +1203,51 @@ function storeSecrets(environment: string, answers: Answers) { for (const newSecret of newSecrets) { log(`Creating secret ${newSecret.name} with value ${newSecret.value}`) - await createSecret( - octokit, - repositoryId, - environment, - key, - key_id, - newSecret.name, - newSecret.value - ) + if (newSecret.scope === 'ENVIRONMENT') { + await createEnvironmentSecret( + octokit, + repositoryId, + environment, + newSecret.name, + newSecret.value, + githubOrganisation, + githubRepository + ) + } else { + await createRepositorySecret( + octokit, + repositoryId, + newSecret.name, + newSecret.value, + githubOrganisation, + githubRepository + ) + } } for (const updatedSecret of updatedSecrets) { log( `Updating secret ${updatedSecret.name} with value ${updatedSecret.value}` ) - await createSecret( - octokit, - repositoryId, - environment, - key, - key_id, - updatedSecret.name, - updatedSecret.value - ) + if (updatedSecret.scope === 'ENVIRONMENT') { + await createEnvironmentSecret( + octokit, + repositoryId, + environment, + updatedSecret.name, + updatedSecret.value, + githubOrganisation, + githubRepository + ) + } else { + await createRepositorySecret( + octokit, + repositoryId, + updatedSecret.name, + updatedSecret.value, + githubOrganisation, + githubRepository + ) + } } for (const newVariable of newVariables) { @@ -1001,25 +1310,34 @@ function getAnswers(existingValues: (Secret | Variable)[]): Answers { const existingSecret = findExistingValue( existingQuestion!.valueLabel!, 'SECRET', + existingQuestion?.scope!, existingValues ) return { type: valueType, name: existingQuestion?.valueLabel!, value: value.toString(), - didExist: existingSecret + didExist: existingSecret, + scope: existingQuestion!.scope! } } - + const existingVariable = findExistingValue( + existingQuestion?.valueLabel!, + valueType, + existingQuestion?.scope!, + existingValues + ) return { type: valueType, name: existingQuestion?.valueLabel!, didExist: findExistingValue( existingQuestion?.valueLabel!, valueType, + existingQuestion?.scope!, existingValues ), - value: value.toString() + value: value.toString() || existingVariable?.value || '', + scope: existingQuestion!.scope! } }) }) diff --git a/infrastructure/deployment/deploy.sh b/infrastructure/deployment/deploy.sh index 2e815bfe3..cfb8e12e0 100755 --- a/infrastructure/deployment/deploy.sh +++ b/infrastructure/deployment/deploy.sh @@ -73,7 +73,6 @@ COMPOSE_FILES_USED="$COMPOSE_FILES_DOWNLOADED_FROM_CORE $INFRASTRUCTURE_DIRECTOR echo $COMPOSE_FILES_USED - # Read environment variable file for the environment # .env.qa # .env.development @@ -164,7 +163,7 @@ configured_rsync() { get_environment_variables() { local env_vars="" # Define an array of variables to exclude - local exclude_vars=("PATH" "SSH_ARGS" "HOME" "LANG" "USER" "SHELL" "PWD" "KNOWN_HOSTS") + local exclude_vars=("PATH" "SSH_ARGS" "HOME" "LANG" "USER" "SHELL" "PWD") while IFS='=' read -r name value; do # Check if the variable is in the exclude list diff --git a/infrastructure/deployment/validate-required-variables-in-compose-files.ts b/infrastructure/deployment/validate-required-variables-in-compose-files.ts index 2e9748cd4..ebf071a3e 100644 --- a/infrastructure/deployment/validate-required-variables-in-compose-files.ts +++ b/infrastructure/deployment/validate-required-variables-in-compose-files.ts @@ -1,4 +1,5 @@ import * as fs from 'fs' +import { basename } from 'path' function extractEnvVars(content: string): string[] { const regex = /(? !process.env[name]) if (missingValues.length > 0) { - console.log('Missing values for the following variables:') - console.log(missingValues) + console.log('\n\n') + console.error( + 'Missing secrets or variables for values found in docker compose files:' + ) + console.error(missingValues) + console.error( + '\nCheck the following files for more details:\n', + filePaths.map((path) => `- ${basename(path)}`).join('\n') + ) + console.log('\n') process.exit(1) } } diff --git a/infrastructure/docker-compose.deploy.yml b/infrastructure/docker-compose.deploy.yml index 85343c792..c55954e87 100644 --- a/infrastructure/docker-compose.deploy.yml +++ b/infrastructure/docker-compose.deploy.yml @@ -13,7 +13,7 @@ services: # Note: these published port will override UFW rules as Docker manages it's own iptables # Only publish the exact ports that are required for OpenCRVS to work traefik: - image: 'traefik:v2.9' + image: 'traefik:v2.10' ports: - target: 80 published: 80 @@ -32,7 +32,7 @@ services: - --api.dashboard=true - --api.insecure=true - --log.level=WARNING - - --certificatesresolvers.certResolver.acme.email=ryan@jembi.org + - --certificatesresolvers.certResolver.acme.email=riku@opencrvs.org - --certificatesresolvers.certResolver.acme.storage=acme.json - --certificatesresolvers.certResolver.acme.caserver=https://acme-v02.api.letsencrypt.org/directory - --certificatesresolvers.certResolver.acme.httpchallenge.entrypoint=web diff --git a/infrastructure/docker-compose.development-deploy.yml b/infrastructure/docker-compose.development-deploy.yml index 58cb19ef6..f26538437 100644 --- a/infrastructure/docker-compose.development-deploy.yml +++ b/infrastructure/docker-compose.development-deploy.yml @@ -99,3 +99,29 @@ services: environment: - QA_ENV=true - NODE_ENV=production + + traefik: + command: + # Use HTTP-01 challenge as the web server is publicly available + # https://doc.traefik.io/traefik/https/acme/#httpchallenge + # For DNS-01 challenge and manual certificates, check staging and production configurations + - --certificatesresolvers.certResolver.acme.email=riku@opencrvs.org + - --certificatesresolvers.certResolver.acme.storage=acme.json + - --certificatesresolvers.certResolver.acme.caserver=https://acme-v02.api.letsencrypt.org/directory + - --certificatesresolvers.certResolver.acme.httpchallenge.entrypoint=web + + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --providers.docker + - --providers.docker.swarmMode=true + - --api.dashboard=true + - --api.insecure=true + - --log.level=WARNING + - --entrypoints.web.http.redirections.entryPoint.to=websecure + - --entrypoints.web.http.redirections.entryPoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --serverstransport.insecureskipverify=true + - --entrypoints.websecure.address=:443 + - --accesslog=true + - --accesslog.format=json + - --ping=true diff --git a/infrastructure/docker-compose.production-deploy.yml b/infrastructure/docker-compose.production-deploy.yml index 24f9af9cd..bcbb7e900 100644 --- a/infrastructure/docker-compose.production-deploy.yml +++ b/infrastructure/docker-compose.production-deploy.yml @@ -1,5 +1,18 @@ version: '3.3' +# +# Production deployments of OpenCRVS should never be exposed to the internet. +# Instead, they should be deployed on a private network and exposed to the internet via a VPN. +# +# Before you deploy staging or production environments, make sure the application servers are +# either in an internal network or protected with a firewall. No ports should be exposed to the internet. +# +# The VPN_HOST_ADDRESS environment variable should be set to the IP address where all inbound traffic is coming from. +# In most cases, this is the VPN server's public IP address. +# +# ${VPN_HOST_ADDRESS} +# + services: gateway: environment: @@ -199,3 +212,49 @@ services: - mongo2 environment: - REPLICAS=2 + + traefik: + # These templates use an Automatic Certificate Management Environment (Let's Encrypt). + # This makes sure that the HTTPS certificates are automatically generated and renewed without manual maintenance. + # + # For your country to do this, your domain's DNS provider must be one of the ones listed here + # https://doc.traefik.io/traefik/https/acme/#providers + # + # If your DNS provider is not listed, you can use manually renewed certificate files instead of Let's Encrypt. + # To do this, remove the `environment` and `certificatesresolvers.certResolver.acme` sections and uncomment the following lines. + # You will also need to place your certificates in the `/data/traefik/certs` directory. + # Ensure that the file names match the ones defined below. + # + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock + # - /data/traefik/certs:/certs + # command: + # - --tls.certificates.certfile=/certs/crvs.cm.crt + # - --tls.certificates.keyfile=/certs/crvs.cm.key + # - --tls.certificates.stores=default + # - --tls.stores.default.defaultcertificate.certfile=/certs/crvs.cm.crt + # - --tls.stores.default.defaultcertificate.keyfile=/certs/crvs.cm.key + + environment: + - GOOGLE_DOMAINS_ACCESS_TOKEN=${GOOGLE_DOMAINS_ACCESS_TOKEN} + command: + - --certificatesresolvers.certResolver.acme.dnschallenge=true + - --certificatesresolvers.certResolver.acme.dnschallenge.provider=googledomains + - --certificatesresolvers.certResolver.acme.email=riku@opencrvs.org + - --certificatesresolvers.certResolver.acme.storage=acme.json + + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --providers.docker + - --providers.docker.swarmMode=true + - --api.dashboard=true + - --api.insecure=true + - --log.level=WARNING + - --entrypoints.web.http.redirections.entryPoint.to=websecure + - --entrypoints.web.http.redirections.entryPoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --serverstransport.insecureskipverify=true + - --entrypoints.websecure.address=:443 + - --accesslog=true + - --accesslog.format=json + - --ping=true diff --git a/infrastructure/docker-compose.qa-deploy.yml b/infrastructure/docker-compose.qa-deploy.yml index 62215a078..238bbd681 100644 --- a/infrastructure/docker-compose.qa-deploy.yml +++ b/infrastructure/docker-compose.qa-deploy.yml @@ -5,6 +5,30 @@ services: networks: - overlay_net - vpn + command: + # Use HTTP-01 challenge as the web server is publicly available + # https://doc.traefik.io/traefik/https/acme/#httpchallenge + # For DNS-01 challenge and manual certificates, check staging and production configurations + - --certificatesresolvers.certResolver.acme.email=riku@opencrvs.org + - --certificatesresolvers.certResolver.acme.storage=acme.json + - --certificatesresolvers.certResolver.acme.caserver=https://acme-v02.api.letsencrypt.org/directory + - --certificatesresolvers.certResolver.acme.httpchallenge.entrypoint=web + + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --providers.docker + - --providers.docker.swarmMode=true + - --api.dashboard=true + - --api.insecure=true + - --log.level=WARNING + - --entrypoints.web.http.redirections.entryPoint.to=websecure + - --entrypoints.web.http.redirections.entryPoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --serverstransport.insecureskipverify=true + - --entrypoints.websecure.address=:443 + - --accesslog=true + - --accesslog.format=json + - --ping=true notification: environment: diff --git a/infrastructure/docker-compose.staging-deploy.yml b/infrastructure/docker-compose.staging-deploy.yml index 16a75b7b8..c658af0b3 100644 --- a/infrastructure/docker-compose.staging-deploy.yml +++ b/infrastructure/docker-compose.staging-deploy.yml @@ -1,5 +1,18 @@ version: '3.3' +# +# Production deployments of OpenCRVS should never be exposed to the internet. +# Instead, they should be deployed on a private network and exposed to the internet via a VPN. +# +# Before you deploy staging or production environments, make sure the application servers are +# either in an internal network or protected with a firewall. No ports should be exposed to the internet. +# +# The VPN_HOST_ADDRESS environment variable should be set to the IP address where all inbound traffic is coming from. +# In most cases, this is the VPN server's public IP address. +# +# ${VPN_HOST_ADDRESS} +# + services: gateway: environment: @@ -166,3 +179,49 @@ services: - mongo1 environment: - REPLICAS=1 + + traefik: + # These templates use an Automatic Certificate Management Environment (Let's Encrypt). + # This makes sure that the HTTPS certificates are automatically generated and renewed without manual maintenance. + # + # For your country to do this, your domain's DNS provider must be one of the ones listed here + # https://doc.traefik.io/traefik/https/acme/#providers + # + # If your DNS provider is not listed, you can use manually renewed certificate files instead of Let's Encrypt. + # To do this, remove the `environment` and `certificatesresolvers.certResolver.acme` sections and uncomment the following lines. + # You will also need to place your certificates in the `/data/traefik/certs` directory. + # Ensure that the file names match the ones defined below. + # + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock + # - /data/traefik/certs:/certs + # command: + # - --tls.certificates.certfile=/certs/crvs.cm.crt + # - --tls.certificates.keyfile=/certs/crvs.cm.key + # - --tls.certificates.stores=default + # - --tls.stores.default.defaultcertificate.certfile=/certs/crvs.cm.crt + # - --tls.stores.default.defaultcertificate.keyfile=/certs/crvs.cm.key + + environment: + - GOOGLE_DOMAINS_ACCESS_TOKEN=${GOOGLE_DOMAINS_ACCESS_TOKEN} + command: + - --certificatesresolvers.certResolver.acme.dnschallenge=true + - --certificatesresolvers.certResolver.acme.dnschallenge.provider=googledomains + - --certificatesresolvers.certResolver.acme.email=riku@opencrvs.org + - --certificatesresolvers.certResolver.acme.storage=acme.json + + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --providers.docker + - --providers.docker.swarmMode=true + - --api.dashboard=true + - --api.insecure=true + - --log.level=WARNING + - --entrypoints.web.http.redirections.entryPoint.to=websecure + - --entrypoints.web.http.redirections.entryPoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --serverstransport.insecureskipverify=true + - --entrypoints.websecure.address=:443 + - --accesslog=true + - --accesslog.format=json + - --ping=true diff --git a/infrastructure/server-setup/staging.yml b/infrastructure/server-setup/staging.yml index 5030c7456..46f4a123d 100644 --- a/infrastructure/server-setup/staging.yml +++ b/infrastructure/server-setup/staging.yml @@ -32,6 +32,11 @@ all: - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDECqHO65UpyrrO8uueD06RxGaVVq22f152Rf8qVQQAAIGAMu6gCs7ztlZ8a3yQgSEIjM/Jl1/RqIVs6CziTEef74nLFTZ5Ufz3CLRVgdebBeSBEmhTfTUV0HLkSyNzwKFpuzJxucGd72ulPvEp6eHvyJAPJz37YcU8cjaL1v05T6s2ee99li35GlDDtCzfjVV4ZPAg5JdfWuTj41RAVC0LQhk2/NB4qEu37UxGGjhRFSjBEsS5LxI9QfvgrsHpl/VOn+soH7ZkK7kS6qRgNP/uYsXRWXhHaamcl5OX68gJWTbrW6c7PCqlbCWGnsHJswCmqPIthwXXMfC7ULDNLSKG6mslAt5Dyc8/MCr3vTW7pDyr2d0FvvY86SMQUggxv3qF7TZewqfX1bhK0fMLarIxVMQ1RFo//wN9QGA+2we8rxd2Y1Kr1DBuJyuwXPfv+Exo8yNYQ+x/AYH5k6UVcSYuaB8eYmplG2KQCxt8RBFtoChrwOKNRWLqXdKyfpdp5XmnnWxPvR95gf3h3yLocVYkF0i0uvKKJ0vt8J0Ezfkdfow0B1kUg5bPXKJROX7PwbaCPdYcxyDaO6wwOigRnSmoFvkH1pLb4j1RQAXcX531CHgfN6Izi/h0mpMS4bnyIUcv2GQr+h4z4TxcCtj7qpH2y6yw7XG12jVh7TfeesXG2Q== euanmillar77@gmail.com state: present sudoer: true + - name: tameem + ssh_keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGUprcQyUFYwRto0aRpgriR95C1pgNxrQ0lEWEe1D8he haidertameem@gmail.com + state: present + sudoer: true docker-manager-first: hosts: farajaland-staging: diff --git a/infrastructure/server-setup/tasks/traefik.yml b/infrastructure/server-setup/tasks/traefik.yml index a6f533c82..304d15b6c 100644 --- a/infrastructure/server-setup/tasks/traefik.yml +++ b/infrastructure/server-setup/tasks/traefik.yml @@ -2,4 +2,17 @@ file: path: /data/traefik/acme.json state: touch - mode: '600' + owner: root + group: application + # Owner has rwx, group r, others no permissions + mode: '0740' + +- name: 'Create certs directory for traefik' + file: + path: /data/traefik/certs + state: directory + mode: '700' + owner: root + group: application + # Owner has rwx, group r, others no permissions + mode: '0740' \ No newline at end of file diff --git a/package.json b/package.json index 9e075d655..9b59d3a8f 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,15 @@ "@octokit/core": "4.2.1", "@types/google-libphonenumber": "^7.4.23", "@types/handlebars": "^4.1.0", - "@types/inquirer": "^9.0.7", "@types/hapi__inert": "5.2.1", + "@types/inquirer": "^9.0.7", + "@types/jsonwebtoken": "^8.5.8", + "@types/libsodium-wrappers": "^0.7.10", + "@types/minimist": "^1.2.5", "@types/prompts": "^2.4.9", "@types/react-intl": "^3.0.0", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", - "@types/jsonwebtoken": "^8.5.8", - "@types/libsodium-wrappers": "^0.7.10", "cypress-xpath": "^2.0.1", "eslint": "^8.43.0", "eslint-config-prettier": "^8.8.0", @@ -54,7 +55,6 @@ "react-intl": "^6.4.3" }, "dependencies": { - "dotenv": "^6.1.0", "@faker-js/faker": "^6.0.0-alpha.5", "@hapi/boom": "^9.1.1", "@hapi/hapi": "^20.0.1", @@ -78,6 +78,7 @@ "cross-env": "^6.0.3", "csv2json": "^1.4.2", "date-fns": "^2.28.0", + "dotenv": "^6.1.0", "esbuild": "^0.18.9", "google-libphonenumber": "^3.2.32", "handlebars": "^4.7.7", @@ -87,6 +88,7 @@ "joi": "^17.4.0", "jwt-decode": "^2.2.0", "lodash": "^4.17.21", + "minimist": "^1.2.8", "node-fetch": "^2.6.1", "nodemailer": "^6.9.8", "opener": "^1.5.1", diff --git a/src/api/application/application-config-default.ts b/src/api/application/application-config-default.ts index 467d9ab6c..c50eac78d 100644 --- a/src/api/application/application-config-default.ts +++ b/src/api/application/application-config-default.ts @@ -12,6 +12,11 @@ export const defaultApplicationConfig = { }, PRINT_IN_ADVANCE: true }, + COUNTRY_LOGO: countryLogo, + CURRENCY: { + languagesAndCountry: ['en-US'], + isoCode: 'USD' + }, DEATH: { REGISTRATION_TARGET: 45, FEE: { @@ -20,7 +25,11 @@ export const defaultApplicationConfig = { }, PRINT_IN_ADVANCE: true }, - MARRIAGE_REGISTRATION: false, + PHONE_NUMBER_PATTERN: '^0(7|9)[0-9]{8}$', + NID_NUMBER_PATTERN: '^[0-9]{10}$', + LOGIN_BACKGROUND: { + backgroundColor: '36304E' + }, MARRIAGE: { REGISTRATION_TARGET: 45, FEE: { @@ -29,22 +38,14 @@ export const defaultApplicationConfig = { }, PRINT_IN_ADVANCE: true }, - DATE_OF_BIRTH_UNKNOWN: false, - CURRENCY: { - languagesAndCountry: ['en-US'], - isoCode: 'USD' - }, - PHONE_NUMBER_PATTERN: '^0(7|9)[0-9]{8}$', - NID_NUMBER_PATTERN: '^[0-9]{10}$', - COUNTRY_LOGO: countryLogo, - LOGIN_BACKGROUND: { - backgroundColor: '36304E' - }, - INFORMANT_SIGNATURE: false, - INFORMANT_SIGNATURE_REQUIRED: false, - EXTERNAL_VALIDATION_WORKQUEUE: false, + // Following constants aren't configurable via UI FIELD_AGENT_AUDIT_LOCATIONS: 'DISTRICT', DECLARATION_AUDIT_LOCATIONS: 'DISTRICT', + EXTERNAL_VALIDATION_WORKQUEUE: false, + MARRIAGE_REGISTRATION: false, + DATE_OF_BIRTH_UNKNOWN: true, + INFORMANT_SIGNATURE: false, + INFORMANT_SIGNATURE_REQUIRED: false, USER_NOTIFICATION_DELIVERY_METHOD: 'email', // or 'sms', or '' ... You can use 'sms' for WhatsApp INFORMANT_NOTIFICATION_DELIVERY_METHOD: 'email', // or 'sms', or '' ... You can use 'sms' for WhatsApp SIGNATURE_REQUIRED_FOR_ROLES: ['LOCAL_REGISTRAR', 'NATIONAL_REGISTRAR'] diff --git a/src/api/tracking-id/service.ts b/src/api/tracking-id/service.ts index a53d94295..14ce57c8e 100644 --- a/src/api/tracking-id/service.ts +++ b/src/api/tracking-id/service.ts @@ -1,4 +1,4 @@ -import * as ShortUIDGen from 'short-uid' +import ShortUIDGen from 'short-uid' const EVENTS = { BIRTH: 'BIRTH' as const, diff --git a/yarn.lock b/yarn.lock index 721175577..48664fa21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2167,6 +2167,11 @@ resolved "https://registry.yarnpkg.com/@types/mime-db/-/mime-db-1.43.3.tgz#f7bec64a9a62ddded3371e82862c0516539710e8" integrity sha512-vg0UsF1p1Qi/8iCARoie7F/Ng92zo7tQlL+sqE15GonkKVl55n/0vB6jSbrYTgDO0PSx9pKfGG1iZg9gJum3wA== +"@types/minimist@^1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" + integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== + "@types/mute-stream@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@types/mute-stream/-/mute-stream-0.0.4.tgz#77208e56a08767af6c5e1237be8888e2f255c478" @@ -5338,6 +5343,11 @@ minimist@^1.2.0, minimist@^1.2.2, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"