* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

@@ -0,0 +1,103 @@
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
performPortKnocking: vi.fn(),
createJumpHostChain: vi.fn(),
createSocks5Connection: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { logger: log, sshLogger: log };
});
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
performPortKnocking: mocks.performPortKnocking,
}));
vi.mock("../../../hosts/jump-host-chain.js", () => ({
createJumpHostChain: mocks.createJumpHostChain,
}));
vi.mock("../../../utils/socks5-helper.js", () => ({
createSocks5Connection: mocks.createSocks5Connection,
}));
vi.mock("../../../hosts/ssh-dns.js", () => ({
resolveSshConnectConfigHost: async (config: unknown) => config,
}));
const { clearSshTransports, openProvidedTransport, provideSshTransport } =
await import("../../../hosts/connect/transport-providers.js");
const { openSshTransport } =
await import("../../../hosts/connect/transport.js");
const host = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
afterEach(() => {
clearSshTransports();
vi.clearAllMocks();
});
describe("ssh transport providers", () => {
it("opens nothing when no provider claims the host", async () => {
provideSshTransport("a", { claims: () => false, open: vi.fn() });
expect(await openProvidedTransport(host)).toBeNull();
});
it("hands back the claiming provider's stream", async () => {
const stream = new PassThrough();
const open = vi.fn(async () => stream);
provideSshTransport("a", { claims: (id) => id === 7, open });
expect(await openProvidedTransport(host)).toBe(stream);
expect(open).toHaveBeenCalledWith(host);
expect(await openProvidedTransport({ ...host, id: 8 })).toBeNull();
});
it("treats a provider that throws while claiming as not claiming", async () => {
provideSshTransport("a", {
claims: () => {
throw new Error("boom");
},
open: vi.fn(),
});
expect(await openProvidedTransport(host)).toBeNull();
});
it("stops using a provider once it is removed", async () => {
const remove = provideSshTransport("a", {
claims: () => true,
open: async () => new PassThrough(),
});
remove();
expect(await openProvidedTransport(host)).toBeNull();
});
});
describe("openSshTransport with a provider", () => {
it("uses the provided stream and skips knocking, jump hosts and proxies", async () => {
const stream = new PassThrough();
provideSshTransport("a", { claims: () => true, open: async () => stream });
const config: Record<string, unknown> = {};
const result = await openSshTransport(
{
...host,
portKnockSequence: [{ port: 1 }],
jumpHosts: [{ hostId: 1 }],
useSocks5: true,
socks5Host: "proxy",
userId: "u1",
} as never,
config as never,
);
expect(result).toEqual({ jumpClient: null, via: "provided" });
expect(config.sock).toBe(stream);
expect(mocks.performPortKnocking).not.toHaveBeenCalled();
expect(mocks.createJumpHostChain).not.toHaveBeenCalled();
expect(mocks.createSocks5Connection).not.toHaveBeenCalled();
});
it("connects directly when nothing claims the host", async () => {
const config: Record<string, unknown> = {};
const result = await openSshTransport(host as never, config as never);
expect(result.via).toBe("direct");
expect(config.sock).toBeUndefined();
});
});
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
hosts: [] as Record<string, unknown>[],
credentials: [] as Record<string, unknown>[],
resolve: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: async () => state.hosts,
findHostByIdForUser: async (id: number) =>
state.hosts.find((host) => host.id === id) ?? null,
}),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => state.credentials,
}),
}));
vi.mock("../../../database/routes/host-normalizers.js", () => ({
hostTerminalExport: () => ({ terminalConfig: null, sshOptions: null }),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
loadHostPluginSettings: async () => new Map(),
}));
vi.mock("../../../hosts/protocol-auth/protocol-auth.js", () => ({
listProtocolLogins: async () => [],
toPortableLogins: () => ({
rdp: {
authType: "credential",
credentialId: 4,
username: "bob",
password: "rdp-secret",
fields: { domain: "corp", token: "t" },
},
}),
}));
vi.mock("../../../hosts/protocol-auth/registry.js", () => ({
findHostProtocol: () => ({
credentialFields: [{ key: "token", secret: true }, { key: "domain" }],
}),
}));
vi.mock("../../../hosts/resolve-host-credentials.js", () => ({
resolveHostCredentials: state.resolve,
}));
import { exportAllHosts } from "../../../hosts/import/host-export.js";
const sshHost = {
id: 1,
connectionType: "ssh",
name: "web",
ip: "192.0.2.1",
port: 22,
username: "alice",
password: "pw",
key: "KEY",
keyPassword: "kp",
sudoPassword: "sp",
socks5Password: "s5",
authType: "credential",
credentialId: 7,
folder: "Prod",
tags: "a,b",
jumpHosts: JSON.stringify([{ hostId: 2 }]),
forceKeyboardInteractive: "true",
};
beforeEach(() => {
state.hosts = [{ ...sshHost }];
state.credentials = [
{ id: 7, name: "deploy", authType: "key", username: "alice", tags: "x" },
];
state.resolve.mockReset().mockImplementation(async (host) => ({
...host,
password: "resolved-pw",
key: "RESOLVED-KEY",
}));
});
describe("exportAllHosts", () => {
it("includes decrypted secrets without share mode", async () => {
const result = await exportAllHosts("u1", false);
expect(result.credentials).toBeUndefined();
expect(result.hosts[0]).toMatchObject({
exportId: 1,
password: "resolved-pw",
key: "RESOLVED-KEY",
sudoPassword: "sp",
credentialId: 7,
tags: ["a", "b"],
jumpHosts: [{ hostId: 2 }],
forceKeyboardInteractive: true,
});
expect(state.resolve).toHaveBeenCalled();
});
it("leaves out every secret and credential id in share mode", async () => {
const result = await exportAllHosts("u1", true);
const [host] = result.hosts;
expect(state.resolve).not.toHaveBeenCalled();
expect(host).toMatchObject({
password: null,
key: null,
keyPassword: null,
sudoPassword: null,
socks5Password: null,
credentialAlias: "deploy",
});
expect(host).not.toHaveProperty("credentialId");
expect(result.credentials).toEqual([
expect.objectContaining({ alias: "deploy", authType: "key" }),
]);
expect(JSON.stringify(result)).not.toMatch(/rdp-secret|"t"/);
expect(host.protocolAuth).toMatchObject({
rdp: {
authType: "direct",
credentialId: null,
password: null,
fields: { domain: "corp" },
},
});
expect(result.version).toBe("1");
});
it("exports a remote desktop host without SSH fields", async () => {
state.hosts = [{ ...sshHost, connectionType: "rdp" }];
const result = await exportAllHosts("u1", false);
expect(result.hosts[0]).not.toHaveProperty("authType");
expect(result.hosts[0]).toHaveProperty("protocolAuth");
});
});
describe("exportAllHosts with hostIds", () => {
it("exports only the asked hosts the user owns", async () => {
state.hosts = [
{ ...sshHost, id: 1 },
{ ...sshHost, id: 2 },
];
const result = await exportAllHosts("u1", false, [2, 99]);
expect(result.hosts.map((host) => host.exportId)).toEqual([2]);
});
it("returns nothing for an id the user does not own", async () => {
const result = await exportAllHosts("u1", false, [99]);
expect(result.hosts).toEqual([]);
});
});
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
list: vi.fn(),
applyDefaults: vi.fn(async () => {}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: vi.fn(),
setHostPluginEnabled: vi.fn(),
}));
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
applyDefaultsAfterHostWrites: mocks.applyDefaults,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({ listHosts: async () => [] }),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => [],
}),
createCurrentHostRepository: () => ({
createEncryptedForUser: mocks.create,
updateEncryptedForUser: mocks.update,
deleteForUser: mocks.remove,
}),
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: mocks.list,
}),
}));
import {
ImportError,
runBulkImport,
runSshConfigImport,
} from "../../../hosts/import/host-import.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
const good = {
ip: "192.0.2.1",
port: 22,
username: "alice",
authType: "none",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.list.mockResolvedValue([]);
mocks.remove.mockResolvedValue({ syncId: null });
});
describe("runBulkImport", () => {
it("creates hosts, returns their ids and tells listeners", async () => {
mocks.create.mockResolvedValueOnce({ id: 11 });
const created: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (p) => created.push(p));
try {
const result = await runBulkImport("u1", { hosts: [good] });
expect(result).toMatchObject({ success: 1, failed: 0 });
expect(result.createdHostIds).toEqual([11]);
expect(mocks.applyDefaults).toHaveBeenCalledWith([11]);
} finally {
off();
}
expect(created).toEqual([{ hostId: 11, userId: "u1" }]);
});
it("refuses an empty list and more than 100 hosts", async () => {
await expect(runBulkImport("u1", { hosts: [] })).rejects.toBeInstanceOf(
ImportError,
);
await expect(
runBulkImport("u1", {
hosts: Array.from({ length: 101 }, () => ({ ...good })),
}),
).rejects.toThrow(/Maximum 100/);
});
it("keeps the rows that worked when atomic is off", async () => {
mocks.create.mockResolvedValueOnce({ id: 11 });
const result = await runBulkImport("u1", {
hosts: [good, { ...good, ip: "" }],
});
expect(result).toMatchObject({ success: 1, failed: 1 });
expect(mocks.remove).not.toHaveBeenCalled();
});
it("deletes what it created and stays quiet when atomic and a row fails", async () => {
mocks.create
.mockResolvedValueOnce({ id: 11 })
.mockResolvedValueOnce({ id: 12 });
const events: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (p) => events.push(p));
try {
const result = await runBulkImport("u1", {
hosts: [good, { ...good, ip: "192.0.2.2" }, { ...good, ip: "" }],
atomic: true,
});
expect(result.rolledBack).toBe(true);
expect(result.success).toBe(0);
expect(result.createdHostIds).toEqual([]);
expect(result.failed).toBe(1);
} finally {
off();
}
expect(mocks.remove.mock.calls.map((call) => call[1])).toEqual([12, 11]);
expect(mocks.applyDefaults).not.toHaveBeenCalled();
expect(events).toEqual([]);
});
it("refuses overwrite together with atomic", async () => {
await expect(
runBulkImport("u1", { hosts: [good], overwrite: true, atomic: true }),
).rejects.toThrow(/cannot overwrite/);
});
});
describe("runSshConfigImport", () => {
const config = "Host web\n HostName 192.0.2.9\n User bob\n";
it("imports Host blocks", async () => {
mocks.create.mockResolvedValueOnce({ id: 31 });
const result = await runSshConfigImport("u1", { content: config });
expect(result).toMatchObject({ success: 1, createdHostIds: [31] });
});
it("rejects empty content", async () => {
await expect(
runSshConfigImport("u1", { content: " " }),
).rejects.toBeInstanceOf(ImportError);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: { warn: vi.fn() },
}));
const {
assertHostsUnlocked,
assertManualCreateAllowed,
clearHostLocks,
hasHostLocks,
hostLock,
HostLockedError,
manualCreateBlockedBy,
provideHostLocks,
requireManualCreate,
requireUnlockedHost,
} = await import("../../hosts/locks.js");
const provider = (locked: number[], allowManualCreate = true) => ({
isLocked: (hostId: number) => locked.includes(hostId),
note: (hostId: number) => `id ${hostId}`,
allowManualCreate: () => allowManualCreate,
});
function fakeRes() {
const res = {
code: 0,
body: null as unknown,
status(code: number) {
res.code = code;
return { json: (body: unknown) => (res.body = body) };
},
};
return res;
}
afterEach(() => clearHostLocks());
describe("host locks", () => {
it("reports nothing without providers", () => {
expect(hasHostLocks()).toBe(false);
expect(hostLock(1)).toBeNull();
expect(() => assertHostsUnlocked([1, 2])).not.toThrow();
expect(manualCreateBlockedBy()).toBeNull();
});
it("names the plugin and note for a locked host", () => {
provideHostLocks("cfg", "Config", provider([3]));
expect(hostLock(3)).toEqual({
pluginId: "cfg",
pluginName: "Config",
note: "id 3",
});
expect(hostLock(4)).toBeNull();
expect(() => assertHostsUnlocked([4, 3])).toThrow(HostLockedError);
});
it("lets the locking plugin through", () => {
provideHostLocks("cfg", "Config", provider([3], false));
expect(() => assertHostsUnlocked([3], "cfg")).not.toThrow();
expect(() => assertManualCreateAllowed("cfg")).not.toThrow();
expect(() => assertManualCreateAllowed()).toThrow(/managed by Config/);
});
it("drops a provider when removed", () => {
const remove = provideHostLocks("cfg", "Config", provider([3], false));
remove();
expect(hostLock(3)).toBeNull();
expect(manualCreateBlockedBy()).toBeNull();
});
it("never locks when a provider throws", () => {
provideHostLocks("cfg", "Config", {
isLocked: () => {
throw new Error("broken");
},
note: () => null,
allowManualCreate: () => {
throw new Error("broken");
},
});
expect(hostLock(1)).toBeNull();
expect(manualCreateBlockedBy()).toBeNull();
});
});
describe("lock route guards", () => {
it("answers 403 HOST_LOCKED for a locked :id", () => {
provideHostLocks("cfg", "Config", provider([7]));
const res = fakeRes();
const next = vi.fn();
requireUnlockedHost({ params: { id: "7" } }, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.code).toBe(403);
expect(res.body).toMatchObject({ code: "HOST_LOCKED", lockedBy: "cfg" });
requireUnlockedHost({ params: { id: "8" } }, fakeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
it("blocks manual creates only while a provider says so", () => {
const next = vi.fn();
requireManualCreate({}, fakeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
provideHostLocks("cfg", "Config", provider([], false));
const res = fakeRes();
requireManualCreate({}, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.code).toBe(403);
});
});
+138
View File
@@ -0,0 +1,138 @@
import net from "node:net";
import type { AddressInfo } from "node:net";
import http from "node:http";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
const mocks = vi.hoisted(() => ({
resolveHostById: vi.fn(),
openSshTransport: vi.fn(),
handler: null as null | ((connection: unknown) => Promise<void>),
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { sshLogger: log, logger: log };
});
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: mocks.resolveHostById,
}));
vi.mock("../../hosts/connect/transport.js", () => ({
openSshTransport: mocks.openSshTransport,
}));
vi.mock("../../plugins/ws.js", () => ({
registerCoreWsRoute: (_path: string, handler: never) => {
mocks.handler = handler;
return () => {};
},
}));
const { registerSshRelay } = await import("../../hosts/ssh-relay.js");
let echo: net.Server;
let echoPort = 0;
let relayServer: http.Server;
let wss: WebSocketServer;
let relayUrl = "";
beforeEach(async () => {
vi.clearAllMocks();
registerSshRelay();
echo = net.createServer((socket) => socket.pipe(socket));
await new Promise<void>((resolve) => echo.listen(0, "127.0.0.1", resolve));
echoPort = (echo.address() as AddressInfo).port;
relayServer = http.createServer();
wss = new WebSocketServer({ server: relayServer });
wss.on("connection", (socket, request) => {
void mocks.handler?.({ userId: "user-1", request, socket });
});
await new Promise<void>((resolve) =>
relayServer.listen(0, "127.0.0.1", resolve),
);
relayUrl = `ws://127.0.0.1:${(relayServer.address() as AddressInfo).port}`;
mocks.resolveHostById.mockResolvedValue({
id: 5,
ip: "127.0.0.1",
port: echoPort,
});
mocks.openSshTransport.mockResolvedValue({ jumpClient: null, via: "direct" });
});
afterEach(async () => {
wss.close();
await new Promise((resolve) => relayServer.close(resolve));
await new Promise((resolve) => echo.close(resolve));
});
function open(query: string): Promise<{
socket: WebSocket;
closed: Promise<{ code: number; reason: string }>;
}> {
return new Promise((resolve) => {
const socket = new WebSocket(`${relayUrl}/${query}`);
const closed = new Promise<{ code: number; reason: string }>((done) =>
socket.once("close", (code, reason) =>
done({ code, reason: reason.toString() }),
),
);
socket.once("open", () => resolve({ socket, closed }));
});
}
describe("ssh relay", () => {
it("carries bytes to the host and back", async () => {
const { socket } = await open("?hostId=5");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send(Buffer.from("SSH-2.0-test\r\n"));
expect(await received).toBe("SSH-2.0-test\r\n");
expect(mocks.resolveHostById).toHaveBeenCalledWith(5, "user-1");
socket.close();
});
it("uses the stream the transport made, such as a jump host channel", async () => {
const channel = new PassThrough();
const end = vi.fn();
mocks.openSshTransport.mockImplementation(
async (_host: unknown, config: { sock?: unknown }) => {
config.sock = channel;
return { jumpClient: { end }, via: "jump" };
},
);
const { socket } = await open("?hostId=5");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send("ping");
expect(await received).toBe("ping");
socket.close();
await vi.waitFor(() => expect(end).toHaveBeenCalled());
});
it("refuses a host the user can't connect to", async () => {
mocks.resolveHostById.mockResolvedValue(null);
const { closed } = await open("?hostId=5");
expect(await closed).toMatchObject({
code: 1008,
reason: "Host not found or access denied",
});
expect(mocks.openSshTransport).not.toHaveBeenCalled();
});
it("needs a host id", async () => {
const { closed } = await open("");
expect((await closed).code).toBe(1008);
expect(mocks.resolveHostById).not.toHaveBeenCalled();
});
it("closes the socket when the host can't be reached", async () => {
mocks.openSshTransport.mockRejectedValue(new Error("no route"));
const { closed } = await open("?hostId=5");
expect((await closed).code).toBe(1000);
});
});
@@ -0,0 +1,111 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { generateKeyPairSync } from "node:crypto";
import { expect, it } from "vitest";
const require = createRequire(import.meta.url);
const ssh2 = require("ssh2");
const {
patchKeyParserSource,
patchAgentSource,
patchProtocolSource,
} = require("../../../../scripts/patch-ssh2-sk.cjs");
const SK_ED = "sk-ssh-ed25519@openssh.com";
function str(value: Buffer | string): Buffer {
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
const len = Buffer.alloc(4);
len.writeUInt32BE(buf.length);
return Buffer.concat([len, buf]);
}
function skPublicBlob(): Buffer {
const { publicKey } = generateKeyPairSync("ed25519");
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
return Buffer.concat([str(SK_ED), str(raw), str("ssh:")]);
}
it("patches are idempotent on the installed dependency", () => {
const files: Array<[string, (s: string) => string]> = [
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
["ssh2/lib/agent.js", patchAgentSource],
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
];
for (const [id, patch] of files) {
const source = readFileSync(require.resolve(id), "utf8");
const result = patch(source);
expect(patch(result)).toBe(result);
}
});
it("fails explicitly when the dependency changes", () => {
expect(() => patchKeyParserSource("changed")).toThrow("review");
expect(() => patchAgentSource("changed")).toThrow("review");
expect(() => patchProtocolSource("changed")).toThrow("review");
});
it("parses a security key public blob and keeps it byte for byte", () => {
const blob = skPublicBlob();
const key = ssh2.utils.parseKey(blob);
expect(key).not.toBeInstanceOf(Error);
expect(key.type).toBe(SK_ED);
expect(Buffer.compare(key.getPublicSSH(), blob)).toBe(0);
});
it("sends flags and counter after the signature in publickey auth", async () => {
const blob = skPublicBlob();
const key = ssh2.utils.parseKey(blob);
const trailer = Buffer.from([0x01, 0, 0, 0, 7]);
const signature = Buffer.alloc(64, 9);
(signature as Buffer & { skTrailer?: Buffer }).skTrailer = trailer;
class FakeAgent extends ssh2.BaseAgent {
getIdentities(cb: (err: Error | null, keys?: unknown[]) => void) {
cb(null, [key]);
}
sign(
_key: unknown,
_data: Buffer,
_opts: unknown,
cb: (err: Error | null, sig?: Buffer) => void,
) {
cb(null, signature);
}
}
const hostKey = ssh2.utils.generateKeyPairSync("ed25519").private;
const received = await new Promise<Buffer>((resolve) => {
const server = new ssh2.Server({ hostKeys: [hostKey] }, (client: any) => {
client.on("authentication", (ctx: any) => {
if (ctx.method !== "publickey") return ctx.reject(["publickey"]);
if (!ctx.signature) return ctx.accept();
resolve(
Buffer.concat([
Buffer.from(ctx.key.data),
Buffer.from(ctx.signature),
]),
);
ctx.reject();
});
client.on("error", () => {});
});
server.listen(0, "127.0.0.1", () => {
const conn = new ssh2.Client();
conn.on("error", () => {});
conn.on("close", () => server.close());
conn.connect({
host: "127.0.0.1",
port: server.address().port,
username: "u",
agent: new FakeAgent(),
readyTimeout: 5000,
});
});
});
expect(
Buffer.compare(
received,
Buffer.concat([blob, Buffer.alloc(64, 9), trailer]),
),
).toBe(0);
});
@@ -1,312 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: vi.fn(),
getCurrentSettingValue: vi.fn(() => null),
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { HostStatusService, toStatusTarget } =
await import("../../../hosts/status/host-status-service.js");
const { hostSessionStatus } =
await import("../../../hosts/host-session-status.js");
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
type Target = ReturnType<typeof toStatusTarget>;
function target(id: number, extra: Partial<Target> = {}): Target {
return {
id,
userId: "owner",
ip: `10.0.0.${id}`,
port: 22,
connectionType: "ssh",
jumpHosts: [],
statusCheckEnabled: true,
statusCheckInterval: null,
...extra,
};
}
function setup(
targets: Target[],
reachable = true,
shared: Record<string, number[]> = {},
) {
const emitted: unknown[] = [];
const ping = vi.fn(async () => reachable);
const pingThroughJumpHosts = vi.fn(async () => reachable);
const loadTargets = vi.fn(
async (filter: { userId?: string; hostIds?: number[] }) =>
targets.filter(
(t) =>
(!filter.userId || t.userId === filter.userId) &&
(!filter.hostIds || filter.hostIds.includes(t.id)),
),
);
const service = new HostStatusService({
loadTargets,
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
retryDelayMs: 0,
});
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
let active: InstanceType<typeof HostStatusService> | null = null;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
});
afterEach(() => {
active?.stop();
active = null;
pluginEvents.clear();
vi.useRealTimers();
});
describe("HostStatusService", () => {
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
"skips probes while a session is open, including jump hosts: %j",
async ({ jumpHosts }) => {
const { service, ping, pingThroughJumpHosts } = setup([
target(7, { jumpHosts }),
]);
active = service;
const close = hostSessionStatus.register(7);
try {
await service.statusesFor("owner", null);
await flush();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(service.get(7)?.status).toBe("online");
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
close();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(
jumpHosts.length ? pingThroughJumpHosts : ping,
).toHaveBeenCalledOnce();
} finally {
close();
}
},
);
it("starts a user's own hosts and reports them online", async () => {
const { service, emitted, ping } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 22);
expect(service.get(1)?.status).toBe("online");
expect(emitted).toContainEqual({
hostId: 1,
ownerUserId: "owner",
status: "online",
previous: null,
online: true,
});
});
it("checks a host shared with the user before its owner ever asks", async () => {
const { service, ping } = setup([target(5)], true, { recipient: [5] });
active = service;
await service.statusesFor("recipient", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.5", 22);
expect(service.get(5)?.status).toBe("online");
});
it("only starts the shared hosts a desktop request names", async () => {
const { service, ping } = setup([target(5), target(6)], true, {
recipient: [5, 6],
});
active = service;
await service.statusesFor("recipient", new Set([6]));
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.6", 22);
expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22);
});
it("reports offline for a host that does not answer", async () => {
const { service } = setup([target(1)], false);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(service.get(1)?.status).toBe("offline");
});
it("never checks a host with status checks off", async () => {
const { service, ping } = setup([target(1, { statusCheckEnabled: false })]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).not.toHaveBeenCalled();
expect(service.get(1)).toBeNull();
});
it("goes through the jump hosts when a host has them", async () => {
const { service, ping, pingThroughJumpHosts } = setup([
target(1, { jumpHosts: [{ hostId: 9 }] }),
]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).toHaveBeenCalledOnce();
});
it("asks a registered plugin for a protocol's port", async () => {
const { service, ping } = setup([target(1, { connectionType: "rdp" })]);
active = service;
service.registerPort("rdp", async () => 3390);
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390);
});
it("falls back to the host's port when no plugin knows the protocol", async () => {
const { service, ping } = setup([
target(1, { connectionType: "rdp", port: 3389 }),
]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
});
it("ignores a failed login and counts a working one as online", async () => {
const { service, emitted } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
const before = emitted.length;
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("online");
expect(emitted).toHaveLength(before);
});
it("marks a host online when a terminal session opens", async () => {
const { service } = setup([target(1)], false);
active = service;
service.start();
await service.statusesFor("owner", null);
expect(service.get(1)?.status).toBe("offline");
const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online");
release();
});
it("tries a failed ping again before reporting offline", async () => {
const { service, ping } = setup([target(1)]);
active = service;
ping.mockResolvedValueOnce(false);
await service.statusesFor("owner", null);
expect(ping).toHaveBeenCalledTimes(2);
expect(service.get(1)?.status).toBe("online");
});
it("answers the first request with the first checks done", async () => {
const { service } = setup([target(1), target(2)]);
active = service;
const statuses = await service.statusesFor("owner", null);
expect(statuses.get(1)?.status).toBe("online");
expect(statuses.get(2)?.status).toBe("online");
});
it("only checks the requested hosts on the desktop app", async () => {
const { service, ping } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", new Set([2]));
await flush();
expect(ping).toHaveBeenCalledTimes(1);
expect(ping).toHaveBeenCalledWith("10.0.0.2", 22);
});
it("checks again on its interval", async () => {
const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(15_000);
await flush();
expect(ping).toHaveBeenCalledTimes(2);
});
it("checks a host on demand unless the last result is fresh", async () => {
const { service, ping } = setup([target(1)]);
active = service;
expect(await service.check(1)).toMatchObject({ status: "online" });
expect(await service.check(1)).toMatchObject({ status: "online" });
expect(ping).toHaveBeenCalledTimes(1);
});
it("forgets a deleted host", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 });
expect(service.get(1)).toBeNull();
});
it("never logs in to check a host", async () => {
// The only transports are the two ping functions: nothing here can
// authenticate, so a RADIUS or Duo backed host gets no 2FA push.
const { service, ping, pingThroughJumpHosts } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]);
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
});
});
describe("toStatusTarget", () => {
it("parses the jump hosts and defaults the connection type", () => {
expect(
toStatusTarget({
id: 1,
userId: "u",
ip: "h",
port: 22,
connectionType: "",
jumpHosts: '[{"hostId":3},{"bad":1}]',
statusCheckEnabled: false,
statusCheckInterval: 30,
}),
).toEqual({
id: 1,
userId: "u",
ip: "h",
port: 22,
connectionType: "ssh",
jumpHosts: [{ hostId: 3 }],
statusCheckEnabled: false,
statusCheckInterval: 30,
});
});
});
@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
"Host denied (verification failed)",
"Host key changed - please connect via Terminal to verify the new key",
])("classifies %s", (message) => {
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
});
it("does not classify ordinary authentication failures", () => {
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
false,
);
});
});
@@ -1,127 +0,0 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
function fakeClient(reachable = true) {
const client = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(
(
_srcIp: string,
_srcPort: number,
_host: string,
_port: number,
callback: (error: Error | null, stream?: { destroy(): void }) => void,
) => {
callback(
reachable ? null : new Error("refused"),
reachable ? { destroy: () => {} } : undefined,
);
},
),
});
return client;
}
afterEach(() => {
vi.useRealTimers();
});
describe("JumpProbePool", () => {
it("shares one chain across hosts behind the same hops", async () => {
const client = fakeClient();
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
const hops = [{ hostId: 1 }, { hostId: 2 }];
const results = await Promise.all([
pool.ping(hops, "u1", "10.0.0.3", 22),
pool.ping(hops, "u1", "10.0.0.4", 22),
pool.ping(hops, "u1", "10.0.0.5", 22),
]);
expect(results).toEqual([true, true, true]);
expect(open).toHaveBeenCalledTimes(1);
expect(client.forwardOut).toHaveBeenCalledTimes(3);
expect(client.end).not.toHaveBeenCalled();
pool.closeAll();
});
it("keeps chains apart per user and hop list", async () => {
const open = vi.fn(async () => fakeClient() as never);
const pool = new JumpProbePool(open);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
await pool.ping([{ hostId: 1 }], "u2", "a", 22);
await pool.ping([{ hostId: 2 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
it("reports an unreachable target without dropping the chain", async () => {
const client = fakeClient(false);
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "b", 22)).toBe(false);
expect(open).toHaveBeenCalledTimes(1);
pool.closeAll();
});
it("drops a chain whose hop stops answering", async () => {
vi.useFakeTimers();
const stale = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(),
});
const fresh = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(fresh);
const pool = new JumpProbePool(open);
const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000);
await vi.advanceTimersByTimeAsync(1000);
expect(await first).toBe(false);
expect(stale.end).toHaveBeenCalled();
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
expect(open).toHaveBeenCalledTimes(2);
pool.closeAll();
});
it("retries a chain that failed to open", async () => {
const open = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(fakeClient());
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
pool.closeAll();
});
it("opens a new chain after the old one closed or sat idle", async () => {
vi.useFakeTimers();
const first = fakeClient();
const second = fakeClient();
const third = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second)
.mockResolvedValueOnce(third);
const pool = new JumpProbePool(open, 1000);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
first.emit("close");
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1500);
expect(second.end).toHaveBeenCalled();
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
});
@@ -1,162 +0,0 @@
import { describe, expect, it } from "vitest";
import { ConcurrentLimiter } from "../../../hosts/status/limiter.js";
describe("ConcurrentLimiter", () => {
it("never exceeds max concurrent runners", async () => {
const limiter = new ConcurrentLimiter(2);
let peak = 0;
let current = 0;
const job = async () => {
current += 1;
peak = Math.max(peak, current);
await new Promise((r) => setTimeout(r, 30));
current -= 1;
};
await Promise.all([
limiter.run(job),
limiter.run(job),
limiter.run(job),
limiter.run(job),
]);
expect(peak).toBeLessThanOrEqual(2);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("runs waiters in FIFO order after a slot frees", async () => {
const limiter = new ConcurrentLimiter(1);
const order: number[] = [];
const first = limiter.run(async () => {
order.push(1);
await new Promise((r) => setTimeout(r, 20));
});
const second = limiter.run(async () => {
order.push(2);
});
const third = limiter.run(async () => {
order.push(3);
});
await Promise.all([first, second, third]);
expect(order).toEqual([1, 2, 3]);
});
it("rejects invalid maxConcurrent", () => {
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
});
describe("setLimit", () => {
it("releases queued waiters as soon as the ceiling is raised", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(1);
expect(limiter.pendingCount).toBe(3);
// Widening must drain the backlog without waiting for the running job.
limiter.setLimit(4);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
expect(limiter.pendingCount).toBe(0);
release.forEach((fn) => fn());
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
});
it("does not over-release beyond the new ceiling", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
// Later waves of woken jobs enqueue their own resolvers, so draining has
// to keep going until nothing is left rather than flushing a snapshot.
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
limiter.setLimit(3);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(3);
expect(limiter.pendingCount).toBe(2);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("lets running work finish when the ceiling shrinks", async () => {
const limiter = new ConcurrentLimiter(4);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
// Shrinking never kills in-flight work; it applies to later releases.
limiter.setLimit(2);
expect(limiter.activeCount).toBe(4);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
// The two queued jobs ran only after the shrink, so they never pushed
// occupancy back up to the old width.
expect(peak).toBe(4);
});
it("rejects an invalid new limit", () => {
const limiter = new ConcurrentLimiter(2);
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
expect(limiter.limit).toBe(2);
});
});
});
@@ -1,57 +0,0 @@
import type { Client } from "ssh2";
import { describe, expect, it, vi } from "vitest";
import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js";
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});