mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
This commit is contained in:
1 parent
44062e30d1
commit
ffa872ebd3
19 files changed
+270
-292
No files matched your search
@@ -1,74 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
import {
|
||||
createWebSocketDuplex,
|
||||
waitForWebSocketOpen,
|
||||
} from "../../hosts/cloudflare-websocket.js";
|
||||
|
||||
function setupSocket() {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
terminate: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
socket.terminate = vi.fn();
|
||||
socket.send = vi.fn();
|
||||
return socket as unknown as WebSocket;
|
||||
}
|
||||
|
||||
describe("waitForWebSocketOpen", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("clears listeners and leaves an opened socket alive", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = setupSocket();
|
||||
const result = waitForWebSocketOpen(socket, 30_000);
|
||||
|
||||
socket.emit("open");
|
||||
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
expect(socket.terminate).not.toHaveBeenCalled();
|
||||
expect(socket.listenerCount("open")).toBe(0);
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates a socket that does not open before the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = setupSocket();
|
||||
const result = waitForWebSocketOpen(socket, 30_000);
|
||||
const rejection = expect(result).rejects.toThrow(
|
||||
"Cloudflare tunnel timeout",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await rejection;
|
||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
||||
expect(socket.listenerCount("open")).toBe(0);
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates the socket when the SSH duplex is destroyed", () => {
|
||||
const socket = setupSocket();
|
||||
const duplex = createWebSocketDuplex(socket);
|
||||
|
||||
duplex.destroy();
|
||||
|
||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
||||
expect(socket.listenerCount("message")).toBe(0);
|
||||
expect(socket.listenerCount("close")).toBe(0);
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("handles established socket errors without an unhandled event", () => {
|
||||
const socket = setupSocket();
|
||||
const duplex = createWebSocketDuplex(socket);
|
||||
|
||||
socket.emit("error", new Error("connection lost"));
|
||||
|
||||
expect(duplex.destroyed).toBe(true);
|
||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HostSessionStatus } from "../../hosts/host-session-status.js";
|
||||
|
||||
describe("HostSessionStatus", () => {
|
||||
it("reports only the first connection and last disconnection per host", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const closeFirst = status.register(7);
|
||||
const closeSecond = status.register(7);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, true);
|
||||
|
||||
closeFirst();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeSecond();
|
||||
expect(listener).toHaveBeenLastCalledWith(7, false);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes connection cleanup idempotent", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const close = status.register(9);
|
||||
close();
|
||||
close();
|
||||
|
||||
expect(listener.mock.calls).toEqual([
|
||||
[9, true],
|
||||
[9, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getJumpHostSocks5Config } from "../../hosts/jump-host-proxy.js";
|
||||
|
||||
describe("getJumpHostSocks5Config", () => {
|
||||
it("uses the first jump host proxy settings", () => {
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
socks5ProxyChain: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use destination proxy settings for the first jump host", () => {
|
||||
expect(getJumpHostSocks5Config({ useSocks5: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a serialized proxy chain from the first jump host", () => {
|
||||
const chain = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
name: "Proxy 1",
|
||||
host: "proxy.internal",
|
||||
port: 1080,
|
||||
type: "socks5" as const,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: JSON.stringify(chain),
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: undefined,
|
||||
socks5Port: undefined,
|
||||
socks5Username: undefined,
|
||||
socks5Password: undefined,
|
||||
socks5ProxyChain: chain,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSshKeepalive } from "../../hosts/ssh-keepalive.js";
|
||||
|
||||
describe("resolveSshKeepalive", () => {
|
||||
it("preserves zero to disable SSH keepalives", () => {
|
||||
expect(resolveSshKeepalive(0, 0, 30000, 5)).toEqual({
|
||||
keepaliveInterval: 0,
|
||||
keepaliveCountMax: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses defaults when keepalive settings are absent", () => {
|
||||
expect(resolveSshKeepalive(undefined, undefined, 60000, 5)).toEqual({
|
||||
keepaliveInterval: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the existing minimums for positive settings", () => {
|
||||
expect(resolveSshKeepalive(1, 0.5, 30000, 5)).toEqual({
|
||||
keepaliveInterval: 5000,
|
||||
keepaliveCountMax: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -197,6 +197,16 @@ describe("core sync entities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("translates every snippet a host points at", () => {
|
||||
const fields = (getEntity("hosts")?.references ?? [])
|
||||
.filter((reference) => reference.entityType === "commandSnippet")
|
||||
.map((reference) => reference.field);
|
||||
expect(fields).toEqual([
|
||||
"quickActions[].snippetId",
|
||||
"terminalConfig.startupSnippetId",
|
||||
]);
|
||||
});
|
||||
|
||||
it("encrypts host and credential secrets", () => {
|
||||
expect(getEntity("hosts")?.encryptedFields).toContain("password");
|
||||
expect(getEntity("sshCredentials")?.encryptedFields).toContain(
|
||||
|
||||
@@ -35,6 +35,7 @@ beforeEach(() => {
|
||||
references: [
|
||||
{ field: "parentId", syncField: "parentSyncId", entityType: "things" },
|
||||
{ field: "hops[].thingId", entityType: "things" },
|
||||
{ field: "config.startId", entityType: "things" },
|
||||
{
|
||||
field: "bindings[].action.snippetId",
|
||||
entityType: "things",
|
||||
@@ -111,6 +112,29 @@ describe("serializing references", () => {
|
||||
expect(back.bindings).toEqual([{ action: { snippetId: "12" } }]);
|
||||
});
|
||||
|
||||
it("rewrites an id nested in a JSON object column", async () => {
|
||||
const wire = await serializeReferences(
|
||||
entity("things"),
|
||||
{ config: JSON.stringify({ theme: "dark", startId: 5 }) },
|
||||
toSync,
|
||||
);
|
||||
expect(JSON.parse(wire.config as string)).toEqual({
|
||||
theme: "dark",
|
||||
startId: "sync-5",
|
||||
});
|
||||
|
||||
const back = await deserializeReferences(
|
||||
entity("things"),
|
||||
wire,
|
||||
toId,
|
||||
null,
|
||||
);
|
||||
expect(JSON.parse(back.config as string)).toEqual({
|
||||
theme: "dark",
|
||||
startId: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the current value when a referenced row is not here", async () => {
|
||||
const back = await deserializeReferences(
|
||||
entity("things"),
|
||||
|
||||
Reference in new issue
Block a user