diff --git a/.changeset/tidy-bears-applaud.md b/.changeset/tidy-bears-applaud.md new file mode 100644 index 0000000000000..cff12f3dc7d31 --- /dev/null +++ b/.changeset/tidy-bears-applaud.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/rest-typings": minor +--- + +Create a deployment fingerprint to identify possible deployment changes caused by database cloning. A question to the admin will confirm if it's a regular deployment change or an intent of a new deployment and correct identification values as needed. +The fingerprint is composed by `${siteUrl}${dbConnectionString}` and hashed via `sha256` in `base64`. +An environment variable named `AUTO_ACCEPT_FINGERPRINT`, when set to `true`, can be used to auto-accept an expected fingerprint change as a regular deployment update. diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index dec4da6bf87b0..ae5a79719cce3 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,13 +1,14 @@ import crypto from 'crypto'; import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; +import { Settings, Users } from '@rocket.chat/models'; import { isShieldSvgProps, isSpotlightProps, isDirectoryProps, isMethodCallProps, isMethodCallAnonProps, + isFingerprintProps, isMeteorCall, validateParamsPwGetPolicyRest, } from '@rocket.chat/rest-typings'; @@ -16,6 +17,7 @@ import EJSON from 'ejson'; import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; +import { v4 as uuidv4 } from 'uuid'; import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; @@ -643,3 +645,75 @@ API.v1.addRoute( }, }, ); + +/** + * @openapi + * /api/v1/fingerprint: + * post: + * description: Update Fingerprint definition as a new workspace or update of configuration + * security: + * $ref: '#/security/authenticated' + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * setDeploymentAs: + * type: string + * example: | + * { + * "setDeploymentAs": "new-workspace" + * } + * responses: + * 200: + * description: Workspace successfully configured + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.addRoute( + 'fingerprint', + { + authRequired: true, + validateParams: isFingerprintProps, + }, + { + async post() { + check(this.bodyParams, { + setDeploymentAs: String, + }); + + if (this.bodyParams.setDeploymentAs === 'new-workspace') { + await Promise.all([ + Settings.resetValueById('uniqueID', process.env.DEPLOYMENT_ID || uuidv4()), + // Settings.resetValueById('Cloud_Url'), + Settings.resetValueById('Cloud_Service_Agree_PrivacyTerms'), + Settings.resetValueById('Cloud_Workspace_Id'), + Settings.resetValueById('Cloud_Workspace_Name'), + Settings.resetValueById('Cloud_Workspace_Client_Id'), + Settings.resetValueById('Cloud_Workspace_Client_Secret'), + Settings.resetValueById('Cloud_Workspace_Client_Secret_Expires_At'), + Settings.resetValueById('Cloud_Workspace_Registration_Client_Uri'), + Settings.resetValueById('Cloud_Workspace_PublicKey'), + Settings.resetValueById('Cloud_Workspace_License'), + Settings.resetValueById('Cloud_Workspace_Had_Trial'), + Settings.resetValueById('Cloud_Workspace_Access_Token'), + Settings.resetValueById('Cloud_Workspace_Access_Token_Expires_At', new Date(0)), + Settings.resetValueById('Cloud_Workspace_Registration_State'), + ]); + } + + await Settings.updateValueById('Deployment_FingerPrint_Verified', true); + + return API.v1.success({}); + }, + }, +); diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index f00718d2e7799..f887c9e6395cb 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -7,6 +7,8 @@ import { LICENSE_VERSION } from '../license'; export type WorkspaceRegistrationData = { uniqueId: string; + deploymentFingerprintHash: string; + deploymentFingerprintVerified: boolean; workspaceId: string; address: string; contactName: string; @@ -50,6 +52,8 @@ export async function buildWorkspaceRegistrationData('NPS_survey_enabled'); const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms'); const setupWizardState = settings.get('Show_Setup_Wizard'); + const deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash'); + const deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified'); const firstUser = await Users.getOldest({ projection: { name: 1, emails: 1 } }); const contactName = firstUser?.name || ''; @@ -59,6 +63,8 @@ export async function buildWorkspaceRegistrationData void; + onCancel: () => void; + onClose: () => void; +}; + +const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps): ReactElement => { + const t = useTranslation(); + return ( + + + + + ); +}; + +export default FingerprintChangeModal; diff --git a/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx new file mode 100644 index 0000000000000..77718de0f441c --- /dev/null +++ b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx @@ -0,0 +1,47 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import GenericModal from './GenericModal'; + +type FingerprintChangeModalConfirmationProps = { + onConfirm: () => void; + onCancel: () => void; + newWorkspace: boolean; +}; + +const FingerprintChangeModalConfirmation = ({ + onConfirm, + onCancel, + newWorkspace, +}: FingerprintChangeModalConfirmationProps): ReactElement => { + const t = useTranslation(); + return ( + + + + + ); +}; + +export default FingerprintChangeModalConfirmation; diff --git a/apps/meteor/client/startup/rootUrlChange.ts b/apps/meteor/client/startup/rootUrlChange.ts index 45f98634a3731..4e42874eba4a3 100644 --- a/apps/meteor/client/startup/rootUrlChange.ts +++ b/apps/meteor/client/startup/rootUrlChange.ts @@ -6,6 +6,8 @@ import { Roles } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { t } from '../../app/utils/lib/i18n'; +import FingerprintChangeModal from '../components/FingerprintChangeModal'; +import FingerprintChangeModalConfirmation from '../components/FingerprintChangeModalConfirmation'; import UrlChangeModal from '../components/UrlChangeModal'; import { imperativeModal } from '../lib/imperativeModal'; import { dispatchToastMessage } from '../lib/toast'; @@ -58,3 +60,72 @@ Meteor.startup(() => { return c.stop(); }); }); + +Meteor.startup(() => { + Tracker.autorun((c) => { + const userId = Meteor.userId(); + if (!userId) { + return; + } + + if (!Roles.ready.get() || !isSyncReady.get()) { + return; + } + + if (hasRole(userId, 'admin') === false) { + return c.stop(); + } + + const deploymentFingerPrintVerified = settings.get('Deployment_FingerPrint_Verified'); + if (deploymentFingerPrintVerified == null || deploymentFingerPrintVerified === true) { + return; + } + + const updateWorkspace = (): void => { + imperativeModal.close(); + void sdk.rest.post('/v1/fingerprint', { setDeploymentAs: 'updated-configuration' }).then(() => { + dispatchToastMessage({ type: 'success', message: t('Configuration_update_confirmed') }); + }); + }; + + const setNewWorkspace = (): void => { + imperativeModal.close(); + void sdk.rest.post('/v1/fingerprint', { setDeploymentAs: 'new-workspace' }).then(() => { + dispatchToastMessage({ type: 'success', message: t('New_workspace_confirmed') }); + }); + }; + + const openModal = (): void => { + imperativeModal.open({ + component: FingerprintChangeModal, + props: { + onConfirm: () => { + imperativeModal.open({ + component: FingerprintChangeModalConfirmation, + props: { + onConfirm: setNewWorkspace, + onCancel: openModal, + newWorkspace: true, + }, + }); + }, + onCancel: () => { + imperativeModal.open({ + component: FingerprintChangeModalConfirmation, + props: { + onConfirm: updateWorkspace, + onCancel: openModal, + newWorkspace: false, + }, + }); + }, + onClose: imperativeModal.close, + }, + }); + }; + + openModal(); + + return c.stop(); + }); +}); diff --git a/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx b/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx index 7570cb71bd1ce..41709d247f8b4 100644 --- a/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx +++ b/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx @@ -66,6 +66,8 @@ export default { _id: '', wizard: {}, uniqueId: '', + deploymentFingerprintHash: '', + deploymentFingerprintVerified: true, installedAt: '', version: '1.0.0', tag: '', diff --git a/apps/meteor/client/views/admin/info/InformationPage.stories.tsx b/apps/meteor/client/views/admin/info/InformationPage.stories.tsx index 0a8e97710ca54..29c0c00d58140 100644 --- a/apps/meteor/client/views/admin/info/InformationPage.stories.tsx +++ b/apps/meteor/client/views/admin/info/InformationPage.stories.tsx @@ -96,6 +96,8 @@ export default { _id: '', wizard: {}, uniqueId: '', + deploymentFingerprintHash: '', + deploymentFingerprintVerified: true, installedAt: '', version: '', tag: '', diff --git a/apps/meteor/client/views/admin/info/UsageCard.stories.tsx b/apps/meteor/client/views/admin/info/UsageCard.stories.tsx index bfe56b6db2d8c..a65e645b17d06 100644 --- a/apps/meteor/client/views/admin/info/UsageCard.stories.tsx +++ b/apps/meteor/client/views/admin/info/UsageCard.stories.tsx @@ -44,6 +44,8 @@ export default { _id: '', wizard: {}, uniqueId: '', + deploymentFingerprintHash: '', + deploymentFingerprintVerified: true, installedAt: '', version: '', tag: '', diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 24662e38d0486..852df2a3906f7 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1106,8 +1106,14 @@ "Confirm_New_Password_Placeholder": "Please re-enter new password...", "Confirm_password": "Confirm password", "Confirm_your_password": "Confirm your password", + "Confirm_configuration_update_description": "Identification data and cloud connection data will be retained.

