Skip to content

Commit 976d98f

Browse files
committed
Use dynamic ports for Steve to avoid conflicts with other software
Steve's hardcoded ports 9443 and 9080 conflict with other software (e.g. Logitech GHub). Replace them with OS-assigned ephemeral ports. Since no external tool connects to Steve directly, fixed port numbers serve no purpose. Ports are resolved before each Steve start and passed to Steve itself (--https-listen-port and --http-listen-port), the certificate-error handler, and DashboardServer. DashboardServer recreates its proxy middleware on each port change so that http-proxy-middleware's options.target always holds a valid URL. The onProxyReqWs callback reads options.target to strip prepended path prefixes from websocket requests; without a proper target, it destroys every websocket connection. Express routes use wrapper functions that delegate to the current proxy instances, so replacing them takes effect without re-registering routes. DashboardServer also exposes /api/steve-port so the dashboard plugin can discover the port dynamically. Fixes #1890 Signed-off-by: Jan Dubois <jan.dubois@suse.com>
1 parent a203ade commit 976d98f

File tree

8 files changed

+142
-34
lines changed

8 files changed

+142
-34
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: 9 additions & 2 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, { setStevePort } 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 { getAvailablePort } 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';
@@ -1277,7 +1278,13 @@ function newK8sManager() {
12771278
currentImageProcessor?.relayNamespaces();
12781279

12791280
if (enabledK8s) {
1280-
await Steve.getInstance().start();
1281+
const steveHttpsPort = await getAvailablePort();
1282+
const steveHttpPort = await getAvailablePort();
1283+
1284+
console.log(`Steve ports: HTTPS=${ steveHttpsPort } HTTP=${ steveHttpPort }`);
1285+
DashboardServer.getInstance().setStevePort(steveHttpsPort);
1286+
setStevePort(steveHttpsPort);
1287+
await Steve.getInstance().start(steveHttpsPort, steveHttpPort);
12811288
}
12821289
}
12831290

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: 13 additions & 6 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
);
@@ -136,7 +143,7 @@ export class Steve {
136143
}
137144

