mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
The incremental cursor never matched. updated_at/deleted_at are TEXT columns
written by CURRENT_TIMESTAMP ("2026-07-29 10:11:21"), while the client sends
an ISO 8601 since ("2026-07-29T10:06:55.172Z"). Both comparisons are lexical
and ' ' sorts below 'T', so a newer row lost at position 10 and every
?since= query came back empty. Pass 1 syncs everything (since is null) and
persists a cursor; every pass after it returns nothing with lastError: null
and reports success. Normalize since into the stored shape on the way in,
leaving an already-normalized value alone -- parsing that would treat it as
local time and, west of UTC, push the cursor past unsynced rows.
POST /sync/tombstones was unreachable. It was registered after
POST /:entityType, and "tombstones" is a valid :entityType, so the wildcard
answered it with 400 "Unknown entity type" and the handler never ran. The
pass has no per-entity error handling, so that 400 also discarded the state
of every entity type already synced in the same pass. Move it ahead of the
wildcards.
The tombstone guard consulted the incremental window. A row deleted on one
side and untouched on the other -- the shape every ordinary deletion takes
once the two sides converge -- is not in that window, so the tombstone was
skipped, and skipped again on each later pass as it slid out of its own
window. The guard cannot just be dropped: recording a tombstone for a row
that was already gone hands the sender a fresh one to push back, and the two
trade the same deletion forever. So only a delete that removed something
records a tombstone, which makes the endpoint idempotent and lets the client
push every tombstone unconditionally.
Deletions missed while the cursor was broken stay missed -- their tombstones
predate the persisted cursor. Ordinary edits do come through, since the
row's updatedAt is still newer than it.
Fixes Termix-SSH/Support#1050
Fixes Termix-SSH/Support#1051
545 lines
16 KiB
JavaScript
545 lines
16 KiB
JavaScript
// 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 } = require("./remote-sync-entities.cjs");
|
|
|
|
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",
|
|
migrationNoticeAcknowledged: false,
|
|
});
|
|
}
|
|
|
|
function saveDesktopSettings(settings) {
|
|
writeJson(getDesktopSettingsPath(), settings);
|
|
return { success: true };
|
|
}
|
|
|
|
function getRemoteSyncConfig() {
|
|
return readJson(getRemoteSyncConfigPath(), null);
|
|
}
|
|
|
|
function saveRemoteSyncConfig(config) {
|
|
writeJson(getRemoteSyncConfigPath(), config);
|
|
return { success: true };
|
|
}
|
|
|
|
function clearRemoteSyncConfig() {
|
|
try {
|
|
fs.unlinkSync(getRemoteSyncConfigPath());
|
|
} catch {
|
|
// already absent
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
function getSafeStorageAvailable() {
|
|
try {
|
|
return safeStorage.isEncryptionAvailable();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function saveRemoteSyncJwt(token) {
|
|
if (!getSafeStorageAvailable()) {
|
|
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 };
|
|
}
|
|
|
|
async function getRemoteSyncUserInfo() {
|
|
const config = getRemoteSyncConfig();
|
|
const token = getRemoteSyncJwt();
|
|
if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) {
|
|
return null;
|
|
}
|
|
|
|
const baseUrl = config.serverUrl.replace(/\/$/, "");
|
|
const userResponse = await fetch(`${baseUrl}/users/me`, {
|
|
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
|
|
});
|
|
if (!userResponse.ok) return null;
|
|
|
|
const user = await userResponse.json();
|
|
const rolesResponse = await fetch(
|
|
`${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
|
|
},
|
|
);
|
|
const roles = rolesResponse.ok
|
|
? (await rolesResponse.json()).roles || []
|
|
: [];
|
|
|
|
return {
|
|
userId: user.userId,
|
|
username: user.username,
|
|
is_admin: !!user.is_admin,
|
|
is_oidc: !!user.is_oidc,
|
|
is_dual_auth: !!user.is_dual_auth,
|
|
totp_enabled: !!user.totp_enabled,
|
|
roles,
|
|
};
|
|
}
|
|
|
|
function decodeJwtExpiry(token) {
|
|
try {
|
|
const payloadB64 = token.split(".")[1];
|
|
const payload = JSON.parse(
|
|
Buffer.from(payloadB64, "base64").toString("utf8"),
|
|
);
|
|
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
|
|
const expiresAt = decodeJwtExpiry(token);
|
|
if (expiresAt === null) return false;
|
|
return Date.now() + marginMs >= expiresAt;
|
|
}
|
|
|
|
class RemoteSyncEngine {
|
|
constructor(getMainWindow) {
|
|
this.getMainWindow = getMainWindow;
|
|
this.localJwt = null;
|
|
this.timer = null;
|
|
this.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 -- this is expected for the
|
|
// first tick or two right after a cold boot (renderer hasn't finished
|
|
// its own session check yet), but if it never arrives (e.g. a gap in
|
|
// whichever code path establishes the local session), sync would
|
|
// otherwise silently no-op forever with no visible error. Surface it
|
|
// as a normal, non-alarming "not synced yet" status rather than
|
|
// leaving lastSyncedAt/lastError untouched.
|
|
this.updateStatus({
|
|
connected: true,
|
|
syncing: false,
|
|
lastError: "Waiting for local session",
|
|
});
|
|
return this.status;
|
|
}
|
|
|
|
this.syncing = true;
|
|
this.updateStatus({ connected: true, syncing: true, lastError: null });
|
|
|
|
try {
|
|
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
|
|
let sawAuthFailure = false;
|
|
|
|
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}`);
|
|
}
|
|
|
|
const text = await res.text();
|
|
// A reverse-proxy SSO in front of the remote server (Pangolin, Authelia,
|
|
// etc.) can intercept even an authenticated, Bearer-token'd request and
|
|
// serve its own login page instead of forwarding to Termix -- that comes
|
|
// back as a normal 200 OK, so the status checks above don't catch it.
|
|
// This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a
|
|
// plain server-to-server fetch() in this main process, with no browser
|
|
// cookie jar at all, so re-authenticating through the login iframe (which
|
|
// only affects the renderer's browser session) can never fix this --
|
|
// reconnecting would tell the user to do something that doesn't help.
|
|
// The proxy has to allow this traffic through some other way (an API
|
|
// bypass rule, a separate hostname/port that isn't proxy-gated, etc.),
|
|
// so this gets its own distinct, honest error rather than piggybacking
|
|
// on needsReauth or a raw JSON.parse crash.
|
|
const looksLikeHtml =
|
|
text.includes("<html") ||
|
|
text.includes("<!DOCTYPE") ||
|
|
text.includes("<head>") ||
|
|
text.includes("<body>");
|
|
if (looksLikeHtml) {
|
|
const err = new Error(
|
|
"The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this -- the proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).",
|
|
);
|
|
err.proxyBlocked = true;
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`Server returned invalid JSON: ${url}`);
|
|
}
|
|
}
|
|
|
|
async pullSide(baseUrl, token, entityType, since) {
|
|
const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
|
|
const data = await this.fetchJson(url, token);
|
|
return data.rows || [];
|
|
}
|
|
|
|
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 every tombstone to the other side. Gating this on the row being
|
|
// present in localBySyncId/remoteBySyncId would skip the common case:
|
|
// once both sides have converged, the surviving copy of a deleted row
|
|
// has not been touched, so it is not in the incremental window at all.
|
|
// The receiving end ignores a deletion for a row it no longer has and
|
|
// records no tombstone for it, so re-sending one costs a no-op request
|
|
// and nothing ping-pongs back.
|
|
for (const tombstone of localTombstones) {
|
|
await this.pushTombstone(
|
|
remoteBaseUrl,
|
|
remoteJwt,
|
|
entityType,
|
|
tombstone.syncId,
|
|
);
|
|
}
|
|
for (const tombstone of remoteTombstones) {
|
|
await this.pushTombstone(
|
|
EMBEDDED_BASE_URL,
|
|
this.localJwt,
|
|
entityType,
|
|
tombstone.syncId,
|
|
);
|
|
}
|
|
|
|
return { syncedAt };
|
|
} catch (error) {
|
|
if (error?.authFailure) {
|
|
return { syncedAt, authFailure: true };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
let engine = null;
|
|
|
|
function initRemoteSync(getMainWindow) {
|
|
engine = new RemoteSyncEngine(getMainWindow);
|
|
engine.start();
|
|
return engine;
|
|
}
|
|
|
|
function getRemoteSyncEngine() {
|
|
return engine;
|
|
}
|
|
|
|
module.exports = {
|
|
initRemoteSync,
|
|
getRemoteSyncEngine,
|
|
getDesktopSettings,
|
|
saveDesktopSettings,
|
|
getRemoteSyncConfig,
|
|
saveRemoteSyncConfig,
|
|
clearRemoteSyncConfig,
|
|
saveRemoteSyncJwt,
|
|
getRemoteSyncJwt,
|
|
clearRemoteSyncJwt,
|
|
getRemoteSyncUserInfo,
|
|
isJwtExpiredOrExpiringSoon,
|
|
decodeJwtExpiry,
|
|
};
|