Skip to content

Commit ba16a03

Browse files
committed
Use dynamic ports for Steve to avoid conflicts with other software
Steve previously listened on hardcoded port 9443, which could conflict with other software. Allocate both the HTTPS and HTTP ports dynamically at startup and propagate them to Steve, the dashboard proxy, and the certificate-error handler. Key changes: - Add getAvailablePorts(), which binds all requested ports simultaneously before releasing any, guaranteeing distinct values despite the inherent TOCTOU window. - Accept port arguments in Steve.start() instead of using a constant. Let startup errors propagate to the caller instead of swallowing them; the state-changed handler catches them locally so the Kubernetes ready notification still reaches the UI. - Recreate dashboard proxy middleware on each Steve start via a new setStevePort() method. Express routes use wrapper functions that dereference the current proxy instances, so routes survive recreation without re-registration. - Add /api/steve-port endpoint for Rancher Dashboard to discover the dynamic HTTPS port. - Rename the networking module's port setter to setSteveCertPort() to distinguish it from DashboardServer.setStevePort(). - Bump rancherDashboard to 2.11.1.rd4 (consumes the new endpoint). Signed-off-by: Jan Dubois <jan.dubois@suse.com>
1 parent a203ade commit ba16a03

File tree

8 files changed

+188
-52
lines changed

8 files changed

+188
-52
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: 15 additions & 6 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';
@@ -1268,21 +1269,29 @@ 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 [steveHttpsPort, steveHttpPort] = await getAvailablePorts(2);
1281+
1282+
console.log(`Steve ports: HTTPS=${ steveHttpsPort } HTTP=${ steveHttpPort }`);
1283+
await Steve.getInstance().start(steveHttpsPort, steveHttpPort);
1284+
DashboardServer.getInstance().setStevePort(steveHttpsPort); // recreate proxy middleware
1285+
setSteveCertPort(steveHttpsPort); // update certificate-error allowed URLs
1286+
} catch (ex) {
1287+
console.error('Failed to start Steve:', ex);
1288+
}
12811289
}
12821290
}
12831291

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

12881297
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.0
1313
golangci-lint: 2.11.3
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: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,17 @@ import K3sHelper from '@pkg/backend/k3sHelper';
88
import Logging from '@pkg/utils/logging';
99
import paths from '@pkg/utils/paths';
1010

11-
const STEVE_PORT = 9443;
12-
1311
const console = Logging.steve;
1412

1513
/**
16-
* @description Singleton that manages the lifecycle of the Steve API
14+
* @description Singleton that manages the lifecycle of the Steve API.
1715
*/
1816
export class Steve {
1917
private static instance: Steve;
2018
private process!: ChildProcess;
2119

2220
private isRunning: boolean;
21+
private httpsPort = 0;
2322

2423
private constructor() {
2524
this.isRunning = false;
@@ -40,8 +39,10 @@ export class Steve {
4039
/**
4140
* @description Starts the Steve API if one is not already running.
4241
* Returns only after Steve is ready to accept connections.
42+
* @param httpsPort The HTTPS port for Steve to listen on.
43+
* @param httpPort The HTTP port for Steve to listen on.
4344
*/
44-
public async start() {
45+
public async start(httpsPort: number, httpPort: 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+
String(httpPort),
7178
],
7279
{ env },
7380
);
@@ -93,22 +100,18 @@ 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();
106108
});
109+
this.process.once('error', (err) => {
110+
reject(new Error(`Failed to spawn Steve: ${ err.message }`, { cause: err }));
111+
});
112+
});
107113

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

114117
/**
@@ -136,7 +139,7 @@ export class Steve {
136139
}
137140

138141
/**
139-
* Check if Steve is accepting connections on its port.
142+
* Check if Steve is accepting connections on its HTTPS port.
140143
*/
141144
private isPortReady(): Promise<boolean> {
142145
return new Promise((resolve) => {
@@ -155,7 +158,7 @@ export class Steve {
155158
socket.destroy();
156159
resolve(false);
157160
});
158-
socket.connect(STEVE_PORT, '127.0.0.1');
161+
socket.connect(this.httpsPort, '127.0.0.1');
159162
});
160163
}
161164

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

