feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server

This commit is contained in:
LukeGus
2026-07-21 01:27:03 -05:00
parent f44d09eef7
commit 08825c256d
77 changed files with 3658 additions and 929 deletions
+9
View File
@@ -226,6 +226,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/termix-id(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+9
View File
@@ -215,6 +215,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/termix-id(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+67 -1
View File
@@ -20,6 +20,7 @@ const net = require("net");
const { URL } = require("url");
const { fork, spawn } = require("child_process");
const WebSocket = require("ws");
const remoteSync = require("./remote-sync.cjs");
// Portable mode: if a `.portable` marker exists next to the executable,
// store all data in a `data` folder beside the exe instead of %APPDATA%.
@@ -852,6 +853,7 @@ function startBackendServer() {
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: "30001",
VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
});
@@ -1335,7 +1337,6 @@ ipcMain.handle("get-embedded-server-status", () => {
return {
running:
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
embedded: !isDev,
dataDir: isDev ? null : getBackendDataDir(),
};
});
@@ -1442,6 +1443,70 @@ ipcMain.handle("save-server-config", (event, config) => {
}
});
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
ipcMain.handle("get-desktop-settings", () => {
return remoteSync.getDesktopSettings();
});
ipcMain.handle("save-desktop-settings", (_event, settings) => {
return remoteSync.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", (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: true,
needsReauth: false,
lastError: null,
});
remoteSync.getRemoteSyncEngine()?.syncNow();
}
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("remote-sync-now", async () => {
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
});
ipcMain.handle("notify-local-login", (_event, token) => {
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
return { success: true };
});
function getC2STunnelConfigPath() {
return path.join(app.getPath("userData"), "c2s-tunnels.json");
}
@@ -2974,6 +3039,7 @@ app.whenReady().then(async () => {
createTray();
createWindow();
remoteSync.initRemoteSync(() => mainWindow);
logToFile("=== Startup complete ===");
});
+7
View File
@@ -31,6 +31,13 @@ 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);
},
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
getSessionCookie: (name, targetUrl) =>
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
+473
View File
@@ -0,0 +1,473 @@
// 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 SYNCED_ENTITY_TYPES = [
"hosts",
"sshCredentials",
"sshFolders",
"snippets",
"snippetFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
];
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 });
}
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
}
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",
});
}
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()) {
return { success: false, 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 };
}
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.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);
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.
setTimeout(() => this.syncNow(), 5000);
}
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = 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 (e.g. very early after
// boot) -- skip this tick rather than fail loudly.
return this.status;
}
this.syncing = true;
this.updateStatus({ connected: true, syncing: true, lastError: null });
try {
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
let sawAuthFailure = false;
for (const entityType of SYNCED_ENTITY_TYPES) {
const entityState = state.entities[entityType] || {
lastPulledAt: null,
lastPushedAt: null,
};
const result = await this.syncEntity({
entityType,
remoteBaseUrl: config.serverUrl.replace(/\/$/, ""),
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;
}
async fetchJson(url, token, options = {}) {
const res = await fetch(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}`);
}
return res.json();
}
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 || [];
}
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(),
]);
for (const syncId of allSyncIds) {
if (tombstonedSyncIds.has(syncId)) continue;
const localRow = localBySyncId.get(syncId);
const remoteRow = remoteBySyncId.get(syncId);
if (localRow && !remoteRow) {
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
} else if (remoteRow && !localRow) {
await this.pushRow(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
remoteRow,
);
} else if (localRow && remoteRow) {
const localUpdatedAt = new Date(localRow.updatedAt || 0).getTime();
const remoteUpdatedAt = new Date(remoteRow.updatedAt || 0).getTime();
if (localUpdatedAt > remoteUpdatedAt) {
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
} else if (remoteUpdatedAt > localUpdatedAt) {
await this.pushRow(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
remoteRow,
);
}
}
}
// Apply tombstones to whichever side hasn't already deleted the row.
for (const tombstone of localTombstones) {
if (remoteBySyncId.has(tombstone.syncId)) {
await this.pushTombstone(
remoteBaseUrl,
remoteJwt,
entityType,
tombstone.syncId,
);
}
}
for (const tombstone of remoteTombstones) {
if (localBySyncId.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,
isJwtExpiredOrExpiringSoon,
decodeJwtExpiry,
};
+28 -15
View File
@@ -39,6 +39,19 @@ const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [
# define __builtin_frame_address(level) _AddressOfReturnAddress()
#endif
// v8::External::New()/->Value() gained a mandatory ExternalPointerTypeTag
// argument in V8 15 (Electron 43+). Plain Node (V8 <= 13.x as of Node 24)
// still uses the old 2-arg signatures, so this must be conditional rather
// than assumed - a build can target either header set.
#include <v8-version.h>
#if defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 15
# define NAN_EXTERNAL_TAG_ARG , static_cast<v8::ExternalPointerTypeTag>(0)
# define NAN_EXTERNAL_TAG_PARAM static_cast<v8::ExternalPointerTypeTag>(0)
#else
# define NAN_EXTERNAL_TAG_ARG
# define NAN_EXTERNAL_TAG_PARAM
#endif
#define NODE_0_10_MODULE_VERSION 11`,
},
]);
@@ -63,23 +76,24 @@ const bindingPatched = patchFile(bindingPath, [
},
]);
// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form.
// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument.
// 2. nan_implementation_12_inl.h: replace v8::External::New() with a form that
// passes NAN_EXTERNAL_TAG_ARG - a macro (defined in the nan.h patch above)
// that expands to the ExternalPointerTypeTag argument only when the target
// V8 headers actually declare it (V8 15+ / Electron 43+).
const implPath = path.join(nanDir, "nan_implementation_12_inl.h");
let implPatched = false;
if (fs.existsSync(implPath)) {
let src = fs.readFileSync(implPath, "utf8");
const before = src;
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
if (!src.includes(TAG)) {
if (!src.includes("NAN_EXTERNAL_TAG_ARG")) {
src = src.replace(
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g,
`v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`,
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
`v8::External::New(v8::Isolate::GetCurrent(), value NAN_EXTERNAL_TAG_ARG)`,
);
src = src.replace(
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)\)/g,
`v8::External::New(isolate, reinterpret_cast<void *>(callback), ${TAG})`,
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
`v8::External::New(isolate, reinterpret_cast<void *>(callback) NAN_EXTERNAL_TAG_ARG)`,
);
}
@@ -89,20 +103,19 @@ if (fs.existsSync(implPath)) {
}
}
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(tag) on v8::External.
// The new API requires an ExternalPointerTypeTag argument.
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(NAN_EXTERNAL_TAG_PARAM)
// on v8::External, same conditional-tag reasoning as above.
const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h");
let callbacksPatched = false;
if (fs.existsSync(callbacksPath)) {
let src = fs.readFileSync(callbacksPath, "utf8");
const before = src;
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
if (!src.includes(TAG)) {
// Pattern: .As<v8::External>()->Value()) — always followed by ))
if (!src.includes("NAN_EXTERNAL_TAG_PARAM")) {
// Pattern: .As<v8::External>()->Value()) or ->Value(<old hardcoded tag>))
src = src.replace(
/\.As<v8::External>\(\)->Value\(\)\)/g,
`.As<v8::External>()->Value(${TAG}))`,
/\.As<v8::External>\(\)->Value\((?:static_cast<v8::ExternalPointerTypeTag>\(0\))?\)\)/g,
`.As<v8::External>()->Value(NAN_EXTERNAL_TAG_PARAM))`,
);
}
+2 -2
View File
@@ -47,7 +47,7 @@ const patches = [
],
[
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}',
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
],
},
@@ -72,7 +72,7 @@ const patches = [
],
[
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}',
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
],
},
+2
View File
@@ -23,6 +23,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js";
import syncRoutes from "./routes/sync.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import fs from "fs";
import path from "path";
@@ -1749,6 +1750,7 @@ registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
app.use("/", alertRulesRoutes);
app.use("/sync", syncRoutes);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
+106 -3
View File
@@ -726,12 +726,16 @@ const addColumnIfNotExists = (
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN "${column}" ${definition};`);
} catch (alterError) {
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
const message =
alterError instanceof Error ? alterError.message : String(alterError);
databaseLogger.warn(
`Failed to add column ${column} to ${table}: ${message}`,
{
operation: "schema_migration",
table,
column,
error: alterError,
});
},
);
}
}
};
@@ -1489,6 +1493,7 @@ const migrateSchema = () => {
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
{ column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
];
for (const migration of sshDataMigrations) {
@@ -2393,6 +2398,104 @@ const migrateSchema = () => {
}
}
// --- sync begin ---
// Stable per-row identity used to match rows across two independently-
// seeded databases (the embedded desktop backend and a connected remote
// server) during sync. Local autoincrement ids collide across instances,
// so a randomly-generated id is the join key instead. SQLite refuses a
// non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for
// tables with existing constraints ("Cannot add a column with
// non-constant default"), so the column is added as plain nullable TEXT;
// repositories set syncId explicitly on insert going forward, and
// existing rows are backfilled by the UPDATE loop below.
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
addColumnIfNotExists("snippets", "sync_id", "TEXT");
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
// SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
// "non-constant default" reason -- add nullable, then backfill from
// created_at below and rely on the repository layer to keep it current.
addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
try {
sqlite.exec(
"UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
databaseLogger.warn(
`Failed to backfill dashboard_service_links.updated_at: ${message}`,
{ operation: "schema_migration", table: "dashboard_service_links" },
);
}
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
const syncIdTables = [
"ssh_data",
"ssh_credentials",
"ssh_folders",
"snippets",
"snippet_folders",
"vault_profiles",
"dashboard_service_links",
"homepage_items",
];
for (const table of syncIdTables) {
try {
const result = sqlite
.prepare(
`UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`,
)
.run();
if (result.changes > 0) {
databaseLogger.info(
`Backfilled sync_id for ${result.changes} row(s) in ${table}`,
{ operation: "sync_id_backfill", table },
);
}
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`,
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
databaseLogger.warn(
`Failed to backfill sync_id for ${table}: ${message}`,
{
operation: "sync_id_backfill",
table,
},
);
}
}
try {
sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
sqlite.exec(
"CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
);
} catch (createError) {
databaseLogger.warn("Failed to create sync_tombstones table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- sync end ---
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
+38
View File
@@ -240,6 +240,12 @@ export const hosts = sqliteTable("ssh_data", {
socks5Password: text("socks5_password"),
socks5ProxyChain: text("socks5_proxy_chain"),
// null = use the desktop app's global default; "local" | "remote" pins
// this specific host's SSH/Docker-console/Serial connections to originate
// from the embedded local backend or a connected remote sync server.
// Ignored for rdp/vnc/telnet, which always require the remote server.
connectionOrigin: text("connection_origin"),
macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"),
@@ -251,6 +257,11 @@ export const hosts = sqliteTable("ssh_data", {
hostKeyLastVerified: text("host_key_last_verified"),
hostKeyChangedCount: integer("host_key_changed_count").default(0),
// Stable identity used to match this row across two independently-seeded
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -357,6 +368,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -391,6 +403,7 @@ export const snippets = sqliteTable("snippets", {
description: text("description"),
folder: text("folder"),
order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -408,6 +421,7 @@ export const snippetFolders = sqliteTable("snippet_folders", {
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -468,6 +482,7 @@ export const sshFolders = sqliteTable("ssh_folders", {
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -786,6 +801,7 @@ export const vaultProfiles = sqliteTable("vault_profiles", {
keyType: text("key_type"),
// When true the profile is visible/usable by all users on the server
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -942,9 +958,13 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
label: text("label").notNull(),
url: text("url").notNull(),
order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- termix-id begin ---
@@ -1130,6 +1150,7 @@ export const homepageItems = sqliteTable("homepage_items", {
title: text("title"),
config: text("config").notNull().default("{}"),
folderId: integer("folder_id"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -1151,3 +1172,20 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
.default(sql`CURRENT_TIMESTAMP`),
});
// --- homepage end ---
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = sqliteTable("sync_tombstones", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- sync end ---
@@ -1,4 +1,5 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -18,7 +19,7 @@ export class CredentialRepository {
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
const rows = await this.context.drizzle
.insert(sshCredentials)
.values(credential)
.values({ syncId: randomUUID(), ...credential })
.returning();
await this.afterWrite();
return rows[0];
@@ -30,7 +31,11 @@ export class CredentialRepository {
): Promise<CredentialRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = credential.id ?? Date.now();
const dataWithTempId = { ...credential, id: tempId };
const dataWithTempId = {
syncId: randomUUID(),
...credential,
id: tempId,
};
const encryptedCredential = this.encryptCredentialRecordForWrite(
dataWithTempId,
userId,
@@ -203,7 +208,10 @@ export class CredentialRepository {
return this.decryptOne(rows[0] ?? null, userId);
}
async deleteForUser(userId: string, credentialId: number): Promise<boolean> {
async deleteForUser(
userId: string,
credentialId: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(sshCredentials)
.where(
@@ -212,10 +220,10 @@ export class CredentialRepository {
eq(sshCredentials.userId, userId),
),
)
.returning({ id: sshCredentials.id });
.returning({ syncId: sshCredentials.syncId });
await this.afterWrite();
return rows.length > 0;
return rows[0] ?? null;
}
async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { dashboardServiceLinks } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -40,11 +41,13 @@ export class DashboardServiceLinkRepository {
const [created] = await this.context.drizzle
.insert(dashboardServiceLinks)
.values({
syncId: randomUUID(),
userId,
label: input.label,
url: input.url,
order: nextOrder,
createdAt,
updatedAt: createdAt,
})
.returning();
await this.afterWrite();
@@ -76,7 +79,7 @@ export class DashboardServiceLinkRepository {
): Promise<DashboardServiceLinkRecord | null> {
const [updated] = await this.context.drizzle
.update(dashboardServiceLinks)
.set(updates)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(
and(
eq(dashboardServiceLinks.id, id),
@@ -92,7 +95,10 @@ export class DashboardServiceLinkRepository {
return updated ?? null;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
async deleteForUser(
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(dashboardServiceLinks)
.where(
@@ -101,13 +107,11 @@ export class DashboardServiceLinkRepository {
eq(dashboardServiceLinks.userId, userId),
),
)
.returning({ id: dashboardServiceLinks.id });
.returning({ syncId: dashboardServiceLinks.syncId });
if (rows.length > 0) {
if (rows.length === 0) return null;
await this.afterWrite();
}
return rows.length > 0;
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
@@ -32,6 +32,7 @@ import { SettingsRepository } from "./settings-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
@@ -126,6 +127,13 @@ export function createCurrentDashboardServiceLinkRepository(): DashboardServiceL
);
}
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
return new SyncTombstoneRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
);
}
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
return new DismissedAlertRepository(
createCurrentRepositoryContext(),
@@ -1,4 +1,5 @@
import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { homepageItems } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -37,6 +38,7 @@ export class HomepageItemRepository {
const [created] = await this.context.drizzle
.insert(homepageItems)
.values({
syncId: randomUUID(),
userId,
typeId: input.typeId,
title: input.title,
@@ -82,17 +84,18 @@ export class HomepageItemRepository {
return updated ?? null;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
async deleteForUser(
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
.returning({ id: homepageItems.id });
.returning({ syncId: homepageItems.syncId });
if (rows.length > 0) {
if (rows.length === 0) return null;
await this.afterWrite();
}
return rows.length > 0;
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, eq, like, or, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -96,6 +97,7 @@ export class HostFolderRepository {
const [created] = await this.context.drizzle
.insert(sshFolders)
.values({
syncId: randomUUID(),
userId,
name,
color,
@@ -126,7 +128,7 @@ export class HostFolderRepository {
async deleteHostsAndFolderRecords(
userId: string,
folderName: string,
): Promise<void> {
): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> {
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`));
@@ -137,11 +139,21 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
await this.context.drizzle
const deletedFolders = await this.context.drizzle
.delete(sshFolders)
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
.returning({ syncId: sshFolders.syncId });
await this.afterWrite();
return {
hostSyncIds: hostsToDelete
.map((h) => h.syncId)
.filter((id): id is string => !!id),
folderSyncIds: deletedFolders
.map((f) => f.syncId)
.filter((id): id is string => !!id),
};
}
async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, eq, inArray } from "drizzle-orm";
import { randomUUID } from "crypto";
import { hostAccess, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -22,7 +23,7 @@ export class HostRepository {
async create(host: NewHostRecord): Promise<HostRecord> {
const rows = await this.context.drizzle
.insert(hosts)
.values(host)
.values({ syncId: randomUUID(), ...host })
.returning();
await this.afterWrite();
return rows[0];
@@ -34,7 +35,11 @@ export class HostRepository {
): Promise<HostRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = host.id ?? Date.now();
const dataWithTempId = { ...host, id: tempId };
const dataWithTempId = {
syncId: randomUUID(),
...host,
id: tempId,
};
const encryptedHost = DataCrypto.encryptRecord(
"ssh_data",
dataWithTempId,
@@ -221,16 +226,19 @@ export class HostRepository {
return rows.length;
}
async deleteForUser(userId: string, hostId: number): Promise<boolean> {
async deleteForUser(
userId: string,
hostId: number,
): Promise<{ syncId: string | null } | null> {
await this.deleteAccessForHost(hostId);
const rows = await this.context.drizzle
.delete(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning({ id: hosts.id });
.returning({ syncId: hosts.syncId });
await this.afterWrite();
return rows.length > 0;
return rows[0] ?? null;
}
async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, asc, eq, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -151,6 +152,7 @@ export class SnippetRepository {
const rows = await this.context.drizzle
.insert(snippets)
.values({
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
@@ -343,6 +345,7 @@ export class SnippetRepository {
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
await this.context.drizzle.insert(snippets).values({
syncId: randomUUID(),
userId,
name: snippet.name.trim(),
content: snippet.content.trim(),
@@ -377,6 +380,7 @@ export class SnippetRepository {
const rows = await this.context.drizzle
.insert(snippetFolders)
.values({
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
@@ -452,19 +456,24 @@ export class SnippetRepository {
return { status: "renamed" };
}
async deleteFolder(userId: string, name: string): Promise<void> {
async deleteFolder(
userId: string,
name: string,
): Promise<{ syncId: string | null } | null> {
await this.context.drizzle
.update(snippets)
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
await this.context.drizzle
const rows = await this.context.drizzle
.delete(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
)
.returning({ syncId: snippetFolders.syncId });
await this.afterWrite();
return rows[0] ?? null;
}
private async findFolderByName(
@@ -0,0 +1,70 @@
import { and, eq, gt } from "drizzle-orm";
import { syncTombstones } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
export type SyncEntityType =
| "hosts"
| "sshCredentials"
| "sshFolders"
| "snippets"
| "snippetFolders"
| "vaultProfiles"
| "dashboardServiceLinks"
| "homepageItems";
export class SyncTombstoneRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async record(
userId: string,
entityType: SyncEntityType,
syncId: string,
): Promise<void> {
if (!syncId) return;
await this.context.drizzle.insert(syncTombstones).values({
userId,
entityType,
syncId,
});
await this.afterWrite();
}
async recordMany(
userId: string,
entityType: SyncEntityType,
syncIds: string[],
): Promise<void> {
const rows = syncIds.filter(Boolean);
if (rows.length === 0) return;
await this.context.drizzle
.insert(syncTombstones)
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
await this.afterWrite();
}
async listSince(
userId: string,
entityType: SyncEntityType,
since: string | null,
): Promise<SyncTombstoneRecord[]> {
const conditions = [
eq(syncTombstones.userId, userId),
eq(syncTombstones.entityType, entityType),
];
if (since) conditions.push(gt(syncTombstones.deletedAt, since));
return this.context.drizzle
.select()
.from(syncTombstones)
.where(and(...conditions));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,4 +1,5 @@
import { desc, eq, or } from "drizzle-orm";
import { randomUUID } from "crypto";
import { vaultProfiles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -47,6 +48,7 @@ export class VaultProfileRepository {
const [created] = await this.context.drizzle
.insert(vaultProfiles)
.values({
syncId: randomUUID(),
userId: input.userId,
name: input.name,
description: input.description,
@@ -98,17 +100,15 @@ export class VaultProfileRepository {
return updated ?? null;
}
async deleteById(id: number): Promise<boolean> {
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(vaultProfiles)
.where(eq(vaultProfiles.id, id))
.returning({ id: vaultProfiles.id });
.returning({ syncId: vaultProfiles.syncId });
if (rows.length > 0) {
if (rows.length === 0) return null;
await this.afterWrite();
}
return rows.length > 0;
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
@@ -460,9 +460,9 @@ export function registerAcmeSSLRoutes(
!certificate.includes("BEGIN CERTIFICATE") ||
!privateKey.includes("PRIVATE KEY")
) {
return res
.status(400)
.json({ error: "A valid PEM certificate and private key are required" });
return res.status(400).json({
error: "A valid PEM certificate and private key are required",
});
}
await fs.mkdir(SSL_DIR, { recursive: true });
@@ -485,7 +485,8 @@ export function registerAcmeSSLRoutes(
);
} catch {
return res.status(400).json({
error: "The provided certificate or private key is not valid PEM data",
error:
"The provided certificate or private key is not valid PEM data",
});
}
@@ -12,6 +12,7 @@ import {
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -642,6 +643,13 @@ router.delete(
userId,
credentialId,
);
if (credentialToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"sshCredentials",
credentialToDelete.syncId,
);
}
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
@@ -4,7 +4,10 @@ import { dashboardLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { isNonEmptyString } from "./host-normalizers.js";
import express from "express";
import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js";
import {
createCurrentDashboardServiceLinkRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
export const dashboardServiceLinksRouter = express.Router();
@@ -152,10 +155,18 @@ dashboardServiceLinksRouter.delete(
return res.status(404).json({ error: "Not found" });
}
const deleted =
await createCurrentDashboardServiceLinkRepository().deleteForUser(
userId,
id,
);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"dashboardServiceLinks",
deleted.syncId,
);
}
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
res.json({ message: "Service link deleted" });
@@ -1,7 +1,10 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { createCurrentHomepageItemRepository } from "../repositories/factory.js";
import {
createCurrentHomepageItemRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import express from "express";
export const homepageItemsRouter = express.Router();
@@ -184,7 +187,14 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
return res.status(404).json({ error: "Not found" });
}
await itemRepository.deleteForUser(userId, id);
const deleted = await itemRepository.deleteForUser(userId, id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"homepageItems",
deleted.syncId,
);
}
res.json({ message: "Homepage item deleted" });
} catch (err) {
homepageLogger.error("Failed to delete homepage item", err);
@@ -11,6 +11,7 @@ import {
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -318,10 +319,18 @@ export function registerHostFolderRoutes(
);
}
const { hostSyncIds, folderSyncIds } =
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
folderName,
);
const tombstoneRepository = createCurrentSyncTombstoneRepository();
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
await tombstoneRepository.recordMany(
userId,
"sshFolders",
folderSyncIds,
);
try {
const axios = (await import("axios")).default;
+18
View File
@@ -26,6 +26,7 @@ import {
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import {
isNonEmptyString,
@@ -201,6 +202,7 @@ router.post(
socks5Username,
socks5Password,
socks5ProxyChain,
connectionOrigin,
portKnockSequence,
overrideCredentialUsername,
macAddress,
@@ -331,6 +333,10 @@ router.post(
socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
connectionOrigin:
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
@@ -843,6 +849,7 @@ router.put(
socks5Username,
socks5Password,
socks5ProxyChain,
connectionOrigin,
portKnockSequence,
overrideCredentialUsername,
macAddress,
@@ -970,6 +977,10 @@ router.put(
socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
connectionOrigin:
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
@@ -2054,6 +2065,13 @@ router.delete(
);
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
if (hostToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"hosts",
hostToDelete.syncId,
);
}
databaseLogger.success("SSH host deleted", {
operation: "host_delete_success",
+20 -1
View File
@@ -12,6 +12,7 @@ import {
createCurrentRoleRepository,
createCurrentSnippetRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -400,7 +401,17 @@ router.delete(
try {
const folderName = decodeURIComponent(name);
await createCurrentSnippetRepository().deleteFolder(userId, folderName);
const deletedFolder = await createCurrentSnippetRepository().deleteFolder(
userId,
folderName,
);
if (deletedFolder?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"snippetFolders",
deletedFolder.syncId,
);
}
authLogger.success(
`Snippet folder deleted: ${folderName} by user ${userId}`,
@@ -1241,6 +1252,14 @@ router.delete(
return res.status(404).json({ error: "Snippet not found" });
}
if (existing.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"snippets",
existing.syncId,
);
}
databaseLogger.info("Command snippet deleted", {
operation: "snippet_delete",
userId,
+415
View File
@@ -0,0 +1,415 @@
import type { Request, Response } from "express";
import express from "express";
import { and, eq, gt } from "drizzle-orm";
import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
} from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentRepositoryContext,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
// Encrypted tables need DataCrypto to translate between the wire payload
// (plaintext) and the stored row (encrypted). Everything else is stored
// and synced as-is.
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
interface EntityConfig {
table:
| typeof hosts
| typeof sshCredentials
| typeof sshFolders
| typeof snippets
| typeof snippetFolders
| typeof vaultProfiles
| typeof dashboardServiceLinks
| typeof homepageItems;
// Fields that only make sense on the device that created the row, or
// that are managed elsewhere and must never be overwritten by a sync
// payload from the other side.
readOnlyFields: string[];
}
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
hosts: {
table: hosts,
readOnlyFields: ["connectionOrigin"],
},
sshCredentials: { table: sshCredentials, readOnlyFields: [] },
sshFolders: { table: sshFolders, readOnlyFields: [] },
snippets: { table: snippets, readOnlyFields: [] },
snippetFolders: { table: snippetFolders, readOnlyFields: [] },
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
homepageItems: { table: homepageItems, readOnlyFields: [] },
};
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
export function isValidEntityType(value: unknown): value is SyncEntityType {
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
function decryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return row;
return DataCrypto.decryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
function encryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = requireUserDataKey(userId);
return DataCrypto.encryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
export function stripWritePayload(
entityType: SyncEntityType,
payload: Record<string, unknown>,
): Record<string, unknown> {
const { readOnlyFields } = ENTITY_CONFIG[entityType];
const clean = { ...payload };
delete clean.id;
delete clean.userId;
delete clean.syncId;
for (const field of readOnlyFields) delete clean[field];
return clean;
}
/**
* @openapi
* /sync/{entityType}:
* get:
* summary: Pull synced rows for an entity type
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Rows updated since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch rows.
*/
router.get(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
conditions.push(gt((table as typeof hosts).updatedAt, since));
}
const rows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(and(...conditions));
const decrypted = rows.map((row) =>
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
);
res.json({ rows: decrypted });
} catch (err) {
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
operation: "sync_pull",
entityType,
userId,
});
res.status(500).json({ error: "Failed to fetch rows" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* post:
* summary: Upsert a synced row by syncId
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Row upserted.
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to upsert row.
*/
router.post(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const payload = req.body?.row;
const syncId = payload?.syncId;
if (!payload || typeof syncId !== "string" || !syncId) {
return res.status(400).json({ error: "Missing row.syncId" });
}
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
const writePayload = stripWritePayload(entityType, payload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
userId,
);
let resultRow: Record<string, unknown>;
if (existing) {
const updatedRows = await context.drizzle
.update(table as typeof hosts)
.set(encryptedPayload)
.where(
and(
eq((table as typeof hosts).id, existing.id as number),
eq(table.userId, userId),
),
)
.returning();
resultRow = updatedRows[0] as Record<string, unknown>;
} else {
const insertedRows = await context.drizzle
.insert(table as typeof hosts)
.values({
...encryptedPayload,
userId,
syncId,
} as typeof hosts.$inferInsert)
.returning();
resultRow = insertedRows[0] as Record<string, unknown>;
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
row: decryptIfNeeded(entityType, resultRow, userId),
created: !existing,
});
} catch (err) {
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
operation: "sync_upsert",
entityType,
userId,
});
res.status(500).json({ error: "Failed to upsert row" });
}
},
);
/**
* @openapi
* /sync/{entityType}/tombstones:
* get:
* summary: Pull deletion tombstones for an entity type
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Tombstones recorded since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch tombstones.
*/
router.get(
"/:entityType/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
userId,
entityType,
since,
);
res.json({ tombstones });
} catch (err) {
databaseLogger.error(
`Failed to fetch sync tombstones for ${entityType}`,
err,
{ operation: "sync_tombstones_pull", entityType, userId },
);
res.status(500).json({ error: "Failed to fetch tombstones" });
}
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
await context.drizzle
.delete(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
);
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
export default router;
+96
View File
@@ -1880,6 +1880,102 @@ router.get("/setup-required", async (req, res) => {
}
});
function isLoopbackRequest(req: Request): boolean {
const ip = req.ip || req.socket?.remoteAddress || "";
return (
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "::ffff:127.0.0.1" ||
ip.endsWith(":127.0.0.1")
);
}
/**
* @openapi
* /users/internal/auto-session:
* post:
* summary: Mint a session for the auto-provisioned local desktop user
* description: Used by the Electron desktop app to skip the login form when running standalone with a single auto-provisioned local user. Only available over loopback and only when exactly one user exists -- a real multi-user or synced install never satisfies this, so no further secret is required.
* tags:
* - Users
* responses:
* 200:
* description: Session created.
* 403:
* description: Forbidden, or more than one user exists.
* 500:
* description: Failed to create session.
*/
router.post("/internal/auto-session", async (req, res) => {
try {
if (!isLoopbackRequest(req)) {
authLogger.warn(
"Rejected non-loopback attempt to access auto-session endpoint",
{ source: req.ip },
);
return res.status(403).json({ error: "Forbidden" });
}
const userRepository = createCurrentUserRepository();
const allUsers = await userRepository.listAll();
if (allUsers.length !== 1) {
return res.status(403).json({
error: "Auto-session is only available for a single local user",
});
}
const userRecord = allUsers[0];
// If the caller already holds a still-valid session for this same
// user (e.g. a duplicate call racing the first one, such as React
// StrictMode's double-invoke of effects in dev), reuse it instead of
// minting a fresh one. Minting unconditionally here would set a new
// `jwt` cookie on every call; since the auth middleware prefers the
// cookie over the Authorization header, a second mint silently
// invalidates whatever token the app already started using.
const existingToken =
(req as Request & { cookies?: Record<string, string> }).cookies?.jwt ||
(req.headers["authorization"]?.startsWith("Bearer ")
? req.headers["authorization"].slice("Bearer ".length)
: undefined);
if (existingToken) {
const existingPayload = await authManager.verifyJWTToken(existingToken);
if (existingPayload?.userId === userRecord.id) {
return res.json({
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
token: existingToken,
});
}
}
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: "desktop",
deviceInfo: "Termix Desktop (local)",
rememberMe: true,
});
const response = {
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
token,
};
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 30 * 24 * 60 * 60 * 1000),
)
.json(response);
} catch (err) {
authLogger.error("Failed to create auto-session", err);
res.status(500).json({ error: "Failed to create auto-session" });
}
});
/**
* @openapi
* /users/count:
+9 -1
View File
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
import {
createCurrentVaultProfileRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
@@ -421,7 +422,14 @@ router.delete(
.status(403)
.json({ error: "Only the owner can delete this profile" });
}
await repository.deleteById(id);
const deleted = await repository.deleteById(id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"vaultProfiles",
deleted.syncId,
);
}
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete vault profile", err);
+58
View File
@@ -15,6 +15,60 @@ import {
setGlobalLogLevel,
} from "./utils/logger.js";
async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { createCurrentUserRepository, createCurrentRoleRepository } =
await import("./database/repositories/factory.js");
const { AuthManager } = await import("./utils/auth-manager.js");
const crypto = await import("crypto");
const userRepository = createCurrentUserRepository();
const existingCount = await userRepository.countAll();
if (existingCount > 0) return;
const id = crypto.randomUUID();
const { isFirstUser } = await userRepository.createFirstLocalUser({
id,
username: "local",
passwordHash: "",
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
try {
await createCurrentRoleRepository().assignRoleNameToUser({
userId: id,
roleName: isFirstUser ? "admin" : "user",
grantedBy: id,
});
} catch (roleError) {
systemLogger.error(
"Failed to assign default role to auto-provisioned local user",
roleError,
{ operation: "desktop_auto_provision_role" },
);
}
await AuthManager.getInstance().registerUser(
id,
crypto.randomBytes(32).toString("hex"),
);
systemLogger.success("Auto-provisioned local desktop user", {
operation: "desktop_auto_provision",
userId: id,
});
}
(async () => {
const initStartTime = Date.now();
try {
@@ -107,6 +161,10 @@ import {
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
await runSharedHostSecretsMigration();
if (process.env.ELECTRON_EMBEDDED === "true") {
await provisionLocalDesktopUserIfNeeded();
}
import("./utils/opkssh-binary-manager.js").then(
({ OPKSSHBinaryManager }) => {
OPKSSHBinaryManager.ensureBinary().catch((error) => {
@@ -32,7 +32,9 @@ describe("DashboardServiceLinkRepository", () => {
label TEXT NOT NULL,
url TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
@@ -99,8 +101,10 @@ describe("DashboardServiceLinkRepository", () => {
);
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", link.id)).toBe(false);
expect(await repo.deleteForUser("user-1", link.id)).toBe(true);
expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
expect(await repo.deleteForUser("user-1", link.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3);
});
@@ -31,6 +31,7 @@ describe("HomepageItemRepository", () => {
title TEXT,
config TEXT NOT NULL DEFAULT '{}',
folder_id INTEGER,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -107,8 +108,10 @@ describe("HomepageItemRepository", () => {
).toBeNull();
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", item.id)).toBe(false);
expect(await repo.deleteForUser("user-1", item.id)).toBe(true);
expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
expect(await repo.deleteForUser("user-1", item.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3);
});
@@ -55,6 +55,7 @@ describe("HostRepository and CredentialRepository", () => {
cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -151,6 +152,8 @@ describe("HostRepository and CredentialRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -226,9 +229,9 @@ describe("HostRepository and CredentialRepository", () => {
expect(
await repo.credentials.findByIdForUser("user-2", created.id),
).toBeNull();
expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe(
true,
);
expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({
syncId: expect.any(String),
});
expect(
await repo.credentials.findByIdForUser("user-1", created.id),
).toBeNull();
@@ -449,7 +452,9 @@ describe("HostRepository and CredentialRepository", () => {
expect(updated?.name).toBe("web-1-renamed");
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
syncId: expect.any(String),
});
expect(await repo.hosts.findById(host.id)).toBeNull();
});
@@ -687,6 +692,8 @@ describe("HostRepository and CredentialRepository", () => {
.run(host.id, "user-2", "user-1");
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
syncId: expect.any(String),
});
});
});
@@ -35,6 +35,7 @@ describe("HostFolderRepository", () => {
name TEXT NOT NULL,
folder TEXT,
auth_type TEXT NOT NULL,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -130,6 +131,8 @@ describe("HostFolderRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -141,6 +144,7 @@ describe("HostFolderRepository", () => {
color TEXT,
icon TEXT,
credential_id INTEGER,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -125,6 +125,8 @@ describe("HostResolutionRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -148,6 +150,7 @@ describe("HostResolutionRepository", () => {
cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -173,6 +176,7 @@ describe("HostResolutionRepository", () => {
color TEXT,
icon TEXT,
credential_id INTEGER,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -29,6 +29,7 @@ describe("SnippetRepository", () => {
description TEXT,
folder TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
host_filter TEXT
@@ -40,6 +41,7 @@ describe("SnippetRepository", () => {
name TEXT NOT NULL,
color TEXT,
icon TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
describe("SyncTombstoneRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<SyncTombstoneRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new SyncTombstoneRepository(context, onWrite);
}
it("records a tombstone and lists it back for the owning user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.record("user-1", "hosts", "sync-abc");
expect(writeCount).toBe(1);
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-1",
entityType: "hosts",
syncId: "sync-abc",
});
});
it("does not record a tombstone for an empty syncId", async () => {
const repo = await createRepository();
await repo.record("user-1", "hosts", "");
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows).toHaveLength(0);
});
it("recordMany writes multiple tombstones and filters out falsy ids", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]);
expect(writeCount).toBe(1);
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]);
});
it("recordMany is a no-op when given no syncIds", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.recordMany("user-1", "hosts", []);
expect(writeCount).toBe(0);
});
it("scopes listSince by userId and entityType", async () => {
const repo = await createRepository();
await repo.record("user-1", "hosts", "sync-1");
await repo.record("user-1", "snippets", "sync-2");
await repo.record("user-2", "hosts", "sync-3");
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows).toHaveLength(1);
expect(rows[0].syncId).toBe("sync-1");
});
it("filters listSince by the since timestamp", async () => {
const adapterLocal = new TestSqliteDatabase();
adapter = adapterLocal;
const context = await adapterLocal.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash');
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
VALUES
('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z');
`);
const repo = new SyncTombstoneRepository(context);
const rows = await repo.listSince(
"user-1",
"hosts",
"2026-03-01T00:00:00.000Z",
);
expect(rows).toHaveLength(1);
expect(rows[0].syncId).toBe("new");
});
});
@@ -113,6 +113,8 @@ describe("UserDataExportRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -136,6 +138,7 @@ describe("UserDataExportRepository", () => {
cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -40,6 +40,7 @@ describe("VaultProfileRepository", () => {
valid_principals TEXT,
key_type TEXT,
shared INTEGER NOT NULL DEFAULT 0,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -119,8 +120,8 @@ describe("VaultProfileRepository", () => {
});
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
expect(await repo.deleteById(1)).toBe(true);
expect(await repo.deleteById(1)).toBe(false);
expect(await repo.deleteById(1)).toEqual({ syncId: null });
expect(await repo.deleteById(1)).toBeNull();
expect(await repo.findById(1)).toBeNull();
expect(writeCount).toBe(2);
});
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
isValidEntityType,
stripWritePayload,
} from "../../../database/routes/sync.js";
describe("isValidEntityType", () => {
it("accepts every whitelisted sync entity type", () => {
for (const type of [
"hosts",
"sshCredentials",
"sshFolders",
"snippets",
"snippetFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
]) {
expect(isValidEntityType(type)).toBe(true);
}
});
it("rejects unknown or non-string entity types", () => {
expect(isValidEntityType("hostAccess")).toBe(false);
expect(isValidEntityType("")).toBe(false);
expect(isValidEntityType(undefined)).toBe(false);
expect(isValidEntityType(42)).toBe(false);
});
});
describe("stripWritePayload", () => {
it("strips id, userId, and syncId from every entity type", () => {
const payload = {
id: 1,
userId: "user-1",
syncId: "abc",
name: "prod-db",
};
expect(stripWritePayload("sshFolders", payload)).toEqual({
name: "prod-db",
});
});
it("also strips desktop-only fields flagged read-only for hosts", () => {
const payload = {
id: 1,
userId: "user-1",
syncId: "abc",
name: "web",
connectionOrigin: "remote",
};
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
});
it("does not mutate the original payload object", () => {
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
stripWritePayload("snippets", payload);
expect(payload).toEqual({
id: 1,
userId: "user-1",
syncId: "abc",
name: "x",
});
});
});
+10 -18
View File
@@ -11,9 +11,8 @@ function makeChain(resolveValue: unknown) {
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (
cb: (v: unknown) => unknown,
) => Promise.resolve(resolveValue).then(cb);
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
Promise.resolve(resolveValue).then(cb);
return chain;
}
@@ -72,9 +71,7 @@ describe("analytics", () => {
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
mockGet.mockResolvedValue("existing-id");
const { getOrCreateInstanceId } = await import(
"../../utils/analytics.js"
);
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
const id = await getOrCreateInstanceId();
@@ -84,9 +81,7 @@ describe("analytics", () => {
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
mockGet.mockResolvedValue(null);
const { getOrCreateInstanceId } = await import(
"../../utils/analytics.js"
);
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
const id = await getOrCreateInstanceId();
@@ -96,9 +91,8 @@ describe("analytics", () => {
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
delete process.env.POSTHOG_API_KEY;
const { collectAndSendHeartbeat } = await import(
"../../utils/analytics.js"
);
const { collectAndSendHeartbeat } =
await import("../../utils/analytics.js");
await collectAndSendHeartbeat();
@@ -108,9 +102,8 @@ describe("analytics", () => {
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
process.env.POSTHOG_API_KEY = "phc_test";
mockGetBoolean.mockResolvedValue(false);
const { collectAndSendHeartbeat } = await import(
"../../utils/analytics.js"
);
const { collectAndSendHeartbeat } =
await import("../../utils/analytics.js");
await collectAndSendHeartbeat();
@@ -122,9 +115,8 @@ describe("analytics", () => {
mockGetBoolean.mockResolvedValue(true);
mockGet.mockResolvedValue("instance-123");
mockPost.mockResolvedValue({});
const { collectAndSendHeartbeat } = await import(
"../../utils/analytics.js"
);
const { collectAndSendHeartbeat } =
await import("../../utils/analytics.js");
await collectAndSendHeartbeat();
+7 -5
View File
@@ -25,7 +25,10 @@ const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
export async function isAnalyticsEnabled(): Promise<boolean> {
return createCurrentSettingsRepository().getBoolean("analytics_enabled", true);
return createCurrentSettingsRepository().getBoolean(
"analytics_enabled",
true,
);
}
export async function getOrCreateInstanceId(): Promise<string> {
@@ -120,10 +123,9 @@ export async function collectAndSendHeartbeat(): Promise<void> {
export function startAnalyticsHeartbeat(): void {
if (!process.env.POSTHOG_API_KEY) {
analyticsLogger.info(
"Analytics disabled: POSTHOG_API_KEY not set",
{ operation: "analytics_disabled_no_key" },
);
analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
operation: "analytics_disabled_no_key",
});
return;
}
+35 -12
View File
@@ -180,6 +180,7 @@ function App() {
stored?.loggedIn ? "verifying" : "idle-auth",
);
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track whether fading-in came from a fresh login (vs. session verification on page load).
// When session-verified, Auth must not mount during the transition — it would trigger
@@ -219,11 +220,36 @@ function App() {
setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
})
.catch(() => {
.catch((err: unknown) => {
// Only treat a genuine auth rejection (401/403) as "not logged in".
// Anything else (network hiccup, backend still starting up, a
// transient 5xx) is not proof the session is invalid -- clearing
// stored auth here would drop the user back to Auth.tsx, which in
// Electron immediately mints a brand-new auto-session, silently
// swapping out the JWT/cookie from under any still-in-flight
// requests and causing spurious "Session expired" toasts.
const status =
(err as { status?: number; response?: { status?: number } })
?.status ??
(err as { response?: { status?: number } })?.response?.status;
if (status === 401 || status === 403) {
clearStoredAuth();
setPhase("idle-auth");
return;
}
// Transient failure: retry shortly rather than logging out. Cap
// retries so a genuinely broken backend still surfaces the login
// screen eventually instead of spinning forever.
if (verifyRetryCount >= 5) {
clearStoredAuth();
setPhase("idle-auth");
return;
}
timerRef.current = setTimeout(() => {
setVerifyRetryCount((c) => c + 1);
}, 3000);
});
}, [phase]);
}, [phase, verifyRetryCount]);
function handleLogin(u: string) {
setAuthUsername(u);
@@ -232,6 +258,12 @@ function App() {
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
if (isElectron()) {
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
const localJwt = localStorage.getItem("jwt");
if (localJwt) {
window.electronAPI
?.invoke?.("notify-local-login", localJwt)
.catch(() => {});
}
}
}
@@ -244,11 +276,6 @@ function App() {
}, 450);
}
function handleChangeServer() {
localStorage.setItem("termix_show_server_config", "true");
handleLogout();
}
const showApp =
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
const showAuth =
@@ -294,11 +321,7 @@ function App() {
}}
>
<Suspense fallback={null}>
<AppShell
username={authUsername}
onLogout={handleLogout}
onChangeServer={handleChangeServer}
/>
<AppShell username={authUsername} onLogout={handleLogout} />
</Suspense>
</div>
)}
+9 -1
View File
@@ -64,6 +64,15 @@ export interface ElectronAPI {
started: number;
errors: string[];
}>;
onRemoteSyncStatusChanged?: (
callback: (status: {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}) => void,
) => () => void;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
@@ -157,7 +166,6 @@ declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
configuredServerUrl?: string | null;
electronClipboard?: {
writeText(text: string): Promise<boolean>;
readText(): Promise<string>;
+1
View File
@@ -71,6 +71,7 @@ export type Host = {
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
connectionOrigin?: "local" | "remote" | null;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: {
+25 -6
View File
@@ -126,10 +126,12 @@ import {
getActiveSessions,
getUserPreferences,
dismissDonationModal,
isElectron,
type UserPreferences,
type OpenTabRecord,
} from "@/main-axios";
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor";
import type { SSHHostWithStatus } from "@/main-axios";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
@@ -193,11 +195,9 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
export function AppShell({
username,
onLogout,
onChangeServer,
}: {
username: string;
onLogout: () => void;
onChangeServer?: () => void;
}) {
const { t, i18n } = useTranslation();
const { setTheme } = useTheme();
@@ -238,6 +238,13 @@ export function AppShell({
const [hostsLoading, setHostsLoading] = useState(true);
const [allHosts, setAllHosts] = useState<Host[]>([]);
const [isAdmin, setIsAdmin] = useState(false);
// Remote sync is not yet configurable (added in a later phase), so this
// is always false for now -- admin/user-management UI stays hidden until
// the desktop app is connected to a remote Termix server, since a
// standalone local install has exactly one implicit user and nothing to
// administer.
const [isRemoteSyncConnected] = useState(false);
const showMultiUserUI = isAdmin && (!isElectron() || isRemoteSyncConnected);
const [userId, setUserId] = useState<string | null>(null);
const [showDonationModal, setShowDonationModal] = useState(false);
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
@@ -1806,7 +1813,6 @@ export function AppShell({
<UserProfilePanel
username={username}
onLogout={onLogout}
onChangeServer={onChangeServer}
userPrefs={userPrefs}
onPrefsChange={(updates) =>
setUserPrefs((current) => ({ ...current, ...updates }))
@@ -1815,7 +1821,7 @@ export function AppShell({
</div>
)}
{railView === "admin-settings" && isAdmin && (
{railView === "admin-settings" && showMultiUserUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel
onEditingChange={setSidebarEditing}
@@ -1870,14 +1876,26 @@ export function AppShell({
return (
<ServerStatusProvider isAuthenticated={!!username}>
<div className="flex w-screen bg-background" style={{ height: "100dvh" }}>
<div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
{isElectron() && (
<RemoteSyncBanner
onReconnect={() => {
setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true);
}}
/>
)}
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail — desktop only, hidden on mobile */}
<AppRail
railView={railView}
sidebarOpen={sidebarOpen}
splitMode={splitMode}
username={username}
isAdmin={isAdmin}
isAdmin={showMultiUserUI}
onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
onLogout={onLogout}
@@ -2047,6 +2065,7 @@ export function AppShell({
/>
</div>
</div>
</div>
{commandPaletteOpen && (
<Suspense fallback={null}>
+9
View File
@@ -1,6 +1,15 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- a synced
// host has a different numeric id on each side (only its syncId matches
// across them). These calls always target the embedded local backend; see
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
// call that IS safely merged across local + remote (a process-local,
// in-memory aggregate keyed by whichever host ids that process happens to
// know about, not a per-host lookup).
export interface MetricsHistoryRow {
ts: string;
cpu_percent: number | null;
+44 -2
View File
@@ -1,8 +1,31 @@
import axios, { type AxiosRequestConfig } from "axios";
import { handleApiError, statsApi } from "@/main-axios";
import {
handleApiError,
statsApi,
getRemoteStatsApi,
isElectron,
} from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
// Metrics collection/viewer registration below (startMetricsPolling,
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
// receives the call must own the target host by numeric database id, and a
// synced host has a different numeric id in each database (only its
// syncId matches across them). Only the aggregate status read is merged
// across local + remote, same as tunnel status.
async function isRemoteSyncConnected(): Promise<boolean> {
if (!isElectron()) return false;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return !!config?.serverUrl;
} catch {
return false;
}
}
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
> {
return getCachedServerStatuses(async () => {
let lastError: unknown = null;
let localStatuses: Record<number, ServerStatus> = {};
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return response.data || {};
localStatuses = response.data || {};
lastError = null;
break;
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
}
}
if (lastError) {
handleApiError(lastError, "fetch server statuses");
return {};
}
if (await isRemoteSyncConnected()) {
try {
const remoteResult = await getRemoteStatsApi().get("/status", {
timeout: 8000,
__silentRetry: true,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return { ...localStatuses, ...(remoteResult.data || {}) };
} catch {
// remote unreachable this tick -- fall back to local-only statuses
}
}
return localStatuses;
});
}
+7 -6
View File
@@ -1,7 +1,7 @@
import axios from "axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { authApi, getServerConfig, handleApiError } from "@/main-axios";
import { authApi, handleApiError } from "@/main-axios";
export interface ResolvedShareLink {
protocol: "ssh" | "rdp" | "vnc" | "telnet";
@@ -30,17 +30,18 @@ const isDev = (): boolean =>
window.location.port === "");
// Guests have no session/JWT, so this deliberately builds a bare base URL
// rather than going through main-axios's authenticated instances.
// rather than going through main-axios's authenticated instances. The
// desktop app always runs its embedded local backend as the source of
// truth, so a share link opened there always resolves against it --
// joining a session hosted on someone else's remote server isn't
// supported from the desktop app today.
async function resolveApiBaseUrl(): Promise<string> {
if (isDev()) {
const protocol = window.location.protocol === "https:" ? "https" : "http";
return `${protocol}://localhost:30001`;
}
if (isElectron()) {
const serverConfig = await getServerConfig();
const configuredUrl = serverConfig?.serverUrl;
if (configuredUrl) return configuredUrl.replace(/\/$/, "");
return "http://localhost:30001";
return "http://127.0.0.1:30001";
}
return getBasePath();
}
+95 -84
View File
@@ -1,5 +1,13 @@
import axios from "axios";
import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
import {
authApi,
fileManagerApi,
handleApiError,
getFileManagerApiForSession,
setSessionOrigin,
clearSessionOrigin,
} from "@/main-axios";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { fileLogger } from "@/lib/frontend-logger";
import type { SSHHost } from "@/types/index";
@@ -72,7 +80,7 @@ export async function connectSSH(
},
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post(
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect",
{ sessionId, ...config },
{ timeout: 120000 },
@@ -121,12 +129,15 @@ export async function disconnectSSH(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/disconnect", {
sessionId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/disconnect",
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "disconnect SSH");
} finally {
clearSessionOrigin(sessionId);
}
}
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
totpCode: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect-totp",
{ sessionId, totpCode },
);
return response.data;
} catch (error) {
handleApiError(error, "verify SSH TOTP");
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
sessionId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect-warpgate",
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "verify SSH Warpgate");
@@ -239,9 +251,10 @@ export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
try {
const response = await fileManagerApi.get("/ssh/status", {
params: { sessionId },
});
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/status",
{ params: { sessionId } },
);
return response.data;
} catch (error) {
handleApiError(error, "get SSH status");
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/keepalive", {
sessionId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/keepalive",
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "SSH keepalive");
@@ -266,9 +280,10 @@ export async function listSSHFiles(
path: string,
): Promise<{ files: unknown[]; path: string }> {
try {
const response = await fileManagerApi.get("/ssh/listFiles", {
params: { sessionId, path },
});
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/listFiles",
{ params: { sessionId, path } },
);
return response.data || { files: [], path };
} catch (error) {
handleApiError(error, "list SSH files");
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
path: string,
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try {
const response = await fileManagerApi.get("/ssh/identifySymlink", {
params: { sessionId, path },
});
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/identifySymlink",
{ params: { sessionId, path } },
);
return response.data;
} catch (error) {
handleApiError(error, "identify SSH symlink");
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
path: string,
): Promise<string> {
try {
const response = await fileManagerApi.get("/ssh/resolvePath", {
params: { sessionId, path },
});
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/resolvePath",
{ params: { sessionId, path } },
);
return response.data?.resolvedPath || path;
} catch {
return path;
@@ -313,9 +330,10 @@ export async function readSSHFile(
encoding?: "base64" | "utf8";
}> {
try {
const response = await fileManagerApi.get("/ssh/readFile", {
params: { sessionId, path },
});
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/readFile",
{ params: { sessionId, path } },
);
return response.data;
} catch (error: unknown) {
if (error.response?.status === 404) {
@@ -340,13 +358,10 @@ export async function writeSSHFile(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/writeFile", {
sessionId,
path,
content,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/writeFile",
{ sessionId, path, content, hostId, userId },
);
if (
response.data &&
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
form.append("totalSize", String(file.size));
form.append("chunk", chunkBlob, fileName);
const response = await fileManagerApi.postForm(
const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileChunk",
form,
{ timeout: 0 },
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
if (userId !== undefined) form.append("userId", userId);
form.append("file", file, fileName);
const response = await fileManagerApi.postForm(
const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileStream",
form,
{
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post(
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFile",
{
sessionId,
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
sessionId: string,
filePath: string,
): Promise<void> {
const response = await fileManagerApi.post(
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFileStream",
{ sessionId, path: filePath },
{ responseType: "blob", timeout: 0 },
@@ -503,14 +518,10 @@ export async function createSSHFile(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/createFile", {
sessionId,
path,
fileName,
content,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/createFile",
{ sessionId, path, fileName, content, hostId, userId },
);
return response.data;
} catch (error) {
handleApiError(error, "create SSH file");
@@ -525,13 +536,10 @@ export async function createSSHFolder(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/createFolder", {
sessionId,
path,
folderName,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/createFolder",
{ sessionId, path, folderName, hostId, userId },
);
return response.data;
} catch (error) {
handleApiError(error, "create SSH folder");
@@ -546,7 +554,9 @@ export async function deleteSSHItem(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/ssh/deleteItem", {
const response = await getFileManagerApiForSession(sessionId).delete(
"/ssh/deleteItem",
{
data: {
sessionId,
path,
@@ -554,7 +564,8 @@ export async function deleteSSHItem(
hostId,
userId,
},
});
},
);
return response.data;
} catch (error) {
handleApiError(error, "delete SSH item");
@@ -566,7 +577,7 @@ export async function setSudoPassword(
password: string,
): Promise<void> {
try {
await fileManagerApi.post("/sudo-password", {
await getFileManagerApiForSession(sessionId).post("/sudo-password", {
sessionId,
password,
});
@@ -583,7 +594,7 @@ export async function copySSHItem(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post(
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/copyItem",
{
sessionId,
@@ -611,13 +622,10 @@ export async function renameSSHItem(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.put("/ssh/renameItem", {
sessionId,
oldPath,
newName,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/renameItem",
{ sessionId, oldPath, newName, hostId, userId },
);
return response.data;
} catch (error) {
handleApiError(error, "rename SSH item");
@@ -633,7 +641,7 @@ export async function moveSSHItem(
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.put(
const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/moveItem",
{
sessionId,
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
userId,
});
const response = await fileManagerApi.post("/ssh/changePermissions", {
sessionId,
path,
permissions,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/changePermissions",
{ sessionId, path, permissions, hostId, userId },
);
fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions",
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
userId,
});
const response = await fileManagerApi.post("/ssh/extractArchive", {
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/extractArchive",
{ sessionId, archivePath, extractPath, hostId, userId },
);
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
userId,
});
const response = await fileManagerApi.post("/ssh/compressFiles", {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/compressFiles",
{
sessionId,
paths,
archiveName,
format: format || "zip",
hostId,
userId,
});
},
);
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
host: SSHHost,
): Promise<EnsureSSHSessionResult> {
const sessionId = host.id.toString();
const origin = await resolveConnectionOrigin({
connectionType: host.connectionType,
connectionOrigin: host.connectionOrigin,
});
setSessionOrigin(sessionId, origin);
try {
const status = await getSSHStatus(sessionId);
if (status?.connected) {
+73 -5
View File
@@ -1,5 +1,11 @@
import axios from "axios";
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
import {
authApi,
handleApiError,
tunnelApi,
getRemoteTunnelApi,
isElectron,
} from "@/main-axios";
import type {
C2STunnelPreset,
TunnelConfig,
@@ -9,13 +15,46 @@ import type {
// TUNNEL MANAGEMENT
// ============================================================================
//
// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
// safe to read from both the embedded backend and a connected remote server
// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
// NOT origin-routed: they resolve the target host by numeric database id
// against whichever backend receives the request, and a synced host has a
// different numeric id in each database (only its syncId matches across
// them) -- routing those calls to a remote backend would need a
// local-id-to-remote-id resolution step that doesn't exist yet. They always
// target the embedded local backend for now.
async function isRemoteSyncConnected(): Promise<boolean> {
if (!isElectron()) return false;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return !!config?.serverUrl;
} catch {
return false;
}
}
export async function getTunnelStatuses(): Promise<
Record<string, TunnelStatus>
> {
try {
const response = await tunnelApi.get("/tunnel/status");
return response.data || {};
const [localResult, remoteConnected] = await Promise.all([
tunnelApi.get("/tunnel/status"),
isRemoteSyncConnected(),
]);
const localStatuses = localResult.data || {};
if (!remoteConnected) return localStatuses;
try {
const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
return { ...localStatuses, ...(remoteResult.data || {}) };
} catch {
return localStatuses;
}
} catch (error) {
handleApiError(error, "fetch tunnel statuses");
}
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
withCredentials: true,
});
let latestLocal: Record<string, TunnelStatus> = {};
let latestRemote: Record<string, TunnelStatus> = {};
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
const emitMerged = () => {
onStatuses({ ...latestLocal, ...latestRemote });
};
source.addEventListener("statuses", (event) => {
try {
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
emitMerged();
} catch {
onError?.();
}
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
onError?.();
};
return () => source.close();
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
// so poll it at a modest interval when a remote server is connected.
isRemoteSyncConnected().then((connected) => {
if (!connected) return;
const pollRemote = async () => {
try {
const result = await getRemoteTunnelApi().get("/tunnel/status");
latestRemote = result.data || {};
emitMerged();
} catch {
// remote unreachable this tick -- keep last known remote statuses
}
};
pollRemote();
remotePollTimer = setInterval(pollRemote, 5000);
});
return () => {
source.close();
if (remotePollTimer) clearInterval(remotePollTimer);
};
}
export async function getTunnelStatusByName(
+46 -153
View File
@@ -29,17 +29,13 @@ import {
completePasswordReset,
getOIDCAuthorizeUrl,
verifyTOTPLogin,
getServerConfig,
saveServerConfig,
isElectron,
getEmbeddedServerStatus,
getCurrentToken,
getOidcSilentLoginDefault,
requestDesktopAutoSession,
} from "@/main-axios";
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
import type { SSOProviderPublic } from "@/types/index";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import { Checkbox } from "@/components/checkbox";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import {
@@ -263,13 +259,22 @@ export function Auth({ onLogin }: AuthProps) {
const [firstUser, setFirstUser] = useState(false);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const [dbHealthChecking, setDbHealthChecking] = useState(true);
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
null,
);
const [currentServerUrl, setCurrentServerUrl] = useState("");
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
// Electron, non-iframed only: the desktop app never shows a login form
// when running standalone -- the embedded backend auto-provisions a
// single local user on first boot, and this component silently exchanges
// that for a session instead of rendering login/register.
// null = probe still in flight (Electron only, blocks rendering below).
// true = probe settled with no auto-login (not applicable outside
// Electron, multiple users exist, or setup is genuinely required) --
// safe to fall through to the normal form/health-check flow.
// Auto-login success never sets this; it calls onLogin directly and this
// component unmounts.
const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState<
boolean | null
>(!isElectron() || isInElectronWebView() ? true : null);
useEffect(() => {
try {
localStorage.setItem("rememberMe", rememberMe.toString());
@@ -320,7 +325,11 @@ export function Auth({ onLogin }: AuthProps) {
}, []);
useEffect(() => {
if (showServerConfig) return;
// Runs once the auto-session probe has settled (immediately outside
// Electron, since it starts at true there; after the probe resolves in
// Electron). Waiting avoids flashing a login screen the user is about
// to skip past via auto-login.
if (desktopAutoSessionDone !== true) return;
setDbHealthChecking(true);
getSetupRequired()
.then((res) => {
@@ -332,53 +341,28 @@ export function Auth({ onLogin }: AuthProps) {
})
.catch(() => setDbConnectionFailed(true))
.finally(() => setDbHealthChecking(false));
}, [showServerConfig]);
}, [desktopAutoSessionDone]);
useEffect(() => {
const checkElectron = async () => {
if (isInElectronWebView()) {
setShowServerConfig(false);
if (desktopAutoSessionDone !== null) return;
let cancelled = false;
requestDesktopAutoSession()
.then((res) => {
if (cancelled) return;
if (res?.success) {
storeAuth(res.username || "");
onLogin(res.username || "", res.userId || undefined, !!res.is_admin);
return;
}
if (isElectron()) {
const forceShow = localStorage.getItem("termix_show_server_config");
if (forceShow === "true") {
localStorage.removeItem("termix_show_server_config");
try {
const config = await getServerConfig();
setCurrentServerUrl(config?.serverUrl || "");
} catch {
// ignore
}
setShowServerConfig(true);
return;
}
try {
const [config, status] = await Promise.all([
getServerConfig(),
getEmbeddedServerStatus(),
]);
if (
status?.embedded &&
status?.running &&
config &&
!config.serverUrl
) {
setShowServerConfig(false);
setCurrentServerUrl("");
return;
}
setCurrentServerUrl(config?.serverUrl || "");
setShowServerConfig(!config || !config.serverUrl);
} catch {
setShowServerConfig(true);
}
} else {
setShowServerConfig(false);
}
setDesktopAutoSessionDone(true);
})
.catch(() => {
if (!cancelled) setDesktopAutoSessionDone(true);
});
return () => {
cancelled = true;
};
checkElectron();
}, []);
}, [desktopAutoSessionDone, onLogin]);
useEffect(() => {
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
@@ -474,36 +458,6 @@ export function Auth({ onLogin }: AuthProps) {
}
}, [onLogin, t]);
const handleElectronAuthSuccess = useCallback(
async (token: string | null) => {
try {
if (!token) {
// No token in postMessage — fall back to waiting for the HttpOnly cookie
const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
"jwt",
currentServerUrl,
null,
5000,
);
if (cookieReady && !cookieReady.success)
throw new Error(cookieReady.error || "Auth cookie not ready");
}
const meRes = await getUserInfo();
if (!meRes) throw new Error("Failed to get user info");
storeAuth(meRes.username || "");
onLogin(
meRes.username || "",
meRes.userId || undefined,
!!meRes.is_admin,
);
toast.success(t("messages.loginSuccess"));
} catch {
toast.error(t("errors.failedUserInfo"));
}
},
[onLogin, currentServerUrl, t],
);
function resetAll() {
setUsername("");
setPassword("");
@@ -935,46 +889,19 @@ export function Auth({ onLogin }: AuthProps) {
oidcSilentLoginDefaultLoaded,
]);
// Electron server config / webview auth success screens
if (isElectron() && !isInElectronWebView()) {
if (showServerConfig === null)
// Electron, non-iframed: wait for the auto-session probe before rendering
// anything, so a standalone desktop install never flashes a login form
// it's about to skip past.
if (
isElectron() &&
!isInElectronWebView() &&
desktopAutoSessionDone === null
) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
if (showServerConfig)
return (
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
<div className="w-full max-w-md">
<ServerConfigComponent
onServerConfigured={() => window.location.reload()}
onUseEmbedded={async () => {
await saveServerConfig({
serverUrl: "",
lastUpdated: new Date().toISOString(),
});
setShowServerConfig(false);
setCurrentServerUrl("");
}}
onCancel={() => setShowServerConfig(false)}
isFirstTime={!currentServerUrl}
/>
</div>
</div>
);
if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl)
return (
<div className="w-full h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-4xl h-[90vh]">
<ElectronLoginForm
serverUrl={currentServerUrl}
onAuthSuccess={handleElectronAuthSuccess}
onChangeServer={() => setShowServerConfig(true)}
/>
</div>
</div>
);
}
if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess))
@@ -1018,30 +945,11 @@ export function Auth({ onLogin }: AuthProps) {
))}
</select>
</div>
{isElectron() && currentServerUrl && (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
{t("serverConfig.serverUrl")}
</span>
<span className="text-xs text-muted-foreground font-mono truncate max-w-[180px]">
{currentServerUrl}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
>
{t("common.edit")}
</Button>
</div>
)}
</div>
</div>
);
if (dbHealthChecking && showServerConfig === false)
if (dbHealthChecking)
return (
<div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
@@ -1068,21 +976,6 @@ export function Auth({ onLogin }: AuthProps) {
return (
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
{isElectron() && !isInElectronWebView() && showServerConfig === false && (
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<button
onClick={() => setShowServerConfig(true)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
{t("serverConfig.changeServer")}
</button>
<span className="text-xs text-muted-foreground">
{t("serverConfig.localServer")}
</span>
<div className="w-20" />
</div>
)}
<div className="flex flex-1 overflow-hidden">
{/* Left decorative panel */}
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
+12 -1
View File
@@ -7,6 +7,12 @@ interface ElectronLoginFormProps {
serverUrl: string;
onAuthSuccess: (token: string | null) => void | Promise<void>;
onChangeServer: () => void;
// "local" (default): the app's own login, JWT goes to localStorage like
// every other client. "remoteSync": this iframe is authenticating a
// Settings-triggered connection to a remote Termix server for the sync
// engine -- the JWT is handed to the Electron main process's encrypted
// store instead, never exposed to the renderer's localStorage.
targetPurpose?: "local" | "remoteSync";
}
const AUTH_MESSAGE_SOURCES = new Set([
@@ -19,6 +25,7 @@ export function ElectronLoginForm({
serverUrl,
onAuthSuccess,
onChangeServer,
targetPurpose = "local",
}: ElectronLoginFormProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
@@ -43,8 +50,12 @@ export function ElectronLoginForm({
try {
if (token) {
if (targetPurpose === "remoteSync") {
await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
} else {
localStorage.setItem("jwt", token);
}
}
await onAuthSuccessRef.current(token);
} catch {
setError(t("errors.authTokenSaveFailed"));
@@ -53,7 +64,7 @@ export function ElectronLoginForm({
hasAuthenticatedRef.current = false;
}
},
[t],
[t, targetPurpose],
);
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
+1 -3
View File
@@ -9,7 +9,6 @@ import {
getServerConfig,
saveServerConfig,
getEmbeddedServerStatus,
setEmbeddedMode,
type ServerConfig,
} from "@/main-axios.ts";
import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react";
@@ -103,7 +102,7 @@ export function ElectronServerConfig({
const checkEmbeddedBackend = async () => {
try {
const status = await getEmbeddedServerStatus();
setEmbeddedAvailable(!!status?.embedded);
setEmbeddedAvailable(!!status?.running);
} catch {
setEmbeddedAvailable(true);
}
@@ -144,7 +143,6 @@ export function ElectronServerConfig({
const maxRetries = 15;
for (let i = 0; i < maxRetries; i++) {
if (await probeBackend()) {
setEmbeddedMode(true);
if (onUseEmbedded) {
onUseEmbedded();
} else {
+7 -36
View File
@@ -24,10 +24,8 @@ import {
completePasswordReset,
getOIDCAuthorizeUrl,
verifyTOTPLogin,
getServerConfig,
saveServerConfig,
isElectron,
getEmbeddedServerStatus,
getCurrentToken,
getOidcSilentLoginDefault,
} from "@/main-axios";
@@ -1019,41 +1017,14 @@ export function Auth({
}, [dbConnectionFailed, t]);
useEffect(() => {
const checkServerConfig = async () => {
if (isInElectronWebView()) {
// The desktop app always runs its embedded local backend as the source
// of truth now -- there is no more server-config startup gate here or
// in the top-level Auth.tsx. This component only still renders (a) when
// iframed by ElectronLoginForm as a *remote* server's own web root, in
// which case isInElectronWebView() is true and this branch is skipped
// anyway, or (b) as a re-auth fallback for popped-out fullscreen
// sub-app windows, which should also never show a server picker.
setShowServerConfig(false);
return;
}
if (isElectron()) {
try {
const [config, status] = await Promise.all([
getServerConfig(),
getEmbeddedServerStatus(),
]);
if (
status?.embedded &&
status?.running &&
config &&
!config.serverUrl
) {
setCurrentServerUrl("");
setShowServerConfig(false);
return;
}
setCurrentServerUrl(config?.serverUrl || "");
setShowServerConfig(!config || !config.serverUrl);
} catch {
setShowServerConfig(true);
}
} else {
setShowServerConfig(false);
}
};
checkServerConfig();
}, []);
if (showServerConfig === null && !isInElectronWebView()) {
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import { isElectron } from "@/lib/electron";
interface RemoteSyncStatus {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}
// Non-blocking banner shown when a connected remote sync server needs
// re-authentication. Never gates or hides any other UI -- the local app
// keeps working fully regardless of remote sync state.
export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) {
const { t } = useTranslation();
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
useEffect(() => {
if (!isElectron()) return;
window.electronAPI
?.invoke?.("get-remote-sync-status")
.then((s) => setStatus((s as RemoteSyncStatus) ?? null))
.catch(() => {});
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
);
return () => unsubscribe?.();
}, []);
if (!status?.connected || !status.needsReauth) return null;
return (
<div className="flex items-center justify-between gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
<div className="flex items-center gap-2">
<AlertTriangle className="size-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span>{t("remoteSync.bannerMessage")}</span>
</div>
<button
type="button"
onClick={onReconnect}
className="font-bold text-accent-brand hover:text-accent-brand/70 transition-colors shrink-0"
>
{t("remoteSync.bannerReconnect")}
</button>
</div>
);
}
+8 -1
View File
@@ -43,6 +43,7 @@ import {
getServiceLinks,
createServiceLink,
deleteServiceLink,
isElectron,
} from "@/main-axios";
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
import { useTranslation } from "react-i18next";
@@ -1365,7 +1366,13 @@ export function DashboardTab({
load();
getUserInfo()
.then((info) => setIsAdmin(!!info.is_admin))
.then((info) => {
// Remote sync is not yet configurable (added in a later phase), so
// a standalone desktop install never shows admin/user-management
// UI -- it has exactly one implicit user and nothing to administer.
const isRemoteSyncConnected = false;
setIsAdmin(!!info.is_admin && (!isElectron() || isRemoteSyncConnected));
})
.catch(() => {});
getUptime()
.then((u) => setUptimeFormatted(u.formatted))
@@ -15,6 +15,10 @@ import {
} from "@/components/select.tsx";
import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
@@ -265,7 +269,7 @@ export function ConsoleTerminal({
}
}, [terminal]);
const connect = React.useCallback(() => {
const connect = React.useCallback(async () => {
if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning"));
return;
@@ -287,20 +291,30 @@ export function ConsoleTerminal({
window.location.port === "5173" ||
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
: isElectronApp
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
let baseWsUrl: string;
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
} else if (isElectronApp) {
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin,
});
const resolvedUrl = await buildOriginWsUrl({
origin,
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
includeLocalJwt: false,
});
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
baseWsUrl = resolvedUrl;
} else {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
}
const ws = new WebSocket(baseWsUrl);
+28 -7
View File
@@ -15,7 +15,9 @@ import {
getGuacdStatus,
getSSHHosts,
logActivity,
isElectron,
} from "@/main-axios.ts";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
@@ -172,19 +174,34 @@ const GuacamoleAppInner = React.forwardRef<
setToken(null);
setGuacamoleConnectionId(null);
setError(null);
getGuacdStatus()
.then((status) => {
(async () => {
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: resolvedProtocolForConnect,
});
if (origin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
setError(t("errors.remoteServerRequired"));
return;
}
}
}
try {
const status = await getGuacdStatus();
if (status.guacd.status !== "connected") {
setError(t("guacamole.guacdUnavailable"));
return;
}
return getGuacamoleTokenFromHost(
const result = await getGuacamoleTokenFromHost(
hostId,
protocol,
promptedCredentials ?? undefined,
);
})
.then((result) => {
if (result) {
setToken(result.token);
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
@@ -192,8 +209,12 @@ const GuacamoleAppInner = React.forwardRef<
() => {},
);
}
})
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : t("guacamole.failedToConnect");
setError(message || t("guacamole.failedToConnect"));
}
})();
}, [
hostId,
hostName,
+27 -7
View File
@@ -8,10 +8,14 @@ import {
} from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import {
isFirefoxBrowser,
isPasteShortcut,
@@ -169,15 +173,31 @@ export const GuacamoleDisplay = forwardRef<
connectionConfig.dpi,
);
const wsBase = buildGuacamoleWebSocketBaseUrl({
let wsBase: string | null;
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: connectionProtocol,
});
wsBase = await buildOriginWsUrl({
origin,
localPort: 30008,
localPath: "/guacamole/websocket/",
remotePath: "/guacamole/websocket/",
includeLocalJwt: false,
});
if (!wsBase) {
onError?.(t("errors.remoteServerRequired"));
return null;
}
} else {
wsBase = buildGuacamoleWebSocketBaseUrl({
isDev,
isElectronApp: isElectron(),
isEmbeddedApp: isEmbeddedMode(),
configuredServerUrl: (window as { configuredServerUrl?: string })
.configuredServerUrl,
isElectronApp: false,
isEmbeddedApp: false,
basePath: getBasePath(),
location: window.location,
});
}
const params = new URLSearchParams({
token,
@@ -193,7 +213,7 @@ export const GuacamoleDisplay = forwardRef<
return null;
}
},
[connectionConfig, onError],
[connectionConfig, onError, t],
);
const refreshKeyboardHandlers = useCallback(() => {
+2 -23
View File
@@ -10,7 +10,6 @@ import { FitAddon } from "@xterm/addon-fit";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react";
import { isElectron } from "@/lib/electron";
import { isEmbeddedMode } from "@/main-axios";
import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
@@ -101,31 +100,11 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
// ── WebSocket (Electron) path ──────────────────────────────────────────
const buildWsUrl = useCallback(() => {
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev || isEmbeddedMode()) {
// Serial is always local -- the device is physically attached to this
// desktop machine, so it never routes through a remote server.
const token = localStorage.getItem("jwt");
const base = "ws://127.0.0.1:30011";
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}
const configuredUrl = (window as { configuredServerUrl?: string | null })
.configuredServerUrl;
if (!configuredUrl) return null;
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
const token = localStorage.getItem("jwt");
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}, []);
const disconnectWs = useCallback(() => {
@@ -11,7 +11,6 @@ import {
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { getServerConfig } from "@/main-axios";
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
const PING_INTERVAL_MS = 30000;
@@ -24,6 +23,9 @@ interface TerminalWsMessage {
// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod).
// Duplicated rather than extracted from that file to avoid touching it here.
// A shared session link is always resolved against the desktop app's
// embedded local backend -- joining a session hosted on someone else's
// remote server isn't supported from the desktop app today.
async function resolveTerminalWsBaseUrl(): Promise<string> {
const isDev =
!isElectron() &&
@@ -36,17 +38,6 @@ async function resolveTerminalWsBaseUrl(): Promise<string> {
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
}
if (isElectron()) {
const serverConfig = await getServerConfig();
const configuredUrl = serverConfig?.serverUrl;
if (configuredUrl) {
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
}
return "ws://127.0.0.1:30002";
}
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
+21 -43
View File
@@ -17,16 +17,18 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import {
getCookie,
isElectron,
isEmbeddedMode,
logActivity,
getSnippets,
deleteCommandFromHistory,
getCommandHistory,
getHostPassword,
getServerConfig,
} from "@/main-axios.ts";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
@@ -973,52 +975,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
} else if (isElectron()) {
let configuredUrl = (window as { configuredServerUrl?: string | null })
.configuredServerUrl;
if (!configuredUrl && !isEmbeddedMode()) {
try {
const serverConfig = await getServerConfig();
configuredUrl = serverConfig?.serverUrl || null;
if (configuredUrl) {
(
window as Window &
typeof globalThis & {
configuredServerUrl?: string | null;
}
).configuredServerUrl = configuredUrl;
}
} catch (error) {
console.error("Failed to resolve Electron server URL:", error);
}
}
if (isEmbeddedMode()) {
baseWsUrl = "ws://127.0.0.1:30002";
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
} else if (!configuredUrl) {
console.error("No configured server URL available for Electron SSH");
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin as
| "local"
| "remote"
| null
| undefined,
});
const resolvedUrl = await buildOriginWsUrl({
origin,
localPort: 30002,
localPath: "",
remotePath: "/ssh/websocket/",
});
if (!resolvedUrl) {
setIsConnected(false);
setIsConnecting(false);
updateConnectionError(t("errors.failedToLoadServer"));
updateConnectionError(t("errors.remoteServerRequired"));
isConnectingRef.current = false;
return;
} else {
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
}
baseWsUrl = resolvedUrl;
} else {
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
}
+112
View File
@@ -0,0 +1,112 @@
import { isElectron } from "@/lib/electron";
export type ConnectionOrigin = "local" | "remote";
interface OriginResolvableHost {
connectionType?: string | null;
connectionOrigin?: ConnectionOrigin | null;
}
/**
* Resolves which backend a given host's interactive connection (SSH,
* Docker console, Serial) should dial: the desktop app's embedded local
* backend, or a connected remote sync server.
*
* RDP/VNC/Telnet always resolve to "remote" -- guacd isn't bundled with the
* embedded backend. Serial always resolves to "local" -- the hardware is
* physically attached to this desktop machine. Everything else follows the
* host's own override if set, falling back to the desktop-wide default.
*/
export async function resolveConnectionOrigin(
host: OriginResolvableHost,
): Promise<ConnectionOrigin> {
if (
host.connectionType === "rdp" ||
host.connectionType === "vnc" ||
host.connectionType === "telnet"
) {
return "remote";
}
if (host.connectionType === "serial") {
return "local";
}
if (!isElectron()) {
return "local";
}
if (host.connectionOrigin === "local" || host.connectionOrigin === "remote") {
return host.connectionOrigin;
}
try {
const settings = (await window.electronAPI?.invoke?.(
"get-desktop-settings",
)) as { defaultConnectionOrigin?: ConnectionOrigin } | null;
return settings?.defaultConnectionOrigin === "remote" ? "remote" : "local";
} catch {
return "local";
}
}
export interface RemoteConnectionTarget {
serverUrl: string;
jwt: string | null;
}
async function getRemoteConnectionTarget(): Promise<RemoteConnectionTarget | null> {
try {
const [config, jwt] = await Promise.all([
window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{
serverUrl?: string;
} | null>,
window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise<
string | null
>,
]);
if (!config?.serverUrl) return null;
return { serverUrl: config.serverUrl, jwt: jwt ?? null };
} catch {
return null;
}
}
/**
* Builds the base WebSocket URL for an interactive connection protocol,
* given a resolved origin. Returns null when origin is "remote" but no
* remote server is connected -- callers must show a blocking message
* rather than attempting to connect.
*/
export async function buildOriginWsUrl({
origin,
localPort,
localPath,
remotePath,
includeLocalJwt = true,
}: {
origin: ConnectionOrigin;
localPort: number;
localPath: string;
remotePath: string;
includeLocalJwt?: boolean;
}): Promise<string | null> {
if (origin === "local") {
let url = `ws://127.0.0.1:${localPort}${localPath}`;
if (includeLocalJwt) {
const token = localStorage.getItem("jwt");
if (token) url += `?token=${encodeURIComponent(token)}`;
}
return url;
}
const remote = await getRemoteConnectionTarget();
if (!remote) return null;
const wsProtocol = remote.serverUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = remote.serverUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
let url = `${wsProtocol}${wsHost}${remotePath}`;
if (remote.jwt) url += `?token=${encodeURIComponent(remote.jwt)}`;
return url;
}
+38 -1
View File
@@ -389,6 +389,37 @@
"noSavedServers": "No saved servers",
"removeServer": "Remove"
},
"remoteSync": {
"title": "Remote Sync",
"description": "Optionally connect this desktop app to a self-hosted Termix server to sync your hosts, credentials, and snippets across devices. The app always works fully offline whether or not you connect.",
"notConnected": "Not connected",
"connected": "Connected",
"connectedTo": "Connected to {{url}}",
"lastSynced": "Last synced {{time}}",
"neverSynced": "Never synced",
"syncError": "Sync error: {{message}}",
"needsReauth": "Sign-in expired",
"connectButton": "Connect to Server",
"disconnectButton": "Disconnect",
"syncNowButton": "Sync Now",
"syncing": "Syncing...",
"serverUrl": "Server URL",
"enterServerUrl": "Please enter a server URL",
"mustIncludeProtocol": "Server URL must start with http:// or https://",
"allowInvalidCertificate": "Allow invalid certificate",
"allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.",
"savedServers": "Saved Servers",
"removeServer": "Remove",
"continueButton": "Continue",
"cancelButton": "Cancel",
"signInTitle": "Sign in to {{url}}",
"originTitle": "Connection Origin",
"originDescription": "Choose where SSH connections originate from by default. This can be overridden per host.",
"originLocal": "This device (local network)",
"originRemote": "Remote server",
"bannerReconnect": "Reconnect",
"bannerMessage": "Remote sync needs re-authentication"
},
"versionCheck": {
"error": "Version Check Error",
"checkFailed": "Failed to check for updates",
@@ -651,6 +682,11 @@
"delayAfterMs": "Delay After (ms)",
"useSocks5Proxy": "Use SOCKS5 Proxy",
"useSocks5ProxyDesc": "Route connection through a proxy server",
"connectionOrigin": "Connection Origin",
"connectionOriginDesc": "Where this host's SSH connection originates from. Overrides the desktop app's global default.",
"connectionOriginDefault": "Use default",
"connectionOriginLocal": "This device (local network)",
"connectionOriginRemote": "Remote server",
"proxyHost": "Proxy Host",
"proxyPort": "Proxy Port",
"proxyUsername": "Proxy Username",
@@ -2360,7 +2396,8 @@
"resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.",
"resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.",
"authTokenSaveFailed": "Failed to save authentication token",
"failedToLoadServer": "Failed to load server"
"failedToLoadServer": "Failed to load server",
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
},
"messages": {
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
+170 -106
View File
@@ -51,7 +51,6 @@ import {
tunnelLogger,
fileLogger,
statsLogger,
systemLogger,
dashboardLogger,
type LogContext,
} from "@/lib/frontend-logger";
@@ -646,8 +645,6 @@ function isDev(): boolean {
}
const apiHost = import.meta.env.VITE_API_HOST || "localhost";
let configuredServerUrl: string | null = null;
let embeddedMode = false;
export interface ServerConfig {
serverUrl: string;
@@ -665,6 +662,13 @@ interface AxiosErrorExtended extends AxiosError {
config?: AxiosRequestConfigExtended;
}
/**
* Reads the remote-sync connection record (repurposed from the old
* server-config.json startup-gate file). Not used to route the app's own
* API instances anymore -- see getApiUrl, which always targets the
* embedded backend in Electron. This is only consumed by the Remote Sync
* settings panel (Phase 3).
*/
export async function getServerConfig(): Promise<ServerConfig | null> {
if (!isElectron()) return null;
@@ -674,7 +678,6 @@ export async function getServerConfig(): Promise<ServerConfig | null> {
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).electronAPI?.invoke("get-server-config");
return result;
@@ -693,33 +696,15 @@ export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).electronAPI?.invoke("save-server-config", config);
if (result?.success) {
configuredServerUrl = config.serverUrl;
(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).configuredServerUrl = configuredServerUrl;
updateApiInstances();
return true;
}
return false;
return !!result?.success;
} catch (error) {
console.error("Failed to save server config:", error);
return false;
}
}
export function getConfiguredServerUrl(): string | null {
return configuredServerUrl;
}
export async function testServerConnection(
serverUrl: string,
): Promise<{ success: boolean; error?: string }> {
@@ -732,7 +717,6 @@ export async function testServerConnection(
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).electronAPI?.invoke("test-server-connection", serverUrl);
return result;
@@ -767,7 +751,6 @@ export async function checkElectronUpdate(): Promise<{
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).electronAPI?.invoke("check-electron-update");
return result;
@@ -777,9 +760,13 @@ export async function checkElectronUpdate(): Promise<{
}
}
/**
* Reports whether the local embedded backend process is alive. The desktop
* app always runs the embedded backend now, so this is purely a liveness
* check -- it no longer reflects a "mode" the app could be in.
*/
export async function getEmbeddedServerStatus(): Promise<{
running: boolean;
embedded: boolean;
dataDir: string | null;
} | null> {
if (!isElectron()) return null;
@@ -796,7 +783,6 @@ export async function getEmbeddedServerStatus(): Promise<{
).electronAPI?.invoke("get-embedded-server-status");
return result as {
running: boolean;
embedded: boolean;
dataDir: string | null;
} | null;
} catch {
@@ -804,33 +790,16 @@ export async function getEmbeddedServerStatus(): Promise<{
}
}
export function isEmbeddedMode(): boolean {
return embeddedMode;
}
export function setEmbeddedMode(value: boolean): void {
embeddedMode = value;
if (value) {
configuredServerUrl = null;
initializeApiInstances();
}
}
function getApiUrl(path: string, defaultPort: number): string {
const devMode = isDev();
const electronMode = isElectron();
if (electronMode) {
if (embeddedMode && !configuredServerUrl) {
// The desktop app always runs its embedded local backend as the
// source of truth. A configured remote sync server is a separate,
// narrow connection used only by the sync engine (see
// remote-sync-axios.ts), not by these shared instances.
return `http://localhost:${defaultPort}${path}`;
}
if (configuredServerUrl) {
const baseUrl = configuredServerUrl.replace(/\/$/, "");
const url = `${baseUrl}${path}`;
return url;
}
console.warn("Electron mode but no server configured!");
return "http://no-server-configured";
} else if (devMode) {
const protocol = window.location.protocol === "https:" ? "https" : "http";
const sslPort = protocol === "https" ? 8443 : defaultPort;
@@ -841,6 +810,116 @@ function getApiUrl(path: string, defaultPort: number): string {
}
}
// ============================================================================
// PER-HOST ORIGIN ROUTING (Electron desktop only)
// ============================================================================
//
// hostApi/fileManagerApi/tunnelApi/statsApi above always point at the
// embedded local backend -- they're the shared, always-on instances. When a
// host's connection origin resolves to "remote" (see
// src/ui/lib/connection-origin.ts), the backend that actually holds that
// host's live SSH session is the connected remote server instead, so file
// manager, tunnel, and stats calls for that host must follow it there.
//
// These dynamically-baseURL'd instances resolve the remote server's URL and
// JWT fresh on every request (cheap, and correct even if the user
// connects/disconnects remote sync without an app reload).
function createRemoteOriginApiInstance(path: string): AxiosInstance {
const instance = axios.create({
headers: { "Content-Type": "application/json" },
timeout: 30000,
});
instance.interceptors.request.use(async (config: AxiosRequestConfig) => {
const [remoteConfig, remoteJwt] = await Promise.all([
window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{
serverUrl?: string;
} | null>,
window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise<
string | null
>,
]);
const baseUrl = (remoteConfig?.serverUrl || "").replace(/\/$/, "");
config.baseURL = baseUrl
? `${baseUrl}${path}`
: "http://no-server-configured";
if (config.headers.set) {
config.headers.set("X-Electron-App", "true");
if (remoteJwt) config.headers.set("Authorization", `Bearer ${remoteJwt}`);
} else {
config.headers["X-Electron-App"] = "true";
if (remoteJwt) config.headers["Authorization"] = `Bearer ${remoteJwt}`;
}
return config;
});
return instance;
}
let remoteFileManagerApi: AxiosInstance | null = null;
let remoteTunnelApi: AxiosInstance | null = null;
let remoteStatsApi: AxiosInstance | null = null;
export function getRemoteFileManagerApi(): AxiosInstance {
if (!remoteFileManagerApi) {
remoteFileManagerApi = createRemoteOriginApiInstance("/ssh/file_manager");
}
return remoteFileManagerApi;
}
export function getRemoteTunnelApi(): AxiosInstance {
if (!remoteTunnelApi) {
remoteTunnelApi = createRemoteOriginApiInstance("/ssh");
}
return remoteTunnelApi;
}
export function getRemoteStatsApi(): AxiosInstance {
if (!remoteStatsApi) {
remoteStatsApi = createRemoteOriginApiInstance("");
}
return remoteStatsApi;
}
// Maps a live SSH session (keyed by sessionId, which today is the host's
// numeric id as a string -- see ensureSSHSessionForHost) to the resolved
// origin it was connected through, so every subsequent file-manager call
// for that session reaches the backend that actually holds it.
const sessionOrigins = new Map<string, "local" | "remote">();
export function setSessionOrigin(
sessionId: string,
origin: "local" | "remote",
): void {
sessionOrigins.set(sessionId, origin);
}
export function clearSessionOrigin(sessionId: string): void {
sessionOrigins.delete(sessionId);
}
export function getFileManagerApiForSession(sessionId: string): AxiosInstance {
return sessionOrigins.get(sessionId) === "remote"
? getRemoteFileManagerApi()
: fileManagerApi;
}
export function getTunnelApiForOrigin(
origin: "local" | "remote",
): AxiosInstance {
return origin === "remote" ? getRemoteTunnelApi() : tunnelApi;
}
export function getStatsApiForOrigin(
origin: "local" | "remote",
): AxiosInstance {
return origin === "remote" ? getRemoteStatsApi() : statsApi;
}
function initializeApiInstances() {
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
@@ -921,44 +1000,9 @@ export const appReadyPromise: Promise<void> = new Promise((resolve) => {
});
function initializeApp() {
if (isElectron()) {
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
.then(([config, status]) => {
if (status?.embedded && status?.running && !config?.serverUrl) {
embeddedMode = true;
}
if (config?.serverUrl) {
configuredServerUrl = config.serverUrl;
(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).configuredServerUrl = configuredServerUrl;
} else if (embeddedMode) {
// Embedded backend running, no remote server needed
} else {
console.warn("No server URL in config");
}
initializeApiInstances();
})
.catch((error) => {
console.error(
"Failed to load server config, initializing with default:",
error,
);
initializeApiInstances();
})
.finally(() => {
_resolveAppReady();
});
} else {
initializeApiInstances();
_resolveAppReady();
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeApp);
@@ -966,29 +1010,6 @@ if (document.readyState === "loading") {
initializeApp();
}
function updateApiInstances() {
systemLogger.info("Updating API instances with new server configuration", {
operation: "api_instance_update",
configuredServerUrl,
});
initializeApiInstances();
(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).configuredServerUrl = configuredServerUrl;
systemLogger.success("All API instances updated successfully", {
operation: "api_instance_update_complete",
configuredServerUrl,
});
}
// ============================================================================
// ERROR HANDLING
// ============================================================================
@@ -1856,6 +1877,49 @@ export async function getSetupRequired(): Promise<{ setup_required: boolean }> {
}
}
// Module-level (not per-component) so concurrent/duplicate mounts -- e.g.
// React StrictMode's intentional double-invoke of effects in dev, or any
// other double-call -- share one in-flight request instead of each minting
// its own session. Minting more than one session here is not just wasted
// work: the backend sets a fresh `jwt` cookie on every call, and since the
// auth middleware prefers the cookie over the Authorization header, a
// second mint silently invalidates whichever token the app already started
// using, surfacing as a spurious "session expired/revoked" on the very
// next request.
let desktopAutoSessionRequest: Promise<AuthResponse | null> | null = null;
/**
* Electron-only, non-iframed local login: exchanges the embedded backend's
* single auto-provisioned local user for a session without ever showing a
* login form. Only succeeds when exactly one user exists locally (a synced
* or otherwise multi-user install never satisfies this, and falls through
* to a normal login screen instead).
*/
export async function requestDesktopAutoSession(): Promise<AuthResponse | null> {
if (desktopAutoSessionRequest) return desktopAutoSessionRequest;
desktopAutoSessionRequest = (async () => {
try {
const response = await authApi.post("/users/internal/auto-session");
if (response.data?.token) {
localStorage.setItem("jwt", response.data.token);
}
if (response.data?.success) {
markUserAuthenticated();
}
return response.data;
} catch {
return null;
}
})();
try {
return await desktopAutoSessionRequest;
} finally {
desktopAutoSessionRequest = null;
}
}
export async function getUserCount(): Promise<UserCount> {
try {
const response = await authApi.get("/users/count");
+269
View File
@@ -0,0 +1,269 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/button.tsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
interface RemoteSyncConfig {
serverUrl: string;
connectedAt: string;
lastSyncedAt?: string | null;
lastSyncStatus?: "ok" | "error" | "never";
lastSyncError?: string | null;
}
interface RemoteSyncStatus {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
export function RemoteSyncPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<RemoteSyncConfig | null>(null);
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
defaultConnectionOrigin: "local",
});
const [step, setStep] = useState<"idle" | "picker" | "login">("idle");
const [pendingServerUrl, setPendingServerUrl] = useState("");
const [syncingNow, setSyncingNow] = useState(false);
const refresh = useCallback(async () => {
const [cfg, st, settings] = await Promise.all([
window.electronAPI?.invoke?.(
"get-remote-sync-config",
) as Promise<RemoteSyncConfig | null>,
window.electronAPI?.invoke?.(
"get-remote-sync-status",
) as Promise<RemoteSyncStatus | null>,
window.electronAPI?.invoke?.(
"get-desktop-settings",
) as Promise<DesktopSettings>,
]);
setConfig(cfg ?? null);
setStatus(st ?? null);
if (settings) setDesktopSettings(settings);
}, []);
useEffect(() => {
refresh();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
);
return () => unsubscribe?.();
}, [refresh]);
const handleConnectClick = () => setStep("picker");
const handleServerConfigured = (serverUrl: string) => {
setPendingServerUrl(serverUrl);
setStep("login");
};
const handleAuthSuccess = async () => {
setStep("idle");
await refresh();
};
const handleDisconnect = async () => {
await window.electronAPI?.invoke?.("clear-remote-sync-config");
await refresh();
};
const handleSyncNow = async () => {
setSyncingNow(true);
try {
await window.electronAPI?.invoke?.("remote-sync-now");
} finally {
setSyncingNow(false);
await refresh();
}
};
const handleOriginChange = async (origin: "local" | "remote") => {
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
setDesktopSettings(next);
await window.electronAPI?.invoke?.("save-desktop-settings", next);
};
const isConnected = !!config?.serverUrl;
return (
<div className="flex flex-col gap-4">
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Server className="size-4 text-accent-brand" />
<p className="font-bold text-sm">{t("remoteSync.title")}</p>
</div>
<p className="text-xs text-muted-foreground">
{t("remoteSync.description")}
</p>
<div className="flex items-center justify-between border-t border-border pt-3">
<div className="flex flex-col gap-0.5">
{isConnected ? (
<>
<span className="text-xs font-medium">
{t("remoteSync.connectedTo", { url: config.serverUrl })}
</span>
<span className="text-[10px] text-muted-foreground">
{status?.needsReauth
? t("remoteSync.needsReauth")
: status?.lastError
? t("remoteSync.syncError", {
message: status.lastError,
})
: status?.lastSyncedAt
? t("remoteSync.lastSynced", {
time: new Date(
status.lastSyncedAt,
).toLocaleString(),
})
: t("remoteSync.neverSynced")}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">
{t("remoteSync.notConnected")}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isConnected ? (
<>
{status?.needsReauth && (
<Button
type="button"
size="sm"
className="text-[10px] h-7"
onClick={handleConnectClick}
>
{t("remoteSync.bannerReconnect")}
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleSyncNow}
disabled={syncingNow || status?.syncing}
>
{syncingNow || status?.syncing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{t("remoteSync.syncNowButton")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleDisconnect}
>
{t("remoteSync.disconnectButton")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleConnectClick}
>
<Server className="size-3" />
{t("remoteSync.connectButton")}
</Button>
)}
</div>
</div>
</div>
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<p className="font-bold text-sm">{t("remoteSync.originTitle")}</p>
<p className="text-xs text-muted-foreground">
{t("remoteSync.originDescription")}
</p>
<div className="flex border border-border overflow-hidden w-fit">
<button
type="button"
onClick={() => handleOriginChange("local")}
className={`px-3 py-1.5 text-xs font-bold uppercase tracking-widest transition-colors ${
desktopSettings.defaultConnectionOrigin === "local"
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{t("remoteSync.originLocal")}
</button>
<button
type="button"
onClick={() => handleOriginChange("remote")}
disabled={!isConnected}
className={`px-3 py-1.5 text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
desktopSettings.defaultConnectionOrigin === "remote"
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{t("remoteSync.originRemote")}
</button>
</div>
</div>
<Dialog
open={step === "picker"}
onOpenChange={(open) => !open && setStep("idle")}
>
<DialogContent className="bg-card border border-border max-w-md">
<DialogHeader>
<DialogTitle className="sr-only">
{t("remoteSync.title")}
</DialogTitle>
</DialogHeader>
<RemoteSyncServerPicker
onServerConfigured={handleServerConfigured}
onCancel={() => setStep("idle")}
/>
</DialogContent>
</Dialog>
<Dialog
open={step === "login"}
onOpenChange={(open) => !open && setStep("idle")}
>
<DialogContent className="bg-card border border-border max-w-4xl h-[80vh] flex flex-col p-0">
<DialogHeader className="p-4 pb-0">
<DialogTitle>
{t("remoteSync.signInTitle", { url: pendingServerUrl })}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={pendingServerUrl}
targetPurpose="remoteSync"
onAuthSuccess={handleAuthSuccess}
onChangeServer={() => setStep("picker")}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Label } from "@/components/label.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
import { Switch } from "@/components/switch.tsx";
import { useTranslation } from "react-i18next";
import { ChevronDown, X } from "lucide-react";
const SAVED_URLS_KEY = "termix_saved_server_urls";
const MAX_SAVED_URLS = 5;
function getSavedUrls(): string[] {
try {
const raw = localStorage.getItem(SAVED_URLS_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function addSavedUrl(url: string) {
const urls = getSavedUrls().filter((u) => u !== url);
urls.unshift(url);
localStorage.setItem(
SAVED_URLS_KEY,
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
);
}
function removeSavedUrl(url: string) {
const urls = getSavedUrls().filter((u) => u !== url);
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
}
interface RemoteSyncServerPickerProps {
onServerConfigured: (serverUrl: string) => void;
onCancel: () => void;
}
export function RemoteSyncServerPicker({
onServerConfigured,
onCancel,
}: RemoteSyncServerPickerProps) {
const { t } = useTranslation();
const [serverUrl, setServerUrl] = useState("");
const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedUrls, setSavedUrls] = useState<string[]>([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSavedUrls(getSavedUrls());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [dropdownOpen]);
const handleSaveConfig = async () => {
if (!serverUrl.trim()) {
setError(t("remoteSync.enterServerUrl"));
return;
}
setLoading(true);
setError(null);
try {
const normalizedUrl = serverUrl.trim();
if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
setError(t("remoteSync.mustIncludeProtocol"));
setLoading(false);
return;
}
const result = await window.electronAPI?.invoke?.(
"save-remote-sync-config",
{
serverUrl: normalizedUrl,
allowInvalidCertificate:
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
connectedAt: new Date().toISOString(),
},
);
if ((result as { success?: boolean })?.success) {
addSavedUrl(normalizedUrl);
setSavedUrls(getSavedUrls());
onServerConfigured(normalizedUrl);
} else {
setError(t("serverConfig.saveFailed"));
}
} catch {
setError(t("serverConfig.saveError"));
} finally {
setLoading(false);
}
};
const handleUrlChange = (value: string) => {
setServerUrl(value);
setError(null);
};
return (
<div className="flex flex-col gap-5 p-6">
<div className="flex flex-col gap-1">
<p className="font-bold">{t("remoteSync.title")}</p>
<p className="text-sm text-muted-foreground">
{t("remoteSync.description")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="remote-sync-server-url">
{t("remoteSync.serverUrl")}
</Label>
<div className="relative" ref={dropdownRef}>
<Input
id="remote-sync-server-url"
type="text"
placeholder="https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
disabled={loading}
className={savedUrls.length > 0 ? "pr-9" : ""}
onFocus={() => {
if (savedUrls.length > 0) setDropdownOpen(true);
}}
/>
{savedUrls.length > 0 && (
<button
type="button"
tabIndex={-1}
onClick={() => setDropdownOpen((o) => !o)}
disabled={loading}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("remoteSync.savedServers")}
>
<ChevronDown className="size-4" />
</button>
)}
{dropdownOpen && savedUrls.length > 0 && (
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
{t("remoteSync.savedServers")}
</p>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center justify-between group hover:bg-muted transition-colors"
>
<button
type="button"
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
onClick={() => {
handleUrlChange(url);
setDropdownOpen(false);
}}
>
{url}
</button>
<button
type="button"
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
title={t("remoteSync.removeServer")}
onClick={(e) => {
e.stopPropagation();
removeSavedUrl(url);
const updated = getSavedUrls();
setSavedUrls(updated);
if (updated.length === 0) setDropdownOpen(false);
}}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{serverUrl.trim().startsWith("https://") && (
<div className="flex items-start justify-between gap-3 border border-border bg-muted/20 p-3">
<div className="flex flex-col gap-1">
<Label htmlFor="remote-sync-allow-invalid-certificate">
{t("remoteSync.allowInvalidCertificate")}
</Label>
<p className="text-xs text-muted-foreground">
{t("remoteSync.allowInvalidCertificateDesc")}
</p>
</div>
<Switch
id="remote-sync-allow-invalid-certificate"
checked={allowInvalidCertificate}
onCheckedChange={setAllowInvalidCertificate}
disabled={loading}
/>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onCancel}
disabled={loading}
>
{t("remoteSync.cancelButton")}
</Button>
<Button
type="button"
className="flex-1 bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
onClick={handleSaveConfig}
disabled={loading || !serverUrl.trim()}
>
{loading ? (
<span className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
{t("serverConfig.saving")}
</span>
) : (
t("remoteSync.continueButton")
)}
</Button>
</div>
</div>
</div>
);
}
+1
View File
@@ -124,6 +124,7 @@ function hostToSSHHost(h: Host): SSHHost {
defaultPath: h.defaultPath ?? "",
tunnelConnections: [],
connectionType: "ssh",
connectionOrigin: h.connectionOrigin ?? null,
createdAt: "",
updatedAt: "",
} as SSHHost;
+2 -3
View File
@@ -33,7 +33,6 @@ import {
getCommandHistoryEnabled,
updateCommandHistoryEnabled,
isElectron,
getConfiguredServerUrl,
getUserRoles,
} from "@/main-axios";
import {
@@ -808,7 +807,7 @@ export function AdminSettingsPanel({
try {
const apiUrl = getDatabaseTransferUrl("export", {
electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(),
configuredServerUrl: null,
location: window.location,
});
@@ -854,7 +853,7 @@ export function AdminSettingsPanel({
try {
const apiUrl = getDatabaseTransferUrl("import", {
electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(),
configuredServerUrl: null,
location: window.location,
});
+38 -1
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { getAlertFirings } from "@/api/alerts-api";
import { isElectron } from "@/lib/electron";
export type RailView =
| "hosts"
@@ -247,8 +248,44 @@ export function AppRail({
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
// Termix ID publishes SSH public keys under a claimed public handle for
// other servers to fetch -- meaningless for a standalone desktop install
// with no synced multi-device account, so it stays hidden until a remote
// server is actually connected.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
() => !isElectron(),
);
useEffect(() => {
if (!isElectron()) return;
let cancelled = false;
const refreshSyncStatus = () => {
window.electronAPI
?.invoke?.("get-remote-sync-config")
.then((config) => {
if (!cancelled) {
setIsRemoteSyncConnected(
!!(config as { serverUrl?: string } | null)?.serverUrl,
);
}
})
.catch(() => {});
};
refreshSyncStatus();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
refreshSyncStatus(),
);
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(splitMode, t, hiddenTabs);
const effectiveHiddenTabs = isRemoteSyncConnected
? hiddenTabs
: new Set([...hiddenTabs, "termix-id"]);
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
return (
<div
+5
View File
@@ -97,6 +97,10 @@ export function createHostEditorForm(
? "chain"
: "single") as "single" | "chain",
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
connectionOrigin: (host?.connectionOrigin ?? null) as
| "local"
| "remote"
| null,
enableTerminal: host?.enableTerminal ?? true,
enableSessionLogging:
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
@@ -322,6 +326,7 @@ export function buildHostEditorPayload(
form.socks5ProxyMode === "single" ? form.socks5Password || null : null,
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
connectionOrigin: form.connectionOrigin,
enableSsh: protocols.enableSsh,
enableRdp: protocols.enableRdp,
enableVnc: protocols.enableVnc,
+26 -1
View File
@@ -16,7 +16,7 @@ import {
X,
} from "lucide-react";
import { FolderPathPicker } from "./FolderPathPicker";
import { getSSHFolders } from "@/main-axios";
import { getSSHFolders, isElectron } from "@/main-axios";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
type HostEditorSetField = <K extends keyof HostEditorForm>(
@@ -717,6 +717,31 @@ export function HostEditorGeneralTab({
) : null}
</div>
)}
{isElectron() && protocols.enableSsh && (
<SettingRow
label={t("hosts.connectionOrigin")}
description={t("hosts.connectionOriginDesc")}
>
<select
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
setField(
"connectionOrigin",
(e.target.value || null) as "local" | "remote" | null,
)
}
>
<option value="">{t("hosts.connectionOriginDefault")}</option>
<option value="local">
{t("hosts.connectionOriginLocal")}
</option>
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</SettingRow>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
+2 -6
View File
@@ -225,9 +225,7 @@ export function HostEditorRdpTab({
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t("hosts.guac.selectCredential")}
</option>
<option value="">{t("hosts.guac.selectCredential")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
@@ -255,9 +253,7 @@ export function HostEditorRdpTab({
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.rdpPassword}
onChange={(e) =>
setField("rdpPassword", e.target.value)
}
onChange={(e) => setField("rdpPassword", e.target.value)}
/>
</div>
</>
+2 -22
View File
@@ -32,31 +32,11 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
const [loadingPorts, setLoadingPorts] = useState(false);
const buildWsUrl = () => {
const isDev =
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (
isDev ||
(isElectron() &&
!(window as { configuredServerUrl?: string }).configuredServerUrl)
) {
// Serial is always local -- the device is physically attached to this
// desktop machine, so it never routes through a remote server.
const token = localStorage.getItem("jwt");
const base = "ws://127.0.0.1:30011";
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}
const configuredUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!configuredUrl) return null;
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
const token = localStorage.getItem("jwt");
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
};
const refreshPorts = useCallback(() => {
+51 -29
View File
@@ -16,7 +16,6 @@ import {
getUserRoles,
saveUserPreferences,
getUserPreferences,
getConfiguredServerUrl,
} from "@/main-axios";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import {
@@ -29,6 +28,7 @@ import {
import type { UserRole } from "@/main-axios";
import type React from "react";
import { isElectron } from "@/lib/electron";
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -426,13 +426,11 @@ function PasswordChangeSection({
export function UserProfilePanel({
username,
onLogout,
onChangeServer,
userPrefs,
onPrefsChange,
}: {
username?: string;
onLogout?: () => void;
onChangeServer?: () => void;
userPrefs?: {
reopenTabsOnLogin: boolean;
storageMode?: string | null;
@@ -541,6 +539,46 @@ export function UserProfilePanel({
}
}, [userPrefs?.storageMode]);
// Remote sync is not connected by default on the desktop app (it's an
// opt-in feature configured from this same panel). "cloud" storage mode
// and Termix ID both assume a real multi-device server account, so they
// stay hidden/forced-off until the user actually connects one.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
() => !isElectron(),
);
useEffect(() => {
if (!isElectron()) return;
let cancelled = false;
const refreshSyncStatus = () => {
window.electronAPI
?.invoke?.("get-remote-sync-config")
.then((config) => {
if (!cancelled) {
setIsRemoteSyncConnected(
!!(config as { serverUrl?: string } | null)?.serverUrl,
);
}
})
.catch(() => {});
};
refreshSyncStatus();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
refreshSyncStatus(),
);
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
useEffect(() => {
if (isElectron() && !isRemoteSyncConnected && storageMode === "cloud") {
setStorageMode("local");
onPrefsChange?.({ storageMode: "local" });
}
}, [isRemoteSyncConnected, storageMode, onPrefsChange]);
// Settings toggles — all backed by localStorage
const [commandAutocomplete, setCommandAutocomplete] = useState(
() => localStorage.getItem("commandAutocomplete") === "true",
@@ -1143,7 +1181,7 @@ export function UserProfilePanel({
try {
const apiUrl = getDatabaseTransferUrl("export", {
electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(),
configuredServerUrl: null,
location: window.location,
});
@@ -1171,9 +1209,7 @@ export function UserProfilePanel({
toast.success(t("newUi.sidebar.userProfile.exportSuccess"));
} else {
const err = await response.json().catch(() => ({}));
toast.error(
err.error || t("newUi.sidebar.userProfile.exportFailed"),
);
toast.error(err.error || t("newUi.sidebar.userProfile.exportFailed"));
}
} catch {
toast.error(t("newUi.sidebar.userProfile.exportFailed"));
@@ -1191,7 +1227,7 @@ export function UserProfilePanel({
try {
const apiUrl = getDatabaseTransferUrl("import", {
electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(),
configuredServerUrl: null,
location: window.location,
});
@@ -1280,7 +1316,10 @@ export function UserProfilePanel({
</a>
</div>
{/* Storage mode toggle */}
{/* Storage mode toggle only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
{(!isElectron() || isRemoteSyncConnected) && (
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")}
@@ -1318,6 +1357,7 @@ export function UserProfilePanel({
{t("newUi.sidebar.userProfile.resetToDefaults")}
</button>
</div>
)}
{/* Account */}
<AccordionSection
@@ -1439,27 +1479,9 @@ export function UserProfilePanel({
</div>
</div>
{isElectron() && onChangeServer && (
{isElectron() && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("serverConfig.changeServer")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.changeServerDescription")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 ml-3 text-[10px] h-7"
onClick={onChangeServer}
>
<Server className="size-3" />
{t("serverConfig.changeServer")}
</Button>
</div>
<RemoteSyncPanel />
</div>
)}
@@ -0,0 +1,88 @@
import { describe, it, expect, afterEach } from "vitest";
import { resolveConnectionOrigin } from "../../lib/connection-origin.js";
const win = window as unknown as Record<string, unknown>;
afterEach(() => {
delete win.IS_ELECTRON;
delete win.electronAPI;
});
describe("resolveConnectionOrigin", () => {
it("always resolves rdp/vnc/telnet to remote, even with a local override", async () => {
win.IS_ELECTRON = true;
for (const connectionType of ["rdp", "vnc", "telnet"]) {
await expect(
resolveConnectionOrigin({ connectionType, connectionOrigin: "local" }),
).resolves.toBe("remote");
}
});
it("always resolves serial to local, even with a remote override", async () => {
win.IS_ELECTRON = true;
await expect(
resolveConnectionOrigin({
connectionType: "serial",
connectionOrigin: "remote",
}),
).resolves.toBe("local");
});
it("resolves to local outside Electron regardless of connectionType", async () => {
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "remote",
}),
).resolves.toBe("local");
});
it("honors a host-level override for ssh in Electron", async () => {
win.IS_ELECTRON = true;
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "remote",
}),
).resolves.toBe("remote");
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "local",
}),
).resolves.toBe("local");
});
it("falls back to the desktop-wide default when no host override is set", async () => {
win.IS_ELECTRON = true;
win.electronAPI = {
invoke: async (channel: string) => {
if (channel === "get-desktop-settings") {
return { defaultConnectionOrigin: "remote" };
}
return null;
},
};
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
}),
).resolves.toBe("remote");
});
it("defaults to local when the desktop settings lookup fails", async () => {
win.IS_ELECTRON = true;
win.electronAPI = {
invoke: async () => {
throw new Error("ipc failed");
},
};
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
}),
).resolves.toBe("local");
});
});