mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: pick a free port for the desktop backend when 30001 is taken (#1663)
This commit is contained in:
1 parent
9949d0711e
commit
49f1914e6e
12 files changed
+115
-20
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user