mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
@@ -21,7 +21,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
|
||||
// Exactly the tables whose unreachable re-creation was deleted.
|
||||
const TABLES = [
|
||||
"c2s_tunnel_presets",
|
||||
"sessions",
|
||||
"trusted_devices",
|
||||
"host_access",
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { C2sTunnelPresetRepository } from "../../../database/repositories/c2s-tunnel-preset-repository.js";
|
||||
|
||||
describe("C2sTunnelPresetRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<C2sTunnelPresetRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new C2sTunnelPresetRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists presets ordered by name", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.createForUser("user-1", {
|
||||
name: "Zulu",
|
||||
config: "[]",
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
});
|
||||
await repo.createForUser("user-1", { name: "Alpha", config: "[]" });
|
||||
await repo.createForUser("user-2", { name: "Other", config: "[]" });
|
||||
|
||||
const presets = await repo.listByUserId("user-1");
|
||||
|
||||
expect(presets.map((preset) => preset.name)).toEqual(["Alpha", "Zulu"]);
|
||||
expect(presets[1]).toMatchObject({
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds, updates, and deletes user-owned presets", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const preset = await repo.createForUser("user-1", {
|
||||
name: "Home",
|
||||
config: "[]",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", preset.id)).toBeNull();
|
||||
expect(await repo.hasNameForUser("user-1", "Home")).toBe(true);
|
||||
expect(await repo.hasNameForUser("user-1", "Home", preset.id)).toBe(false);
|
||||
|
||||
const updated = await repo.updateForUser("user-1", preset.id, {
|
||||
name: "Renamed",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: preset.id,
|
||||
name: "Renamed",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(
|
||||
await repo.updateForUser("user-2", preset.id, { name: "Nope" }),
|
||||
).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", preset.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", preset.id)).toBe(true);
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all presets for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.createForUser("user-1", { name: "One", config: "[]" });
|
||||
await repo.createForUser("user-1", { name: "Two", config: "[]" });
|
||||
await repo.createForUser("user-2", { name: "Other", config: "[]" });
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserId("user-1")).toEqual([]);
|
||||
expect(
|
||||
(await repo.listByUserId("user-2")).map((preset) => preset.name),
|
||||
).toEqual(["Other"]);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -521,6 +521,30 @@ describe("connectHost", () => {
|
||||
).toEqual({ proxied: true });
|
||||
});
|
||||
|
||||
it("connects over a stream it is given and skips the transport", async () => {
|
||||
const connection = await connectHost(
|
||||
host({
|
||||
jumpHosts: [{ hostId: 2 }],
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
portKnockSequence: [{ port: 7000 }],
|
||||
}),
|
||||
{
|
||||
userId: "user-1",
|
||||
purpose: "tunnel",
|
||||
sock: { throughSource: true } as never,
|
||||
},
|
||||
);
|
||||
const config = (connection.client as unknown as FakeSshClient)
|
||||
.connectConfig!;
|
||||
expect(config.sock).toEqual({ throughSource: true });
|
||||
expect(config.hostVerifier).toBe(mocks.verifier);
|
||||
expect(mocks.createJumpHostChain).not.toHaveBeenCalled();
|
||||
expect(mocks.createSocks5Connection).not.toHaveBeenCalled();
|
||||
expect(mocks.performPortKnocking).not.toHaveBeenCalled();
|
||||
expect(connection.jumpClient).toBeNull();
|
||||
});
|
||||
|
||||
it("knocks before connecting when the host has a sequence", async () => {
|
||||
await connectHost(
|
||||
host({ portKnockSequence: [{ port: 7000 }, { port: 8000 }] }),
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const canAccessHost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: { getInstance: () => ({ canAccessHost }) },
|
||||
}));
|
||||
|
||||
/**
|
||||
* /ssh/tunnel/disconnect and /ssh/tunnel/cancel are addressed by tunnel NAME.
|
||||
* Their ownership check used to run only inside
|
||||
* `if (config && config.sourceHostId)`, and web endpoint tunnels are
|
||||
* deliberately kept out of tunnelConfigs -- so `config` was always undefined
|
||||
* for them and the check never ran.
|
||||
*
|
||||
* Host ids are small sequential integers and endpoint ids are client-supplied
|
||||
* with no server-side randomness, so `web:{hostId}:{endpointId}` is guessable.
|
||||
* That let any authenticated user force-close another user's live tunnel, and
|
||||
* manualDisconnects then made the victim's own re-open fail for several
|
||||
* seconds.
|
||||
*/
|
||||
describe("authorizeTunnelAction", () => {
|
||||
beforeEach(() => {
|
||||
canAccessHost.mockReset();
|
||||
});
|
||||
|
||||
it("checks the host encoded in a reserved name when no config exists", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: true });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
const result = await authorizeTunnelAction("u1", "web:7:e1", undefined);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(canAccessHost).toHaveBeenCalledWith("u1", 7, "connect");
|
||||
});
|
||||
|
||||
it("denies a reserved name whose host the user cannot access", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: false });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
expect(
|
||||
(await authorizeTunnelAction("u1", "web:7:e1", undefined)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on a reserved name it cannot parse", async () => {
|
||||
// Never fall through to the unchecked path: an unparseable reserved name
|
||||
// means "cannot verify ownership", which must deny.
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
for (const name of ["web:", "web:abc:e1", "web:0:e1"]) {
|
||||
expect((await authorizeTunnelAction("u1", name, undefined)).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
expect(canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still checks a registered config's host id", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: true });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
await authorizeTunnelAction("u1", "my-tunnel", { sourceHostId: 42 });
|
||||
expect(canAccessHost).toHaveBeenCalledWith("u1", 42, "connect");
|
||||
});
|
||||
|
||||
it("leaves an ordinary unregistered name allowed, as before", async () => {
|
||||
// Pre-existing behaviour for non-reserved names, preserved deliberately:
|
||||
// tightening it is a separate change with its own blast radius.
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
expect(
|
||||
(await authorizeTunnelAction("u1", "my-tunnel", undefined)).allowed,
|
||||
).toBe(true);
|
||||
expect(canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RESERVED_TUNNEL_NAME_PREFIX,
|
||||
buildWebEndpointTunnelName,
|
||||
isReservedTunnelName,
|
||||
parseReservedTunnelName,
|
||||
} from "../../hosts/tunnel/utils.js";
|
||||
|
||||
describe("reserved tunnel names", () => {
|
||||
it("builds a name from a host id and endpoint id", () => {
|
||||
expect(buildWebEndpointTunnelName(7, "e1")).toBe("web:7:e1");
|
||||
});
|
||||
|
||||
it("recognises its own names and nothing else", () => {
|
||||
expect(isReservedTunnelName(buildWebEndpointTunnelName(7, "e1"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isReservedTunnelName("my-tunnel")).toBe(false);
|
||||
expect(isReservedTunnelName("website")).toBe(false);
|
||||
expect(isReservedTunnelName(RESERVED_TUNNEL_NAME_PREFIX)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips through parse", () => {
|
||||
expect(
|
||||
parseReservedTunnelName(buildWebEndpointTunnelName(7, "e1")),
|
||||
).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an endpoint id containing colons intact", () => {
|
||||
// Endpoint ids are client-supplied strings with no character restriction,
|
||||
// so splitting on the LAST colon would corrupt them. The host id segment
|
||||
// is digits only, so splitting on the first colon after it is safe.
|
||||
expect(parseReservedTunnelName("web:7:a:b:c")).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "a:b:c",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for anything it cannot verify", () => {
|
||||
// Callers use this for an ownership check, so null must mean "cannot
|
||||
// verify" and they must fail closed. Never return a partial guess.
|
||||
for (const name of [
|
||||
"my-tunnel",
|
||||
"web:",
|
||||
"web:7",
|
||||
"web::e1",
|
||||
"web:abc:e1",
|
||||
"web:-1:e1",
|
||||
"web:0:e1",
|
||||
"web:7:",
|
||||
"web:7.5:e1",
|
||||
]) {
|
||||
expect(parseReservedTunnelName(name)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveC2SSourceHostId } from "../../../hosts/tunnel/c2s-relay.js";
|
||||
|
||||
describe("resolveC2SSourceHostId", () => {
|
||||
it("uses the remote row matching the stable sync id", async () => {
|
||||
const findBySyncId = vi.fn().mockResolvedValue(42);
|
||||
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "host-sync-id" },
|
||||
findBySyncId,
|
||||
),
|
||||
).resolves.toBe(42);
|
||||
expect(findBySyncId).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
it("keeps legacy local ids when no sync id is available", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId({ sourceHostId: 7 }, vi.fn()),
|
||||
).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it("does not silently fall back to a mismatched id", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "missing" },
|
||||
vi.fn().mockResolvedValue(null),
|
||||
),
|
||||
).rejects.toThrow("not found on the remote server");
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,27 @@ describe("ctx.ssh", () => {
|
||||
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("passes a given stream through to the pipeline, gated like any connect", async () => {
|
||||
const stream = { throughSource: true };
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
await expect(ssh.connect(7, { sock: stream })).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.connects).toEqual([]);
|
||||
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
await ssh.connect(7, { purpose: "tunnel", sock: stream });
|
||||
expect(h.connects[0].options).toMatchObject({
|
||||
purpose: "tunnel",
|
||||
sock: stream,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes open connections when the plugin is disposed", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The tunnel host-columns migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host with tunnels on and a saved tunnel list
|
||||
* must keep both once the ssh_data columns are dropped. Running it twice
|
||||
* must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_tunnel: boolean;
|
||||
tunnel_connections: string | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) => row.enable_tunnel || row.tunnel_connections !== null,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runTunnelsSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/tunnels-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
const tunnel = {
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
sourcePort: 8080,
|
||||
endpointHost: "db",
|
||||
endpointPort: 5432,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runTunnelsSettingsMigration", () => {
|
||||
it("moves a host's switch and saved tunnels into plugin settings", async () => {
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_tunnel: true,
|
||||
tunnel_connections: JSON.stringify([tunnel]),
|
||||
});
|
||||
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableTunnel")).toBe(true);
|
||||
expect(storedValue(1, "tunnelConnections")).toEqual([tunnel]);
|
||||
expect(settingsRows.every((row) => row.pluginId === "tunnels")).toBe(true);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a disabled host's saved tunnels and its disabled switch", async () => {
|
||||
hostRows.push({
|
||||
id: 3,
|
||||
enable_tunnel: false,
|
||||
tunnel_connections: JSON.stringify([tunnel]),
|
||||
});
|
||||
|
||||
await runTunnelsSettingsMigration();
|
||||
|
||||
expect(storedValue(3, "enableTunnel")).toBe(false);
|
||||
expect(storedValue(3, "tunnelConnections")).toEqual([tunnel]);
|
||||
});
|
||||
|
||||
it("stores an unreadable tunnel list as empty rather than failing", async () => {
|
||||
hostRows.push({ id: 4, enable_tunnel: true, tunnel_connections: "{nope" });
|
||||
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(4, "tunnelConnections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_tunnel: true, tunnel_connections: null });
|
||||
|
||||
await runTunnelsSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({
|
||||
id: 5,
|
||||
enable_tunnel: true,
|
||||
tunnel_connections: JSON.stringify([tunnel]),
|
||||
});
|
||||
await pluginSettingsRepository.set(
|
||||
"tunnels",
|
||||
"host",
|
||||
"5",
|
||||
"enableTunnel",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableTunnel")).toBe(false);
|
||||
expect(storedValue(5, "tunnelConnections")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user