fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

This commit is contained in:
LukeGus committed 2026-09-28 21:59:37 -05:00
1 parent 44062e30d1
commit ffa872ebd3
19 files changed
+270 -292

No files matched your search

-64
View File
@@ -1,64 +0,0 @@
import { Duplex } from "node:stream";
import type { RawData, WebSocket } from "ws";
export function waitForWebSocketOpen(
socket: WebSocket,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
socket.off("open", onOpen);
socket.off("error", onError);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (error: Error) => {
cleanup();
socket.terminate();
reject(error);
};
const timer = setTimeout(() => {
cleanup();
socket.terminate();
reject(new Error("Cloudflare tunnel timeout"));
}, timeoutMs);
socket.once("open", onOpen);
socket.once("error", onError);
});
}
export function createWebSocketDuplex(socket: WebSocket): Duplex {
const duplex = new Duplex({
read() {},
write(chunk, _encoding, callback) {
try {
socket.send(chunk, (error) => callback(error || undefined));
} catch (error) {
callback(error instanceof Error ? error : new Error(String(error)));
}
},
destroy(error, callback) {
cleanup();
socket.terminate();
callback(error);
},
});
const onMessage = (data: RawData) => duplex.push(data);
const onClose = () => duplex.destroy();
const onError = () => duplex.destroy();
const cleanup = () => {
socket.off("message", onMessage);
socket.off("close", onClose);
socket.off("error", onError);
};
socket.on("message", onMessage);
socket.on("close", onClose);
socket.on("error", onError);
return duplex;
}
+4 -44
View File
@@ -1,6 +1,6 @@
/**
* How the bytes get to a host: port knocking, a Cloudflare Access tunnel, a
* jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
* proxy, in that order. Sets config.sock and
* returns the jump client so the caller can close it with the connection.
*/
@@ -39,7 +39,7 @@ function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
class SshTransportError extends Error {
constructor(
message: string,
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
readonly stage: "jump-host" | "jump-forward" | "proxy",
options?: { cause?: unknown },
) {
super(message, options);
@@ -50,8 +50,6 @@ class SshTransportError extends Error {
export interface OpenTransportOptions {
/** Knock before connecting when the host has a sequence. Default true. */
portKnock?: boolean;
/** Use terminalConfig.cfAccessClient* when present. Default true. */
cloudflare?: boolean;
/** Resolve DNS up front for a direct connection. Default true. */
resolveDns?: boolean;
log?: SshAuthLog;
@@ -59,7 +57,7 @@ export interface OpenTransportOptions {
export interface OpenedTransport {
jumpClient: Client | null;
via: "direct" | "proxy" | "jump" | "cloudflare";
via: "direct" | "proxy" | "jump";
}
function forwardThrough(
@@ -94,8 +92,6 @@ export async function openSshTransport(
config: MutableConnectConfig,
options: OpenTransportOptions = {},
): Promise<OpenedTransport> {
const log = options.log ?? (() => {});
if (
options.portKnock !== false &&
Array.isArray(host.portKnockSequence) &&
@@ -113,42 +109,6 @@ export async function openSshTransport(
let via: OpenedTransport["via"] = "direct";
const terminalConfig = (host.terminalConfig ?? {}) as Record<string, unknown>;
if (
options.cloudflare !== false &&
terminalConfig.cfAccessClientId &&
terminalConfig.cfAccessClientSecret
) {
try {
const WebSocket = (await import("ws")).default;
const { createWebSocketDuplex, waitForWebSocketOpen } =
await import("../cloudflare-websocket.js");
const cfHostname = (terminalConfig.cfTunnelHostname as string) || host.ip;
const cfWs = new WebSocket(
`wss://${cfHostname}/cdn-cgi/access/ssh-connect`,
{
headers: {
"CF-Access-Client-Id": terminalConfig.cfAccessClientId as string,
"CF-Access-Client-Secret":
terminalConfig.cfAccessClientSecret as string,
},
},
);
await waitForWebSocketOpen(cfWs, 30000);
config.sock = createWebSocketDuplex(
cfWs,
) as unknown as typeof config.sock;
via = "cloudflare";
log("info", "Connected via Cloudflare Tunnel");
} catch (error) {
throw new SshTransportError(
"Cloudflare tunnel connection failed: " + getErrorMessage(error),
"cloudflare",
{ cause: error },
);
}
}
const jumpUserId = host.userId || "";
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId);
@@ -1,38 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { HostSessionStatus } from "./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],
]);
});
});
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, it } from "vitest";
import { getJumpHostSocks5Config } from "./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,
});
});
});
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveSshKeepalive } from "./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,
});
});
});