feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
// Desktop-only settings kept next to the app's data, and the server address
// older versions saved, so linking can offer it again.
const { app } = require("electron");
const fs = require("fs");
const path = require("path");
function dataPath(filename) {
return path.join(app.getPath("userData"), filename);
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
mode: 0o600,
});
fs.renameSync(temporaryPath, filePath);
} catch (error) {
fs.rmSync(temporaryPath, { force: true });
throw error;
}
}
const DEFAULTS = { defaultConnectionOrigin: "local" };
function getDesktopSettings() {
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
}
function saveDesktopSettings(settings) {
writeJson(dataPath("desktop-settings.json"), {
...getDesktopSettings(),
...settings,
});
return { success: true };
}
// The server a 2.8 or older desktop was pointed at, if any.
function getPreviousServerUrl() {
for (const file of ["remote-sync-config.json", "server-config.json"]) {
const config = readJson(dataPath(file), null);
if (config && typeof config.serverUrl === "string" && config.serverUrl) {
return config.serverUrl;
}
}
return null;
}
// The old sync engine's session and cursors are useless to the new one,
// and the session is a credential, so they go.
function removeOldSyncFiles() {
for (const file of [
"remote-sync-credential.json",
"remote-sync-state.json",
]) {
try {
fs.rmSync(dataPath(file), { force: true });
} catch {
// Best effort.
}
}
}
module.exports = {
getDesktopSettings,
saveDesktopSettings,
getPreviousServerUrl,
removeOldSyncFiles,
};
+98
View File
@@ -0,0 +1,98 @@
// The server this desktop is linked to, as the embedded backend reports it.
// The backend owns the link (and its secrets); main only keeps what it needs
// to reach that one origin: the proxy headers and basic auth a reverse proxy
// in front of it wants, whether its certificate may be self-signed, and the
// session for requests main makes itself (C2S tunnels).
let linked = null;
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function setLinkedServer(config) {
if (!config || !config.origin) {
linked = null;
return;
}
linked = {
origin: config.origin,
serverUrl: config.serverUrl || config.origin,
token: config.token || null,
headers: Array.isArray(config.headers) ? config.headers : [],
basicAuth:
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
allowInvalidCertificate: !!config.allowInvalidCertificate,
};
}
function getLinkedServer() {
return linked;
}
function isLinkedOrigin(url) {
return !!linked && getOrigin(url) === linked.origin;
}
function basicAuthValue(basicAuth) {
return `Basic ${Buffer.from(
`${basicAuth.username}:${basicAuth.password || ""}`,
).toString("base64")}`;
}
// Adds the link's proxy headers to a request bound for the linked origin.
// With basic auth in front, Authorization belongs to the proxy, so a Termix
// bearer token moves into the jwt cookie, which Termix reads first.
function applyLinkHeaders(url, headers) {
if (!isLinkedOrigin(url)) return headers;
const out = { ...headers };
for (const header of linked.headers) {
if (header && header.name && header.value) out[header.name] = header.value;
}
if (linked.basicAuth) {
const authKey = Object.keys(out).find(
(key) => key.toLowerCase() === "authorization",
);
const current = authKey ? String(out[authKey]) : "";
if (authKey) delete out[authKey];
const bearer = current.match(/^Bearer\s+(.+)$/i);
if (bearer) {
const cookieKey =
Object.keys(out).find((key) => key.toLowerCase() === "cookie") ||
"Cookie";
const existing = out[cookieKey] ? `${out[cookieKey]}; ` : "";
out[cookieKey] = `${existing}jwt=${bearer[1]}`;
}
out.Authorization = basicAuthValue(linked.basicAuth);
}
return out;
}
// Headers for a request main itself makes to the linked server.
function linkedRequestHeaders(extra = {}) {
if (!linked) return { ...extra };
const base = { "X-Electron-App": "true", ...extra };
if (linked.token) base.Authorization = `Bearer ${linked.token}`;
return applyLinkHeaders(linked.origin, base);
}
// Answers a basic auth challenge from the linked origin's proxy.
function answerLogin(authInfo, url) {
if (!linked || !linked.basicAuth) return null;
if (authInfo && authInfo.isProxy) return null;
if (!isLinkedOrigin(url)) return null;
return linked.basicAuth;
}
module.exports = {
setLinkedServer,
getLinkedServer,
isLinkedOrigin,
applyLinkHeaders,
linkedRequestHeaders,
answerLogin,
};
+15 -26
View File
@@ -10,8 +10,8 @@
// Trust boundary: the renderer never supplies a URL or headers. It names an
// origin ("local" | "remote") and a route from a fixed allowlist; the main
// process resolves the actual Termix backend URL and attaches credentials
// itself (session cookies for the embedded backend, the stored Remote Sync JWT
// for the remote server). Nothing here can be pointed at another host.
// itself (session cookies for the embedded backend, the linked server's
// session). Nothing here can be pointed at another host.
const fs = require("fs");
const fsp = require("fs/promises");
@@ -344,12 +344,11 @@ function normalizeHttpBase(candidate, what) {
}
// Resolves where a transfer may go. Only the two file-manager streaming
// routes are reachable, and only on the embedded backend or the configured
// Remote Sync server; credentials come from the main process, not the caller.
// routes are reachable, and only on the embedded backend or the linked
// server; credentials come from the main process, not the caller.
function createTargetResolver({
localBaseUrl = DEFAULT_LOCAL_FILE_MANAGER_BASE,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
}) {
return function resolveTransferTarget({
origin,
@@ -396,20 +395,13 @@ function createTargetResolver({
}
if (origin === "remote") {
const config =
typeof getRemoteSyncConfig === "function"
? getRemoteSyncConfig()
: null;
if (!config || !config.serverUrl) {
throw new Error("Remote sync server is not configured");
const linked =
typeof getLinkedServer === "function" ? getLinkedServer() : null;
if (!linked || !linked.serverUrl) {
throw new Error("This device is not linked to a server");
}
const base = normalizeHttpBase(
config.serverUrl,
"Remote sync server URL",
);
const jwt =
typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null;
if (jwt) headers.Authorization = `Bearer ${jwt}`;
const base = normalizeHttpBase(linked.serverUrl, "Linked server URL");
if (linked.token) headers.Authorization = `Bearer ${linked.token}`;
return { url: `${base}${FILE_MANAGER_API}${routePath}`, headers };
}
@@ -880,8 +872,7 @@ function wrap(handler) {
function createLocalFileHandlers({
net,
shell,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
localBaseUrl,
publishFs,
}) {
@@ -890,8 +881,7 @@ function createLocalFileHandlers({
}
const resolveTransferTarget = createTargetResolver({
localBaseUrl,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
});
const deps = {
net,
@@ -1020,12 +1010,11 @@ function createLocalFileHandlers({
function registerLocalFileHandlers({ ipcMain, shell }) {
// Real Electron wiring; tests build the handlers directly instead.
const { net } = require("electron");
const remoteSync = require("./remote-sync.cjs");
const { getLinkedServer } = require("./linked-server.cjs");
const handlers = createLocalFileHandlers({
net,
shell,
getRemoteSyncConfig: remoteSync.getRemoteSyncConfig,
getRemoteSyncJwt: remoteSync.getRemoteSyncJwt,
getLinkedServer,
});
for (const [channel, handler] of Object.entries(handlers)) {
ipcMain.handle(channel, handler);
+62 -356
View File
@@ -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 ===");
});
+8 -19
View File
@@ -1,20 +1,9 @@
const { contextBridge, ipcRenderer } = require("electron");
const { contextBridge, ipcRenderer, webFrame } = require("electron");
const ALLOWED_INVOKE_CHANNELS = new Set([
"check-electron-update",
"clear-remote-sync-config",
"get-desktop-settings",
"get-legacy-server-config",
"get-remote-sync-config",
"get-remote-sync-jwt",
"get-remote-sync-status",
"get-remote-sync-user-info",
"notify-local-login",
"remote-sync-now",
"get-previous-server-url",
"save-desktop-settings",
"save-remote-sync-config",
"save-remote-sync-jwt",
"test-server-connection",
"allow-invalid-certificate-for-origin",
]);
@@ -27,6 +16,12 @@ function invokeAllowed(channel, ...args) {
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
const value = Number(factor);
if (!Number.isFinite(value)) return;
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
},
getPlatform: () => ipcRenderer.invoke("get-platform"),
getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"),
@@ -59,12 +54,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
onRemoteSyncStatusChanged: (callback) => {
const listener = (_event, status) => callback(status);
ipcRenderer.on("remote-sync-status-changed", listener);
return () =>
ipcRenderer.removeListener("remote-sync-status-changed", listener);
},
onCloseActiveTab: (callback) => {
const listener = () => callback();
ipcRenderer.on("close-active-tab", listener);
-23
View File
@@ -1,23 +0,0 @@
// The fallback list, for a server older than GET /sync/entity-types.
//
// Current servers return the entity types and their order, which is the only
// way this process learns about one a plugin added. This array is what those
// servers would have synced anyway, so an old pairing keeps working.
const SYNCED_ENTITY_TYPES = Object.freeze([
// Ordered by reference dependency: hosts and snippets resolve credential,
// vault and folder syncIds, so those have to exist on the other side first.
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
// networkTopology stores host references by their local numeric id inside
// the topology JSON, so it must sync after hosts have been reconciled.
"networkTopology",
]);
module.exports = { SYNCED_ENTITY_TYPES };
-27
View File
@@ -1,27 +0,0 @@
const { Agent, fetch: undiciFetch } = require("undici");
function createRemoteSyncFetch(getConfig) {
let invalidCertificateAgent;
return function fetchRemoteSync(url, options = {}) {
const config = getConfig();
const target = new URL(url);
const allowInvalidCertificate =
config?.allowInvalidCertificate === true &&
config.serverUrl &&
target.protocol === "https:" &&
target.origin === new URL(config.serverUrl).origin;
if (!allowInvalidCertificate) return fetch(url, options);
invalidCertificateAgent ??= new Agent({
connect: { rejectUnauthorized: false },
});
return undiciFetch(url, {
...options,
dispatcher: invalidCertificateAgent,
// A redirected request must not inherit the configured origin's TLS exception.
redirect: "error",
});
};
}
module.exports = { createRemoteSyncFetch };
-644
View File
@@ -1,644 +0,0 @@
// Remote sync engine for the desktop app's optional connection to a
// self-hosted Termix server. Runs entirely in the Electron main process:
// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to
// the renderer's localStorage) and the local embedded backend's JWT
// (cached in memory only, handed over by the renderer at local-login
// time via notify-local-login).
// - On a timer, pulls + pushes each synced entity type between the
// embedded backend (always localhost:30001) and the configured remote
// server, reconciling by syncId with last-write-wins on updatedAt, and
// propagating tombstones (deletions) in both directions.
// - Pushes connection/sync status to the renderer via IPC so the Settings
// UI and a global banner can reflect it without polling.
const { app, safeStorage } = require("electron");
const fs = require("fs");
const path = require("path");
const { orderSyncWrites } = require("./sync-write-order.cjs");
const { SYNCED_ENTITY_TYPES } = require("./remote-sync-entities.cjs");
const { createRemoteSyncFetch } = require("./remote-sync-fetch.cjs");
const fetchRemoteSync = createRemoteSyncFetch(getRemoteSyncConfig);
const SYNC_INTERVAL_MS = 90 * 1000;
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
function dataPath(filename) {
return path.join(app.getPath("userData"), filename);
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
const userDataPath = app.getPath("userData");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
mode: 0o600,
});
fs.renameSync(temporaryPath, filePath);
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch {
// already absent
}
throw error;
}
}
function getDesktopSettingsPath() {
return dataPath("desktop-settings.json");
}
function getRemoteSyncConfigPath() {
return dataPath("remote-sync-config.json");
}
function getRemoteSyncCredentialPath() {
return dataPath("remote-sync-credential.json");
}
function getRemoteSyncStatePath() {
return dataPath("remote-sync-state.json");
}
function getDesktopSettings() {
return readJson(getDesktopSettingsPath(), {
defaultConnectionOrigin: "local",
migrationNoticeAcknowledged: false,
});
}
function saveDesktopSettings(settings) {
writeJson(getDesktopSettingsPath(), settings);
return { success: true };
}
function getRemoteSyncConfig() {
return readJson(getRemoteSyncConfigPath(), null);
}
function saveRemoteSyncConfig(config) {
writeJson(getRemoteSyncConfigPath(), config);
return { success: true };
}
function clearRemoteSyncConfig() {
try {
fs.unlinkSync(getRemoteSyncConfigPath());
} catch {
// already absent
}
return { success: true };
}
function getSafeStorageAvailable() {
try {
return safeStorage.isEncryptionAvailable();
} catch {
return false;
}
}
function saveRemoteSyncJwt(token) {
if (!getSafeStorageAvailable()) {
// Carries a stable reason alongside the message: the renderer has a
// translated explanation for this one, because "no OS keyring" is a
// machine-level problem the user has to go and fix, not something signing
// in again can resolve.
return {
success: false,
reason: "encryption_unavailable",
error: "Encryption unavailable on this system",
};
}
writeJson(getRemoteSyncCredentialPath(), {
encrypted: true,
value: safeStorage.encryptString(token).toString("base64"),
obtainedAt: new Date().toISOString(),
});
return { success: true };
}
function getRemoteSyncJwt() {
const record = readJson(getRemoteSyncCredentialPath(), null);
if (!record?.encrypted || !getSafeStorageAvailable()) return null;
try {
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
} catch {
return null;
}
}
function clearRemoteSyncJwt() {
try {
fs.unlinkSync(getRemoteSyncCredentialPath());
} catch {
// already absent
}
return { success: true };
}
async function getRemoteSyncUserInfo() {
const config = getRemoteSyncConfig();
const token = getRemoteSyncJwt();
if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) {
return null;
}
const baseUrl = config.serverUrl.replace(/\/$/, "");
const userResponse = await fetchRemoteSync(`${baseUrl}/users/me`, {
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
});
if (!userResponse.ok) return null;
const user = await userResponse.json();
const rolesResponse = await fetchRemoteSync(
`${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`,
{
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
},
);
const roles = rolesResponse.ok
? (await rolesResponse.json()).roles || []
: [];
return {
userId: user.userId,
username: user.username,
is_admin: !!user.is_admin,
is_oidc: !!user.is_oidc,
is_dual_auth: !!user.is_dual_auth,
totp_enabled: !!user.totp_enabled,
roles,
};
}
function decodeJwtExpiry(token) {
try {
const payloadB64 = token.split(".")[1];
const payload = JSON.parse(
Buffer.from(payloadB64, "base64").toString("utf8"),
);
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
} catch {
return null;
}
}
function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
const expiresAt = decodeJwtExpiry(token);
if (expiresAt === null) return false;
return Date.now() + marginMs >= expiresAt;
}
class RemoteSyncEngine {
constructor(getMainWindow) {
this.getMainWindow = getMainWindow;
this.localJwt = null;
this.timer = null;
this.startupTimer = null;
this.syncing = false;
this.status = {
connected: false,
syncing: false,
lastSyncedAt: null,
lastError: null,
needsReauth: false,
};
}
setLocalJwt(token) {
this.localJwt = token || null;
}
emitStatus() {
const win = this.getMainWindow?.();
if (!win || win.isDestroyed()) return;
win.webContents.send("remote-sync-status-changed", this.status);
}
updateStatus(patch) {
this.status = { ...this.status, ...patch };
this.emitStatus();
}
start() {
const config = getRemoteSyncConfig();
this.status.connected = !!config?.serverUrl;
if (this.timer) clearInterval(this.timer);
if (this.startupTimer) clearTimeout(this.startupTimer);
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
if (config?.serverUrl) {
// Fire an initial sync shortly after startup rather than waiting a
// full interval, but don't block app boot on it.
this.startupTimer = setTimeout(() => {
this.startupTimer = null;
this.syncNow();
}, 5000);
}
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.startupTimer) {
clearTimeout(this.startupTimer);
this.startupTimer = null;
}
}
async syncNow() {
if (this.syncing) return this.status;
const config = getRemoteSyncConfig();
if (!config?.serverUrl) {
this.updateStatus({ connected: false, syncing: false });
return this.status;
}
const remoteJwt = getRemoteSyncJwt();
if (!remoteJwt) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Not signed in to remote server",
});
return this.status;
}
if (isJwtExpiredOrExpiringSoon(remoteJwt)) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Remote session expired",
});
return this.status;
}
if (!this.localJwt) {
// Local login hasn't handed us a token yet -- this is expected for the
// first tick or two right after a cold boot (renderer hasn't finished
// its own session check yet), but if it never arrives (e.g. a gap in
// whichever code path establishes the local session), sync would
// otherwise silently no-op forever with no visible error. Surface it
// as a normal, non-alarming "not synced yet" status rather than
// leaving lastSyncedAt/lastError untouched.
this.updateStatus({
connected: true,
syncing: false,
lastError: "Waiting for local session",
});
return this.status;
}
this.syncing = true;
this.updateStatus({ connected: true, syncing: true, lastError: null });
try {
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
let sawAuthFailure = false;
const remoteBase = config.serverUrl.replace(/\/$/, "");
const entityTypes = await this.resolveEntityTypes(remoteBase, remoteJwt);
for (const entityType of entityTypes) {
const entityState = state.entities[entityType] || {
lastPulledAt: null,
lastPushedAt: null,
};
const result = await this.syncEntity({
entityType,
remoteBaseUrl: remoteBase,
remoteJwt,
since: entityState.lastPulledAt,
});
if (result.authFailure) {
sawAuthFailure = true;
break;
}
state.entities[entityType] = {
lastPulledAt: result.syncedAt,
lastPushedAt: result.syncedAt,
};
}
if (sawAuthFailure) {
this.updateStatus({
syncing: false,
needsReauth: true,
lastError: "Remote server rejected the session",
});
return this.status;
}
writeJson(getRemoteSyncStatePath(), state);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncedAt: new Date().toISOString(),
lastSyncStatus: "ok",
lastSyncError: null,
});
this.updateStatus({
connected: true,
syncing: false,
needsReauth: false,
lastSyncedAt: new Date().toISOString(),
lastError: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncStatus: "error",
lastSyncError: message,
});
this.updateStatus({ syncing: false, lastError: message });
} finally {
this.syncing = false;
}
return this.status;
}
/**
* Which entities to sync, and in what order.
*
* Asked of the remote server, because a plugin installed there can add one
* and this process has no way to know. Intersected with what the embedded
* backend supports: the two sides may have different plugins installed, and
* pushing an entity the local backend has never heard of just 400s.
*
* A server older than this endpoint 404s, and the frozen list is what it
* would have synced anyway.
*/
async resolveEntityTypes(remoteBaseUrl, remoteJwt) {
const fetchTypes = async (base, token) => {
try {
const body = await this.fetchJson(`${base}/sync/entity-types`, token);
const types = (body?.entityTypes || [])
.slice()
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((entity) => entity.type)
.filter((type) => typeof type === "string" && type);
return types.length > 0 ? types : null;
} catch {
return null;
}
};
const remote = await fetchTypes(remoteBaseUrl, remoteJwt);
if (!remote) return SYNCED_ENTITY_TYPES;
const local = await fetchTypes(EMBEDDED_BASE_URL, this.localJwt);
if (!local) return remote;
const supported = new Set(local);
const shared = remote.filter((type) => supported.has(type));
return shared.length > 0 ? shared : SYNCED_ENTITY_TYPES;
}
async fetchJson(url, token, options = {}) {
const res = await fetchRemoteSync(url, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(options.headers || {}),
},
});
if (res.status === 401 || res.status === 403) {
const err = new Error(`Auth failed (${res.status})`);
err.authFailure = true;
throw err;
}
if (!res.ok) {
throw new Error(`Request failed (${res.status}): ${url}`);
}
const text = await res.text();
// A reverse-proxy SSO in front of the remote server (Pangolin, Authelia,
// etc.) can intercept even an authenticated, Bearer-token'd request and
// serve its own login page instead of forwarding to Termix -- that comes
// back as a normal 200 OK, so the status checks above don't catch it.
// This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a
// plain server-to-server fetch() in this main process, with no browser
// cookie jar at all, so re-authenticating through the login iframe (which
// only affects the renderer's browser session) can never fix this --
// reconnecting would tell the user to do something that doesn't help.
// The proxy has to allow this traffic through some other way (an API
// bypass rule, a separate hostname/port that isn't proxy-gated, etc.),
// so this gets its own distinct, honest error rather than piggybacking
// on needsReauth or a raw JSON.parse crash.
const looksLikeHtml =
text.includes("<html") ||
text.includes("<!DOCTYPE") ||
text.includes("<head>") ||
text.includes("<body>");
if (looksLikeHtml) {
const err = new Error(
"The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this. The proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).",
);
err.proxyBlocked = true;
throw err;
}
try {
return JSON.parse(text);
} catch {
throw new Error(`Server returned invalid JSON: ${url}`);
}
}
async pullSide(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.rows || [];
}
/**
* Every syncId a side currently holds, ignoring the incremental window.
* Used only to decide whether a deletion still has something to delete.
*/
async pullSyncIds(baseUrl, token, entityType) {
const rows = await this.pullSide(baseUrl, token, entityType, null);
return new Set(rows.filter((row) => row.syncId).map((row) => row.syncId));
}
async pullTombstones(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.tombstones || [];
}
async pushRow(baseUrl, token, entityType, row) {
await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, {
method: "POST",
body: JSON.stringify({ row }),
});
}
async pushTombstone(baseUrl, token, entityType, syncId) {
await this.fetchJson(`${baseUrl}/sync/tombstones`, token, {
method: "POST",
body: JSON.stringify({ entityType, syncId }),
});
}
async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) {
const syncedAt = new Date().toISOString();
try {
const [localRows, remoteRows, localTombstones, remoteTombstones] =
await Promise.all([
this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since),
this.pullSide(remoteBaseUrl, remoteJwt, entityType, since),
this.pullTombstones(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
since,
),
this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since),
]);
const tombstonedSyncIds = new Set([
...localTombstones.map((t) => t.syncId),
...remoteTombstones.map((t) => t.syncId),
]);
const localBySyncId = new Map(
localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const remoteBySyncId = new Map(
remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const allSyncIds = new Set([
...localBySyncId.keys(),
...remoteBySyncId.keys(),
]);
const writes = [];
for (const syncId of allSyncIds) {
if (tombstonedSyncIds.has(syncId)) continue;
const localRow = localBySyncId.get(syncId);
const remoteRow = remoteBySyncId.get(syncId);
const localTime = new Date(localRow?.updatedAt || 0).getTime();
const remoteTime = new Date(remoteRow?.updatedAt || 0).getTime();
if (localRow && (!remoteRow || localTime > remoteTime)) {
writes.push({
baseUrl: remoteBaseUrl,
token: remoteJwt,
row: localRow,
});
} else if (remoteRow && (!localRow || remoteTime > localTime)) {
writes.push({
baseUrl: EMBEDDED_BASE_URL,
token: this.localJwt,
row: remoteRow,
});
}
}
for (const { baseUrl, token, row } of orderSyncWrites(
entityType,
writes,
)) {
await this.pushRow(baseUrl, token, entityType, row);
}
// Apply tombstones to whichever side hasn't already deleted the row.
//
// The presence check cannot use localRows/remoteRows: those are the
// incremental window, and a row deleted on one side while untouched on
// the other is by definition outside it, so every deletion was dropped.
// It also cannot be skipped -- pushing unconditionally makes the
// receiving side record a fresh tombstone, which the next pass would push
// back, forever. So ask the receiving side what it actually still holds,
// and only when there is a deletion to apply.
if (localTombstones.length) {
const remoteSyncIds = await this.pullSyncIds(
remoteBaseUrl,
remoteJwt,
entityType,
);
for (const tombstone of localTombstones) {
if (remoteSyncIds.has(tombstone.syncId)) {
await this.pushTombstone(
remoteBaseUrl,
remoteJwt,
entityType,
tombstone.syncId,
);
}
}
}
if (remoteTombstones.length) {
const localSyncIds = await this.pullSyncIds(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
);
for (const tombstone of remoteTombstones) {
if (localSyncIds.has(tombstone.syncId)) {
await this.pushTombstone(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
tombstone.syncId,
);
}
}
}
return { syncedAt };
} catch (error) {
if (error?.authFailure) {
return { syncedAt, authFailure: true };
}
throw error;
}
}
}
let engine = null;
function initRemoteSync(getMainWindow) {
engine = new RemoteSyncEngine(getMainWindow);
engine.start();
return engine;
}
function getRemoteSyncEngine() {
return engine;
}
module.exports = {
initRemoteSync,
getRemoteSyncEngine,
getDesktopSettings,
saveDesktopSettings,
getRemoteSyncConfig,
saveRemoteSyncConfig,
clearRemoteSyncConfig,
saveRemoteSyncJwt,
getRemoteSyncJwt,
clearRemoteSyncJwt,
getRemoteSyncUserInfo,
isJwtExpiredOrExpiringSoon,
decodeJwtExpiry,
};
-49
View File
@@ -1,49 +0,0 @@
// Sort writes per destination: response order is lost when both sides are merged.
//
// Keyed on the self-reference field the rows actually carry rather than on the
// entity name, so an entity a plugin adds gets the same treatment without this
// process needing the server's entity registry.
const SELF_REFERENCE_FIELDS = { hosts: "parentHostSyncId" };
function selfReferenceField(entityType, writes) {
const known = SELF_REFERENCE_FIELDS[entityType];
if (known) return known;
// A plugin entity declares its own; detect it from the payload.
const sample = writes.find((write) => write && write.row);
if (!sample) return null;
const candidate = Object.keys(sample.row).find(
(key) => key === `parent${entityType}SyncId`,
);
return candidate || null;
}
function orderSyncWrites(entityType, writes) {
const field = selfReferenceField(entityType, writes);
if (!field) return writes;
const destinations = new Map();
for (const write of writes) {
if (!destinations.has(write.baseUrl)) {
destinations.set(write.baseUrl, new Map());
}
destinations.get(write.baseUrl).set(write.row.syncId, write);
}
const ordered = [];
const visited = new Set();
const visiting = new Set();
const visit = (write) => {
if (visited.has(write)) return;
if (visiting.has(write))
throw new Error("Cyclic parent host sync dependency");
visiting.add(write);
const parent = destinations.get(write.baseUrl).get(write.row[field]);
if (parent) visit(parent);
visiting.delete(write);
visited.add(write);
ordered.push(write);
};
writes.forEach(visit);
return ordered;
}
module.exports = { orderSyncWrites };