Warning: If this is actually a new workspace, please go back and select new workspace option to avoid communication conflicts.", + "Confirm_configuration_update": "Confirm configuration update", + "Confirm_new_workspace_description": "Identification data and cloud connection data will be reset.

Warning: License can be affected if changing workspace URL.", + "Confirm_new_workspace": "Confirm new workspace", "Confirmation": "Confirmation", "Configure_video_conference": "Configure conference call", + "Configuration_update_confirmed": "Configuration update confirmed", + "Configuration_update": "Configuration update", "Connect": "Connect", "Connected": "Connected", "Connect_SSL_TLS": "Connect with SSL/TLS", @@ -3658,6 +3664,8 @@ "New_version_available_(s)": "New version available (%s)", "New_videocall_request": "New Video Call Request", "New_visitor_navigation": "New Navigation: {{history}}", + "New_workspace_confirmed": "New workspace confirmed", + "New_workspace": "New workspace", "Newer_than": "Newer than", "Newer_than_may_not_exceed_Older_than": "\"Newer than\" may not exceed \"Older than\"", "Nickname": "Nickname", @@ -5259,6 +5267,9 @@ "Uninstall": "Uninstall", "Units": "Units", "Unit_removed": "Unit Removed", + "Unique_ID_change_detected_description": "Information that identifies this workspace has changed. This can happen when the site URL or database connection string are changed or when a new workspace is created from a copy of an existing database.

