mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: store rdp, vnc and telnet logins in a generic per-protocol table
This commit is contained in:
1 parent
e0c30a4e6f
commit
bba06f7ff6
107 files changed
+18750
-1224
No files matched your search
@@ -0,0 +1,178 @@
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import {
|
||||
HostProtocolAuthRepository,
|
||||
decryptProtocolLogin,
|
||||
protocolAuthRecordId,
|
||||
type HostProtocolLogin,
|
||||
} from "../../../database/repositories/host-protocol-auth-repository.js";
|
||||
import { FieldCrypto } from "../../../utils/field-crypto.js";
|
||||
|
||||
const ownerKey = crypto.randomBytes(32);
|
||||
const otherKey = crypto.randomBytes(32);
|
||||
|
||||
function login(overrides: Partial<HostProtocolLogin> = {}): HostProtocolLogin {
|
||||
return {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t-secret" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HostProtocolAuthRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
let writes = 0;
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.close();
|
||||
adapter = null;
|
||||
});
|
||||
|
||||
async function createRepository(): Promise<HostProtocolAuthRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('owner-1', 'owner-1', 'hash'),
|
||||
('owner-2', 'owner-2', 'hash');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(5, 'owner-1', 'cred-5', 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
|
||||
(42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 'password'),
|
||||
(43, 'owner-1', 'stage', '10.0.0.43', 22, 'root', 'password'),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 'password');
|
||||
`);
|
||||
writes = 0;
|
||||
return new HostProtocolAuthRepository(context, () => {
|
||||
writes++;
|
||||
});
|
||||
}
|
||||
|
||||
it("round-trips a login with its secrets encrypted at rest", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
|
||||
const row = await repository.findRow(42, "spice");
|
||||
expect(row).toMatchObject({
|
||||
hostId: 42,
|
||||
userId: "owner-1",
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
fields: JSON.stringify({ display: "2" }),
|
||||
});
|
||||
expect(FieldCrypto.isEncrypted(row!.password!)).toBe(true);
|
||||
expect(row!.password).not.toContain("spice-pass");
|
||||
expect(row!.secretFields).not.toContain("t-secret");
|
||||
expect(await repository.find(42, "spice", ownerKey)).toEqual(login());
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("updates in place, one row per host and protocol", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
42,
|
||||
login({ username: "other", password: null, secretFields: {} }),
|
||||
ownerKey,
|
||||
);
|
||||
|
||||
expect(await repository.listRowsForHost(42)).toHaveLength(1);
|
||||
expect(await repository.find(42, "spice", ownerKey)).toMatchObject({
|
||||
username: "other",
|
||||
password: null,
|
||||
secretFields: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("opens nothing with another user's key", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
|
||||
expect(await repository.find(42, "spice", otherKey)).toMatchObject({
|
||||
username: "viewer",
|
||||
password: null,
|
||||
secretFields: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a tampered secret as missing", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
const row = (await repository.findRow(42, "spice"))!;
|
||||
const envelope = JSON.parse(row.password!);
|
||||
envelope.data = envelope.data.replace(/^./, (c: string) =>
|
||||
c === "0" ? "1" : "0",
|
||||
);
|
||||
|
||||
expect(
|
||||
decryptProtocolLogin(
|
||||
{ ...row, password: JSON.stringify(envelope) },
|
||||
ownerKey,
|
||||
).password,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("binds each secret to its host and protocol", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
const row = (await repository.findRow(42, "spice"))!;
|
||||
|
||||
expect(JSON.parse(row.password!).recordId).toBe(
|
||||
protocolAuthRecordId(42, "spice"),
|
||||
);
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(row.password!, ownerKey, "", "secretFields"),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("finds the owner's hosts whose login uses a credential", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
42,
|
||||
login({ authType: "credential", credentialId: 5, password: null }),
|
||||
ownerKey,
|
||||
);
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
43,
|
||||
login({ protocol: "x2go" }),
|
||||
ownerKey,
|
||||
);
|
||||
|
||||
expect(await repository.listHostIdsForCredential("owner-1", 5)).toEqual([
|
||||
42,
|
||||
]);
|
||||
expect(await repository.listHostIdsForCredential("owner-2", 5)).toEqual([]);
|
||||
expect(
|
||||
(await repository.listRowsForUser("owner-1"))
|
||||
.map((row) => row.hostId)
|
||||
.sort(),
|
||||
).toEqual([42, 43]);
|
||||
});
|
||||
|
||||
it("deletes one protocol's login", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
42,
|
||||
login({ protocol: "x2go" }),
|
||||
ownerKey,
|
||||
);
|
||||
|
||||
expect(await repository.delete(42, "spice")).toBe(true);
|
||||
expect(await repository.delete(42, "spice")).toBe(false);
|
||||
expect(
|
||||
(await repository.listRowsForHost(42)).map((row) => row.protocol),
|
||||
).toEqual(["x2go"]);
|
||||
});
|
||||
});
|
||||
@@ -192,9 +192,6 @@ describe("HostResolutionRepository", () => {
|
||||
await expect(repository.findHostUpdateState(1)).resolves.toEqual({
|
||||
userId: "user-1",
|
||||
credentialId: 7,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
authType: "password",
|
||||
parentHostId: null,
|
||||
folder: null,
|
||||
|
||||
@@ -36,8 +36,8 @@ describe("RbacAccessRepository", () => {
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
|
||||
(44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password');
|
||||
INSERT INTO ssh_data (
|
||||
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password');
|
||||
id, user_id, name, ip, port, username, credential_id, folder, tags, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'servers', 'linux', 'password');
|
||||
INSERT INTO host_access (
|
||||
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
||||
)
|
||||
@@ -138,9 +138,6 @@ describe("RbacAccessRepository", () => {
|
||||
{
|
||||
hostAccessId: 2,
|
||||
credentialId: 123,
|
||||
rdpCredentialId: 124,
|
||||
vncCredentialId: 125,
|
||||
telnetCredentialId: 126,
|
||||
hostId: 42,
|
||||
hostOwnerId: "owner-1",
|
||||
},
|
||||
|
||||
@@ -31,10 +31,13 @@ describe("SharedHostSecretsRepository", () => {
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(123, 'user-1', 'cred-123', 'root', 'password'),
|
||||
(124, 'user-1', 'cred-124', 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'),
|
||||
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'password'),
|
||||
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, 'password'),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, 'password');
|
||||
INSERT INTO host_protocol_auth (host_id, user_id, protocol, auth_type, credential_id)
|
||||
VALUES (42, 'owner-1', 'spice', 'credential', 124),
|
||||
(43, 'owner-1', 'spice', 'direct', NULL);
|
||||
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
|
||||
VALUES
|
||||
(1, 42, 'user-1', NULL, 'owner-1'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
@@ -47,13 +48,13 @@ describe("containsOwnerPrivateAuthUpdate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps protocol field definitions isolated", () => {
|
||||
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "rdp")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "ssh")).toBe(
|
||||
false,
|
||||
);
|
||||
it("leaves plugin protocol logins to their own guard", () => {
|
||||
expect(
|
||||
containsOwnerPrivateAuthUpdate(
|
||||
{ protocolAuth: { spice: { credentialId: 7 } } },
|
||||
"ssh",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows shared editors to update non-authentication host settings", () => {
|
||||
@@ -171,11 +172,41 @@ describe("normalizeImportedHost", () => {
|
||||
expect(host.enableSsh).toBe(true);
|
||||
});
|
||||
|
||||
it("infers rdp from enableRdp and uses default rdp port", () => {
|
||||
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
|
||||
expect(host.connectionType).toBe("rdp");
|
||||
expect(host.port).toBe(3389);
|
||||
expect(host.enableSsh).toBe(false);
|
||||
describe("with a plugin protocol declared", () => {
|
||||
afterEach(() => setHostProtocolSource(() => []));
|
||||
|
||||
function declareSpice() {
|
||||
setHostProtocolSource(() => [
|
||||
{
|
||||
id: "spice",
|
||||
defaultPort: 5930,
|
||||
pluginId: "spice-plugin",
|
||||
pluginName: "Spice",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it("infers the protocol from its enable flag and uses its default port", () => {
|
||||
declareSpice();
|
||||
const host = normalizeImportedHost({ enableSpice: true, ip: "10.0.0.2" });
|
||||
expect(host.connectionType).toBe("spice");
|
||||
expect(host.port).toBe(5930);
|
||||
expect(host.enableSsh).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the protocol's own port field", () => {
|
||||
declareSpice();
|
||||
const host = normalizeImportedHost({
|
||||
connectionType: "spice",
|
||||
spicePort: 5999,
|
||||
});
|
||||
expect(host.port).toBe(5999);
|
||||
});
|
||||
|
||||
it("ignores the flag of a protocol nobody declares", () => {
|
||||
const host = normalizeImportedHost({ enableSpice: true });
|
||||
expect(host.connectionType).toBe("ssh");
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit port over protocol defaults", () => {
|
||||
@@ -267,26 +298,13 @@ describe("stripSensitiveFields", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips rdp/vnc/telnet passwords and adds their presence flags", () => {
|
||||
const result = stripSensitiveFields({
|
||||
name: "rdp-box",
|
||||
rdpPassword: "rdp-secret",
|
||||
vncPassword: "vnc-secret",
|
||||
telnetPassword: "telnet-secret",
|
||||
});
|
||||
expect(result.rdpPassword).toBeUndefined();
|
||||
expect(result.vncPassword).toBeUndefined();
|
||||
expect(result.telnetPassword).toBeUndefined();
|
||||
expect(result.hasRdpPassword).toBe(true);
|
||||
expect(result.hasVncPassword).toBe(true);
|
||||
expect(result.hasTelnetPassword).toBe(true);
|
||||
});
|
||||
|
||||
it("marks rdp/vnc/telnet presence flags false when absent", () => {
|
||||
const result = stripSensitiveFields({ name: "rdp-box" });
|
||||
expect(result.hasRdpPassword).toBe(false);
|
||||
expect(result.hasVncPassword).toBe(false);
|
||||
expect(result.hasTelnetPassword).toBe(false);
|
||||
it("leaves protocol login summaries alone for the owner", () => {
|
||||
const protocolAuth = {
|
||||
spice: { authType: "direct", hasPassword: true, secretFieldKeys: [] },
|
||||
};
|
||||
expect(stripSensitiveFields({ protocolAuth }).protocolAuth).toEqual(
|
||||
protocolAuth,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,8 +370,17 @@ describe("sanitizeHostForRecipient", () => {
|
||||
password: "hunter2",
|
||||
key: "PRIVATE",
|
||||
sudoPassword: "sudo",
|
||||
rdpPassword: "rdp",
|
||||
socks5Password: "socks",
|
||||
protocolAuth: {
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "0" },
|
||||
hasPassword: true,
|
||||
secretFieldKeys: ["ticket"],
|
||||
},
|
||||
},
|
||||
enableSsh: true,
|
||||
enableRdp: true,
|
||||
sshPort: 22,
|
||||
@@ -371,8 +398,15 @@ describe("sanitizeHostForRecipient", () => {
|
||||
expect(result.password).toBeUndefined();
|
||||
expect(result.key).toBeUndefined();
|
||||
expect(result.sudoPassword).toBeUndefined();
|
||||
expect(result.rdpPassword).toBeUndefined();
|
||||
expect(result.socks5Password).toBeUndefined();
|
||||
expect(result.protocolAuth).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "0" },
|
||||
},
|
||||
});
|
||||
expect(result.credentialId).toBeUndefined();
|
||||
expect(result.overrideCredentialUsername).toBeUndefined();
|
||||
expect(result.terminalConfig).toEqual({ theme: "termix" });
|
||||
@@ -404,7 +438,6 @@ describe("sanitizeHostForRecipient", () => {
|
||||
{
|
||||
...sharedHost,
|
||||
permissionLevel: "connect",
|
||||
rdpAuthType: "none",
|
||||
authOverrides: {
|
||||
ssh: {
|
||||
credentialId: 9,
|
||||
@@ -417,7 +450,7 @@ describe("sanitizeHostForRecipient", () => {
|
||||
);
|
||||
expect(result.name).toBe("prod");
|
||||
expect(result.ip).toBe("10.0.0.42");
|
||||
expect(result.rdpAuthType).toBe("none");
|
||||
expect(result.protocolAuth).toEqual({ spice: { authType: "direct" } });
|
||||
expect(result.permissionLevel).toBe("connect");
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
expect(result.authOverrides).toEqual({
|
||||
|
||||
@@ -263,12 +263,21 @@ describe("shared host authentication override routes", () => {
|
||||
expect(unauthenticatedResponse.status).toBe(401);
|
||||
});
|
||||
|
||||
it("serves every real protocol and rejects invalid protocol names", async () => {
|
||||
const rdpResponse = await invoke("get", {}, "rdp");
|
||||
expect(rdpResponse.status).toBe(200);
|
||||
expect(rdpResponse.body.protocol).toBe("rdp");
|
||||
it("serves any protocol a plugin declares and rejects the rest", async () => {
|
||||
const { setHostProtocolSource } =
|
||||
await import("../../../hosts/protocol-auth/registry.js");
|
||||
setHostProtocolSource(() => [
|
||||
{ id: "spice", pluginId: "spice-plugin", pluginName: "Spice" },
|
||||
]);
|
||||
const declared = await invoke("get", {}, "spice");
|
||||
setHostProtocolSource(() => []);
|
||||
expect(declared.status).toBe(200);
|
||||
expect(declared.body.protocol).toBe("spice");
|
||||
expect(state.writes).toEqual([]);
|
||||
|
||||
const undeclared = await invoke("get", {}, "spice");
|
||||
expect(undeclared.status).toBe(400);
|
||||
|
||||
const invalidResponse = await invoke("get", {}, "smtp");
|
||||
expect(invalidResponse).toEqual({
|
||||
status: 400,
|
||||
|
||||
Reference in new issue
Block a user