fix: pick a free port for the desktop backend when 30001 is taken (#1663)

This commit is contained in:
LukeGus committed 2026-10-16 23:37:00 -05:00
1 parent 9949d0711e
commit 49f1914e6e
12 files changed
+115 -20

No files matched your search

+1
View File
@@ -38,6 +38,7 @@
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24 - The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language - Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required - Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
## 26.10.1 ## 26.10.1
+8 -2
View File
@@ -330,7 +330,11 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted, // path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes. // which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/; const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
const LOCAL_BACKEND = "http://localhost:30001"; let localBackendPort = 30001;
function setLocalBackendPort(port) {
localBackendPort = port;
}
let registeredApiPath = null; let registeredApiPath = null;
function setTransferApiPath(apiPath) { function setTransferApiPath(apiPath) {
@@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
} }
return { return {
url: `${normalizeHttpBase( url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`, localBaseUrl ??
`http://localhost:${localBackendPort}${resolveApiPath()}`,
"Local backend URL", "Local backend URL",
)}${routePath}`, )}${routePath}`,
headers, headers,
@@ -1050,6 +1055,7 @@ module.exports = {
createLocalFileHandlers, createLocalFileHandlers,
createTargetResolver, createTargetResolver,
setTransferApiPath, setTransferApiPath,
setLocalBackendPort,
publishDownload, publishDownload,
defaultPublishFs, defaultPublishFs,
assertWithinRoot, assertWithinRoot,
+38 -2
View File
@@ -35,7 +35,10 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs"); const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs"); const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs"); const {
registerLocalFileHandlers,
setLocalBackendPort,
} = require("./local-files.cjs");
const { const {
EXTERNAL_EDITOR_SETTING, EXTERNAL_EDITOR_SETTING,
isLaunchableFileName, isLaunchableFileName,
@@ -1124,6 +1127,35 @@ function reapOrphanedBackendProcess() {
} }
} }
const DEFAULT_BACKEND_PORT = 30001;
let backendPort = DEFAULT_BACKEND_PORT;
function canListenOn(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(null));
probe.listen(port, "127.0.0.1", () => {
const { port: bound } = probe.address();
probe.close(() => resolve(bound));
});
});
}
// 30001 when it is free, otherwise any free port. Only the desktop app talks
// to this backend, so the number itself does not matter.
async function findBackendPort() {
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
if (preferred) return preferred;
const fallback = await canListenOn(0);
if (fallback) {
logToFile(
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
);
return fallback;
}
return DEFAULT_BACKEND_PORT;
}
function startBackendServer() { function startBackendServer() {
reapOrphanedBackendProcess(); reapOrphanedBackendProcess();
backendFailure = null; backendFailure = null;
@@ -1164,7 +1196,8 @@ function startBackendServer() {
DATA_DIR: dataDir, DATA_DIR: dataDir,
NODE_ENV: "production", NODE_ENV: "production",
ELECTRON_EMBEDDED: "true", ELECTRON_EMBEDDED: "true",
PORT: "30001", PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
VERSION: app.getVersion(), VERSION: app.getVersion(),
}, },
stdio: ["pipe", "pipe", "pipe", "ipc"], stdio: ["pipe", "pipe", "pipe", "ipc"],
@@ -1429,6 +1462,7 @@ function createWindow() {
sandbox: true, sandbox: true,
webSecurity: true, webSecurity: true,
preload: path.join(__dirname, "preload.js"), preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition, partition: termixSessionPartition,
allowRunningInsecureContent: false, allowRunningInsecureContent: false,
webviewTag: false, webviewTag: false,
@@ -3814,6 +3848,8 @@ app.whenReady().then(async () => {
await clearElectronJwtCookiesAtStartup(); await clearElectronJwtCookiesAtStartup();
if (!isDev) { if (!isDev) {
backendPort = await findBackendPort();
setLocalBackendPort(backendPort);
const result = await startBackendServer(); const result = await startBackendServer();
logToFile("startBackendServer result:", result); logToFile("startBackendServer result:", result);
} else { } else {
+9
View File
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(channel, ...args); return ipcRenderer.invoke(channel, ...args);
} }
// The embedded backend's port, picked by the main process at startup.
const backendPortArg = process.argv.find((arg) =>
arg.startsWith("--termix-backend-port="),
);
const backendPort = backendPortArg
? Number(backendPortArg.split("=")[1])
: undefined;
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"), getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied. // Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => { setZoomFactor: (factor) => {
+6 -10
View File
@@ -71,7 +71,6 @@
"group": "server", "group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket." "description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
}, },
{ {
"name": "ENABLE_SSL", "name": "ENABLE_SSL",
"group": "https", "group": "https",
@@ -108,7 +107,6 @@
"internal": true, "internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend." "description": "Set by the image when nginx serves HTTPS in front of the backend."
}, },
{ {
"name": "DATABASE_DIALECT", "name": "DATABASE_DIALECT",
"group": "database", "group": "database",
@@ -161,7 +159,6 @@
"internal": true, "internal": true,
"description": "Where database migrations are read from. Set by the image." "description": "Where database migrations are read from. Set by the image."
}, },
{ {
"name": "JWT_SECRET", "name": "JWT_SECRET",
"group": "secrets", "group": "secrets",
@@ -204,7 +201,6 @@
"secret": true, "secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it." "description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
}, },
{ {
"name": "ALLOW_PASSWORD_LOGIN", "name": "ALLOW_PASSWORD_LOGIN",
"group": "auth", "group": "auth",
@@ -283,7 +279,6 @@
"group": "auth", "group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on." "description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
}, },
{ {
"name": "TRUSTED_PROXIES", "name": "TRUSTED_PROXIES",
"group": "proxy", "group": "proxy",
@@ -315,7 +310,6 @@
"group": "proxy", "group": "proxy",
"description": "Comma separated hosts that skip the proxy." "description": "Comma separated hosts that skip the proxy."
}, },
{ {
"name": "LOG_LEVEL", "name": "LOG_LEVEL",
"group": "logging", "group": "logging",
@@ -355,7 +349,6 @@
"deprecated": "the session-recording plugin setting", "deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin." "description": "Only read once to carry a 2.8 value into the session-recording plugin."
}, },
{ {
"name": "TERMIX_PLUGIN_REGISTRY_URL", "name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins", "group": "plugins",
@@ -406,7 +399,6 @@
"internal": true, "internal": true,
"description": "Set by npm run dev." "description": "Set by npm run dev."
}, },
{ {
"name": "ENABLE_INSECURE_MODE", "name": "ENABLE_INSECURE_MODE",
"group": "desktop", "group": "desktop",
@@ -435,6 +427,12 @@
"internal": true, "internal": true,
"description": "Set by the desktop app when it runs its own backend." "description": "Set by the desktop app when it runs its own backend."
}, },
{
"name": "EMBEDDED_BACKEND_PORT",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
},
{ {
"name": "TERMIX_DATA_DIR", "name": "TERMIX_DATA_DIR",
"group": "desktop", "group": "desktop",
@@ -459,14 +457,12 @@
"internal": true, "internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them." "description": "Set by Flatpak. The desktop app links new versions instead of installing them."
}, },
{ {
"name": "DESKTOP_SESSION", "name": "DESKTOP_SESSION",
"group": "desktop", "group": "desktop",
"internal": true, "internal": true,
"description": "Read on Linux to pick the password store." "description": "Read on Linux to pick the password store."
}, },
{ {
"name": "VITE_BASE_PATH", "name": "VITE_BASE_PATH",
"group": "internal", "group": "internal",
+1
View File
@@ -53,6 +53,7 @@ function start() {
isDev: false, isDev: false,
appRoot: "/app", appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096, BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest, handleBackendRequest,
setTimeout, setTimeout,
clearTimeout, clearTimeout,
+2 -1
View File
@@ -1716,7 +1716,8 @@ app.use(
}, },
); );
const HTTP_PORT = 30001; // The desktop app moves its embedded backend off 30001 when that is taken.
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
async function initializeSecurity() { async function initializeSecurity() {
try { try {
+3 -1
View File
@@ -1,4 +1,5 @@
import { getBasePath } from "./base-path"; import { getBasePath } from "./base-path";
import { localBackendPort } from "./electron";
type BrowserLocation = Pick< type BrowserLocation = Pick<
Location, Location,
@@ -16,7 +17,8 @@ export function getDatabaseTransferUrl(
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions, { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
): string { ): string {
if (electron) { if (electron) {
const serverUrl = configuredServerUrl || "http://localhost:30001"; const serverUrl =
configuredServerUrl || `http://localhost:${localBackendPort()}`;
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`; return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
} }
+18
View File
@@ -3,6 +3,7 @@ type ElectronWindow = Window &
IS_ELECTRON?: boolean; IS_ELECTRON?: boolean;
electronAPI?: { electronAPI?: {
isElectron?: boolean; isElectron?: boolean;
backendPort?: number;
}; };
}; };
@@ -16,3 +17,20 @@ export function isElectron(): boolean {
return hasISElectron || hasElectronAPI || isElectronProp; return hasISElectron || hasElectronAPI || isElectronProp;
} }
const DEFAULT_LOCAL_BACKEND_PORT = 30001;
/**
* The port of the desktop app's embedded backend. It moves off 30001 when
* another program holds that port.
*/
export function localBackendPort(): number {
if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
const port = (window as ElectronWindow).electronAPI?.backendPort;
return typeof port === "number" &&
Number.isInteger(port) &&
port > 0 &&
port < 65536
? port
: DEFAULT_LOCAL_BACKEND_PORT;
}
+2 -2
View File
@@ -19,7 +19,7 @@
import type { AxiosInstance } from "axios"; import type { AxiosInstance } from "axios";
import { authApi, createRemoteOriginApiInstance } from "@/main-axios"; import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron"; import { isElectron, localBackendPort } from "@/lib/electron";
import { getDeviceId } from "@/lib/device-id"; import { getDeviceId } from "@/lib/device-id";
import { websocketAuthProtocols } from "@/lib/ws-auth"; import { websocketAuthProtocols } from "@/lib/ws-auth";
import { import {
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
if (isElectron()) { if (isElectron()) {
return buildOriginWsUrl({ return buildOriginWsUrl({
origin: options.origin ?? "local", origin: options.origin ?? "local",
localPort: BACKEND_PORT, localPort: localBackendPort(),
localPath: route, localPath: route,
remotePath: route, remotePath: route,
}); });
+2 -2
View File
@@ -8,7 +8,7 @@ import axios, {
import { toast } from "sonner"; import { toast } from "sonner";
import i18n from "i18next"; import i18n from "i18next";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron"; import { isElectron, localBackendPort } from "@/lib/electron";
import { clearTermixSessionStorage } from "@/shell/TabContext"; import { clearTermixSessionStorage } from "@/shell/TabContext";
import type { SSHHost } from "@/types/index"; import type { SSHHost } from "@/types/index";
@@ -603,7 +603,7 @@ function getApiUrl(path: string, defaultPort: number): string {
if (electronMode) { if (electronMode) {
// The desktop app always talks to its embedded backend. The server it // The desktop app always talks to its embedded backend. The server it
// may be linked to is reached through createRemoteOriginApiInstance. // may be linked to is reached through createRemoteOriginApiInstance.
return `http://localhost:${defaultPort}${path}`; return `http://localhost:${localBackendPort()}${path}`;
} else if (devMode) { } else if (devMode) {
if (!import.meta.env.VITE_API_HOST) { if (!import.meta.env.VITE_API_HOST) {
return `/__termix_api/${defaultPort}${path}`; return `/__termix_api/${defaultPort}${path}`;
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from "vitest";
import { localBackendPort } from "@/lib/electron";
function setElectronAPI(api: unknown) {
(window as unknown as { electronAPI?: unknown }).electronAPI = api;
}
afterEach(() => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
describe("localBackendPort", () => {
it("uses the port the desktop app picked", () => {
setElectronAPI({ backendPort: 41234 });
expect(localBackendPort()).toBe(41234);
});
it("falls back to 30001", () => {
expect(localBackendPort()).toBe(30001);
setElectronAPI({ backendPort: Number.NaN });
expect(localBackendPort()).toBe(30001);
setElectronAPI({ backendPort: 70000 });
expect(localBackendPort()).toBe(30001);
});
});