mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -1,97 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OPKSSHBinaryManager } from "../../utils/opkssh-binary-manager.js";
|
||||
|
||||
// Mirrors the manager's own platform/arch mapping so the test's fake release
|
||||
// asset matches whatever OS and architecture actually run the suite.
|
||||
const OS_MAP: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "osx",
|
||||
};
|
||||
const ARCH_MAP: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const mappedOs = OS_MAP[process.platform] ?? process.platform;
|
||||
const mappedArch = ARCH_MAP[process.arch] ?? process.arch;
|
||||
const binaryName =
|
||||
process.platform === "win32"
|
||||
? `opkssh-${mappedOs}-${mappedArch}.exe`
|
||||
: `opkssh-${mappedOs}-${mappedArch}`;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-opkssh-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.OPKSSH_VERSION = "v-test";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.OPKSSH_VERSION;
|
||||
delete process.env.OPKSSH_SHA256;
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function mockRelease(binary: Buffer): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
tag_name: "v-test",
|
||||
assets: [
|
||||
{ name: binaryName, browser_download_url: "https://asset.test" },
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(binary, { status: 200 })),
|
||||
);
|
||||
}
|
||||
|
||||
describe("OPKSSHBinaryManager download integrity", () => {
|
||||
it("installs a release only after its configured checksum matches", async () => {
|
||||
const binary = Buffer.from("verified-opkssh-binary");
|
||||
process.env.OPKSSH_SHA256 = crypto
|
||||
.createHash("sha256")
|
||||
.update(binary)
|
||||
.digest("hex");
|
||||
mockRelease(binary);
|
||||
|
||||
await OPKSSHBinaryManager.downloadBinary();
|
||||
|
||||
const installDir = path.join(dataDir, "opkssh");
|
||||
expect(await fs.readFile(path.join(installDir, binaryName))).toEqual(
|
||||
binary,
|
||||
);
|
||||
expect(
|
||||
await fs.readFile(path.join(installDir, "version.txt"), "utf8"),
|
||||
).toBe("v-test");
|
||||
expect(
|
||||
await fs.readFile(path.join(installDir, "checksum.txt"), "utf8"),
|
||||
).toBe(process.env.OPKSSH_SHA256);
|
||||
});
|
||||
|
||||
it("rejects a mismatched release without replacing the installed binary", async () => {
|
||||
const installDir = path.join(dataDir, "opkssh");
|
||||
const binaryPath = path.join(installDir, binaryName);
|
||||
await fs.mkdir(installDir, { recursive: true });
|
||||
await fs.writeFile(binaryPath, "known-good");
|
||||
process.env.OPKSSH_SHA256 = "0".repeat(64);
|
||||
mockRelease(Buffer.from("tampered"));
|
||||
|
||||
await expect(OPKSSHBinaryManager.downloadBinary()).rejects.toThrow(
|
||||
"checksum mismatch",
|
||||
);
|
||||
expect(await fs.readFile(binaryPath, "utf8")).toBe("known-good");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The OPKSSH config move. A 2.8 install's config must reach the opkssh
|
||||
* plugin's folder, the old file must stay for a downgrade, and the install
|
||||
* must keep the redirect URI its identity providers know. Running it twice
|
||||
* must not overwrite anything.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string | null>(),
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "opkssh" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (_p: string, _s: string, _id: string | null, key: string) =>
|
||||
state.settings.has(key) ? { key, value: state.settings.get(key) } : null,
|
||||
set: async (
|
||||
_p: string,
|
||||
_s: string,
|
||||
_id: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js";
|
||||
|
||||
let dataDir: string;
|
||||
const legacy = () => path.join(dataDir, ".opk", "config.yml");
|
||||
const target = () => path.join(dataDir, "plugins", "opkssh", "config.yml");
|
||||
|
||||
beforeEach(async () => {
|
||||
state.settings = new Map();
|
||||
state.pluginInstalled = true;
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-config-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("runOpksshConfigMigration", () => {
|
||||
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
|
||||
await fs.mkdir(path.dirname(legacy()), { recursive: true });
|
||||
await fs.writeFile(legacy(), "providers:\n - alias: google\n");
|
||||
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: true,
|
||||
legacyCallback: true,
|
||||
});
|
||||
expect(await fs.readFile(target(), "utf8")).toContain("alias: google");
|
||||
expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google");
|
||||
expect(state.settings.get("legacyCallback")).toBe("true");
|
||||
|
||||
await fs.writeFile(target(), "providers:\n - alias: edited\n");
|
||||
state.settings.set("legacyCallback", "false");
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: false,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(await fs.readFile(target(), "utf8")).toContain("alias: edited");
|
||||
expect(state.settings.get("legacyCallback")).toBe("false");
|
||||
});
|
||||
|
||||
it("does nothing on an install that never set up OPKSSH", async () => {
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: false,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(state.settings.size).toBe(0);
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
await fs.mkdir(path.dirname(legacy()), { recursive: true });
|
||||
await fs.writeFile(legacy(), "providers: []\n");
|
||||
await runOpksshConfigMigration(dataDir);
|
||||
await expect(fs.access(target())).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The Warpgate host settings migration.
|
||||
*
|
||||
* Every host with use_warpgate on, whatever shape the engine returned it in,
|
||||
* and every pre-2.7 host still on auth type "warpgate", must come through
|
||||
* into the warpgate plugin's useWarpgate host setting. Running it twice must
|
||||
* change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
statements: [] as string[],
|
||||
pluginInstalled: true,
|
||||
columnGone: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "warpgate" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
state.pluginRows.find(
|
||||
(row) =>
|
||||
row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: { queryChunks?: unknown[] }) => {
|
||||
const text = JSON.stringify(query);
|
||||
if (text.includes("use_warpgate") && state.columnGone) {
|
||||
throw new Error("no such column: use_warpgate");
|
||||
}
|
||||
return state.hostRows.filter(
|
||||
(row) => !text.includes("WHERE") || row.auth_type === "warpgate",
|
||||
);
|
||||
},
|
||||
runStatement: async (query: unknown) => {
|
||||
const text = JSON.stringify(query);
|
||||
state.statements.push(text);
|
||||
const id = (query as { queryChunks: unknown[] }).queryChunks.find(
|
||||
(chunk) => typeof chunk === "number",
|
||||
);
|
||||
const row = state.hostRows.find((candidate) => candidate.id === id);
|
||||
if (row) row.auth_type = "none";
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
runWarpgateSettingsMigration,
|
||||
usesWarpgate,
|
||||
} from "../../utils/crypto-migration/warpgate-settings-migration.js";
|
||||
|
||||
const enabled = (hostId: number) =>
|
||||
state.pluginRows.some(
|
||||
(row) =>
|
||||
row.scopeId === String(hostId) &&
|
||||
row.key === "useWarpgate" &&
|
||||
row.value === "true",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
state.pluginRows = [];
|
||||
state.hostRows = [];
|
||||
state.statements = [];
|
||||
state.pluginInstalled = true;
|
||||
state.columnGone = false;
|
||||
});
|
||||
|
||||
describe("usesWarpgate", () => {
|
||||
it("reads the flag in every shape the engines return", () => {
|
||||
for (const flag of [true, 1, "1", "true"]) {
|
||||
expect(
|
||||
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
|
||||
).toBe(true);
|
||||
}
|
||||
for (const flag of [false, 0, "0", null]) {
|
||||
expect(
|
||||
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(usesWarpgate({ id: 1, auth_type: "warpgate" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWarpgateSettingsMigration", () => {
|
||||
it("moves every flagged host and fixes the old auth type, and a second run changes nothing", async () => {
|
||||
state.hostRows = [
|
||||
{ id: 1, use_warpgate: 1, auth_type: "password" },
|
||||
{ id: 2, use_warpgate: 0, auth_type: "key" },
|
||||
{ id: 3, use_warpgate: true, auth_type: "none" },
|
||||
{ id: 4, use_warpgate: 0, auth_type: "warpgate" },
|
||||
];
|
||||
|
||||
const first = await runWarpgateSettingsMigration();
|
||||
expect(first).toEqual({
|
||||
hostsMoved: 3,
|
||||
hostsSkipped: 0,
|
||||
authTypesFixed: 1,
|
||||
});
|
||||
expect(enabled(1)).toBe(true);
|
||||
expect(enabled(2)).toBe(false);
|
||||
expect(enabled(3)).toBe(true);
|
||||
expect(enabled(4)).toBe(true);
|
||||
expect(state.hostRows[3].auth_type).toBe("none");
|
||||
|
||||
const rows = state.pluginRows.length;
|
||||
const second = await runWarpgateSettingsMigration();
|
||||
// Host 4 is on auth type "none" now, so only the flagged two are seen.
|
||||
expect(second).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 2,
|
||||
authTypesFixed: 0,
|
||||
});
|
||||
expect(state.pluginRows).toHaveLength(rows);
|
||||
});
|
||||
|
||||
it("still fixes old auth types on an install without the column", async () => {
|
||||
state.columnGone = true;
|
||||
state.hostRows = [{ id: 5, auth_type: "warpgate" }];
|
||||
await runWarpgateSettingsMigration();
|
||||
expect(enabled(5)).toBe(true);
|
||||
expect(state.hostRows[0].auth_type).toBe("none");
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.hostRows = [{ id: 1, use_warpgate: 1, auth_type: "password" }];
|
||||
await runWarpgateSettingsMigration();
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user