mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: ssh transport providers, core ssh relay and plugin folders api
This commit is contained in:
1 parent
3caf6cca62
commit
901a235de4
20 files changed
+736
-77
No files matched your search
@@ -35,6 +35,7 @@ import {
|
||||
mountPluginLegacyPaths,
|
||||
} from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import { registerSshRelay } from "../hosts/ssh-relay.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
@@ -1898,6 +1899,7 @@ const httpServer = http.createServer(app);
|
||||
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
|
||||
// left alone, so core's own upgrade handling is unaffected.
|
||||
attachPluginWebSockets(httpServer);
|
||||
registerSshRelay();
|
||||
|
||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
|
||||
@@ -236,6 +236,29 @@ export class HostFolderRepository {
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
/** Drops the folder rows (and subfolders) only when no host is left in them. */
|
||||
async deleteEmptyFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<boolean> {
|
||||
if ((await this.listHostsInFolder(userId, folderName)).length > 0) {
|
||||
return false;
|
||||
}
|
||||
await deleteReturning(
|
||||
this.context,
|
||||
sshFolders,
|
||||
and(
|
||||
eq(sshFolders.userId, userId),
|
||||
or(
|
||||
eq(sshFolders.name, folderName),
|
||||
like(sshFolders.name, `${folderName} / %`),
|
||||
),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteHostsAndFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Other ways to reach a host, provided by plugins through
|
||||
* ctx.ssh.transports.provide. A provider claims some hosts by id and hands the
|
||||
* pipeline an open stream to them instead of a TCP connection, for example a
|
||||
* relay through another Termix server. The first provider that claims a host
|
||||
* wins, and nothing else (jump hosts, proxies) is tried for it.
|
||||
*/
|
||||
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginSshTransportProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideSshTransport(
|
||||
pluginId: string,
|
||||
provider: PluginSshTransportProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearSshTransports(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
/** Opens a stream to the host if a provider claims it, else null. */
|
||||
export async function openProvidedTransport(host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}): Promise<Duplex | null> {
|
||||
for (const { provider } of providers.values()) {
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = provider.claims(host.id);
|
||||
} catch {
|
||||
claimed = false;
|
||||
}
|
||||
if (claimed) return provider.open(host);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
|
||||
* proxy, in that order. Sets config.sock and
|
||||
* How the bytes get to a host: a transport a plugin provides, else 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.
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../../utils/socks5-helper.js";
|
||||
import type { ProxyNode } from "../../../types/index.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { openProvidedTransport } from "./transport-providers.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { performPortKnocking } from "../terminal-auth-helpers.js";
|
||||
import type {
|
||||
@@ -59,7 +60,7 @@ export interface OpenTransportOptions {
|
||||
|
||||
export interface OpenedTransport {
|
||||
jumpClient: Client | null;
|
||||
via: "direct" | "proxy" | "jump";
|
||||
via: "direct" | "proxy" | "jump" | "provided";
|
||||
}
|
||||
|
||||
function forwardThrough(
|
||||
@@ -94,6 +95,16 @@ export async function openSshTransport(
|
||||
config: MutableConnectConfig,
|
||||
options: OpenTransportOptions = {},
|
||||
): Promise<OpenedTransport> {
|
||||
const provided = await openProvidedTransport({
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
port: host.port || 22,
|
||||
});
|
||||
if (provided) {
|
||||
config.sock = provided;
|
||||
return { jumpClient: null, via: "provided" };
|
||||
}
|
||||
|
||||
if (
|
||||
options.portKnock !== false &&
|
||||
Array.isArray(host.portKnockSequence) &&
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* A relay to a host's SSH port, for another Termix server that manages this
|
||||
* one. It opens a WebSocket at /plugin-ws/core/ssh-relay with an API key
|
||||
* ticket, names a host, and the bytes of an SSH connection run through it.
|
||||
* This server reaches the host the way it always does, jump hosts, proxy and
|
||||
* port knocking included, so the other server never needs a route to it. The
|
||||
* SSH session itself stays between that server and the host.
|
||||
*
|
||||
* The caller needs connect access to the host, the same as for a session.
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { createWebSocketStream, type WebSocket } from "ws";
|
||||
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolveHostById } from "./host-resolver.js";
|
||||
import { openSshTransport } from "./connect/transport.js";
|
||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { registerCoreWsRoute } from "../plugins/ws.js";
|
||||
|
||||
const MAX_RELAYS_PER_USER = 100;
|
||||
const open = new Map<string, number>();
|
||||
|
||||
function close(ws: WebSocket, code: number, reason: string): void {
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelay(
|
||||
connection: PluginWebSocketConnection,
|
||||
): Promise<void> {
|
||||
const ws = connection.socket as WebSocket;
|
||||
const url = String((connection.request as { url?: string }).url ?? "");
|
||||
const hostId = Number(
|
||||
new URL(url, "http://relay").searchParams.get("hostId"),
|
||||
);
|
||||
const userId = connection.userId;
|
||||
|
||||
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
|
||||
close(ws, 1008, "A host is required");
|
||||
return;
|
||||
}
|
||||
if ((open.get(userId) ?? 0) >= MAX_RELAYS_PER_USER) {
|
||||
close(ws, 1013, "Too many connections");
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) {
|
||||
close(ws, 1008, "Host not found or access denied");
|
||||
return;
|
||||
}
|
||||
|
||||
open.set(userId, (open.get(userId) ?? 0) + 1);
|
||||
let stream: Duplex | null = null;
|
||||
let jumpEnd: (() => void) | null = null;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
open.set(userId, Math.max(0, (open.get(userId) ?? 1) - 1));
|
||||
stream?.destroy();
|
||||
jumpEnd?.();
|
||||
close(ws, 1000, "Closed");
|
||||
};
|
||||
ws.once("close", finish);
|
||||
|
||||
try {
|
||||
const sshHost = host as unknown as SshConnectHost;
|
||||
const config = {
|
||||
host: sshHost.ip,
|
||||
port: sshHost.port || 22,
|
||||
} as MutableConnectConfig;
|
||||
const { jumpClient } = await openSshTransport(sshHost, config);
|
||||
jumpEnd = () => jumpClient?.end();
|
||||
stream = (config.sock as Duplex | undefined) ?? null;
|
||||
if (!stream) {
|
||||
stream = net.connect({
|
||||
host: String(config.host),
|
||||
port: Number(config.port),
|
||||
});
|
||||
}
|
||||
const pipe = createWebSocketStream(ws);
|
||||
stream.once("error", finish);
|
||||
stream.once("close", finish);
|
||||
pipe.once("error", finish);
|
||||
stream.pipe(pipe);
|
||||
pipe.pipe(stream);
|
||||
} catch (error) {
|
||||
sshLogger.warn("A relay could not reach its host", {
|
||||
operation: "ssh_relay_open",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Mounts the relay once, for servers that other Termix servers manage. */
|
||||
export function registerSshRelay(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerCoreWsRoute("/ssh-relay", handleRelay);
|
||||
}
|
||||
@@ -1041,6 +1041,31 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
},
|
||||
},
|
||||
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
const { createCurrentHostFolderRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentHostFolderRepository().upsertMetadata(
|
||||
userId,
|
||||
name,
|
||||
style?.color ?? null,
|
||||
style?.icon ?? null,
|
||||
);
|
||||
},
|
||||
removeEmpty: async (name) => {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
const { createCurrentHostFolderRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentHostFolderRepository().deleteEmptyFolderRecords(
|
||||
userId,
|
||||
name,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
activity: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("hosts:activity-provide")) {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
registerSecondFactor,
|
||||
} from "../auth/registry.js";
|
||||
import { provideAuthPolicy } from "../auth/policy.js";
|
||||
import { provideSshTransport } from "../hosts/connect/transport-providers.js";
|
||||
import type { VerifiedIdentity } from "../auth/types.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
@@ -296,6 +297,26 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
}`;
|
||||
|
||||
return {
|
||||
transports: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("ssh:transport")) {
|
||||
throw capabilityRefused(pluginId, "ssh:transport");
|
||||
}
|
||||
const remove = provideSshTransport(pluginId, provider);
|
||||
void audit(
|
||||
"ssh_transport_provide",
|
||||
`${pluginId} carries host connections`,
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
);
|
||||
const drop = bag.add(remove, "ssh transport");
|
||||
return () => {
|
||||
drop();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
probe: async (hostId, probeOptions) => {
|
||||
await assertCapability(pluginId, "ssh:connect", declared);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) return false;
|
||||
|
||||
@@ -54,6 +54,8 @@ interface Route {
|
||||
rawHandler?: RawUpgradeHandler;
|
||||
/** Sockets handed to rawHandler, destroyed when the route goes away. */
|
||||
rawSockets?: Set<Duplex>;
|
||||
/** Core's own socket: served under /plugin-ws/core/, no plugin grant. */
|
||||
core?: boolean;
|
||||
}
|
||||
|
||||
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
|
||||
@@ -163,7 +165,10 @@ async function handlePluginUpgrade(
|
||||
// checked per upgrade rather than at registration, and a revoke takes effect
|
||||
// on the next connection without a restart.
|
||||
const { hasCapability } = await import("./permissions.js");
|
||||
if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
|
||||
if (
|
||||
!route.core &&
|
||||
!(await hasCapability(route.pluginId, "network:serve", route.declared))
|
||||
) {
|
||||
reject(socket, 403, "Forbidden");
|
||||
return true;
|
||||
}
|
||||
@@ -303,6 +308,32 @@ export function registerPluginWsRoute(
|
||||
return () => disposeRoute(routeKey, pluginId, normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket core itself serves, at /plugin-ws/core/<path>, so it rides the same
|
||||
* proxy rules and token checks as plugin sockets. Authenticated like them.
|
||||
*/
|
||||
export function registerCoreWsRoute(
|
||||
path: string,
|
||||
handler: PluginWebSocketHandler,
|
||||
): () => void {
|
||||
const routeKey = key("core", path);
|
||||
const normalized = normalizePath(path);
|
||||
if (routes.has(routeKey)) return () => {};
|
||||
routes.set(routeKey, {
|
||||
pluginId: "core",
|
||||
path: normalized,
|
||||
options: {},
|
||||
declared: [],
|
||||
handler,
|
||||
core: true,
|
||||
});
|
||||
servers.set(
|
||||
routeKey,
|
||||
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
|
||||
);
|
||||
return () => disposeRoute(routeKey, "core", normalized);
|
||||
}
|
||||
|
||||
export function registerPluginWsUpgrade(
|
||||
pluginId: string,
|
||||
path: string,
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,8 @@ const h = vi.hoisted(() => ({
|
||||
existingAccess: [] as Array<{ id: number }>,
|
||||
revoked: [] as number[],
|
||||
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
|
||||
folderCalls: [] as Array<unknown[]>,
|
||||
folderEmpty: true,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -96,6 +98,15 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => h.users,
|
||||
}),
|
||||
createCurrentHostFolderRepository: () => ({
|
||||
upsertMetadata: async (...args: unknown[]) => {
|
||||
h.folderCalls.push(["ensure", ...args]);
|
||||
},
|
||||
deleteEmptyFolderRecords: async (...args: unknown[]) => {
|
||||
h.folderCalls.push(["remove", ...args]);
|
||||
return h.folderEmpty;
|
||||
},
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
@@ -961,3 +972,45 @@ describe("ctx.hosts.locks", () => {
|
||||
expect(h.updates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.folders", () => {
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.actor = "user-1";
|
||||
h.folderCalls = [];
|
||||
h.folderEmpty = true;
|
||||
});
|
||||
|
||||
it("needs hosts:write", async () => {
|
||||
h.granted = new Set();
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.folders.ensure("Site")).rejects.toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the folder row for the acting user with its style", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await hosts.folders.ensure("Site", { icon: "cloud-cog" });
|
||||
expect(h.folderCalls).toEqual([
|
||||
["ensure", "user-1", "Site", null, "cloud-cog"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes only an empty folder", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.folders.removeEmpty("Site")).toBe(true);
|
||||
h.folderEmpty = false;
|
||||
expect(await hosts.folders.removeEmpty("Site")).toBe(false);
|
||||
expect(h.folderCalls[0]).toEqual(["remove", "user-1", "Site"]);
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,26 @@ describe("ctx.ws routing", () => {
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("serves a socket core owns without any plugin grant", async () => {
|
||||
state.granted = new Set();
|
||||
const seen: string[] = [];
|
||||
ws.registerCoreWsRoute("/relay", ({ userId }) => {
|
||||
seen.push(userId);
|
||||
});
|
||||
|
||||
const base = await startServer();
|
||||
const signedIn = await connect(
|
||||
`${base}/plugin-ws/core/relay`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
const anonymous = await connect(`${base}/plugin-ws/core/relay`);
|
||||
|
||||
expect(signedIn.outcome).toBe("open");
|
||||
expect(anonymous.outcome).toBe(401);
|
||||
expect(seen).toEqual([state.userId]);
|
||||
if (signedIn.outcome === "open") signedIn.socket.close();
|
||||
});
|
||||
|
||||
it("404s a path nothing registered", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
|
||||
@@ -1143,6 +1143,10 @@
|
||||
"title": "Set sign in rules",
|
||||
"consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Choose how hosts are reached",
|
||||
"consequence": "It can carry the SSH connection to a host over its own route, so it sees everything sent to and from those hosts, passwords included."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Run sync",
|
||||
"consequence": "It can answer all sync requests, so it sees the data that devices and servers sync."
|
||||
|
||||
Reference in new issue
Block a user