mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
+62
-356
@@ -27,7 +27,8 @@ const { URL, pathToFileURL } = require("url");
|
||||
const { fork, spawn } = require("child_process");
|
||||
const pty = require("node-pty");
|
||||
const WebSocket = require("ws");
|
||||
const remoteSync = require("./remote-sync.cjs");
|
||||
const linkedServer = require("./linked-server.cjs");
|
||||
const desktopSettings = require("./desktop-settings.cjs");
|
||||
const { launchNativeRdp } = require("./native-rdp.cjs");
|
||||
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
||||
const { quitApp } = require("./app-quit.cjs");
|
||||
@@ -54,7 +55,7 @@ function closeLocalTerminalsFor(ownerId) {
|
||||
}
|
||||
|
||||
// The main process's Node.js networking (the `https`/`http` modules used by
|
||||
// httpFetch below, and the global `fetch` used by remote-sync.cjs) only
|
||||
// httpFetch below and the global `fetch`) only
|
||||
// trusts Node's bundled Mozilla CA list by default, not the OS/system trust
|
||||
// store. Chromium (the renderer, i.e. the web app and the login iframe) uses
|
||||
// the OS trust store instead, so a certificate that's valid in-browser --
|
||||
@@ -432,20 +433,6 @@ function isInsecureModeEnabled() {
|
||||
);
|
||||
}
|
||||
|
||||
function getServerConfigPath() {
|
||||
return path.join(app.getPath("userData"), "server-config.json");
|
||||
}
|
||||
|
||||
function getServerConfigSync() {
|
||||
try {
|
||||
const configPath = getServerConfigPath();
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrigin(url) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
@@ -493,13 +480,11 @@ function isInvalidCertificateAllowedForUrl(url) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
// The only remaining "connected remote server" a self-signed/invalid
|
||||
// certificate could legitimately apply to is the Remote Sync server
|
||||
// (also used for C2S tunnel relaying, see getC2SRelayUrl).
|
||||
const config = remoteSync.getRemoteSyncConfig();
|
||||
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
|
||||
|
||||
return getOrigin(url) === getOrigin(config.serverUrl);
|
||||
// Otherwise only the server this desktop is linked to, when its link
|
||||
// allows a self-signed certificate.
|
||||
const linked = linkedServer.getLinkedServer();
|
||||
if (!linked?.allowInvalidCertificate) return false;
|
||||
return getOrigin(url) === linked.origin;
|
||||
}
|
||||
|
||||
function getTlsVerificationOptions(url) {
|
||||
@@ -646,6 +631,10 @@ const isolatedWindows = createIsolatedWindows({
|
||||
const BACKEND_REQUEST_HANDLERS = {
|
||||
"open-isolated-window": (payload) => isolatedWindows.open(payload),
|
||||
"launch-native-rdp": (payload) => launchNativeRdp(payload),
|
||||
"sync-proxy-config": (payload) => {
|
||||
linkedServer.setLinkedServer(payload);
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
|
||||
async function handleBackendRequest(msg) {
|
||||
@@ -888,6 +877,16 @@ app.on(
|
||||
},
|
||||
);
|
||||
|
||||
// A reverse proxy in front of the linked server may ask for basic auth; the
|
||||
// link's saved username and password answer it. Anything else gets the
|
||||
// default behaviour.
|
||||
app.on("login", (event, _webContents, details, authInfo, callback) => {
|
||||
const credentials = linkedServer.answerLogin(authInfo, details?.url);
|
||||
if (!credentials) return;
|
||||
event.preventDefault();
|
||||
callback(credentials.username, credentials.password || "");
|
||||
});
|
||||
|
||||
function getElectronBuildTimestamp() {
|
||||
try {
|
||||
const buildInfo = require("./build-info.cjs");
|
||||
@@ -1420,7 +1419,12 @@ function createWindow() {
|
||||
);
|
||||
}
|
||||
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
callback({
|
||||
requestHeaders: linkedServer.applyLinkHeaders(
|
||||
details.url,
|
||||
details.requestHeaders,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1564,111 +1568,6 @@ ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
const githubCache = new Map();
|
||||
const CACHE_DURATION = 30 * 60 * 1000;
|
||||
|
||||
async function fetchGitHubAPI(endpoint, cacheKey) {
|
||||
const cached = githubCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return {
|
||||
data: cached.data,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cached.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixElectronUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
githubCache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch from GitHub API:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("check-electron-update", async () => {
|
||||
try {
|
||||
const localVersion = app.getVersion();
|
||||
|
||||
const releaseData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
"latest_release_electron",
|
||||
);
|
||||
|
||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||
|
||||
if (!remoteVersion) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Remote version not found",
|
||||
localVersion,
|
||||
};
|
||||
}
|
||||
|
||||
const versionComparison = compareSemver(localVersion, remoteVersion);
|
||||
const status =
|
||||
versionComparison === null || versionComparison === 0
|
||||
? "up_to_date"
|
||||
: versionComparison > 0
|
||||
? "beta"
|
||||
: "requires_update";
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
status,
|
||||
localVersion: localVersion,
|
||||
remoteVersion: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
published_at: releaseData.data.published_at,
|
||||
html_url: releaseData.data.html_url,
|
||||
body: releaseData.data.body,
|
||||
},
|
||||
cached: releaseData.cached,
|
||||
cache_age: releaseData.cache_age,
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
localVersion: app.getVersion(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
@@ -1769,109 +1668,20 @@ ipcMain.handle(
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("get-server-config", () => {
|
||||
try {
|
||||
return getServerConfigSync();
|
||||
} catch (error) {
|
||||
console.error("Error reading server config:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("save-server-config", (event, config) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = getServerConfigPath();
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error saving server config:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
|
||||
|
||||
// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
|
||||
// ever set in it) so the renderer can prompt upgraded installs to set up
|
||||
// Remote Sync -- their hosts live on that old server and won't appear
|
||||
// locally until sync is enabled. A fresh install never had this file, so
|
||||
// this is naturally false for anyone who never used the old architecture.
|
||||
ipcMain.handle("get-legacy-server-config", () => {
|
||||
const config = getServerConfigSync();
|
||||
return { serverUrl: config?.serverUrl || null };
|
||||
});
|
||||
|
||||
// Desktop-only settings, such as whether hosts connect from this device or
|
||||
// through the linked server by default.
|
||||
ipcMain.handle("get-desktop-settings", () => {
|
||||
return remoteSync.getDesktopSettings();
|
||||
return desktopSettings.getDesktopSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
||||
return remoteSync.saveDesktopSettings(settings);
|
||||
return desktopSettings.saveDesktopSettings(settings);
|
||||
});
|
||||
|
||||
ipcMain.handle("get-remote-sync-config", () => {
|
||||
return remoteSync.getRemoteSyncConfig();
|
||||
});
|
||||
|
||||
ipcMain.handle("save-remote-sync-config", (_event, config) => {
|
||||
return remoteSync.saveRemoteSyncConfig(config);
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-remote-sync-config", async () => {
|
||||
const result = remoteSync.clearRemoteSyncConfig();
|
||||
remoteSync.clearRemoteSyncJwt();
|
||||
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||
connected: false,
|
||||
syncing: false,
|
||||
needsReauth: false,
|
||||
lastError: null,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
|
||||
const result = remoteSync.saveRemoteSyncJwt(token);
|
||||
if (result.success) {
|
||||
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||
connected: true,
|
||||
needsReauth: false,
|
||||
lastError: null,
|
||||
});
|
||||
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||
return { ...result, status: status || null };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-remote-sync-jwt", () => {
|
||||
return remoteSync.getRemoteSyncJwt();
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-remote-sync-jwt", () => {
|
||||
return remoteSync.clearRemoteSyncJwt();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-remote-sync-status", () => {
|
||||
return remoteSync.getRemoteSyncEngine()?.status || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-remote-sync-user-info", () => {
|
||||
return remoteSync.getRemoteSyncUserInfo();
|
||||
});
|
||||
|
||||
ipcMain.handle("remote-sync-now", async () => {
|
||||
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("notify-local-login", (_event, token) => {
|
||||
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
|
||||
return { success: true };
|
||||
// The server an older version of the app was pointed at, offered again when
|
||||
// linking so upgraded installs do not have to look it up.
|
||||
ipcMain.handle("get-previous-server-url", () => {
|
||||
return { serverUrl: desktopSettings.getPreviousServerUrl() };
|
||||
});
|
||||
|
||||
function getC2STunnelConfigPath() {
|
||||
@@ -2040,40 +1850,25 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
// C2S (client-to-server) tunnels relay through a connected, self-hosted
|
||||
// Termix server -- the same "remote server" concept Remote Sync connects
|
||||
// to, not the always-local embedded backend. There's no separate C2S
|
||||
// server-URL setting in the UI; it has always shared whatever remote
|
||||
// server the rest of the app was pointed at. Before the standalone-first
|
||||
// rework that was server-config.json; now it's remote-sync-config.json,
|
||||
// since that's the only remaining notion of "a connected remote server."
|
||||
function getC2SRelayUrl() {
|
||||
const config = remoteSync.getRemoteSyncConfig();
|
||||
const serverUrl = config?.serverUrl;
|
||||
if (!serverUrl) {
|
||||
throw new Error(
|
||||
"No remote Termix server connected -- enable Remote Sync first",
|
||||
);
|
||||
}
|
||||
|
||||
const base = serverUrl.replace(/\/$/, "");
|
||||
const relayHttpUrl = `${base}/plugin-ws/tunnels/c2s/stream`;
|
||||
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
||||
}
|
||||
|
||||
// C2S (client-to-server) tunnels relay through the server this desktop is
|
||||
// linked to, not the embedded backend.
|
||||
function getC2SRemoteBaseUrl() {
|
||||
const config = remoteSync.getRemoteSyncConfig();
|
||||
const serverUrl = config?.serverUrl;
|
||||
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
|
||||
if (!serverUrl) {
|
||||
throw new Error(
|
||||
"No remote Termix server connected -- enable Remote Sync first",
|
||||
"This device is not linked to a Termix server. Link it in Sync first.",
|
||||
);
|
||||
}
|
||||
return serverUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function getC2SRelayUrl() {
|
||||
const relayHttpUrl = `${getC2SRemoteBaseUrl()}/plugin-ws/tunnels/c2s/stream`;
|
||||
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
||||
}
|
||||
|
||||
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
|
||||
"Remote Termix session expired. Reconnect Remote Sync and try again.";
|
||||
"This device was signed out of its server. Sign in again from Sync and try again.";
|
||||
|
||||
function normalizeC2SAuthToken(authToken) {
|
||||
return typeof authToken === "string" ? authToken.trim() : "";
|
||||
@@ -2111,17 +1906,13 @@ function createC2SFailure(message, fallback) {
|
||||
}
|
||||
|
||||
async function fetchC2SRemoteJson(pathname) {
|
||||
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
|
||||
if (!remoteSyncJwt || remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
|
||||
if (!linkedServer.getLinkedServer()?.token) {
|
||||
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
||||
}
|
||||
|
||||
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${remoteSyncJwt}`,
|
||||
"X-Electron-App": "true",
|
||||
},
|
||||
const response = await httpFetch(url, {
|
||||
headers: linkedServer.linkedRequestHeaders(),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
||||
@@ -2140,14 +1931,18 @@ async function resolveC2SRemoteSourceHost(tunnel) {
|
||||
: "";
|
||||
if (!sourceHostSyncId) return normalized;
|
||||
|
||||
const data = await fetchC2SRemoteJson("/sync/hosts");
|
||||
const remoteHost = (Array.isArray(data?.rows) ? data.rows : []).find(
|
||||
(row) => row?.syncId === sourceHostSyncId,
|
||||
);
|
||||
let remoteHost = null;
|
||||
try {
|
||||
remoteHost = await fetchC2SRemoteJson(
|
||||
`/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error;
|
||||
}
|
||||
const remoteHostId = Number(remoteHost?.id);
|
||||
if (!Number.isInteger(remoteHostId) || remoteHostId < 1) {
|
||||
throw new Error(
|
||||
"Intermediate Host is not available on the remote Termix server yet. Run Remote Sync for hosts, then try again.",
|
||||
"The intermediate host is not on the server yet. Sync, then try again.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2161,18 +1956,11 @@ async function resolveC2SRemoteSourceHost(tunnel) {
|
||||
}
|
||||
|
||||
async function getC2SRelayHeaders(relayUrl) {
|
||||
if (linkedServer.getLinkedServer()?.token) {
|
||||
return linkedServer.linkedRequestHeaders();
|
||||
}
|
||||
const headers = { "X-Electron-App": "true" };
|
||||
|
||||
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
|
||||
if (remoteSyncJwt && !remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
|
||||
headers.Authorization = `Bearer ${remoteSyncJwt}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (remoteSyncJwt) {
|
||||
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
||||
}
|
||||
|
||||
if (!mainWindow?.webContents?.session) {
|
||||
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
||||
}
|
||||
@@ -3779,88 +3567,6 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function testServerConnection(
|
||||
_event,
|
||||
serverUrl,
|
||||
allowInvalidCertificate = false,
|
||||
) {
|
||||
try {
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
const healthUrl = `${normalizedServerUrl}/health`;
|
||||
|
||||
// This is a best-effort reachability probe, not a hard gate: a reverse
|
||||
// proxy doing SSO in front of the real server (Pangolin, Authelia,
|
||||
// Cloudflare Access, etc.) intercepts this unauthenticated request
|
||||
// before it ever reaches Termix's own /health route, and returns its
|
||||
// own login page (HTML, or a redirect) instead of {"status":"ok"}.
|
||||
// That's a legitimate, working setup -- the login iframe shown right
|
||||
// after this check is what actually proves the server is real, by
|
||||
// completing an authenticated round-trip. So any response at all here
|
||||
// (any status code, any body) means "something is there, let the user
|
||||
// proceed"; only a network-level failure (nothing answered at all)
|
||||
// blocks continuing.
|
||||
try {
|
||||
const response = await httpFetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
allowInvalidCertificate,
|
||||
});
|
||||
|
||||
const data = await response.text();
|
||||
const looksLikeHtml =
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>");
|
||||
|
||||
if (response.ok && !looksLikeHtml) {
|
||||
try {
|
||||
const healthData = JSON.parse(data);
|
||||
if (
|
||||
healthData &&
|
||||
(healthData.status === "ok" ||
|
||||
healthData.status === "healthy" ||
|
||||
healthData.healthy === true ||
|
||||
healthData.database === "connected")
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: healthUrl,
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Health endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Reachable, but not a recognized Termix health response -- likely a
|
||||
// proxy/SSO login page in front of the real server. Let the user
|
||||
// proceed; the login step next will fail clearly if this really
|
||||
// isn't a Termix server.
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: healthUrl,
|
||||
warning: looksLikeHtml
|
||||
? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
|
||||
: "Server responded, but not with the expected health check format. Continuing anyway.",
|
||||
};
|
||||
} catch (urlError) {
|
||||
console.error("Health check failed:", urlError);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server is not responding. Please ensure the server is running and accessible.",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("test-server-connection", testServerConnection);
|
||||
|
||||
// Local disk browsing + streamed local<->remote transfers for the file
|
||||
// manager's dual-pane mode (see electron/local-files.cjs).
|
||||
registerLocalFileHandlers({ ipcMain, shell });
|
||||
@@ -3953,7 +3659,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
createTray();
|
||||
createWindow();
|
||||
remoteSync.initRemoteSync(() => mainWindow);
|
||||
desktopSettings.removeOldSyncFiles();
|
||||
logToFile("=== Startup complete ===");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user