feat: Deployment Fingerprint (#30411)

pull/29502/merge
Rodrigo Nascimento 2 years ago committed by GitHub
parent aab18ef654
commit ec1b2b9846
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      .changeset/tidy-bears-applaud.md
  2. 76
      apps/meteor/app/api/server/v1/misc.ts
  3. 6
      apps/meteor/app/cloud/server/functions/buildRegistrationData.ts
  4. 3
      apps/meteor/app/statistics/server/lib/statistics.ts
  5. 44
      apps/meteor/client/components/FingerprintChangeModal.tsx
  6. 47
      apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx
  7. 71
      apps/meteor/client/startup/rootUrlChange.ts
  8. 2
      apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx
  9. 2
      apps/meteor/client/views/admin/info/InformationPage.stories.tsx
  10. 2
      apps/meteor/client/views/admin/info/UsageCard.stories.tsx
  11. 11
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  12. 8
      apps/meteor/server/configureLogLevel.ts
  13. 1
      apps/meteor/server/main.ts
  14. 19
      apps/meteor/server/models/raw/Settings.ts
  15. 65
      apps/meteor/server/settings/misc.ts
  16. 2
      apps/meteor/server/settings/omnichannel.ts
  17. 2
      apps/meteor/server/settings/setup-wizard.ts
  18. 2
      packages/core-typings/src/ISetting.ts
  19. 2
      packages/core-typings/src/IStats.ts
  20. 5
      packages/model-typings/src/models/ISettingsModel.ts
  21. 22
      packages/rest-typings/src/v1/misc.ts

@ -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.

@ -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({});
},
},
);

@ -7,6 +7,8 @@ import { LICENSE_VERSION } from '../license';
export type WorkspaceRegistrationData<T> = {
uniqueId: string;
deploymentFingerprintHash: string;
deploymentFingerprintVerified: boolean;
workspaceId: string;
address: string;
contactName: string;
@ -50,6 +52,8 @@ export async function buildWorkspaceRegistrationData<T extends string | undefine
const npsEnabled = settings.get<string>('NPS_survey_enabled');
const agreePrivacyTerms = settings.get<string>('Cloud_Service_Agree_PrivacyTerms');
const setupWizardState = settings.get<string>('Show_Setup_Wizard');
const deploymentFingerprintHash = settings.get<string>('Deployment_FingerPrint_Hash');
const deploymentFingerprintVerified = settings.get<boolean>('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<T extends string | undefine
return {
uniqueId: stats.uniqueId,
deploymentFingerprintHash,
deploymentFingerprintVerified,
workspaceId,
address,
contactName,

@ -101,6 +101,9 @@ export const statistics = {
statistics.installedAt = uniqueID.createdAt.toISOString();
}
statistics.deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash');
statistics.deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified');
if (Info) {
statistics.version = Info.version;
statistics.tag = Info.tag;

@ -0,0 +1,44 @@
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 FingerprintChangeModalProps = {
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
};
const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps): ReactElement => {
const t = useTranslation();
return (
<GenericModal
variant='warning'
title={t('Unique_ID_change_detected')}
onConfirm={onConfirm}
onClose={onClose}
onCancel={onCancel}
confirmText={t('New_workspace')}
cancelText={t('Configuration_update')}
>
<Box
is='p'
mbe={16}
dangerouslySetInnerHTML={{
__html: t('Unique_ID_change_detected_description'),
}}
/>
<Box
is='p'
mbe={16}
dangerouslySetInnerHTML={{
__html: t('Unique_ID_change_detected_learn_more_link'),
}}
/>
</GenericModal>
);
};
export default FingerprintChangeModal;

@ -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 (
<GenericModal
variant='warning'
title={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
onConfirm={onConfirm}
onCancel={onCancel}
cancelText={t('Back')}
confirmText={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
>
<Box
is='p'
mbe={16}
dangerouslySetInnerHTML={{
__html: newWorkspace ? t('Confirm_new_workspace_description') : t('Confirm_configuration_update_description'),
}}
/>
<Box
is='p'
mbe={16}
dangerouslySetInnerHTML={{
__html: t('Unique_ID_change_detected_learn_more_link'),
}}
/>
</GenericModal>
);
};
export default FingerprintChangeModalConfirmation;

@ -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();
});
});

@ -66,6 +66,8 @@ export default {
_id: '',
wizard: {},
uniqueId: '',
deploymentFingerprintHash: '',
deploymentFingerprintVerified: true,
installedAt: '',
version: '1.0.0',
tag: '',

@ -96,6 +96,8 @@ export default {
_id: '',
wizard: {},
uniqueId: '',
deploymentFingerprintHash: '',
deploymentFingerprintVerified: true,
installedAt: '',
version: '',
tag: '',

@ -44,6 +44,8 @@ export default {
_id: '',
wizard: {},
uniqueId: '',
deploymentFingerprintHash: '',
deploymentFingerprintVerified: true,
installedAt: '',
version: '',
tag: '',

@ -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.<br/><br/><strong>Warning</strong>: 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.<br/><br/><strong>Warning</strong>: 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",
@ -3652,6 +3658,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",
@ -5253,6 +5261,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.<br/><br/>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": "<a href=\"https://go.rocket.chat/i/fingerprint-changed-faq\" target=\"_blank\">Learn more</a>",
"Unique_ID_change_detected": "Unique ID change detected",
"Unknown_Import_State": "Unknown Import State",
"Unknown_User": "Unknown User",
"Unlimited": "Unlimited",

@ -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);
}

@ -1,4 +1,5 @@
import './models/startup';
import './configureLogLevel';
import './settings/index';
import '../ee/server/models/startup';
import './services/startup';

@ -69,6 +69,25 @@ export class SettingsRaw extends BaseRaw<ISetting> implements ISettingsModel {
return this.updateOne(query, update);
}
async resetValueById(
_id: string,
value?: (ISetting['value'] extends undefined ? never : ISetting['value']) | null,
): Promise<Document | UpdateResult | undefined> {
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<Document | UpdateResult> {
return this.updateOne(
{

@ -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, {

@ -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',

@ -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,

@ -72,7 +72,7 @@ export interface ISettingBase {
hidden?: boolean;
modules?: Array<string>;
invalidValue?: SettingValue;
valueSource?: string;
valueSource?: 'packageValue' | 'processEnvValue';
secret?: boolean;
i18nDescription?: string;
autocomplete?: boolean;

@ -17,6 +17,8 @@ export interface IStats {
registerServer?: boolean;
};
uniqueId: string;
deploymentFingerprintHash: string;
deploymentFingerprintVerified: boolean;
installedAt?: string;
version?: string;
tag?: string;

@ -17,6 +17,11 @@ export interface ISettingsModel extends IBaseModel<ISetting> {
value: (ISetting['value'] extends undefined ? never : ISetting['value']) | null,
): Promise<Document | UpdateResult>;
resetValueById(
_id: string,
value?: (ISetting['value'] extends undefined ? never : ISetting['value']) | null,
): Promise<Document | UpdateResult | undefined>;
incrementValueById(_id: ISetting['_id'], value?: number): Promise<Document | UpdateResult>;
updateOptionsById<T extends ISetting = ISetting>(

@ -164,6 +164,22 @@ const MethodCallAnonSchema = {
export const isMethodCallAnonProps = ajv.compile<MethodCallAnon>(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<Fingerprint>(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;

Loading…
Cancel
Save