Lines changed: 61 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<string, 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,24 @@ 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 optional
99+
// chaining is safe: proxies are always created before the UI is
100+
// notified that Kubernetes is ready, and the dashboard button is
101+
// disabled until then.
102+
//
103+
// ?? next() fires only when the proxy is absent: createProxyMiddleware
104+
// returns an async function, so the call always yields a Promise (truthy).
71105
ProxyKeys.forEach((key) => {
72-
this.dashboardServer.use(key, this.proxies[key]);
106+
this.dashboardServer.use(key, (req, res, next) => this.proxies[key]?.(req, res, next) ?? next());
73107
});
74108

75109
this.dashboardApp = this.dashboardServer
@@ -99,17 +133,22 @@ export class DashboardServer {
99133
return;
100134
}
101135

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 }`);
136+
// TODO: drive this from ProxyKeys instead of maintaining a parallel list.
137+
const key = req.url?.startsWith('/v1')
138+
? '/v1'
139+
: req.url?.startsWith('/v3')
140+
? '/v3'
141+
: req.url?.startsWith('/k8s/')
142+
? '/k8s'
143+
: req.url?.startsWith('/api/')
144+
? '/api'
145+
: undefined;
146+
147+
if (key) {
148+
return this.proxies[key]?.upgrade(req, socket, head);
112149
}
150+
console.log(`Unknown Web socket upgrade request for ${ req.url }`);
151+
socket.destroy();
113152
});
114153
}
115154

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ import { windowMapping } from '@pkg/window';
1717

1818
const console = Logging.networking;
1919

20+
let stevePort = 0;
21+
22+
/**
23+
* Update the Steve HTTPS port used by the certificate-error handler.
24+
* Call this before each Steve start so that dynamic port changes are
25+
* reflected in the allowed-URL list.
26+
*/
27+
export function setSteveCertPort(port: number) {
28+
stevePort = port;
29+
}
30+
2031
export default async function setupNetworking() {
2132
const agentOptions = { ...https.globalAgent.options };
2233

@@ -42,10 +53,11 @@ export default async function setupNetworking() {
4253

4354
// Set up certificate handling for system certificates on Windows and macOS
4455
Electron.app.on('certificate-error', async(event, webContents, url, error, certificate, callback) => {
45-
const tlsPort = 9443;
56+
// stevePort is 0 until setSteveCertPort() is called, which is harmless:
57+
// no cert errors for Steve can arrive before Steve starts.
4658
const dashboardUrls = [
47-
`https://127.0.0.1:${ tlsPort }`,
48-
`wss://127.0.0.1:${ tlsPort }`,
59+
`https://127.0.0.1:${ stevePort }`,
60+
`wss://127.0.0.1:${ stevePort }`,
4961
'http://127.0.0.1:6120',
5062
'ws://127.0.0.1:6120',
5163
];
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import net from 'net';
2+
3+
import { getAvailablePorts } from '../networks';
4+
5+
describe('getAvailablePorts', () => {
6+
it('returns the requested number of ports', async() => {
7+
const ports = await getAvailablePorts(3);
8+
9+
expect(ports).toHaveLength(3);
10+
});
11+
12+
it('returns ports greater than zero', async() => {
13+
const ports = await getAvailablePorts(2);
14+
15+
for (const port of ports) {
16+
expect(port).toBeGreaterThan(0);
17+
}
18+
});
19+
20+
it('returns usable ports', async() => {
21+
const ports = await getAvailablePorts(2);
22+
23+
// Verify both returned ports can actually be bound.
24+
for (const port of ports) {
25+
const server = net.createServer();
26+
27+
await new Promise<void>((resolve, reject) => {
28+
server.once('error', reject);
29+
server.listen(port, '127.0.0.1', resolve);
30+
});
31+
32+
await new Promise<void>((resolve) => {
33+
server.close(() => resolve());
34+
});
35+
}
36+
});
37+
38+
it('returns distinct ports', async() => {
39+
const ports = await getAvailablePorts(2);
40+
41+
expect(ports[0]).not.toBe(ports[1]);
42+
});
43+
});

0 commit comments

Comments
 (0)