diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd669051..629552b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - 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 - 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 diff --git a/electron/local-files.cjs b/electron/local-files.cjs index 487127134..971cfa9bf 100644 --- a/electron/local-files.cjs +++ b/electron/local-files.cjs @@ -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/ path is accepted, // which keeps every transfer on the backend's plugin routes. 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; function setTransferApiPath(apiPath) { @@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) { } return { url: `${normalizeHttpBase( - localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`, + localBaseUrl ?? + `http://localhost:${localBackendPort}${resolveApiPath()}`, "Local backend URL", )}${routePath}`, headers, @@ -1050,6 +1055,7 @@ module.exports = { createLocalFileHandlers, createTargetResolver, setTransferApiPath, + setLocalBackendPort, publishDownload, defaultPublishFs, assertWithinRoot, diff --git a/electron/main.cjs b/electron/main.cjs index bf54001c3..c28fa2939 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -35,7 +35,10 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs"); const { quitApp } = require("./app-quit.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs"); -const { registerLocalFileHandlers } = require("./local-files.cjs"); +const { + registerLocalFileHandlers, + setLocalBackendPort, +} = require("./local-files.cjs"); const { EXTERNAL_EDITOR_SETTING, 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() { reapOrphanedBackendProcess(); backendFailure = null; @@ -1164,7 +1196,8 @@ function startBackendServer() { DATA_DIR: dataDir, NODE_ENV: "production", ELECTRON_EMBEDDED: "true", - PORT: "30001", + PORT: String(backendPort), + EMBEDDED_BACKEND_PORT: String(backendPort), VERSION: app.getVersion(), }, stdio: ["pipe", "pipe", "pipe", "ipc"], @@ -1429,6 +1462,7 @@ function createWindow() { sandbox: true, webSecurity: true, preload: path.join(__dirname, "preload.js"), + additionalArguments: [`--termix-backend-port=${backendPort}`], partition: termixSessionPartition, allowRunningInsecureContent: false, webviewTag: false, @@ -3814,6 +3848,8 @@ app.whenReady().then(async () => { await clearElectronJwtCookiesAtStartup(); if (!isDev) { + backendPort = await findBackendPort(); + setLocalBackendPort(backendPort); const result = await startBackendServer(); logToFile("startBackendServer result:", result); } else { diff --git a/electron/preload.js b/electron/preload.js index f08063d8f..69eb4a4b0 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -14,7 +14,16 @@ function invokeAllowed(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", { + backendPort, getAppVersion: () => ipcRenderer.invoke("get-app-version"), // Kept so the interface size can reset a zoom an earlier build applied. setZoomFactor: (factor) => { diff --git a/env.catalog.json b/env.catalog.json index b24538151..94988e38d 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -71,7 +71,6 @@ "group": "server", "description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket." }, - { "name": "ENABLE_SSL", "group": "https", @@ -108,7 +107,6 @@ "internal": true, "description": "Set by the image when nginx serves HTTPS in front of the backend." }, - { "name": "DATABASE_DIALECT", "group": "database", @@ -161,7 +159,6 @@ "internal": true, "description": "Where database migrations are read from. Set by the image." }, - { "name": "JWT_SECRET", "group": "secrets", @@ -204,7 +201,6 @@ "secret": true, "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", "group": "auth", @@ -283,7 +279,6 @@ "group": "auth", "description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on." }, - { "name": "TRUSTED_PROXIES", "group": "proxy", @@ -315,7 +310,6 @@ "group": "proxy", "description": "Comma separated hosts that skip the proxy." }, - { "name": "LOG_LEVEL", "group": "logging", @@ -355,7 +349,6 @@ "deprecated": "the session-recording plugin setting", "description": "Only read once to carry a 2.8 value into the session-recording plugin." }, - { "name": "TERMIX_PLUGIN_REGISTRY_URL", "group": "plugins", @@ -406,7 +399,6 @@ "internal": true, "description": "Set by npm run dev." }, - { "name": "ENABLE_INSECURE_MODE", "group": "desktop", @@ -435,6 +427,12 @@ "internal": true, "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", "group": "desktop", @@ -459,14 +457,12 @@ "internal": true, "description": "Set by Flatpak. The desktop app links new versions instead of installing them." }, - { "name": "DESKTOP_SESSION", "group": "desktop", "internal": true, "description": "Read on Linux to pick the password store." }, - { "name": "VITE_BASE_PATH", "group": "internal", diff --git a/scripts/electron-backend-ready.test.ts b/scripts/electron-backend-ready.test.ts index f047ec074..773b544a8 100644 --- a/scripts/electron-backend-ready.test.ts +++ b/scripts/electron-backend-ready.test.ts @@ -53,6 +53,7 @@ function start() { isDev: false, appRoot: "/app", BACKEND_STDERR_TAIL_LIMIT: 4096, + backendPort: 30001, handleBackendRequest, setTimeout, clearTimeout, diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 0d09b5897..e14612201 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -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() { try { diff --git a/src/ui/lib/database-transfer-url.ts b/src/ui/lib/database-transfer-url.ts index db19cac1d..99f7220b1 100644 --- a/src/ui/lib/database-transfer-url.ts +++ b/src/ui/lib/database-transfer-url.ts @@ -1,4 +1,5 @@ import { getBasePath } from "./base-path"; +import { localBackendPort } from "./electron"; type BrowserLocation = Pick< Location, @@ -16,7 +17,8 @@ export function getDatabaseTransferUrl( { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions, ): string { if (electron) { - const serverUrl = configuredServerUrl || "http://localhost:30001"; + const serverUrl = + configuredServerUrl || `http://localhost:${localBackendPort()}`; return `${serverUrl.replace(/\/$/, "")}/database/${operation}`; } diff --git a/src/ui/lib/electron.ts b/src/ui/lib/electron.ts index 87ede70dc..ec163872d 100644 --- a/src/ui/lib/electron.ts +++ b/src/ui/lib/electron.ts @@ -3,6 +3,7 @@ type ElectronWindow = Window & IS_ELECTRON?: boolean; electronAPI?: { isElectron?: boolean; + backendPort?: number; }; }; @@ -16,3 +17,20 @@ export function isElectron(): boolean { 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; +} diff --git a/src/ui/lib/plugin-transport.ts b/src/ui/lib/plugin-transport.ts index 501bd4b64..73fabd822 100644 --- a/src/ui/lib/plugin-transport.ts +++ b/src/ui/lib/plugin-transport.ts @@ -19,7 +19,7 @@ import type { AxiosInstance } from "axios"; import { authApi, createRemoteOriginApiInstance } from "@/main-axios"; import { getBasePath } from "@/lib/base-path"; -import { isElectron } from "@/lib/electron"; +import { isElectron, localBackendPort } from "@/lib/electron"; import { getDeviceId } from "@/lib/device-id"; import { websocketAuthProtocols } from "@/lib/ws-auth"; import { @@ -165,7 +165,7 @@ export async function pluginWsUrl( if (isElectron()) { return buildOriginWsUrl({ origin: options.origin ?? "local", - localPort: BACKEND_PORT, + localPort: localBackendPort(), localPath: route, remotePath: route, }); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 67ca0874e..3b7e52ec9 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -8,7 +8,7 @@ import axios, { import { toast } from "sonner"; import i18n from "i18next"; import { getBasePath } from "@/lib/base-path"; -import { isElectron } from "@/lib/electron"; +import { isElectron, localBackendPort } from "@/lib/electron"; import { clearTermixSessionStorage } from "@/shell/TabContext"; import type { SSHHost } from "@/types/index"; @@ -603,7 +603,7 @@ function getApiUrl(path: string, defaultPort: number): string { if (electronMode) { // The desktop app always talks to its embedded backend. The server it // may be linked to is reached through createRemoteOriginApiInstance. - return `http://localhost:${defaultPort}${path}`; + return `http://localhost:${localBackendPort()}${path}`; } else if (devMode) { if (!import.meta.env.VITE_API_HOST) { return `/__termix_api/${defaultPort}${path}`; diff --git a/src/ui/tests/lib/local-backend-port.test.ts b/src/ui/tests/lib/local-backend-port.test.ts new file mode 100644 index 000000000..cd6e56a8f --- /dev/null +++ b/src/ui/tests/lib/local-backend-port.test.ts @@ -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); + }); +});