Skip to content

Commit fdb7029

Browse files
authored
Merge pull request #10018 from jandubois/steve-dynamic-ports
Use dynamic ports for Steve to avoid conflicts with other software
2 parents 4ae6601 + fa2cbca commit fdb7029

File tree

8 files changed

+248
-68
lines changed

8 files changed

+248
-68
lines changed

.github/actions/spelling/expect.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,7 @@ toggleable
641641
togglefullscreen
642642
tonistiigi
643643
toolsets
644+
TOCTOU
644645
topmenu
645646
TQF
646647
traefik

background.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { ImageEventHandler } from '@pkg/main/imageEvents';
3535
import { getIpcMainProxy } from '@pkg/main/ipcMain';
3636
import mainEvents from '@pkg/main/mainEvents';
3737
import buildApplicationMenu from '@pkg/main/mainmenu';
38-
import setupNetworking from '@pkg/main/networking';
38+
import setupNetworking, { setSteveCertPort } from '@pkg/main/networking';
3939
import { Snapshots } from '@pkg/main/snapshots/snapshots';
4040
import { Snapshot, SnapshotDialog } from '@pkg/main/snapshots/types';
4141
import { Tray } from '@pkg/main/tray';
@@ -45,6 +45,7 @@ import getCommandLineArgs from '@pkg/utils/commandLine';
4545
import dockerDirManager from '@pkg/utils/dockerDirManager';
4646
import { isDevEnv } from '@pkg/utils/environment';
4747
import Logging, { clearLoggingDirectory, setLogLevel } from '@pkg/utils/logging';
48+
import { getAvailablePorts } from '@pkg/utils/networks';
4849
import { fetchMacOsVersion, getMacOsVersion } from '@pkg/utils/osVersion';
4950
import paths from '@pkg/utils/paths';
5051
import { protocolsRegistered, setupProtocolHandlers } from '@pkg/utils/protocols';
@@ -227,10 +228,10 @@ Electron.app.whenReady().then(async() => {
227228
// Check for required OS versions and features
228229
await checkPrerequisites();
229230

230-
DashboardServer.getInstance().init();
231-
232231
await setupNetworking();
233232

233+
DashboardServer.getInstance().init();
234+
234235
try {
235236
deploymentProfiles = await readDeploymentProfiles();
236237
} catch (ex: any) {
@@ -1268,21 +1269,31 @@ function newK8sManager() {
12681269

12691270
mgr.on('state-changed', async(state: K8s.State) => {
12701271
try {
1271-
mainEvents.emit('k8s-check-state', mgr);
1272-
12731272
if ([K8s.State.STARTED, K8s.State.DISABLED].includes(state)) {
12741273
if (!cfg.kubernetes.version) {
12751274
writeSettings({ kubernetes: { version: mgr.kubeBackend.version } });
12761275
}
12771276
currentImageProcessor?.relayNamespaces();
12781277

12791278
if (enabledK8s) {
1280-
await Steve.getInstance().start();
1279+
try {
1280+
const [stevePort] = await getAvailablePorts(1);
1281+
1282+
console.log(`Steve HTTPS port: ${ stevePort }`);
1283+
// Set the Steve HTTPS port for certificate checking before setting
1284+
// up Steve itself.
1285+
setSteveCertPort(stevePort);
1286+
await Steve.getInstance().start(stevePort);
1287+
DashboardServer.getInstance().setStevePort(stevePort); // recreate proxy middleware
1288+
} catch (ex) {
1289+
console.error('Failed to start Steve:', ex);
1290+
}
12811291
}
12821292
}
12831293

1284-
// Notify UI after Steve is ready, so the dashboard button is only enabled
1285-
// when Steve can accept connections.
1294+
// Notify the tray and renderer after Steve is ready, so the dashboard
1295+
// button is only enabled when Steve can accept connections.
1296+
mainEvents.emit('k8s-check-state', mgr);
12861297
window.send('k8s-check-state', state);
12871298

12881299
if (state === K8s.State.STOPPING) {

pkg/rancher-desktop/assets/dependencies.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ dockerCompose: 5.1.1
1313
golangci-lint: 2.11.4
1414
trivy: 0.69.3
1515
steve: 0.1.0-beta9.1
16-
rancherDashboard: 2.11.1.rd3
16+
rancherDashboard: 2.11.1.rd4
1717
dockerProvidedCredentialHelpers: 0.9.5
1818
ECRCredentialHelper: 0.12.0
1919
mobyOpenAPISpec: "1.54"

pkg/rancher-desktop/backend/steve.ts

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
import { ChildProcess, spawn } from 'child_process';
2-
import net from 'net';
32
import os from 'os';
43
import path from 'path';
54
import { setTimeout } from 'timers/promises';
65

6+
import Electron from 'electron';
7+
78
import K3sHelper from '@pkg/backend/k3sHelper';
89
import Logging from '@pkg/utils/logging';
910
import paths from '@pkg/utils/paths';
1011

11-
const STEVE_PORT = 9443;
12-
1312
const console = Logging.steve;
1413

1514
/**
16-
* @description Singleton that manages the lifecycle of the Steve API
15+
* @description Singleton that manages the lifecycle of the Steve API.
1716
*/
1817
export class Steve {
1918
private static instance: Steve;
2019
private process!: ChildProcess;
2120

2221
private isRunning: boolean;
22+
private httpsPort = 0;
2323

2424
private constructor() {
2525
this.isRunning = false;
@@ -40,8 +40,9 @@ export class Steve {
4040
/**
4141
* @description Starts the Steve API if one is not already running.
4242
* Returns only after Steve is ready to accept connections.
43+
* @param httpsPort The HTTPS port for Steve to listen on.
4344
*/
44-
public async start() {
45+
public async start(httpsPort: number) {
4546
const { pid } = this.process || { };
4647

4748
if (this.isRunning && pid) {
@@ -50,6 +51,8 @@ export class Steve {
5051
return;
5152
}
5253

54+
this.httpsPort = httpsPort;
55+
5356
const osSpecificName = /^win/i.test(os.platform()) ? 'steve.exe' : 'steve';
5457
const stevePath = path.join(paths.resources, os.platform(), 'internal', osSpecificName);
5558
const env = Object.assign({}, process.env);
@@ -68,6 +71,10 @@ export class Steve {
6871
path.join(paths.resources, 'rancher-dashboard'),
6972
'--offline',
7073
'true',
74+
'--https-listen-port',
75+
String(httpsPort),
76+
'--http-listen-port',
77+
'0', // Disable HTTP support; it does not work correctly anyway.
7178
],
7279
{ env },
7380
);
@@ -93,26 +100,22 @@ export class Steve {
93100
this.isRunning = false;
94101
});
95102

96-
try {
97-
await new Promise<void>((resolve, reject) => {
98-
this.process.once('spawn', () => {
99-
this.isRunning = true;
100-
console.debug(`Spawned child pid: ${ this.process.pid }`);
101-
resolve();
102-
});
103-
this.process.once('error', (err) => {
104-
reject(new Error(`Failed to spawn Steve: ${ err.message }`, { cause: err }));
105-
});
103+
await new Promise<void>((resolve, reject) => {
104+
this.process.once('spawn', () => {
105+
this.isRunning = true;
106+
console.debug(`Spawned child pid: ${ this.process.pid }`);
107+
resolve();
108+
});
109+
this.process.once('error', (err) => {
110+
reject(new Error(`Failed to spawn Steve: ${ err.message }`, { cause: err }));
106111
});
112+
});
107113

108-
await this.waitForReady();
109-
} catch (ex) {
110-
console.error(ex);
111-
}
114+
await this.waitForReady();
112115
}
113116

114117
/**
115-
* Wait for Steve to be ready to accept connections.
118+
* Wait for Steve to be ready to serve API requests.
116119
*/
117120
private async waitForReady(): Promise<void> {
118121
const maxAttempts = 60;
@@ -124,7 +127,7 @@ export class Steve {
124127
}
125128

126129
if (await this.isPortReady()) {
127-
console.debug(`Steve is ready after ${ attempt } attempt(s)`);
130+
console.debug(`Steve is ready after ${ attempt } / ${ maxAttempts } attempt(s)`);
128131

129132
return;
130133
}
@@ -136,26 +139,61 @@ export class Steve {
136139
}
137140

138141
/**
139-
* Check if Steve is accepting connections on its port.
142+
* Check if Steve has finished initializing its API controllers.
143+
* Steve accepts HTTP connections and responds to /v1 before its
144+
* controllers have discovered all resource schemas from the K8s
145+
* API server. The dashboard fails if schemas are incomplete, so
146+
* we probe a core resource endpoint that returns 404 until the
147+
* schema controller has registered it.
140148
*/
141149
private isPortReady(): Promise<boolean> {
150+
// Steve's HTTP port just redirects to HTTPS, so we might as well go to the
151+
// HTTPS port directly. We will need to ignore certificate errors; however,
152+
// neither the NodeJS stack nor Electron.net.request() would pass through
153+
// the `Electron.app.on('certificate-error', ...)` handler, so we cannot use
154+
// the normal certificate handling for this health check. Instead, we
155+
// create a temporary session with a certificate verify proc that ignores
156+
// errors, and use that session for the health check request.
142157
return new Promise((resolve) => {
143-
const socket = new net.Socket();
144-
145-
socket.setTimeout(1000);
146-
socket.once('connect', () => {
147-
socket.destroy();
148-
resolve(true);
158+
const session = Electron.session.fromPartition('steve-healthcheck', { cache: false });
159+
160+
session.setCertificateVerifyProc((request, callback) => {
161+
if (request.hostname === '127.0.0.1') {
162+
// We do not have any more information to narrow down the certificate;
163+
// given that we're doing this in a private partition, it should be
164+
// safe to allow all localhost certificates. In particular, we do not
165+
// get access to the port number, and all the Steve certificates have
166+
// generic fields (e.g. subject).
167+
callback(0);
168+
} else {
169+
// Unexpected request; not sure how this could happen in a new session,
170+
// but we can at least pretend to do the right thing.
171+
callback(-3); // Use Chromium's default verification.
172+
}
149173
});
150-
socket.once('error', () => {
151-
socket.destroy();
152-
resolve(false);
174+
175+
const req = Electron.net.request({
176+
protocol: 'https:',
177+
hostname: '127.0.0.1',
178+
port: this.httpsPort,
179+
path: '/v1/namespaces',
180+
method: 'GET',
181+
redirect: 'error',
182+
session,
153183
});
154-
socket.once('timeout', () => {
155-
socket.destroy();
184+
185+
req.on('response', (res) => resolve(res.statusCode === 200));
186+
req.on('error', () => resolve(false));
187+
// Timeout if we don't get a response in a reasonable time.
188+
setTimeout(1_000).then(() => {
189+
try {
190+
req.abort();
191+
} catch {
192+
// ignore
193+
}
156194
resolve(false);
157195
});
158-
socket.connect(STEVE_PORT, '127.0.0.1');
196+
req.end();
159197
});
160198
}
161199

pkg/rancher-desktop/main/dashboardServer/index.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,28 @@ export class DashboardServer {
2626
private dashboardApp: Server = new Server();
2727
private host = '127.0.0.1';
2828
private port = 6120;
29-
private api = 'https://127.0.0.1:9443';
29+
private stevePort = 0;
30+
private proxies: Record<ProxyKeys, ReturnType<typeof createProxyMiddleware>> = Object.create(null);
3031

31-
private proxies = (() => {
32+
/**
33+
* Checks for an existing instance of Dashboard server.
34+
* Instantiate a new one if it does not exist.
35+
*/
36+
public static getInstance(): DashboardServer {
37+
DashboardServer.instance ??= new DashboardServer();
38+
39+
return DashboardServer.instance;
40+
}
41+
42+
/**
43+
* Recreate proxy middleware instances with the current Steve URL as
44+
* the target. Each proxy must be created with a static `target` (not
45+
* a dynamic `router`) because the onProxyReqWs callback in proxyUtils
46+
* reads options.target to correct websocket paths — without a valid
47+
* target URL, it destroys every websocket connection.
48+
*/
49+
private createProxies() {
50+
const api = `https://127.0.0.1:${ this.stevePort }`;
3251
const proxy: Record<ProxyKeys, Options> = {
3352
'/k8s': proxyWsOpts, // Straight to a remote cluster (/k8s/clusters/<id>/)
3453
'/pp': proxyWsOpts, // For (epinio) standalone API
@@ -42,20 +61,19 @@ export class DashboardServer {
4261
'/v1-*etc': proxyOpts, // SAML, KDM, etc
4362
};
4463
const entries = Object.entries(proxy).map(([key, options]) => {
45-
return [key, createProxyMiddleware({ ...options, target: this.api + key })] as const;
64+
return [key, createProxyMiddleware({ ...options, target: api + key })] as const;
4665
});
4766

48-
return Object.fromEntries(entries);
49-
})();
67+
this.proxies = Object.fromEntries(entries) as typeof this.proxies;
68+
}
5069

5170
/**
52-
* Checks for an existing instance of Dashboard server.
53-
* Instantiate a new one if it does not exist.
71+
* Update the Steve HTTPS port and recreate proxies with the new
72+
* target. Call this before each Steve start.
5473
*/
55-
public static getInstance(): DashboardServer {
56-
DashboardServer.instance ??= new DashboardServer();
57-
58-
return DashboardServer.instance;
74+
public setStevePort(stevePort: number) {
75+
this.stevePort = stevePort;
76+
this.createProxies();
5977
}
6078

6179
/**
@@ -68,8 +86,22 @@ export class DashboardServer {
6886
return;
6987
}
7088

89+
// Consumed by Rancher Dashboard to discover Steve's dynamic HTTPS
90+
// port. Registered before the proxy routes so it is not captured
91+
// by the /api proxy to Steve.
92+
this.dashboardServer.get('/api/steve-port', (_req, res) => {
93+
res.json({ port: this.stevePort });
94+
});
95+
96+
// Register wrapper functions so that when createProxies() replaces
97+
// this.proxies (on each Steve restart), express and the upgrade
98+
// handler automatically use the new instances. The call is safe: proxies
99+
// are always created before the UI is notified that Kubernetes is ready,
100+
// and the dashboard button is disabled until then.
71101
ProxyKeys.forEach((key) => {
72-
this.dashboardServer.use(key, this.proxies[key]);
102+
this.dashboardServer.use(key, (req, res, next) => {
103+
return this.proxies[key] ? this.proxies[key](req, res, next) : next();
104+
});
73105
});
74106

75107
this.dashboardApp = this.dashboardServer
@@ -99,17 +131,17 @@ export class DashboardServer {
99131
return;
100132
}
101133

102-
if (req.url?.startsWith('/v1')) {
103-
return this.proxies['/v1'].upgrade(req, socket, head);
104-
} else if (req.url?.startsWith('/v3')) {
105-
return this.proxies['/v3'].upgrade(req, socket, head);
106-
} else if (req.url?.startsWith('/k8s/')) {
107-
return this.proxies['/k8s'].upgrade(req, socket, head);
108-
} else if (req.url?.startsWith('/api/')) {
109-
return this.proxies['/api'].upgrade(req, socket, head);
110-
} else {
111-
console.log(`Unknown Web socket upgrade request for ${ req.url }`);
134+
const upgradeKeys = new Set<ProxyKeys>(['/v1', '/v3', '/k8s', '/api']);
135+
const key = Array.from(upgradeKeys).find((key) => {
136+
return req.url === key || req.url?.startsWith(key + '/');
137+
});
138+
139+
if (key && this.proxies[key]) {
140+
return this.proxies[key].upgrade(req, socket, head);
112141
}
142+
143+
console.log(`Unknown WebSocket upgrade request for ${ req.url }`);
144+
socket.destroy();
113145
});
114146
}
115147

0 commit comments

Comments
 (0)