Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ toggleable
togglefullscreen
tonistiigi
toolsets
TOCTOU
topmenu
TQF
traefik
Expand Down
16 changes: 14 additions & 2 deletions background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { ImageEventHandler } from '@pkg/main/imageEvents';
import { getIpcMainProxy } from '@pkg/main/ipcMain';
import mainEvents from '@pkg/main/mainEvents';
import buildApplicationMenu from '@pkg/main/mainmenu';
import setupNetworking from '@pkg/main/networking';
import setupNetworking, { setStevePort } from '@pkg/main/networking';
import { Snapshots } from '@pkg/main/snapshots/snapshots';
import { Snapshot, SnapshotDialog } from '@pkg/main/snapshots/types';
import { Tray } from '@pkg/main/tray';
Expand All @@ -45,6 +45,7 @@ import getCommandLineArgs from '@pkg/utils/commandLine';
import dockerDirManager from '@pkg/utils/dockerDirManager';
import { isDevEnv } from '@pkg/utils/environment';
import Logging, { clearLoggingDirectory, setLogLevel } from '@pkg/utils/logging';
import { findAvailablePort } from '@pkg/utils/networks';
import { fetchMacOsVersion, getMacOsVersion } from '@pkg/utils/osVersion';
import paths from '@pkg/utils/paths';
import { protocolsRegistered, setupProtocolHandlers } from '@pkg/utils/protocols';
Expand Down Expand Up @@ -113,6 +114,9 @@ let deploymentProfiles: settings.DeploymentProfileType = { defaults: {}, locked:
*/
let pendingRestartContext: CommandWorkerInterface.CommandContext | undefined;

const DEFAULT_STEVE_HTTPS_PORT = 9443;
const DEFAULT_STEVE_HTTP_PORT = 9080;

let httpCommandServer: HttpCommandServer | null = null;
const httpCredentialHelperServer = new HttpCredentialHelperServer();

Expand Down Expand Up @@ -1277,7 +1281,15 @@ function newK8sManager() {
currentImageProcessor?.relayNamespaces();

if (enabledK8s) {
await Steve.getInstance().start();
const steveHttpsPort = await findAvailablePort(DEFAULT_STEVE_HTTPS_PORT);
const steveHttpPort = await findAvailablePort(DEFAULT_STEVE_HTTP_PORT);

if (steveHttpsPort !== DEFAULT_STEVE_HTTPS_PORT || steveHttpPort !== DEFAULT_STEVE_HTTP_PORT) {
console.log(`Steve default ports in use; using HTTPS=${ steveHttpsPort } HTTP=${ steveHttpPort }`);
}
DashboardServer.getInstance().setStevePort(steveHttpsPort);
setStevePort(steveHttpsPort);
await Steve.getInstance().start(steveHttpsPort, steveHttpPort);
}
}

Expand Down
23 changes: 15 additions & 8 deletions pkg/rancher-desktop/backend/steve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,24 @@ import K3sHelper from '@pkg/backend/k3sHelper';
import Logging from '@pkg/utils/logging';
import paths from '@pkg/utils/paths';

const STEVE_PORT = 9443;

const console = Logging.steve;

/**
* @description Singleton that manages the lifecycle of the Steve API
* Singleton that manages the lifecycle of the Steve API.
*/
export class Steve {
private static instance: Steve;
private process!: ChildProcess;

private isRunning: boolean;
private httpsPort = 0;

private constructor() {
this.isRunning = false;
}

/**
* @description Checks for an existing instance of Steve. If one does not
* Checks for an existing instance of Steve. If one does not
* exist, instantiate a new one.
*/
public static getInstance(): Steve {
Expand All @@ -38,10 +37,12 @@ export class Steve {
}

/**
* @description Starts the Steve API if one is not already running.
* Starts the Steve API if one is not already running.
* Returns only after Steve is ready to accept connections.
* @param httpsPort The HTTPS port for Steve to listen on.
* @param httpPort The HTTP port for Steve to listen on.
*/
public async start() {
public async start(httpsPort: number, httpPort: number) {
const { pid } = this.process || { };

if (this.isRunning && pid) {
Expand All @@ -50,6 +51,8 @@ export class Steve {
return;
}

this.httpsPort = httpsPort;

const osSpecificName = /^win/i.test(os.platform()) ? 'steve.exe' : 'steve';
const stevePath = path.join(paths.resources, os.platform(), 'internal', osSpecificName);
const env = Object.assign({}, process.env);
Expand All @@ -68,6 +71,10 @@ export class Steve {
path.join(paths.resources, 'rancher-dashboard'),
'--offline',
'true',
'--https-listen-port',
String(httpsPort),
'--http-listen-port',
String(httpPort),
],
{ env },
);
Expand Down Expand Up @@ -136,7 +143,7 @@ export class Steve {
}

/**
* Check if Steve is accepting connections on its port.
* Check if Steve is accepting connections on its HTTPS port.
*/
private isPortReady(): Promise<boolean> {
return new Promise((resolve) => {
Expand All @@ -155,7 +162,7 @@ export class Steve {
socket.destroy();
resolve(false);
});
socket.connect(STEVE_PORT, '127.0.0.1');
socket.connect(this.httpsPort, '127.0.0.1');
});
}

Expand Down
34 changes: 23 additions & 11 deletions pkg/rancher-desktop/main/dashboardServer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,20 @@ export class DashboardServer {
private dashboardApp: Server = new Server();
private host = '127.0.0.1';
private port = 6120;
private api = 'https://127.0.0.1:9443';
private api = '';
private proxies: Record<string, ReturnType<typeof createProxyMiddleware>> = Object.create(null);

private proxies = (() => {
/**
* Checks for an existing instance of Dashboard server.
* Instantiate a new one if it does not exist.
*/
public static getInstance(): DashboardServer {
DashboardServer.instance ??= new DashboardServer();

return DashboardServer.instance;
}

private createProxies() {
const proxy: Record<ProxyKeys, Options> = {
'/k8s': proxyWsOpts, // Straight to a remote cluster (/k8s/clusters/<id>/)
'/pp': proxyWsOpts, // For (epinio) standalone API
Expand All @@ -42,20 +53,19 @@ export class DashboardServer {
'/v1-*etc': proxyOpts, // SAML, KDM, etc
};
const entries = Object.entries(proxy).map(([key, options]) => {
return [key, createProxyMiddleware({ ...options, target: this.api + key })] as const;
return [key, createProxyMiddleware({ ...options, router: () => this.api + key })] as const;
});

return Object.fromEntries(entries);
})();
this.proxies = Object.fromEntries(entries) as typeof this.proxies;
}

/**
* Checks for an existing instance of Dashboard server.
* Instantiate a new one if it does not exist.
* Update the Steve HTTPS port that proxies forward to.
* Call this before each Steve start so that dynamic port changes are
* reflected in subsequent proxy requests.
*/
public static getInstance(): DashboardServer {
DashboardServer.instance ??= new DashboardServer();

return DashboardServer.instance;
public setStevePort(stevePort: number) {
this.api = `https://127.0.0.1:${ stevePort }`;
}

/**
Expand All @@ -68,6 +78,8 @@ export class DashboardServer {
return;
}

this.createProxies();

ProxyKeys.forEach((key) => {
this.dashboardServer.use(key, this.proxies[key]);
});
Expand Down
16 changes: 13 additions & 3 deletions pkg/rancher-desktop/main/networking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ import { windowMapping } from '@pkg/window';

const console = Logging.networking;

let stevePort = 0;

/**
* Update the Steve HTTPS port used by the certificate-error handler.
* Call this before each Steve start so that dynamic port changes are
* reflected in the allowed-URL list.
*/
export function setStevePort(port: number) {
stevePort = port;
}

export default async function setupNetworking() {
const agentOptions = { ...https.globalAgent.options };

Expand All @@ -42,10 +53,9 @@ export default async function setupNetworking() {

// Set up certificate handling for system certificates on Windows and macOS
Electron.app.on('certificate-error', async(event, webContents, url, error, certificate, callback) => {
const tlsPort = 9443;
const dashboardUrls = [
`https://127.0.0.1:${ tlsPort }`,
`wss://127.0.0.1:${ tlsPort }`,
`https://127.0.0.1:${ stevePort }`,
`wss://127.0.0.1:${ stevePort }`,
'http://127.0.0.1:6120',
'ws://127.0.0.1:6120',
];
Expand Down
54 changes: 54 additions & 0 deletions pkg/rancher-desktop/utils/__tests__/networks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import net from 'net';

import { findAvailablePort } from '../networks';

describe('findAvailablePort', () => {
it('returns the preferred port when it is available', async() => {
// Use a high ephemeral port unlikely to be in use.
const port = await findAvailablePort(59123);

expect(port).toBe(59123);
});

it('returns a different port when the preferred port is in use', async() => {
// Occupy a port so findAvailablePort must fall back.
const blocker = net.createServer();

await new Promise<void>((resolve) => {
blocker.listen(59124, '127.0.0.1', resolve);
});

try {
const port = await findAvailablePort(59124);

expect(port).not.toBe(59124);
expect(port).toBeGreaterThan(0);
} finally {
await new Promise<void>((resolve) => {
blocker.close(() => resolve());
});
}
});

it('returns the actual port when preferred port is 0', async() => {
const port = await findAvailablePort(0);

expect(port).toBeGreaterThan(0);
});

it('returns a usable port', async() => {
const port = await findAvailablePort(59125);

// Verify the returned port can actually be bound.
const server = net.createServer();

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolve);
});

await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
});
39 changes: 39 additions & 0 deletions pkg/rancher-desktop/utils/networks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import net from 'net';
import os from 'os';

export enum networkStatus {
Expand All @@ -6,6 +7,44 @@ export enum networkStatus {
OFFLINE = 'offline',
}

/**
* Try to use preferredPort; if it is already in use, let the OS assign a
* free port instead. Returns the port that is available.
*
* The port is released before returning, so there is a TOCTOU race before
* the caller actually binds it. In practice the risk is low because the
* default ports (9443/9080) sit outside the OS ephemeral range and are
* unlikely to be claimed by another process in the interim.
*/
export function findAvailablePort(preferredPort: number): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();

server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code !== 'EADDRINUSE') {
reject(err);

return;
}
// Preferred port is taken — ask the OS for a free one.
const fallback = net.createServer();

fallback.once('error', reject);
fallback.listen(0, '127.0.0.1', () => {
const addr = fallback.address() as net.AddressInfo;

fallback.close(() => resolve(addr.port));
});
});

server.listen(preferredPort, '127.0.0.1', () => {
const addr = server.address() as net.AddressInfo;

server.close(() => resolve(addr.port));
});
});
}

export function wslHostIPv4Address(): string | undefined {
const interfaces = os.networkInterfaces();
// The veth interface name changed at some time on Windows 11, so try the new name if the old one doesn't exist
Expand Down