138145
/**
139-
* Check if Steve is accepting connections on its port.
146+
* Check if Steve is accepting connections on its HTTPS port.
140147
*/
141148
private isPortReady(): Promise<boolean> {
142149
return new Promise((resolve) => {
@@ -155,7 +162,7 @@ export class Steve {
155162
socket.destroy();
156163
resolve(false);
157164
});
158-
socket.connect(STEVE_PORT, '127.0.0.1');
165+
socket.connect(this.httpsPort, '127.0.0.1');
159166
});
160167
}
161168

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

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,27 @@ 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. Called from setStevePort() so each Steve restart gets
45+
* proxies whose target (used by onProxyReqWs for path correction)
46+
* matches the actual Steve address.
47+
*/
48+
private createProxies() {
49+
const api = `https://127.0.0.1:${ this.stevePort }`;
3250
const proxy: Record<ProxyKeys, Options> = {
3351
'/k8s': proxyWsOpts, // Straight to a remote cluster (/k8s/clusters/<id>/)
3452
'/pp': proxyWsOpts, // For (epinio) standalone API
@@ -42,20 +60,19 @@ export class DashboardServer {
4260
'/v1-*etc': proxyOpts, // SAML, KDM, etc
4361
};
4462
const entries = Object.entries(proxy).map(([key, options]) => {
45-
return [key, createProxyMiddleware({ ...options, target: this.api + key })] as const;
63+
return [key, createProxyMiddleware({ ...options, target: api + key })] as const;
4664
});
4765

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

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

6178
/**
@@ -68,8 +85,17 @@ export class DashboardServer {
6885
return;
6986
}
7087

88+
// Register before the proxy routes so /api/steve-port is not
89+
// captured by the /api proxy to Steve.
90+
this.dashboardServer.get('/api/steve-port', (_req, res) => {
91+
res.json({ port: this.stevePort });
92+
});
93+
94+
// Register wrapper functions so that when createProxies() replaces
95+
// this.proxies (on each Steve restart), express and the upgrade
96+
// handler automatically use the new instances.
7197
ProxyKeys.forEach((key) => {
72-
this.dashboardServer.use(key, this.proxies[key]);
98+
this.dashboardServer.use(key, (req, res, next) => this.proxies[key]?.(req, res, next) ?? next());
7399
});
74100

75101
this.dashboardApp = this.dashboardServer
@@ -99,17 +125,20 @@ export class DashboardServer {
99125
return;
100126
}
101127

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 }`);
128+
const key = req.url?.startsWith('/v1')
129+
? '/v1'
130+
: req.url?.startsWith('/v3')
131+
? '/v3'
132+
: req.url?.startsWith('/k8s/')
133+
? '/k8s'
134+
: req.url?.startsWith('/api/')
135+
? '/api'
136+
: undefined;
137+
138+
if (key) {
139+
return this.proxies[key]?.upgrade(req, socket, head);
112140
}
141+
console.log(`Unknown Web socket upgrade request for ${ req.url }`);
113142
});
114143
}
115144

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

Lines changed: 13 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 setStevePort(port: number) {
28+
stevePort = port;
29+
}
30+
2031
export default async function setupNetworking() {
2132
const agentOptions = { ...https.globalAgent.options };
2233

@@ -42,10 +53,9 @@ 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;
4656
const dashboardUrls = [
47-
`https://127.0.0.1:${ tlsPort }`,
48-
`wss://127.0.0.1:${ tlsPort }`,
57+
`https://127.0.0.1:${ stevePort }`,
58+
`wss://127.0.0.1:${ stevePort }`,
4959
'http://127.0.0.1:6120',
5060
'ws://127.0.0.1:6120',
5161
];
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import net from 'net';
2+
3+
import { getAvailablePort } from '../networks';
4+
5+
describe('getAvailablePort', () => {
6+
it('returns a port greater than zero', async() => {
7+
const port = await getAvailablePort();
8+
9+
expect(port).toBeGreaterThan(0);
10+
});
11+
12+
it('returns a usable port', async() => {
13+
const port = await getAvailablePort();
14+
15+
// Verify the returned port can actually be bound.
16+
const server = net.createServer();
17+
18+
await new Promise<void>((resolve, reject) => {
19+
server.once('error', reject);
20+
server.listen(port, '127.0.0.1', resolve);
21+
});
22+
23+
await new Promise<void>((resolve) => {
24+
server.close(() => resolve());
25+
});
26+
});
27+
28+
it('returns distinct ports on consecutive calls', async() => {
29+
const port1 = await getAvailablePort();
30+
const port2 = await getAvailablePort();
31+
32+
expect(port1).not.toBe(port2);
33+
});
34+
});

pkg/rancher-desktop/utils/networks.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import net from 'net';
12
import os from 'os';
23

34
export enum networkStatus {
@@ -6,6 +7,25 @@ export enum networkStatus {
67
OFFLINE = 'offline',
78
}
89

10+
/**
11+
* Ask the OS to assign a free port on localhost. The port is released
12+
* before returning, so there is a small TOCTOU race before the caller
13+
* binds it. In practice the risk is negligible because the caller
14+
* (Steve) binds within seconds.
15+
*/
16+
export function getAvailablePort(): Promise<number> {
17+
return new Promise((resolve, reject) => {
18+
const server = net.createServer();
19+
20+
server.once('error', reject);
21+
server.listen(0, '127.0.0.1', () => {
22+
const addr = server.address() as net.AddressInfo;
23+
24+
server.close(() => resolve(addr.port));
25+
});
26+
});
27+
}
28+
929
export function wslHostIPv4Address(): string | undefined {
1030
const interfaces = os.networkInterfaces();
1131
// The veth interface name changed at some time on Windows 11, so try the new name if the old one doesn't exist

0 commit comments

Comments
 (0)