Would you like to proceed with a configuration update to the existing workspace or create a new workspace and unique ID?", + "Unique_ID_change_detected_learn_more_link": "Learn more", + "Unique_ID_change_detected": "Unique ID change detected", "Unknown_Import_State": "Unknown Import State", "Unknown_User": "Unknown User", "Unlimited": "Unlimited", diff --git a/apps/meteor/server/configureLogLevel.ts b/apps/meteor/server/configureLogLevel.ts new file mode 100644 index 0000000000000..b328d79a023aa --- /dev/null +++ b/apps/meteor/server/configureLogLevel.ts @@ -0,0 +1,8 @@ +import type { LogLevelSetting } from '@rocket.chat/logger'; +import { logLevel } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; + +const LogLevel = await Settings.getValueById('Log_Level'); +if (LogLevel) { + logLevel.emit('changed', LogLevel as LogLevelSetting); +} diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index 09edca7015400..b9418fe438302 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -1,4 +1,5 @@ import './models/startup'; +import './configureLogLevel'; import './settings/index'; import '../ee/server/models/startup'; import './services/startup'; diff --git a/apps/meteor/server/models/raw/Settings.ts b/apps/meteor/server/models/raw/Settings.ts index 3a5d150c01585..1154b7dfe630c 100644 --- a/apps/meteor/server/models/raw/Settings.ts +++ b/apps/meteor/server/models/raw/Settings.ts @@ -69,6 +69,25 @@ export class SettingsRaw extends BaseRaw implements ISettingsModel { return this.updateOne(query, update); } + async resetValueById( + _id: string, + value?: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, + ): Promise { + if (value == null) { + const record = await this.findOneById(_id); + if (record) { + const prop = record.valueSource || 'packageValue'; + value = record[prop]; + } + } + + if (value == null) { + return; + } + + return this.updateValueById(_id, value); + } + async incrementValueById(_id: ISetting['_id'], value = 1): Promise { return this.updateOne( { diff --git a/apps/meteor/server/settings/misc.ts b/apps/meteor/server/settings/misc.ts index 127d0e6e97bae..fa7b6bbde3d0e 100644 --- a/apps/meteor/server/settings/misc.ts +++ b/apps/meteor/server/settings/misc.ts @@ -1,11 +1,70 @@ -import { Random } from '@rocket.chat/random'; +import crypto from 'crypto'; -import { settingsRegistry } from '../../app/settings/server'; +import { Logger } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; +import { v4 as uuidv4 } from 'uuid'; + +import { settingsRegistry, settings } from '../../app/settings/server'; + +const logger = new Logger('FingerPrint'); + +const generateFingerprint = function () { + const siteUrl = settings.get('Site_Url'); + const dbConnectionString = process.env.MONGO_URL; + + const fingerprint = `${siteUrl}${dbConnectionString}`; + return crypto.createHash('sha256').update(fingerprint).digest('base64'); +}; + +const updateFingerprint = async function (fingerprint: string, verified: boolean) { + await Settings.updateValueById('Deployment_FingerPrint_Hash', fingerprint); + + await Settings.updateValueById('Deployment_FingerPrint_Verified', verified); +}; + +const verifyFingerPrint = async function () { + const DeploymentFingerPrintRecordHash = await Settings.getValueById('Deployment_FingerPrint_Hash'); + + const fingerprint = generateFingerprint(); + + if (!DeploymentFingerPrintRecordHash) { + logger.info('Generating fingerprint for the first time', fingerprint); + await updateFingerprint(fingerprint, true); + return; + } + + if (DeploymentFingerPrintRecordHash === fingerprint) { + return; + } + + if (process.env.AUTO_ACCEPT_FINGERPRINT === 'true') { + logger.info('Updating fingerprint as AUTO_ACCEPT_FINGERPRINT is true', fingerprint); + await updateFingerprint(fingerprint, true); + } + + logger.warn('Updating fingerprint as pending for admin verification', fingerprint); + await updateFingerprint(fingerprint, false); +}; + +settings.watch('Site_Url', () => { + void verifyFingerPrint(); +}); // Insert server unique id if it doesn't exist export const createMiscSettings = async () => { - await settingsRegistry.add('uniqueID', process.env.DEPLOYMENT_ID || Random.id(), { + await settingsRegistry.add('uniqueID', process.env.DEPLOYMENT_ID || uuidv4(), { + public: true, + }); + + await settingsRegistry.add('Deployment_FingerPrint_Hash', '', { + public: false, + readonly: true, + }); + + await settingsRegistry.add('Deployment_FingerPrint_Verified', false, { + type: 'boolean', public: true, + readonly: true, }); await settingsRegistry.add('Initial_Channel_Created', false, { diff --git a/apps/meteor/server/settings/omnichannel.ts b/apps/meteor/server/settings/omnichannel.ts index fe5d27c1e677e..cc9da54748627 100644 --- a/apps/meteor/server/settings/omnichannel.ts +++ b/apps/meteor/server/settings/omnichannel.ts @@ -778,7 +778,7 @@ await settingsRegistry.addGroup('SMS', async function () { i18nLabel: 'Mobex_sms_gateway_password', }); await this.add('SMS_Mobex_from_number', '', { - type: 'int', + type: 'string', enableQuery: { _id: 'SMS_Service', value: 'mobex', diff --git a/apps/meteor/server/settings/setup-wizard.ts b/apps/meteor/server/settings/setup-wizard.ts index 62da3f1471cfb..9799c2017afd7 100644 --- a/apps/meteor/server/settings/setup-wizard.ts +++ b/apps/meteor/server/settings/setup-wizard.ts @@ -1270,7 +1270,7 @@ export const createSetupWSettings = () => secret: true, }); - await this.add('Cloud_Workspace_Client_Secret_Expires_At', '', { + await this.add('Cloud_Workspace_Client_Secret_Expires_At', 0, { type: 'int', hidden: true, readonly: true, diff --git a/packages/core-typings/src/ISetting.ts b/packages/core-typings/src/ISetting.ts index 0766d782980c3..7b3aaa5cf2a48 100644 --- a/packages/core-typings/src/ISetting.ts +++ b/packages/core-typings/src/ISetting.ts @@ -72,7 +72,7 @@ export interface ISettingBase { hidden?: boolean; modules?: Array; invalidValue?: SettingValue; - valueSource?: string; + valueSource?: 'packageValue' | 'processEnvValue'; secret?: boolean; i18nDescription?: string; autocomplete?: boolean; diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 70f9f638358fb..443cbfb239578 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -17,6 +17,8 @@ export interface IStats { registerServer?: boolean; }; uniqueId: string; + deploymentFingerprintHash: string; + deploymentFingerprintVerified: boolean; installedAt?: string; version?: string; tag?: string; diff --git a/packages/model-typings/src/models/ISettingsModel.ts b/packages/model-typings/src/models/ISettingsModel.ts index 9dc2005867fa6..d382d4853a4b7 100644 --- a/packages/model-typings/src/models/ISettingsModel.ts +++ b/packages/model-typings/src/models/ISettingsModel.ts @@ -17,6 +17,11 @@ export interface ISettingsModel extends IBaseModel { value: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, ): Promise; + resetValueById( + _id: string, + value?: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, + ): Promise; + incrementValueById(_id: ISetting['_id'], value?: number): Promise; updateOptionsById( diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 4af37334e287d..804b72a763de1 100644 --- a/packages/rest-typings/src/v1/misc.ts +++ b/packages/rest-typings/src/v1/misc.ts @@ -164,6 +164,22 @@ const MethodCallAnonSchema = { export const isMethodCallAnonProps = ajv.compile(MethodCallAnonSchema); +type Fingerprint = { setDeploymentAs: 'new-workspace' | 'updated-configuration' }; + +const FingerprintSchema = { + type: 'object', + properties: { + setDeploymentAs: { + type: 'string', + enum: ['new-workspace', 'updated-configuration'], + }, + }, + required: ['setDeploymentAs'], + additionalProperties: false, +}; + +export const isFingerprintProps = ajv.compile(FingerprintSchema); + type PwGetPolicyReset = { token: string }; const PwGetPolicyResetSchema = { @@ -229,6 +245,12 @@ export type MiscEndpoints = { }; }; + '/v1/fingerprint': { + POST: (params: Fingerprint) => { + success: boolean; + }; + }; + '/v1/smtp.check': { GET: () => { isSMTPConfigured: boolean;