Skip to content
6 changes: 6 additions & 0 deletions .changeset/offline-license-no-egress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/license': minor
---

Adds support for the `offline` license flag, suppressing every outbound connection to Rocket.Chat Cloud services and the Push Gateway at its source, so air-gapped workspaces never initiate calls that would violate their security compliance.
5 changes: 5 additions & 0 deletions apps/meteor/ee/server/apps/appRequestsCron.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cronJobs } from '@rocket.chat/cron';
import { License } from '@rocket.chat/license';
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';

import { appRequestNotififyForUsers } from './marketplace/appRequestNotifyUsers';
Expand All @@ -8,6 +9,10 @@ import { settings } from '../../../server/settings';

const appsNotifyAppRequests = async function _appsNotifyAppRequests() {
try {
if (License.hasOfflineLicense()) {
return;
}

const installedApps = await Apps.installedApps({ enabled: true });
if (!installedApps || installedApps.length === 0) {
return;
Expand Down
12 changes: 10 additions & 2 deletions apps/meteor/ee/server/apps/communication/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { registerAppLogsExportHandler } from './endpoints/appLogsExportHandler';
import { registerAppLogsHandler } from './endpoints/appLogsHandler';
import { registerAppsCountHandler } from './endpoints/appsCountHandler';
import { Info } from '../../../../app/utils/rocketchat.info';
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
import { API } from '../../../../server/api';
import type { APIClass } from '../../../../server/api/ApiClass';
import { getUploadFormData } from '../../../../server/api/lib/getUploadFormData';
Expand Down Expand Up @@ -96,6 +97,13 @@ export class AppsRestApi {
const manager = this._manager;

const handleError = (message: string, err: any) => {
// Offline (air-gapped) licenses suppress marketplace requests at the source;
// report the real reason instead of a generic connectivity failure.
if (err instanceof CloudOfflineLicenseError) {
orchestrator.getRocketChatLogger().info({ msg: err.message });
return API.v1.failure({ error: err.message });
}

// when there is no `response` field in the error, it means the request
// couldn't even make it to the server
if (!err.hasOwnProperty('response')) {
Expand Down Expand Up @@ -149,7 +157,7 @@ export class AppsRestApi {
const apps = await fetchMarketplaceApps({ ...(this.queryParams.isAdminUser === 'false' && { endUserID: this.user._id }) });
return API.v1.success(apps);
} catch (err) {
if (err instanceof MarketplaceConnectionError) {
if (err instanceof MarketplaceConnectionError || err instanceof CloudOfflineLicenseError) {
return handleError('Unable to access Marketplace. Does the server has access to the internet?', err);
}

Expand Down Expand Up @@ -178,7 +186,7 @@ export class AppsRestApi {
return API.v1.success(categories);
} catch (err) {
orchestrator.getRocketChatLogger().error({ msg: 'Error fetching categories from Marketplace:', err });
if (err instanceof MarketplaceConnectionError) {
if (err instanceof MarketplaceConnectionError || err instanceof CloudOfflineLicenseError) {
return handleError('Unable to access Marketplace. Does the server has access to the internet?', err);
}

Expand Down
5 changes: 5 additions & 0 deletions apps/meteor/ee/server/apps/cron.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { cronJobs } from '@rocket.chat/cron';
import { License } from '@rocket.chat/license';
import { Settings, Users } from '@rocket.chat/models';

import { Apps } from './orchestrator';
Expand Down Expand Up @@ -75,6 +76,10 @@ const notifyAdminsAboutRenewedApps = async function _notifyAdminsAboutRenewedApp
};

const appsUpdateMarketplaceInfo = async function _appsUpdateMarketplaceInfo() {
Comment thread
KevLehman marked this conversation as resolved.
if (License.hasOfflineLicense()) {
return;
}

const token = await getWorkspaceAccessToken();
const workspaceIdSetting = await Settings.getValueById('Cloud_Workspace_Id');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { License } from '@rocket.chat/license';
import { type ExtendedFetchOptions, Response, serverFetch } from '@rocket.chat/server-fetch';

import { isTesting } from './isTesting';
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';

export class MarketplaceAPIClient {
#fetchStrategy: (input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean) => Promise<Response>;
Expand Down Expand Up @@ -41,6 +43,12 @@ export class MarketplaceAPIClient {
}

public fetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): ReturnType<typeof serverFetch> {
if (License.hasOfflineLicense()) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
cardoso marked this conversation as resolved.
return Promise.reject(
new CloudOfflineLicenseError('Marketplace connectivity is disabled by the offline license applied to this workspace'),
);
}

if (!input.startsWith('http://') && !input.startsWith('https://')) {
input = this.getMarketplaceUrl().concat(!input.startsWith('/') ? '/' : '', input);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from 'zod';

import { getMarketplaceHeaders } from './getMarketplaceHeaders';
import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors';
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
import { settings } from '../../../../server/settings';
import { Apps } from '../orchestrator';
Expand Down Expand Up @@ -157,6 +158,12 @@ export async function fetchMarketplaceApps({ endUserID }: FetchMarketplaceAppsPa
},
});
} catch (error) {
// Offline (air-gapped) licenses reject before any request is made; keep the
// typed error so the REST layer can report the real reason instead of a
// generic connectivity failure.
if (error instanceof CloudOfflineLicenseError) {
throw error;
}
throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from 'zod';

import { getMarketplaceHeaders } from './getMarketplaceHeaders';
import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors';
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
import { settings } from '../../../../server/settings';
import { Apps } from '../orchestrator';
Expand Down Expand Up @@ -41,6 +42,12 @@ export async function fetchMarketplaceCategories(): Promise<AppCategory[]> {
allowList: settings.get<string>('SSRF_Allowlist'),
});
} catch (error) {
// Offline (air-gapped) licenses reject before any request is made; keep the
// typed error so the REST layer can report the real reason instead of a
// generic connectivity failure.
if (error instanceof CloudOfflineLicenseError) {
throw error;
}
throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection');
}

Expand Down
10 changes: 10 additions & 0 deletions apps/meteor/ee/server/lib/license/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import moment from 'moment';
import { getAppCount } from './lib/getAppCount';
import { callbacks } from '../../../../server/lib/callbacks';
import { syncWorkspace } from '../../../../server/lib/cloud/syncWorkspace';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener';
import { settings } from '../../../../server/settings';

Expand Down Expand Up @@ -125,6 +126,15 @@ export const startLicense = async () => {
}
}

License.onInstall(() => {
if (License.hasOfflineLicense()) {
// startup level so it is visible at the default Log_Level, like 'License installed'
SystemLogger.startup(
'Offline license detected: outbound connections to Rocket.Chat Cloud services and the Rocket.Chat Push Gateway are disabled',
);
}
});

// After the current license is already loaded, watch the setting value to react to new licenses being applied.
settings.change<string>('Enterprise_License', (license) => applyLicenseOrRemove(license, true));

Expand Down
5 changes: 5 additions & 0 deletions apps/meteor/lib/errors/CloudOfflineLicenseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CloudWorkspaceError } from './CloudWorkspaceError';

export class CloudOfflineLicenseError extends CloudWorkspaceError {
override name = CloudOfflineLicenseError.name;
}
3 changes: 3 additions & 0 deletions apps/meteor/server/lib/cloud/connectWorkspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

import { getRedirectUri } from './getRedirectUri';
import { assertNotOfflineLicense } from './offlineLicense';
import { saveRegistrationData } from './saveRegistrationData';
import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError';
import { settings } from '../../settings';
Expand Down Expand Up @@ -47,6 +48,8 @@ const fetchRegistrationDataPayload = async ({
};

export async function connectWorkspace(token: string) {
assertNotOfflineLicense();

if (!token) {
throw new CloudWorkspaceConnectionError('Invalid registration token');
}
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { Meteor } from 'meteor/meteor';

import { getRedirectUri } from './getRedirectUri';
import { userScopes } from './oauthScopes';
import { assertNotOfflineLicense } from './offlineLicense';
import { settings } from '../../settings';
import { SystemLogger } from '../logger/system';

export async function finishOAuthAuthorization(code: string, state: string) {
assertNotOfflineLicense();

if (settings.get<string>('Cloud_Workspace_Registration_State') !== state) {
throw new Meteor.Error('error-invalid-state', 'Invalid state provided', {
method: 'cloud:finishOAuthAuthorization',
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/server/lib/cloud/getConfirmationPoll.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { CloudConfirmationPollData } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

import { assertNotOfflineLicense } from './offlineLicense';
import { settings } from '../../settings';
import { SystemLogger } from '../logger/system';

export async function getConfirmationPoll(deviceCode: string): Promise<CloudConfirmationPollData> {
assertNotOfflineLicense();

try {
const cloudUrl = settings.get<string>('Cloud_Url');
const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, {
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { Random } from '@rocket.chat/random';

import { getRedirectUri } from './getRedirectUri';
import { userScopes } from './oauthScopes';
import { assertNotOfflineLicense } from './offlineLicense';
import { settings } from '../../settings';
import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates';
import { notifyOnSettingChangedById } from '../notifyListener';

export async function getOAuthAuthorizationUrl() {
assertNotOfflineLicense();

const state = Random.id();

await updateAuditedBySystem({
Expand Down
5 changes: 5 additions & 0 deletions apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IWorkspaceCredentials } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { WorkspaceCredentials } from '@rocket.chat/models';

import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope';
Expand Down Expand Up @@ -26,6 +27,10 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save
return '';
}

if (License.hasOfflineLicense()) {
return '';
}

// Note: If no scope is given, it means we should assume the default scope, we store the default scopes
// in the global variable workspaceScopes.
if (scope === '') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { License } from '@rocket.chat/license';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

import { getRedirectUri } from './getRedirectUri';
Expand Down Expand Up @@ -31,6 +32,10 @@ export async function getWorkspaceAccessTokenWithScope({
return tokenResponse;
}

if (License.hasOfflineLicense()) {
return tokenResponse;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
const client_id = settings.get<string>('Cloud_Workspace_Client_Id');
if (!client_id) {
Expand Down
13 changes: 13 additions & 0 deletions apps/meteor/server/lib/cloud/offlineLicense.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { License } from '@rocket.chat/license';

import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError';

/**
* Guard for interactive cloud flows (registration, OAuth, billing). Background
* jobs should instead skip silently by checking {@link License.hasOfflineLicense}.
*/
export function assertNotOfflineLicense(): void {
if (License.hasOfflineLicense()) {
throw new CloudOfflineLicenseError('Cloud connectivity is disabled by the offline license applied to this workspace');
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Users } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

Expand All @@ -7,6 +8,10 @@ import { settings } from '../../settings';
import { SystemLogger } from '../logger/system';

export async function registerPreIntentWorkspaceWizard(): Promise<boolean> {
if (License.hasOfflineLicense()) {
return false;
}

const firstUser = (await Users.getOldest({ projection: { name: 1, emails: 1 } })) as IUser | undefined;
const email = firstUser?.emails?.find((address) => address)?.address;

Expand All @@ -16,6 +21,12 @@ export async function registerPreIntentWorkspaceWizard(): Promise<boolean> {

const regInfo = await buildWorkspaceRegistrationData(email);

// Re-validated at dispatch time: an offline license applied while the
// registration data was being built must still suppress the request.
if (License.hasOfflineLicense()) {
return false;
}

try {
const cloudUrl = settings.get<string>('Cloud_Url');
const response = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, {
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/server/lib/cloud/startRegisterWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Settings } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

import { buildWorkspaceRegistrationData } from './buildRegistrationData';
import { assertNotOfflineLicense } from './offlineLicense';
import { retrieveRegistrationStatus } from './retrieveRegistrationStatus';
import { syncWorkspace } from './syncWorkspace';
import { settings } from '../../settings';
Expand All @@ -10,6 +11,8 @@ import { SystemLogger } from '../logger/system';
import { notifyOnSettingChangedById } from '../notifyListener';

export async function startRegisterWorkspace(resend = false) {
assertNotOfflineLicense();

const { workspaceRegistered } = await retrieveRegistrationStatus();
if (workspaceRegistered || process.env.TEST_MODE) {
await syncWorkspace();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import type { CloudRegistrationIntentData } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

import { buildWorkspaceRegistrationData } from './buildRegistrationData';
import { assertNotOfflineLicense } from './offlineLicense';
import { settings } from '../../settings';
import { SystemLogger } from '../logger/system';

export async function startRegisterWorkspaceSetupWizard(resend = false, email: string): Promise<CloudRegistrationIntentData> {
assertNotOfflineLicense();

const regInfo = await buildWorkspaceRegistrationData(email);

let payload;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ const getSupportedVersionsFromCloud = async () => {

const headers = await generateWorkspaceBearerHttpHeader();

// Re-validated at dispatch time: an offline license applied while this async
// operation was in flight must still suppress the request.
if (License.hasOfflineLicense()) {
Comment thread
KevLehman marked this conversation as resolved.
return { success: true, result: undefined } as const;
}

const response = await handleResponse<SupportedVersions>(
fetch(releaseEndpoint, {
headers,
Expand Down Expand Up @@ -141,7 +147,10 @@ const getSupportedVersionsToken = async (retry = 0) => {
* Gets the latest version
* return the token
*/
const [versionsFromLicense, cloudResponse] = await Promise.all([License.getLicense(), getSupportedVersionsFromCloud()]);
const [versionsFromLicense, cloudResponse] = await Promise.all([
License.getLicense(),
License.hasOfflineLicense() ? ({ success: true, result: undefined } as const) : getSupportedVersionsFromCloud(),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
]);

const supportedVersions = await supportedVersionsChooseLatest(
supportedVersionsFromBuild,
Expand Down
8 changes: 8 additions & 0 deletions apps/meteor/server/lib/cloud/syncWorkspace/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { License } from '@rocket.chat/license';

import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError';
import { SystemLogger } from '../../logger/system';
import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError, isAbortError } from '../getWorkspaceAccessToken';
Expand All @@ -12,6 +14,12 @@ import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/suppo
* @throws {Error} - If there is an unexpected error during sync like a network error
*/
export async function syncWorkspace() {
if (License.hasOfflineLicense()) {
SystemLogger.debug({ msg: 'Skipping cloud sync: workspace has an offline license', function: 'syncWorkspace' });
await getCachedSupportedVersionsToken.reset();
return;
}

try {
await announcementSync();
await syncCloudData();
Expand Down
Loading
Loading