mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user