Move cluster state to main process (#59643)

* Create `ClusterStore` that manages cluster state

* Fix tests that mocked tshd directly

* Remove IPC to notify the main process about cluster list changes

* Load immer plugins in `MainProcess`

* Improve comments

* Refactor `useSender`

* Get rid of unnecessary Map and try/catch around send

* Get rid of `MainProcess.create`

* Do not return early `c.proxyHost` is falsy

* Add more context to test

* Add missing logout handler in main process

* Fix applying patches

* Adjust `subscribeToClusterStore` to updated `startAwaitableSenderListener`

* Crash window when sending state update fails

* Extract WebContents navigation handlers and add tests for opening links

* Improve error message

* Initialize `ClusterStore` synchronously

* Convert `lazyTshdClient` field to `getTshdClient` function, add docs

* Remove unused eslint directive
This commit is contained in:
Grzegorz Zdunek
2025-10-22 10:49:30 +00:00
committed by GitHub
parent 5dc76fecac
commit a41d021f8a
21 changed files with 865 additions and 464 deletions
+10 -83
View File
@@ -19,7 +19,7 @@
import os from 'node:os';
import path from 'node:path';
import { app, dialog, nativeTheme, shell } from 'electron';
import { app, dialog, nativeTheme } from 'electron';
import { CUSTOM_PROTOCOL } from 'shared/deepLinks';
import { ensureError } from 'shared/utils/error';
@@ -27,8 +27,8 @@ import { ensureError } from 'shared/utils/error';
import { parseDeepLink } from 'teleterm/deepLinks';
import Logger from 'teleterm/logger';
import MainProcess from 'teleterm/mainProcess';
import { registerNavigationHandlers } from 'teleterm/mainProcess/navigationHandler';
import { enableWebHandlersProtection } from 'teleterm/mainProcess/protocolHandler';
import { manageRootClusterProxyHostAllowList } from 'teleterm/mainProcess/rootClusterProxyHostAllowList';
import { getRuntimeSettings } from 'teleterm/mainProcess/runtimeSettings';
import { WindowsManager } from 'teleterm/mainProcess/windowsManager';
import { createConfigService } from 'teleterm/services/config';
@@ -95,7 +95,7 @@ async function initializeApp(): Promise<void> {
let mainProcess: MainProcess;
try {
mainProcess = MainProcess.create({
mainProcess = new MainProcess({
settings,
logger,
configService,
@@ -146,23 +146,6 @@ async function initializeApp(): Promise<void> {
// window before processing the listener for deep links.
setUpDeepLinks(logger, windowsManager, settings);
const rootClusterProxyHostAllowList = new Set<string>();
(async () => {
const { terminalService } = await mainProcess.getTshdClients();
manageRootClusterProxyHostAllowList({
tshdClient: terminalService,
logger,
allowList: rootClusterProxyHostAllowList,
});
})().catch(error => {
const message = 'Could not initialize the tshd client in the main process';
logger.error(message, error);
showDialogWithError(message, error);
app.exit(1);
});
app
.whenReady()
.then(() => {
@@ -181,69 +164,13 @@ async function initializeApp(): Promise<void> {
app.exit(1);
});
// Limit navigation capabilities to reduce the attack surface.
// See TEL-Q122-19 from "Teleport Core Testing Q1 2022" security audit.
//
// See also points 12, 13 and 14 from the Electron's security tutorial.
// https://github.com/electron/electron/blob/v17.2.0/docs/tutorial/security.md#12-verify-webview-options-before-creation
app.on('web-contents-created', (_, contents) => {
contents.on('will-navigate', (event, navigationUrl) => {
// Allow reloading the renderer app in dev mode.
if (settings.dev && new URL(navigationUrl).host === 'localhost:8080') {
return;
}
logger.warn(`Navigation to ${navigationUrl} blocked by 'will-navigate'`);
event.preventDefault();
});
// The usage of webview is blocked by default, but let's include the handler just in case.
// https://github.com/electron/electron/blob/v17.2.0/docs/api/webview-tag.md#enabling
contents.on('will-attach-webview', (event, _, params) => {
logger.warn(
`Opening a webview to ${params.src} blocked by 'will-attach-webview'`
);
event.preventDefault();
});
contents.setWindowOpenHandler(details => {
const url = new URL(details.url);
function isUrlSafe(): boolean {
if (url.protocol !== 'https:') {
return false;
}
if (url.host === 'goteleport.com') {
return true;
}
if (
url.host === 'github.com' &&
url.pathname.startsWith('/gravitational/')
) {
return true;
}
// Allow opening links to the Web UIs of root clusters currently added in the app.
if (rootClusterProxyHostAllowList.has(url.host)) {
return true;
}
}
// Open links to documentation and GitHub issues in the external browser.
// They need to have `target` set to `_blank`.
if (isUrlSafe()) {
shell.openExternal(url.toString());
} else {
logger.warn(
`Opening a new window to ${url} blocked by 'setWindowOpenHandler'`
);
dialog.showErrorBox(
'Cannot open this link',
'The domain does not match any of the allowed domains. Check main.log for more details.'
);
}
return { action: 'deny' };
});
app.on('web-contents-created', (_, webContents) => {
registerNavigationHandlers(
webContents,
settings,
mainProcess.clusterStore,
logger
);
});
}
@@ -148,7 +148,7 @@ export class AwaitableSender<T> {
/** Error thrown when waiting for message acknowledgement confirmation was abandoned. */
export class MessageAcknowledgementError extends Error {
constructor(cause?: unknown) {
super('Stopped waiting for message acknowledgment from renderer', {
super('Failed to receive message acknowledgement from the renderer', {
cause,
});
}
@@ -0,0 +1,119 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { enableMapSet, enablePatches } from 'immer';
import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';
import { MockTshClient } from 'teleterm/services/tshd/fixtures/mocks';
import {
makeLeafCluster,
makeRootCluster,
} from 'teleterm/services/tshd/testHelpers';
import { ClusterStore } from './clusterStore';
enablePatches();
enableMapSet();
const cluster = makeRootCluster({ connected: false });
const clusterWithDetails = makeRootCluster({
...cluster,
connected: true,
features: {
advancedAccessWorkflows: true,
isUsageBasedBilling: true,
},
});
const leafCluster = makeLeafCluster();
const mockWindowsManager = {
crashWindow: async () => undefined,
};
test('adds cluster', async () => {
const mockClient = new MockTshClient();
mockClient.addCluster = () => new MockedUnaryCall(cluster);
const clusterStore = new ClusterStore(
() => Promise.resolve(mockClient),
mockWindowsManager
);
await clusterStore.add(cluster.uri);
const state = clusterStore.getState();
expect(state.get(cluster.uri)).toEqual(cluster);
});
test('adding a cluster does not overwrite an existing one', async () => {
const mockClient = new MockTshClient();
mockClient.addCluster = () => new MockedUnaryCall(cluster);
mockClient.getCluster = () => new MockedUnaryCall(clusterWithDetails);
const clusterStore = new ClusterStore(
() => Promise.resolve(mockClient),
mockWindowsManager
);
await clusterStore.sync(cluster.uri);
// addCluster call returns fewer details than getCluster,
// so clusterStore.add shouldn't overwrite details already acquired
// by clusterStore.sync.
await clusterStore.add(cluster.uri);
const state = clusterStore.getState();
expect(state.get(cluster.uri)).toEqual(clusterWithDetails);
});
test('syncs cluster', async () => {
const mockClient = new MockTshClient();
mockClient.getCluster = () => new MockedUnaryCall(clusterWithDetails);
mockClient.listLeafClusters = () =>
new MockedUnaryCall({ clusters: [leafCluster] });
const clusterStore = new ClusterStore(
() => Promise.resolve(mockClient),
mockWindowsManager
);
await clusterStore.sync(cluster.uri);
const state = clusterStore.getState();
expect(state.get(clusterWithDetails.uri)).toStrictEqual(clusterWithDetails);
expect(state.get(leafCluster.uri)).toStrictEqual(leafCluster);
});
test('logs out of cluster', async () => {
const mockClient = new MockTshClient();
mockClient.getCluster = () => new MockedUnaryCall(clusterWithDetails);
mockClient.listLeafClusters = () =>
new MockedUnaryCall({ clusters: [leafCluster] });
const logoutMock = jest.spyOn(mockClient, 'logout');
const removeClusterMock = jest.spyOn(mockClient, 'removeCluster');
const clusterStore = new ClusterStore(
() => Promise.resolve(mockClient),
mockWindowsManager
);
await clusterStore.sync(cluster.uri);
await clusterStore.logout(cluster.uri);
expect(logoutMock).toHaveBeenCalledWith({ clusterUri: cluster.uri });
expect(removeClusterMock).toHaveBeenCalledWith({
clusterUri: cluster.uri,
});
const state = clusterStore.getState();
expect(state.get(clusterWithDetails.uri)).toBeUndefined();
expect(state.get(leafCluster.uri)).toBeUndefined();
});
@@ -0,0 +1,220 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Patch, Producer, produceWithPatches } from 'immer';
import {
Cluster,
ShowResources,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import Logger from 'teleterm/logger';
import { TshdClient } from 'teleterm/services/tshd';
import { ClusterUri, RootClusterUri, routing } from 'teleterm/ui/uri';
import { AwaitableSender } from '../awaitableSender';
import type { WindowsManager } from '../windowsManager';
export type State = ReadonlyMap<ClusterUri, Cluster>;
export type ClusterStoreUpdate =
/**
* Patches allow the other side to keep reference stability
* so that not the whole state is recreated.
*/
| { kind: 'patches'; value: Patch[] }
/** The full state, useful to get the initial state on the other side. */
| { kind: 'state'; value: State };
export class ClusterStore {
private senders = new Set<AwaitableSender<ClusterStoreUpdate>>();
private state: State = new Map();
private logger = new Logger('ClusterStore');
constructor(
private readonly getTshdClient: () => Promise<TshdClient>,
private readonly windowsManager: Pick<WindowsManager, 'crashWindow'>
) {}
/** Adds a cluster. */
async add(proxyAddress: string): Promise<Cluster> {
const client = await this.getTshdClient();
const { response } = await client.addCluster({
name: proxyAddress,
});
await this.update(draft => {
// Do not overwrite the existing cluster;
// otherwise we may lose properties fetched from the auth server.
// Consider separating properties read from profile and those
// fetched from the auth server at the RPC message level.
if (draft.has(response.uri)) {
return;
}
draft.set(response.uri, response);
});
return response;
}
/** Logs out of the cluster and removes its profile.*/
async logout(uri: RootClusterUri): Promise<void> {
const client = await this.getTshdClient();
// TODO(gzdunek): logout and removeCluster should be combined into
// a single acton in tshd.
await client.logout({ clusterUri: uri });
await client.removeCluster({ clusterUri: uri });
await this.update(draft => {
for (let d of draft.values()) {
if (routing.belongsToProfile(uri, d.uri)) {
draft.delete(d.uri);
}
}
});
}
/**
* Synchronizes the root clusters.
* Does not make a network call, only reads profiles from the disk.
*/
async syncRootClusters(): Promise<void> {
const client = await this.getTshdClient();
const { response } = await client.listRootClusters({});
await this.update(draft => {
draft.clear();
response.clusters.forEach(cluster => {
draft.set(cluster.uri, cluster);
});
});
}
/**
* Synchronizes a root cluster.
* Makes network calls to get cluster details and its leaf clusters.
*/
async sync(uri: RootClusterUri): Promise<void> {
let cluster: Cluster;
let leafs: Cluster[];
const client = await this.getTshdClient();
try {
const clusterAndLeafs = await getClusterAndLeafs(client, uri);
cluster = clusterAndLeafs.cluster;
leafs = clusterAndLeafs.leafs;
} catch (error) {
await this.update(draft => {
const cluster = draft.get(uri);
if (cluster) {
// TODO(gzdunek): We should rather store the cluster synchronization status,
// so the callsites could check it before reading the field.
// The workaround is to update the field in case of a failure,
// so the places that wait for showResources !== UNSPECIFIED don't get stuck indefinitely.
cluster.showResources = ShowResources.ACCESSIBLE_ONLY;
}
});
throw error;
}
await this.update(draft => {
draft.set(cluster.uri, cluster);
leafs.forEach(leaf => {
draft.set(leaf.uri, leaf);
});
});
}
getRootClusters(): Cluster[] {
return this.state
.values()
.toArray()
.filter(c => !c.leaf);
}
getState(): State {
return this.state;
}
/**
* Registers an `AwaitableSender` to send updates and automatically unregisters
* it when disposed.
*
* Upon registration, the current state is sent immediately as the initial
* message to the sender.
*/
registerSender(sender: AwaitableSender<ClusterStoreUpdate>): void {
this.logger.info('Sender registered');
this.senders.add(sender);
const send = this.withErrorHandling(update => sender.send(update));
void send({
kind: 'state',
value: this.state,
});
sender.whenDisposed().then(() => {
this.senders.delete(sender);
this.logger.info('Sender unregistered');
});
}
private async update(producer: Producer<State>): Promise<void> {
const [state, patches] = produceWithPatches(this.state, producer);
this.state = state;
await Promise.all(
this.senders.values().map(sender => {
const send = this.withErrorHandling(update => sender.send(update));
return send({
kind: 'patches',
value: patches,
});
})
);
}
/**
* Wraps a cluster store update sender function with error handling.
*
* Any error indicates that the renderer state may be out of sync with the cluster store.
* Applying further updates may fail.
* Prompt the user to reload the window or quit the app.
*/
private withErrorHandling(
sender: (update: ClusterStoreUpdate) => Promise<void>
): (update: ClusterStoreUpdate) => Promise<void> {
return async update => {
try {
await sender(update);
} catch (e) {
await this.windowsManager.crashWindow(e);
}
};
}
}
async function getClusterAndLeafs(tshdClient: TshdClient, uri: RootClusterUri) {
const resolved = await Promise.all([
tshdClient.getCluster({
clusterUri: uri,
}),
tshdClient.listLeafClusters({
clusterUri: uri,
}),
]);
return {
cluster: resolved[0].response,
leafs: resolved[1].response.clusters,
};
}
@@ -0,0 +1,19 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export * from './clusterStore';
@@ -25,6 +25,8 @@ import { AgentProcessState } from 'teleterm/mainProcess/types';
// Importing electron breaks the fixtures if that's done from within storybook.
import { createConfigService } from 'teleterm/services/config/configService';
import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mocks';
import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
import { Cluster } from 'teleterm/services/tshd/types';
import { MainProcessClient, RuntimeSettings } from 'teleterm/types';
export class MockMainProcessClient implements MainProcessClient {
@@ -172,8 +174,6 @@ export class MockMainProcessClient implements MainProcessClient {
return this.frontendAppInit.promise;
}
refreshClusterList() {}
async selectDirectoryForDesktopSession() {
return '';
}
@@ -202,6 +202,20 @@ export class MockMainProcessClient implements MainProcessClient {
} {
return { cleanup: () => undefined };
}
subscribeToClusterStore(): {
cleanup: () => void;
} {
return { cleanup: () => undefined };
}
async logoutCluster(): Promise<void> {}
async syncCluster(): Promise<void> {}
async addCluster(): Promise<Cluster> {
return makeRootCluster();
}
async syncRootClusters(): Promise<Cluster[]> {
return [];
}
}
export const makeRuntimeSettings = (
@@ -32,6 +32,7 @@ import {
nativeTheme,
shell,
} from 'electron';
import { enableMapSet, enablePatches } from 'immer';
import { AbortError } from 'shared/utils/error';
@@ -77,6 +78,8 @@ import {
} from '../services/config';
import { downloadAgent, FileDownloader, verifyAgent } from './agentDownloader';
import { AgentRunner } from './agentRunner';
import { AwaitableSender } from './awaitableSender';
import { ClusterStore } from './clusterStore';
import { subscribeToTabContextMenuEvent } from './contextMenus/tabContextMenu';
import { subscribeToTerminalContextMenuEvent } from './contextMenus/terminalContextMenu';
import {
@@ -127,8 +130,18 @@ export default class MainProcess {
autoUpdateService: AutoUpdateClient;
}>;
private readonly appUpdater: AppUpdater;
public readonly clusterStore: ClusterStore;
private constructor(opts: Options) {
/**
* Starts necessary child processes such as tsh daemon and the shared process. It also sets
* up IPC handlers and resolves the network addresses under which the child processes set up gRPC
* servers.
*
* Might throw an error if spawning a child process fails, see initTshd for more details.
*/
constructor(opts: Options) {
enablePatches();
enableMapSet();
this.settings = opts.settings;
this.logger = opts.logger;
this.configService = opts.configService;
@@ -151,6 +164,13 @@ export default class MainProcess {
}
);
this.updateAboutPanelIfNeeded();
this.setAppMenu();
this.initTshd();
this.initSharedProcess();
this.initResolvingChildProcessAddresses();
this.initIpc();
const getClusterVersions = async () => {
const { autoUpdateService } = await this.getTshdClients();
const { response } = await autoUpdateService.getClusterVersions({});
@@ -177,19 +197,10 @@ export default class MainProcess {
},
process.env[TELEPORT_TOOLS_VERSION_ENV_VAR]
);
}
/**
* create starts necessary child processes such as tsh daemon and the shared process. It also sets
* up IPC handlers and resolves the network addresses under which the child processes set up gRPC
* servers.
*
* create might throw an error if spawning a child process fails, see initTshd for more details.
*/
static create(opts: Options) {
const instance = new MainProcess(opts);
instance.init();
return instance;
this.clusterStore = new ClusterStore(
() => this.getTshdClients().then(c => c.terminalService),
this.windowsManager
);
}
async dispose(): Promise<void> {
@@ -209,16 +220,13 @@ export default class MainProcess {
]);
}
private init() {
this.updateAboutPanelIfNeeded();
this.setAppMenu();
this.initTshd();
this.initSharedProcess();
this.initResolvingChildProcessAddresses();
this.initIpc();
}
async getTshdClients(): Promise<{
/**
* Returns the tshd client.
*
* If the client setup fails, the resulting error will propagate
* to callers of this method.
*/
private async getTshdClients(): Promise<{
terminalService: TshdClient;
autoUpdateService: AutoUpdateClient;
}> {
@@ -334,6 +342,16 @@ export default class MainProcess {
);
}
/**
* Initializes the resolution of child process addresses.
*
* Both the internal tshd client (in the main process) and the one in the renderer
* depend on this initialization promise.
*
* If the promise rejects, the error will propagate to the renderer via the IPC
* handler (causing the renderer to stop initialization and show the error)
* and also surface when attempting to access `getTshdClients()`.
*/
private initResolvingChildProcessAddresses(): void {
this.resolvedChildProcessAddresses = Promise.all([
resolveNetworkAddress(
@@ -693,6 +711,27 @@ export default class MainProcess {
this.appUpdater.quitAndInstall()
);
ipcMain.handle(MainProcessIpc.AddCluster, (ev, proxyAddress) =>
this.clusterStore.add(proxyAddress)
);
ipcMain.handle(MainProcessIpc.SyncRootClusters, () =>
this.clusterStore.syncRootClusters()
);
ipcMain.handle(MainProcessIpc.SyncCluster, (_, args) =>
this.clusterStore.sync(args.clusterUri)
);
ipcMain.handle(MainProcessIpc.Logout, (_, args) =>
this.clusterStore.logout(args.clusterUri)
);
ipcMain.on(MainProcessIpc.InitClusterStoreSubscription, ev => {
const port = ev.ports[0];
this.clusterStore.registerSender(new AwaitableSender(port));
});
subscribeToTerminalContextMenuEvent(this.configService);
subscribeToTabContextMenuEvent(
this.settings.availableShells,
@@ -189,9 +189,6 @@ export default function createMainProcessClient(): MainProcessClient {
signalUserInterfaceReadiness(args: { success: boolean }) {
ipcRenderer.send(WindowsManagerIpc.SignalUserInterfaceReadiness, args);
},
refreshClusterList() {
ipcRenderer.send(MainProcessIpc.RefreshClusterList);
},
selectDirectoryForDesktopSession(args: {
desktopUri: string;
login: string;
@@ -264,6 +261,28 @@ export default function createMainProcessClient(): MainProcessClient {
),
};
},
subscribeToClusterStore: listener => {
const { close } = startAwaitableSenderListener(
MainProcessIpc.InitClusterStoreSubscription,
listener
);
return {
cleanup: close,
};
},
addCluster: async (proxyAddress: string) => {
return await ipcRenderer.invoke(MainProcessIpc.AddCluster, proxyAddress);
},
syncRootClusters: async options => {
return await ipcRenderer.invoke(MainProcessIpc.SyncRootClusters, options);
},
syncCluster: (clusterUri: RootClusterUri) => {
return ipcRenderer.invoke(MainProcessIpc.SyncCluster, { clusterUri });
},
logoutCluster: (clusterUri: RootClusterUri) => {
return ipcRenderer.invoke(MainProcessIpc.Logout, { clusterUri });
},
};
}
@@ -274,7 +293,6 @@ export default function createMainProcessClient(): MainProcessClient {
* The main process is expected to create an `AwaitableSender` using the received port,
* enabling it to send messages that require acknowledgments from the renderer.
*/
// eslint-disable-next-line unused-imports/no-unused-vars
function startAwaitableSenderListener<T>(
channel: string,
listener: (value: T) => void
@@ -0,0 +1,120 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { dialog, shell, WebContents } from 'electron';
import Logger, { NullService } from 'teleterm/logger';
import { makeRuntimeSettings } from 'teleterm/mainProcess/fixtures/mocks';
import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
import { registerNavigationHandlers } from './navigationHandler';
beforeAll(() => {
Logger.init(new NullService());
});
beforeEach(() => {
jest.resetAllMocks();
});
jest.mock('electron', () => ({
dialog: {
showErrorBox: jest.fn(),
},
shell: {
openExternal: jest.fn(),
},
}));
const cluster = makeRootCluster();
describe('opening links to', () => {
test.each([
{
name: 'Teleport',
url: 'https://goteleport.com/',
allowed: true,
},
{
name: 'Gravitational GitHub',
url: 'https://github.com/gravitational/',
allowed: true,
},
{
name: 'cluster SSO host',
// comes from makeRootCluster
url: 'https://example.auth0.com/some-path',
allowed: true,
},
{
name: 'cluster proxy host',
// comes from makeRootCluster
url: 'https://teleport-local.com:3080/some-path',
allowed: true,
},
{
name: 'non-HTTPS URLs',
url: 'http://goteleport.com',
allowed: false,
},
{
name: 'non-Gravitational GitHub',
url: 'https://github.com/abc',
allowed: false,
},
{
name: 'arbitrary URLs',
url: 'https://google.com',
allowed: false,
},
])('$name', test => {
let handler: Parameters<WebContents['setWindowOpenHandler']>[0];
registerNavigationHandlers(
{
setWindowOpenHandler: d => {
handler = d;
},
on: jest.fn(),
},
makeRuntimeSettings(),
{ getRootClusters: () => [cluster] },
new Logger()
);
const result = handler({
url: test.url,
frameName: '',
features: '',
disposition: 'default',
referrer: undefined,
});
expect(result).toEqual({ action: 'deny' });
/* eslint-disable jest/no-conditional-expect */
if (test.allowed) {
expect(shell.openExternal).toHaveBeenCalledWith(test.url);
expect(dialog.showErrorBox).not.toHaveBeenCalled();
} else {
expect(shell.openExternal).not.toHaveBeenCalled();
expect(dialog.showErrorBox).toHaveBeenCalledWith(
'Cannot open this link',
'The domain does not match any of the allowed domains. Check main.log for more details.'
);
}
});
});
@@ -0,0 +1,147 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { dialog, shell, WebContents } from 'electron';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { proxyHostToBrowserProxyHost } from 'teleterm/services/tshd/cluster';
import { Logger, RuntimeSettings } from 'teleterm/types';
import { ClusterStore } from './clusterStore';
/**
* Limits navigation capabilities to reduce the attack surface.
* See TEL-Q122-19 from "Teleport Core Testing Q1 2022" security audit.
*
* See also points 12, 13 and 14 from the Electron's security tutorial.
* https://github.com/electron/electron/blob/v17.2.0/docs/tutorial/security.md#12-verify-webview-options-before-creation
*/
export function registerNavigationHandlers(
webContents: Pick<WebContents, 'setWindowOpenHandler' | 'on'>,
settings: Pick<RuntimeSettings, 'dev'>,
clusterStore: Pick<ClusterStore, 'getRootClusters'>,
logger: Logger
): void {
webContents.on('will-navigate', (event, navigationUrl) => {
// Allow reloading the renderer app in dev mode.
if (settings.dev && new URL(navigationUrl).host === 'localhost:8080') {
return;
}
logger.warn(`Navigation to ${navigationUrl} blocked by 'will-navigate'`);
event.preventDefault();
});
// The usage of webview is blocked by default, but let's include the handler just in case.
// https://github.com/electron/electron/blob/v17.2.0/docs/api/webview-tag.md#enabling
webContents.on('will-attach-webview', (event, _, params) => {
logger.warn(
`Opening a webview to ${params.src} blocked by 'will-attach-webview'`
);
event.preventDefault();
});
// Open links in the browser.
webContents.setWindowOpenHandler(details => {
const url = new URL(details.url);
if (isUrlSafe(url, clusterStore, logger)) {
shell.openExternal(url.toString());
} else {
logger.warn(
`Opening a new window to ${url} blocked by 'setWindowOpenHandler'`
);
dialog.showErrorBox(
'Cannot open this link',
'The domain does not match any of the allowed domains. Check main.log for more details.'
);
}
return { action: 'deny' };
});
}
function isUrlSafe(
url: URL,
clusterStore: Pick<ClusterStore, 'getRootClusters'>,
logger: Logger
): boolean {
if (url.protocol !== 'https:') {
return false;
}
if (url.host === 'goteleport.com') {
return true;
}
if (url.host === 'github.com' && url.pathname.startsWith('/gravitational/')) {
return true;
}
const rootClusterProxyHostAllowList = makeRootClusterProxyHostAllowList(
clusterStore.getRootClusters(),
logger
);
// Allow opening links to the Web UIs of root clusters currently added in the app.
if (rootClusterProxyHostAllowList.has(url.host)) {
return true;
}
}
/**
* Produces a list of proxy and SSO hosts of root clusters. This enables us to
* open links to Web UIs of clusters from within Connect.
*
* The port part of a proxy host is dropped if the port is 443. See `proxyHostToBrowserProxyHost` for
* more details.
*/
function makeRootClusterProxyHostAllowList(
clusters: Cluster[],
logger: Logger
): Set<string> {
return new Set(
clusters
.flatMap(c => {
let browserProxyHost: string;
if (c.proxyHost) {
try {
browserProxyHost = proxyHostToBrowserProxyHost(c.proxyHost);
} catch (error) {
logger.error(
'Ran into an error when converting proxy host to browser proxy host',
error
);
}
}
// Allow the SSO host for SSO login/mfa redirects.
let browserSsoHost: string;
if (c.ssoHost) {
try {
browserSsoHost = proxyHostToBrowserProxyHost(c.ssoHost);
} catch (error) {
logger.error(
'Ran into an error when converting sso host to browser sso host',
error
);
}
}
return [browserProxyHost, browserSsoHost];
})
.filter(Boolean)
);
}
@@ -1,114 +0,0 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ipcMain } from 'electron';
import { isAbortError } from 'shared/utils/abortError';
import { MainProcessIpc } from 'teleterm/mainProcess/types';
import { TshdClient } from 'teleterm/services/tshd';
import { cloneAbortSignal } from 'teleterm/services/tshd/cloneableClient';
import { proxyHostToBrowserProxyHost } from 'teleterm/services/tshd/cluster';
import * as tshd from 'teleterm/services/tshd/types';
import { Logger } from 'teleterm/types';
export type RootClusterProxyHostAllowList = Set<string>;
/**
* Refreshes the allow list whenever the renderer process notifies the main process that the list of
* clusters in ClustersService got updated.
*
* The allow list includes proxy hosts of root clusters. This enables us to open links to Web UIs of
* clusters from within Connect.
*
* The port part of a proxy host is dropped if the port is 443. See proxyHostToBrowserProxyHost for
* more details.
*/
export function manageRootClusterProxyHostAllowList({
tshdClient,
logger,
allowList,
}: {
tshdClient: TshdClient;
logger: Logger;
allowList: RootClusterProxyHostAllowList;
}) {
let abortController: AbortController;
const refreshAllowList = async () => {
// Allow only one call to be in progress. This ensures that on subsequent calls to
// refreshAllowList, we always store only the most recent version of the list.
abortController?.abort();
abortController = new AbortController();
let rootClusters: tshd.Cluster[];
try {
const { response } = await tshdClient.listRootClusters(
{},
{ abort: cloneAbortSignal(abortController.signal) }
);
rootClusters = response.clusters;
} catch (error) {
if (isAbortError(error)) {
// Ignore abort errors. They will be logged by the gRPC client middleware.
return;
}
logger.error('Could not fetch root clusters', error);
// Return instead of throwing as there's nothing else we can do with the error at this place
// in the program.
return;
}
allowList.clear();
for (const rootCluster of rootClusters) {
if (rootCluster.proxyHost) {
let browserProxyHost: string;
try {
browserProxyHost = proxyHostToBrowserProxyHost(rootCluster.proxyHost);
allowList.add(browserProxyHost);
} catch (error) {
logger.error(
'Ran into an error when converting proxy host to browser proxy host',
error
);
}
}
// Allow the SSO host for SSO login/mfa redirects.
if (rootCluster.ssoHost) {
let browserSsoHost: string;
try {
browserSsoHost = proxyHostToBrowserProxyHost(rootCluster.ssoHost);
allowList.add(browserSsoHost);
} catch (error) {
logger.error(
'Ran into an error when converting sso host to browser sso host',
error
);
}
}
}
};
refreshAllowList();
ipcMain.on(MainProcessIpc.RefreshClusterList, () => {
refreshAllowList();
});
}
+18 -2
View File
@@ -16,10 +16,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { DeepLinkParseResult } from 'teleterm/deepLinks';
import type { ClusterStoreUpdate } from 'teleterm/mainProcess/clusterStore';
import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile';
import { AppUpdateEvent } from 'teleterm/services/appUpdater';
import { FileStorage } from 'teleterm/services/fileStorage';
import { CloneableAbortSignal } from 'teleterm/services/tshd';
import { Document } from 'teleterm/ui/services/workspacesService';
import { RootClusterUri } from 'teleterm/ui/uri';
@@ -195,7 +199,6 @@ export type MainProcessClient = {
* interacted with the relevant modals during startup and is free to use the app.
*/
signalUserInterfaceReadiness(args: { success: boolean }): void;
refreshClusterList(): void;
/**
* Opens the Electron directory picker and sends the selected path to tshd through SetSharedDirectoryForDesktopSession.
* tshd then verifies whether there is an active session for the specified desktop user and attempts to open the directory.
@@ -229,6 +232,15 @@ export type MainProcessClient = {
): {
cleanup: () => void;
};
addCluster(proxyAddress: string): Promise<Cluster>;
syncCluster(clusterUri: RootClusterUri): Promise<void>;
syncRootClusters(options: {
abortSignal: CloneableAbortSignal;
}): Promise<Cluster[]>;
logoutCluster(clusterUri: RootClusterUri): Promise<void>;
subscribeToClusterStore(listener: (value: ClusterStoreUpdate) => void): {
cleanup: () => void;
};
};
export type ChildProcessAddresses = {
@@ -342,7 +354,6 @@ export enum RendererIpc {
export enum MainProcessIpc {
GetRuntimeSettings = 'main-process-get-runtime-settings',
TryRemoveConnectMyComputerAgentBinary = 'main-process-try-remove-connect-my-computer-agent-binary',
RefreshClusterList = 'main-process-refresh-cluster-list',
DownloadConnectMyComputerAgent = 'main-process-connect-my-computer-download-agent',
VerifyConnectMyComputerAgent = 'main-process-connect-my-computer-verify-agent',
SaveTextToFile = 'main-process-save-text-to-file',
@@ -355,6 +366,11 @@ export enum MainProcessIpc {
ChangeAppUpdatesManagingCluster = 'main-process-change-app-updates-managing-cluster',
MaybeRemoveAppUpdatesManagingCluster = 'main-process-maybe-remove-app-updates-managing-cluster',
SupportsAppUpdates = 'main-process-supports-app-updates',
InitClusterStoreSubscription = 'main-process-init-cluster-store-subscription',
SyncCluster = 'main-process-sync-cluster',
AddCluster = 'main-process-add-cluster',
SyncRootClusters = 'main-process-sync-root-clusters',
Logout = 'main-process-logout',
}
export enum WindowsManagerIpc {
@@ -31,6 +31,8 @@ import {
screen,
} from 'electron';
import { ensureError } from 'shared/utils/error';
import { DeepLinkParseResult } from 'teleterm/deepLinks';
import Logger from 'teleterm/logger';
import {
@@ -69,6 +71,7 @@ export class WindowsManager {
* by the OS (e.g. Command+H).
*/
private isInBackgroundMode: boolean;
private crashWindowPromise: Promise<void>;
constructor(
private fileStorage: FileStorage,
@@ -516,6 +519,39 @@ export class WindowsManager {
return keepRunning;
}
/**
* Displays an error in a system dialog and offers reloading the window
* or quitting the app.
*/
async crashWindow(error: unknown): Promise<void> {
if (this.crashWindowPromise) {
return this.crashWindowPromise;
}
this.crashWindowPromise = this.doCrashWindow(error);
try {
await this.crashWindowPromise;
} finally {
this.crashWindowPromise = undefined;
}
}
private async doCrashWindow(error: unknown): Promise<void> {
this.logger.error('Window crashed', error);
const { response } = await dialog.showMessageBox(this.window, {
type: 'error',
message: 'Teleport Connect has crashed',
detail: ensureError(error).message,
buttons: ['Reload Window', 'Quit'],
defaultId: 0,
noLink: true,
});
if (response === 0) {
this.window.reload();
} else {
app.quit();
}
}
private isWindowUsable(): boolean {
return this.window && !this.window.isDestroyed();
}
+4 -11
View File
@@ -25,7 +25,6 @@ import { mockIntersectionObserver } from 'jsdom-testing-mocks';
import { render } from 'design/utils/testing';
import Logger, { NullService } from 'teleterm/logger';
import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';
import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
import { App } from 'teleterm/ui/App';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
@@ -67,6 +66,9 @@ test('activating a workspace via deep link overrides the previously active works
connected: false,
});
const appContext = new MockAppContext();
appContext.addRootCluster(deepLinkCluster);
appContext.addRootCluster(previouslyActiveCluster);
jest
.spyOn(appContext.statePersistenceService, 'getWorkspacesState')
.mockReturnValue({
@@ -88,11 +90,6 @@ test('activating a workspace via deep link overrides the previously active works
'usageReporting.enabled',
false
);
jest.spyOn(appContext.tshd, 'listRootClusters').mockReturnValue(
new MockedUnaryCall({
clusters: [deepLinkCluster, previouslyActiveCluster],
})
);
render(<App ctx={appContext} />);
@@ -198,11 +195,7 @@ test.each<{
'usageReporting.enabled',
false
);
jest.spyOn(appContext.tshd, 'listRootClusters').mockReturnValue(
new MockedUnaryCall({
clusters: [rootCluster],
})
);
appContext.addRootCluster(rootCluster);
render(<App ctx={appContext} />);
@@ -172,11 +172,11 @@ it('shows two separate prompt texts during SSO login', async () => {
.spyOn(appContext.tshd, 'login')
.mockImplementation(async () => loginPromise);
const { resolve: resolveGetClusterPromise, promise: getClusterPromise } =
Promise.withResolvers<ReturnType<TshdClient['getCluster']>>();
const { resolve: resolveSyncClusterPromise, promise: syncClusterPromise } =
Promise.withResolvers<void>();
jest
.spyOn(appContext.tshd, 'getCluster')
.mockImplementation(async () => getClusterPromise);
.spyOn(appContext.mainProcessClient, 'syncCluster')
.mockImplementation(async () => syncClusterPromise);
render(
<MockAppContextProvider appContext={appContext}>
@@ -205,6 +205,6 @@ it('shows two separate prompt texts during SSO login', async () => {
await act(async () => {
// Resolve the promise to avoid leaving a hanging promise around.
resolveGetClusterPromise(new MockedUnaryCall(cluster));
resolveSyncClusterPromise();
});
});
@@ -70,6 +70,7 @@ test.each(tests)(
const user = userEvent.setup();
const ctx = new MockAppContext();
const rootCluster = makeRootCluster();
ctx.addRootCluster(rootCluster, { noActivate: true });
ctx.configService.set('usageReporting.enabled', false);
jest.spyOn(ctx.tshd, 'listUnifiedResources').mockReturnValue(
@@ -83,11 +84,6 @@ test.each(tests)(
],
})
);
jest.spyOn(ctx.tshd, 'listRootClusters').mockReturnValue(
new MockedUnaryCall({
clusters: [rootCluster],
})
);
jest.spyOn(ctx.vnet, 'getServiceInfo').mockReturnValue(
new MockedUnaryCall({
appDnsZones: [proxyHostname(rootCluster.proxyHost)],
@@ -164,6 +160,7 @@ test.each(tests)(
const user = userEvent.setup();
const ctx = new MockAppContext();
const rootCluster = makeRootCluster();
ctx.addRootCluster(rootCluster, { noActivate: true });
ctx.configService.set('usageReporting.enabled', false);
ctx.statePersistenceService.putState({
...ctx.statePersistenceService.getState(),
@@ -181,11 +178,6 @@ test.each(tests)(
],
})
);
jest.spyOn(ctx.tshd, 'listRootClusters').mockReturnValue(
new MockedUnaryCall({
clusters: [rootCluster],
})
);
jest.spyOn(ctx.vnet, 'getServiceInfo').mockReturnValue(
new MockedUnaryCall({
appDnsZones: [proxyHostname(rootCluster.proxyHost)],
@@ -242,13 +234,9 @@ test('launching VNet for the first time from the connections panel does not open
const user = userEvent.setup();
const ctx = new MockAppContext();
const rootCluster = makeRootCluster();
ctx.addRootCluster(rootCluster, { noActivate: true });
ctx.configService.set('usageReporting.enabled', false);
jest.spyOn(ctx.tshd, 'listRootClusters').mockReturnValue(
new MockedUnaryCall({
clusters: [rootCluster],
})
);
jest.spyOn(ctx.vnet, 'getServiceInfo').mockReturnValue(
new MockedUnaryCall({
appDnsZones: [proxyHostname(rootCluster.proxyHost)],
@@ -16,8 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { debounce } from 'shared/utils/highbar';
import { ConfigService } from 'teleterm/services/config';
import { TshdClient, VnetClient } from 'teleterm/services/tshd/createClient';
import {
@@ -161,7 +159,6 @@ export default class AppContext implements IAppContext {
createTshdEventsContextBridgeService(this)
);
this.notifyMainProcessAboutClusterListChanges();
void this.clustersService.syncGatewaysAndCatchErrors();
await this.clustersService.syncRootClustersAndCatchErrors();
this.workspacesService.restorePersistedState();
@@ -197,23 +194,4 @@ export default class AppContext implements IAppContext {
get unexpectedVnetShutdownListener(): UnexpectedVnetShutdownListener {
return this._unexpectedVnetShutdownListener;
}
private notifyMainProcessAboutClusterListChanges() {
// Debounce the notifications sent to the main process so that we don't unnecessarily send more
// than one notification per frame. The main process doesn't need to be notified absolutely
// immediately after a change in the cluster list.
//
// The clusters map in ClustersService gets updated a bunch of times during the start of the
// app. After each update, the renderer tells the main process to refresh the list. The main
// process sends a request to list root clusters and cancels any pending ones. Debouncing here
// helps to minimize those cancellations.
const refreshClusterList = debounce(
this.mainProcessClient.refreshClusterList,
16
);
this.clustersService.subscribeWithSelector(
state => state.clusters,
refreshClusterList
);
}
}
+12 -4
View File
@@ -55,7 +55,8 @@ export class MockAppContext extends AppContext {
addRootClusterWithDoc(
cluster: Cluster,
doc: Document[] | Document | undefined
doc: Document[] | Document | undefined,
options?: AddRootClusterOptions
) {
this.clustersService.setState(draftState => {
draftState.clusters.set(cluster.uri, cluster);
@@ -63,13 +64,20 @@ export class MockAppContext extends AppContext {
const docs = Array.isArray(doc) ? doc : [doc];
this.workspacesService.addWorkspace(cluster.uri);
this.workspacesService.setState(draftState => {
draftState.rootClusterUri = cluster.uri;
if (!options?.noActivate) {
draftState.rootClusterUri = cluster.uri;
}
draftState.workspaces[cluster.uri].documents = docs.filter(Boolean);
draftState.workspaces[cluster.uri].location = docs[0]?.uri;
});
}
addRootCluster(cluster: Cluster) {
this.addRootClusterWithDoc(cluster, undefined);
addRootCluster(cluster: Cluster, options?: AddRootClusterOptions) {
this.addRootClusterWithDoc(cluster, undefined, options);
}
}
interface AddRootClusterOptions {
/** Does not set the cluster as active in workspaces service. */
noActivate?: boolean;
}
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { MainProcessClient } from 'teleterm/mainProcess/types';
import { MockMainProcessClient } from 'teleterm/mainProcess/fixtures/mocks';
import type { TshdClient } from 'teleterm/services/tshd';
import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';
import {
@@ -60,9 +60,7 @@ const UsageServiceMock = UsageService as jest.MockedClass<typeof UsageService>;
function createService(client: Partial<TshdClient>): ClustersService {
return new ClustersService(
client as TshdClient,
{
removeKubeConfig: jest.fn().mockResolvedValueOnce(undefined),
} as unknown as MainProcessClient,
new MockMainProcessClient(),
new NotificationsServiceMock(),
new UsageServiceMock(undefined, undefined, undefined, undefined, undefined)
);
@@ -93,49 +91,6 @@ function getClientMocks(): Partial<TshdClient> {
};
}
test('add cluster', async () => {
const { addCluster } = getClientMocks();
const service = createService({
addCluster,
});
await service.addRootCluster(clusterUri);
expect(addCluster).toHaveBeenCalledWith({ name: clusterUri });
expect(service.state.clusters).toStrictEqual(
new Map([[clusterUri, clusterMock]])
);
});
test('add cluster does not overwrite the existing cluster', async () => {
const { addCluster } = getClientMocks();
const service = createService({
addCluster,
});
service.state.clusters.set(clusterUri, {
...clusterMock,
features: { advancedAccessWorkflows: true, isUsageBasedBilling: true },
});
await service.addRootCluster(clusterUri);
expect(addCluster).toHaveBeenCalledWith({ name: clusterUri });
expect(service.state.clusters).toStrictEqual(
new Map([
[
clusterUri,
{
...clusterMock,
features: {
advancedAccessWorkflows: true,
isUsageBasedBilling: true,
},
},
],
])
);
});
test('remove gateways', async () => {
const { removeGateway } = getClientMocks();
const service = createService({ removeGateway });
@@ -153,10 +108,6 @@ test('remove gateways', async () => {
});
service.setState(draftState => {
draftState.clusters = new Map([
[clusterMock.uri, clusterMock],
[leafClusterMock.uri, leafClusterMock],
]);
draftState.gateways = new Map([
[gatewayFromRootCluster.uri, gatewayFromRootCluster],
[gatewayFromLeafCluster.uri, gatewayFromLeafCluster],
@@ -180,49 +131,6 @@ test('remove gateways', async () => {
});
});
test('sync root cluster', async () => {
const { getCluster, listLeafClusters, startHeadlessWatcher } =
getClientMocks();
const service = createService({
getCluster,
listLeafClusters,
startHeadlessWatcher,
});
await service.syncAndWatchRootClusterWithErrorHandling(clusterUri);
expect(service.findCluster(clusterUri)).toStrictEqual(clusterMock);
expect(service.findCluster(leafClusterMock.uri)).toStrictEqual(
leafClusterMock
);
expect(listLeafClusters).toHaveBeenCalledWith({ clusterUri });
expect(startHeadlessWatcher).toHaveBeenCalledWith({
rootClusterUri: clusterUri,
});
});
test('logout from cluster', async () => {
const { logout, removeCluster } = getClientMocks();
const service = createService({
logout,
removeCluster,
getCluster: () => new MockedUnaryCall({ ...clusterMock, connected: false }),
});
service.setState(draftState => {
draftState.clusters = new Map([
[clusterMock.uri, clusterMock],
[leafClusterMock.uri, leafClusterMock],
]);
});
await service.logout(clusterUri);
expect(logout).toHaveBeenCalledWith({ clusterUri });
expect(removeCluster).toHaveBeenCalledWith({ clusterUri });
expect(service.findCluster(clusterMock.uri)).toBeUndefined();
expect(service.findCluster(leafClusterMock.uri)).toBeUndefined();
});
test('create a gateway', async () => {
const { createGateway } = getClientMocks();
const service = createService({
@@ -16,10 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
Cluster,
ShowResources,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { applyPatches, castDraft, enablePatches } from 'immer';
import { Gateway } from 'gen-proto-ts/teleport/lib/teleterm/v1/gateway_pb';
import {
CreateAccessRequestRequest,
@@ -28,8 +26,9 @@ import {
ReviewAccessRequestRequest,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/service_pb';
import { useStore } from 'shared/libs/stores';
import { isAbortError } from 'shared/utils/error';
import { AbortError, isAbortError } from 'shared/utils/error';
import type { State as ClustersState } from 'teleterm/mainProcess/clusterStore';
import { MainProcessClient } from 'teleterm/mainProcess/types';
import { cloneAbortSignal, TshdClient } from 'teleterm/services/tshd';
import { getGatewayTargetUriKind } from 'teleterm/services/tshd/gateway';
@@ -42,10 +41,17 @@ import { ImmutableStore } from '../immutableStore';
const { routing } = uri;
type ClustersServiceState = {
clusters: Map<uri.ClusterUri, Cluster>;
/**
* `clusters` is a local mirror of the `ClusterStore` state from the main process.
* This state is read-only and must not be updated manually — it is managed exclusively
* through `subscribeToClusterStore`.
*/
clusters: ClustersState;
gateways: Map<uri.GatewayUri, Gateway>;
};
enablePatches();
export function createClusterServiceState(): ClustersServiceState {
return {
clusters: new Map(),
@@ -63,36 +69,16 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
private usageService: UsageService
) {
super();
this.subscribeToClusterStore();
}
async addRootCluster(addr: string) {
const { response: cluster } = await this.client.addCluster({ name: addr });
// Do not overwrite the existing cluster;
// otherwise we may lose properties fetched from the auth server.
// Consider separating properties read from profile and those
// fetched from the auth server at the RPC message level.
if (!this.state.clusters.has(cluster.uri)) {
this.setState(draft => {
draft.clusters.set(cluster.uri, cluster);
});
}
return cluster;
async addRootCluster(proxyAddress: string) {
return this.mainProcessClient.addCluster(proxyAddress);
}
/** Logs out of the cluster. */
async logout(clusterUri: uri.RootClusterUri) {
// TODO(gzdunek): logout and removeCluster should be combined into a single acton in tshd
await this.client.logout({ clusterUri });
await this.client.removeCluster({ clusterUri });
this.setState(draft => {
draft.clusters.forEach(cluster => {
if (routing.belongsToProfile(clusterUri, cluster.uri)) {
draft.clusters.delete(cluster.uri);
}
});
});
await this.mainProcessClient.logoutCluster(clusterUri);
}
async authenticateWebDevice(
@@ -128,7 +114,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
clusterUri: uri.RootClusterUri
) {
try {
await this.syncRootCluster(clusterUri);
await this.mainProcessClient.syncCluster(clusterUri);
} catch (e) {
const cluster = this.findCluster(clusterUri);
const clusterName =
@@ -178,10 +164,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
* errors up.
*/
async syncRootCluster(clusterUri: uri.RootClusterUri) {
await Promise.all([
this.syncClusterInfo(clusterUri),
this.syncLeafClustersList(clusterUri),
]);
await this.mainProcessClient.syncCluster(clusterUri);
}
/**
@@ -192,14 +175,26 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
* its associated workspace, resulting in an invalid state.
*/
async syncRootClustersAndCatchErrors(abortSignal?: AbortSignal) {
let clusters: Cluster[];
//TODO(gzdunek): Implement passing abort signals over IPC.
// In this particular case it's fine to discard waiting for the result.
const abortPromise =
abortSignal &&
new Promise<never>((_, reject) => {
if (abortSignal.aborted) {
reject(new AbortError());
return;
}
abortSignal.addEventListener('abort', () => reject(new AbortError()), {
once: true,
});
});
try {
const { response } = await this.client.listRootClusters(
{},
{ abortSignal: abortSignal && cloneAbortSignal(abortSignal) }
);
clusters = response.clusters;
await Promise.race([
abortPromise,
await this.mainProcessClient.syncRootClusters({
abortSignal: abortSignal && cloneAbortSignal(abortSignal),
}),
]);
} catch (error) {
if (isAbortError(error)) {
this.logger.info('Listing root clusters aborted');
@@ -219,12 +214,8 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
return;
}
this.setState(draft => {
draft.clusters = new Map(clusters.map(c => [c.uri, c]));
});
// Sync root clusters and resume headless watchers for any active login sessions.
clusters
this.getRootClusters()
.filter(c => c.connected)
.forEach(c => this.syncAndWatchRootClusterWithErrorHandling(c.uri));
}
@@ -250,20 +241,6 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
}
}
private async syncLeafClustersList(clusterUri: uri.RootClusterUri) {
const { response } = await this.client.listLeafClusters({
clusterUri,
});
this.setState(draft => {
for (const leaf of response.clusters) {
draft.clusters.set(leaf.uri, leaf);
}
});
return response.clusters;
}
/** Assumes roles for the given requests. */
async assumeRoles(
rootClusterUri: uri.RootClusterUri,
@@ -502,27 +479,15 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
return useStore(this).state;
}
private async syncClusterInfo(clusterUri: uri.RootClusterUri) {
try {
const { response: cluster } = await this.client.getCluster({
clusterUri,
});
this.setState(draft => {
draft.clusters.set(clusterUri, cluster);
});
} catch (error) {
this.setState(draft => {
const cluster = draft.clusters.get(clusterUri);
if (cluster) {
// TODO(gzdunek): We should rather store the cluster synchronization status,
// so the callsites could check it before reading the field.
// The workaround is to update the field in case of a failure,
// so the places that wait for showResources !== UNSPECIFIED don't get stuck indefinitely.
cluster.showResources = ShowResources.ACCESSIBLE_ONLY;
private subscribeToClusterStore(): void {
this.mainProcessClient.subscribeToClusterStore(e => {
this.setState(c => {
if (e.kind === 'state') {
c.clusters = castDraft(e.value);
return;
}
applyPatches(c.clusters, e.value);
});
throw error;
}
});
}
}
@@ -18,7 +18,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment*/
import { enableMapSet, produce } from 'immer';
import { enableMapSet, produce, Producer } from 'immer';
import stateLogger from 'shared/libs/stores/logger';
import Store from 'shared/libs/stores/store';
@@ -31,7 +31,7 @@ export class ImmutableStore<T> extends Store<T> {
protected logger = new Logger(this.constructor.name);
// @ts-ignore
setState(nextState: (draftState: T) => T | void): void {
setState(nextState: Producer<T>): void {
const prevState = this.state;
this.state = produce(this.state, nextState);
stateLogger.logState(this.constructor.name, prevState, 'with', this.state);