mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 09:35:24 +00:00
v26.11.0 (#1756)
* fix: only show connection lost when the server check fails too * fix: load the macOS tray icon from the right unpacked folder (#1728) * fix: hide update and review buttons on disabled plugins (#1745) * fix: save the database without blocking the server, bump to 26.11.0 * fix: try the key before the password for hosts with both (Plugin-SSH-Terminal#50) * fix: add macOS Window menu item to reopen main window * feat: upload Mac App Store screenshots on submit * chore: drop internal note from changelog * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.11.0
This commit is contained in:
1 parent
c759a147c6
commit
1d4eac3e20
24 files changed
+337
-56
No files matched your search
@@ -1812,6 +1812,8 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
// Before the copy, so a write during the slow part below stays dirty.
|
||||
DatabaseSaveTrigger.markClean();
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
@@ -1832,11 +1834,10 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
encryptedDbPath,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
await fs.promises.writeFile(dbPath, buffer);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.markClean();
|
||||
} catch (error) {
|
||||
DatabaseSaveTrigger.markDirty();
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
enableFileEncryption,
|
||||
@@ -1855,7 +1856,8 @@ async function handlePostInitFileEncryption() {
|
||||
|
||||
setInterval(() => {
|
||||
if (DatabaseSaveTrigger.isDirty) {
|
||||
saveMemoryDatabaseToFile();
|
||||
// Through the trigger so it waits for any save already running.
|
||||
void DatabaseSaveTrigger.forceSave("periodic_save").catch(() => {});
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,12 @@ async function prepareKey(
|
||||
|
||||
if (passphrase) config.passphrase = passphrase;
|
||||
// Lets a server that wants key then password (or password only) still work.
|
||||
if (host.password) config.password = host.password;
|
||||
// ssh2 tries password first by default, so it has none left to answer with
|
||||
// once the key was accepted as the first step.
|
||||
if (host.password) {
|
||||
config.password = host.password;
|
||||
config.authHandler = ["publickey", "password", "keyboard-interactive"];
|
||||
}
|
||||
|
||||
const certPublicKey = host.certPublicKey;
|
||||
if (certPublicKey && certPublicKey.trim()) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import ssh2 from "ssh2";
|
||||
|
||||
const { Client, Server, utils } = ssh2;
|
||||
|
||||
// The order prepareKey sets when a host has both a key and a password.
|
||||
const AUTH_ORDER = ["publickey", "password", "keyboard-interactive"];
|
||||
const keys = utils.generateKeyPairSync("ed25519");
|
||||
|
||||
let server: InstanceType<typeof Server> | null = null;
|
||||
afterEach(() => server?.close());
|
||||
|
||||
/** A server with AuthenticationMethods publickey,password. */
|
||||
function start(): Promise<number> {
|
||||
server = new Server({ hostKeys: [keys.private] }, (client) => {
|
||||
let keyDone = false;
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "publickey") {
|
||||
if (ctx.signature) {
|
||||
keyDone = true;
|
||||
ctx.reject(["password"], true);
|
||||
} else {
|
||||
ctx.accept();
|
||||
}
|
||||
} else if (ctx.method === "password" && keyDone) {
|
||||
ctx.accept();
|
||||
} else {
|
||||
ctx.reject(keyDone ? ["password"] : ["publickey"]);
|
||||
}
|
||||
});
|
||||
client.on("ready", () => client.end());
|
||||
client.on("error", () => {});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server!.listen(0, "127.0.0.1", () =>
|
||||
resolve((server!.address() as { port: number }).port),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function connect(port: number, authHandler?: string[]) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const client = new Client();
|
||||
client.on("ready", () => {
|
||||
client.end();
|
||||
resolve("ready");
|
||||
});
|
||||
client.on("error", (error) => resolve(error.message));
|
||||
client.connect({
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
username: "u",
|
||||
privateKey: keys.private,
|
||||
password: "pw",
|
||||
...(authHandler ? { authHandler } : {}),
|
||||
} as never);
|
||||
});
|
||||
}
|
||||
|
||||
describe("a server that wants a key, then a password", () => {
|
||||
it("logs in with the key first order", async () => {
|
||||
expect(await connect(await start(), AUTH_ORDER)).toBe("ready");
|
||||
});
|
||||
|
||||
it("fails with ssh2's default order, which tries the password first", async () => {
|
||||
expect(await connect(await start())).toMatch(/authentication/i);
|
||||
});
|
||||
});
|
||||
@@ -205,6 +205,11 @@ describe("buildConnectConfig per auth type", () => {
|
||||
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
||||
expect(config.passphrase).toBe("secret");
|
||||
expect(config.password).toBe("fallback");
|
||||
expect(config.authHandler).toEqual([
|
||||
"publickey",
|
||||
"password",
|
||||
"keyboard-interactive",
|
||||
]);
|
||||
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import {
|
||||
DatabaseSaveTrigger,
|
||||
lazySaveGapMs,
|
||||
} from "../../utils/database-save-trigger.js";
|
||||
|
||||
describe("lazySaveGapMs", () => {
|
||||
it("waits longer after slow saves, within limits", () => {
|
||||
expect(lazySaveGapMs(0)).toBe(30_000);
|
||||
expect(lazySaveGapMs(2_000)).toBe(60_000);
|
||||
expect(lazySaveGapMs(60_000)).toBe(300_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatabaseSaveTrigger", () => {
|
||||
it("stays dirty when a write lands while a save is running", async () => {
|
||||
let finish: () => void = () => {};
|
||||
const save = vi.fn(
|
||||
() => new Promise<void>((resolve) => (finish = resolve)),
|
||||
);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
DatabaseSaveTrigger.markDirty();
|
||||
const running = DatabaseSaveTrigger.forceSave("first");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
DatabaseSaveTrigger.markDirty();
|
||||
finish();
|
||||
await running;
|
||||
expect(DatabaseSaveTrigger.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
DatabaseSaveTrigger.cleanup();
|
||||
|
||||
@@ -16,6 +16,8 @@ interface EncryptedFileMetadata {
|
||||
dataSize?: number;
|
||||
}
|
||||
|
||||
const ENCRYPT_CHUNK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
class DatabaseFileEncryption {
|
||||
private static readonly VERSION = "v2";
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
@@ -27,7 +29,7 @@ class DatabaseFileEncryption {
|
||||
buffer: Buffer,
|
||||
targetPath: string,
|
||||
): Promise<string> {
|
||||
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`;
|
||||
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
||||
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
try {
|
||||
@@ -38,7 +40,21 @@ class DatabaseFileEncryption {
|
||||
key,
|
||||
iv,
|
||||
) as crypto.CipherGCM;
|
||||
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
||||
// This runs on every save, so a big database must not hold up the event
|
||||
// loop for the whole encryption.
|
||||
const parts: Buffer[] = [];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < buffer.length;
|
||||
offset += ENCRYPT_CHUNK_BYTES
|
||||
) {
|
||||
parts.push(
|
||||
cipher.update(buffer.subarray(offset, offset + ENCRYPT_CHUNK_BYTES)),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
parts.push(cipher.final());
|
||||
const encrypted = Buffer.concat(parts);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const metadata: EncryptedFileMetadata = {
|
||||
@@ -62,8 +78,8 @@ class DatabaseFileEncryption {
|
||||
encrypted,
|
||||
]);
|
||||
|
||||
fs.writeFileSync(tmpPath, finalBuffer);
|
||||
fs.renameSync(tmpPath, targetPath);
|
||||
await fs.promises.writeFile(tmpPath, finalBuffer);
|
||||
await fs.promises.rename(tmpPath, targetPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
|
||||
@@ -6,6 +6,17 @@ const DEBOUNCE_MS = 2000;
|
||||
// Every save rewrites the whole database file, so background writes (metrics
|
||||
// samples, audit rows) may only cause one save per this window.
|
||||
const MIN_LAZY_SAVE_GAP_MS = 30_000;
|
||||
const MAX_LAZY_SAVE_GAP_MS = 5 * 60_000;
|
||||
// A database that takes long to save is saved less often by background writes.
|
||||
const LAZY_GAP_PER_SAVE_MS = 30;
|
||||
|
||||
/** How long background writes wait after a save that took saveMs. */
|
||||
export function lazySaveGapMs(saveMs: number): number {
|
||||
return Math.min(
|
||||
MAX_LAZY_SAVE_GAP_MS,
|
||||
Math.max(MIN_LAZY_SAVE_GAP_MS, saveMs * LAZY_GAP_PER_SAVE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export class DatabaseSaveTrigger {
|
||||
private static saveFunction: (() => Promise<void>) | null = null;
|
||||
@@ -14,6 +25,7 @@ export class DatabaseSaveTrigger {
|
||||
private static activeSave: Promise<void> | null = null;
|
||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||
private static lastSaveAt = 0;
|
||||
private static lastSaveMs = 0;
|
||||
private static _dirty = false;
|
||||
private static batch = new AsyncLocalStorage<{
|
||||
open: boolean;
|
||||
@@ -56,7 +68,7 @@ export class DatabaseSaveTrigger {
|
||||
|
||||
const delay = Math.max(
|
||||
DEBOUNCE_MS,
|
||||
this.lastSaveAt + MIN_LAZY_SAVE_GAP_MS - Date.now(),
|
||||
this.lastSaveAt + lazySaveGapMs(this.lastSaveMs) - Date.now(),
|
||||
);
|
||||
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
@@ -64,7 +76,6 @@ export class DatabaseSaveTrigger {
|
||||
|
||||
try {
|
||||
await this.runSave();
|
||||
this._dirty = false;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database save failed", error, {
|
||||
operation: "db_save_trigger_failed",
|
||||
@@ -122,7 +133,6 @@ export class DatabaseSaveTrigger {
|
||||
|
||||
try {
|
||||
await this.runSave();
|
||||
this._dirty = false;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database force save failed", error, {
|
||||
operation: "db_save_trigger_force_failed",
|
||||
@@ -152,10 +162,17 @@ export class DatabaseSaveTrigger {
|
||||
this.activeSave = save;
|
||||
this.pendingSave = true;
|
||||
this.lastSaveAt = Date.now();
|
||||
// Cleared up front: saves are slow, and a write landing during one must
|
||||
// stay dirty for the next.
|
||||
this._dirty = false;
|
||||
|
||||
try {
|
||||
await save;
|
||||
} catch (error) {
|
||||
this._dirty = true;
|
||||
throw error;
|
||||
} finally {
|
||||
this.lastSaveMs = Date.now() - this.lastSaveAt;
|
||||
if (this.activeSave === save) {
|
||||
this.activeSave = null;
|
||||
this.pendingSave = false;
|
||||
@@ -181,6 +198,7 @@ export class DatabaseSaveTrigger {
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
this.lastSaveAt = 0;
|
||||
this.lastSaveMs = 0;
|
||||
|
||||
this.pendingSave = false;
|
||||
this.activeSave = null;
|
||||
|
||||
@@ -31,6 +31,8 @@ class DatabaseHealthMonitor {
|
||||
private static instance: DatabaseHealthMonitor;
|
||||
private listeners: Map<string, EventListener[]> = new Map();
|
||||
private degradedActive: boolean = false;
|
||||
private probe: (() => Promise<boolean>) | null = null;
|
||||
private probing: Promise<void> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -83,22 +85,48 @@ class DatabaseHealthMonitor {
|
||||
errorCode === "DATABASE_ERROR" ||
|
||||
errorCode === "DB_CONNECTION_FAILED";
|
||||
|
||||
// A cancel is on purpose and says nothing about the backend.
|
||||
if (errorCode === "ERR_CANCELED" || lowerMessage === "canceled") return;
|
||||
|
||||
const isBackendUnreachable =
|
||||
errorCode === "ERR_NETWORK" ||
|
||||
errorCode === "ECONNREFUSED" ||
|
||||
errorCode === "ECONNABORTED" ||
|
||||
errorCode === "ECONNRESET" ||
|
||||
errorCode === "ETIMEDOUT" ||
|
||||
errorCode === "ERR_CANCELED" ||
|
||||
(lowerMessage.includes("network error") &&
|
||||
errorLike.response === undefined) ||
|
||||
errorLike.response === undefined);
|
||||
|
||||
// One slow request (like a plugin waiting on an SSH host) is not a lost
|
||||
// backend, so a timeout only counts when a quick check fails too.
|
||||
const isTimeout =
|
||||
errorCode === "ECONNABORTED" ||
|
||||
errorCode === "ETIMEDOUT" ||
|
||||
lowerMessage.includes("request aborted") ||
|
||||
lowerMessage.includes("timeout");
|
||||
|
||||
if (!(isDatabaseError || isBackendUnreachable)) {
|
||||
return;
|
||||
if (isDatabaseError || isBackendUnreachable) {
|
||||
this.markDegraded(errorMessage, errorCode);
|
||||
} else if (isTimeout) {
|
||||
if (!this.probe) {
|
||||
this.markDegraded(errorMessage, errorCode);
|
||||
return;
|
||||
}
|
||||
this.probing ??= this.probe()
|
||||
.catch(() => false)
|
||||
.then((reachable) => {
|
||||
if (!reachable) this.markDegraded(errorMessage, errorCode);
|
||||
})
|
||||
.finally(() => {
|
||||
this.probing = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the quick check used after a request times out. */
|
||||
setReachabilityProbe(probe: (() => Promise<boolean>) | null) {
|
||||
this.probe = probe;
|
||||
}
|
||||
|
||||
private markDegraded(errorMessage: string, errorCode: string | undefined) {
|
||||
if (!this.degradedActive) {
|
||||
this.degradedActive = true;
|
||||
this.emit("database-connection-degraded", {
|
||||
|
||||
@@ -676,6 +676,18 @@ function initializeApiInstances() {
|
||||
|
||||
// Authentication API (port 30001)
|
||||
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
||||
const healthApi = authApi;
|
||||
dbHealthMonitor.setReachabilityProbe(() =>
|
||||
healthApi
|
||||
.get("/health", {
|
||||
timeout: 5000,
|
||||
__silentRetry: true,
|
||||
} as AxiosRequestConfigExtended)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
);
|
||||
|
||||
// RBAC API (port 30001)
|
||||
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
||||
|
||||
@@ -155,10 +155,12 @@ export function mergePlugins(
|
||||
latestBeta: entry?.latestBeta ?? null,
|
||||
channel: summary.channel ?? entry?.channel ?? "stable",
|
||||
isBeta: isPrerelease(summary.version),
|
||||
// The server keeps its own copy up to date.
|
||||
updateAvailable: summary.runsOnServer
|
||||
? false
|
||||
: (entry?.updateAvailable ?? false),
|
||||
// The server keeps its own copy up to date, and a plugin that is off
|
||||
// should not ask for attention.
|
||||
updateAvailable:
|
||||
summary.runsOnServer || !summary.enabled
|
||||
? false
|
||||
: (entry?.updateAvailable ?? false),
|
||||
addedCapabilities: entry?.addedCapabilities ?? [],
|
||||
versions: entry?.versions ?? [],
|
||||
capabilities: summary.capabilities ?? [],
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
|
||||
function watch() {
|
||||
const degraded = vi.fn<(...args: unknown[]) => void>();
|
||||
dbHealthMonitor.on("database-connection-degraded", degraded);
|
||||
return degraded;
|
||||
}
|
||||
|
||||
describe("dbHealthMonitor", () => {
|
||||
let degraded: ReturnType<typeof watch>;
|
||||
|
||||
afterEach(() => {
|
||||
dbHealthMonitor.off("database-connection-degraded", degraded);
|
||||
dbHealthMonitor.setReachabilityProbe(null);
|
||||
dbHealthMonitor.reset();
|
||||
});
|
||||
|
||||
it("flags a network error at once", () => {
|
||||
degraded = watch();
|
||||
dbHealthMonitor.reportDatabaseError({ code: "ERR_NETWORK" });
|
||||
expect(degraded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores canceled requests", () => {
|
||||
degraded = watch();
|
||||
dbHealthMonitor.reportDatabaseError({
|
||||
code: "ERR_CANCELED",
|
||||
message: "canceled",
|
||||
});
|
||||
expect(degraded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not flag a timeout when the backend answers", async () => {
|
||||
degraded = watch();
|
||||
const probe = vi.fn(async () => true);
|
||||
dbHealthMonitor.setReachabilityProbe(probe);
|
||||
dbHealthMonitor.reportDatabaseError({
|
||||
code: "ECONNABORTED",
|
||||
message: "timeout of 30000ms exceeded",
|
||||
});
|
||||
await vi.waitFor(() => expect(probe).toHaveBeenCalled());
|
||||
await Promise.resolve();
|
||||
expect(degraded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flags a timeout when the check fails too", async () => {
|
||||
degraded = watch();
|
||||
dbHealthMonitor.setReachabilityProbe(async () => false);
|
||||
dbHealthMonitor.reportDatabaseError({
|
||||
code: "ECONNABORTED",
|
||||
message: "timeout of 30000ms exceeded",
|
||||
});
|
||||
await vi.waitFor(() => expect(degraded).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("runs one check for timeouts at the same time", async () => {
|
||||
degraded = watch();
|
||||
let answer: (ok: boolean) => void = () => {};
|
||||
const probe = vi.fn(
|
||||
() => new Promise<boolean>((resolve) => (answer = resolve)),
|
||||
);
|
||||
dbHealthMonitor.setReachabilityProbe(probe);
|
||||
dbHealthMonitor.reportDatabaseError({ code: "ECONNABORTED" });
|
||||
dbHealthMonitor.reportDatabaseError({ code: "ECONNABORTED" });
|
||||
answer(true);
|
||||
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1));
|
||||
expect(degraded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,14 @@ describe("mergePlugins", () => {
|
||||
expect(row).toMatchObject({ status: "server", updateAvailable: false });
|
||||
});
|
||||
|
||||
it("offers no update or review for a disabled plugin", () => {
|
||||
const [row] = mergePlugins(
|
||||
[summary({ enabled: false, state: "disabled" })],
|
||||
[entry({ addedCapabilities: ["host:read"] })],
|
||||
);
|
||||
expect(row.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps installed plugins when the registry is unreachable", () => {
|
||||
const rows = mergePlugins([summary()], null);
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
Reference in new issue
Block a user