feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

+3
View File
@@ -243,6 +243,9 @@ export async function runLogin(
const { runNotificationChannelMigration } =
await import("../upgrade/notification-channel-migration.js");
await runNotificationChannelMigration(user.id);
const { runProtocolAuthMigration } =
await import("../upgrade/protocol-auth-migration.js");
await runProtocolAuthMigration(user.id);
}
await syncSharedCredentialsForUserRoles(
user.id,
+6
View File
@@ -47,6 +47,12 @@ export async function runCoreBootMigrations(): Promise<void> {
await AuthManager.getInstance().initialize();
DataCrypto.initialize();
// Before anything snapshots shared secrets, which would drop a protocol
// snapshot whose login has not been copied yet.
const { runProtocolAuthMigration } =
await import("./upgrade/protocol-auth-migration.js");
await runProtocolAuthMigration();
const { runLegacySharedSshAuthOptInMigration } =
await import("./utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.js");
await runLegacySharedSshAuthOptInMigration();
+92 -4
View File
@@ -64,6 +64,13 @@ import {
} from "./repositories/factory.js";
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
import {
keepUsableProtocolCredentials,
listProtocolLogins,
readProtocolAuthPayload,
toPortableLogins,
writeProtocolAuth,
} from "../hosts/protocol-auth/protocol-auth.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import type { GitHubRelease, AuthenticatedRequest } from "../../types/index.js";
import { DatabaseSaveTrigger } from "./db/index.js";
@@ -732,7 +739,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
socks5_username TEXT,
socks5_password TEXT,
socks5_proxy_chain TEXT,
domain TEXT,
port_knock_sequence TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -745,6 +751,16 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
value TEXT
);
CREATE TABLE host_protocol_auth (
host_id INTEGER NOT NULL,
protocol TEXT NOT NULL,
auth_type TEXT NOT NULL,
credential_id INTEGER,
username TEXT,
password TEXT,
fields TEXT
);
CREATE TABLE ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -801,8 +817,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -842,7 +858,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.socks5Username || null,
decrypted.socks5Password || null,
decrypted.socks5ProxyChain || null,
decrypted.domain || null,
decrypted.portKnockSequence || null,
decrypted.createdAt,
decrypted.updatedAt,
@@ -869,6 +884,27 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
// Plugin protocol logins, decrypted like the host's own secrets.
const insertProtocolAuth = exportDb.prepare(
"INSERT INTO host_protocol_auth (host_id, protocol, auth_type, credential_id, username, password, fields) VALUES (?, ?, ?, ?, ?, ?, ?)",
);
for (const host of sshHosts) {
const logins = toPortableLogins(
await listProtocolLogins(host.id as number, userId),
);
for (const [protocol, login] of Object.entries(logins)) {
insertProtocolAuth.run(
host.id,
protocol,
login.authType,
login.credentialId,
login.username,
login.password,
JSON.stringify(login.fields),
);
}
}
const credentials =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const insertCred = exportDb.prepare(`
@@ -1178,6 +1214,12 @@ app.post(
Number(created.id),
importedHostPluginSettings(importDb, host),
);
await importHostProtocolLogins(
importDb,
host,
Number(created.id),
userId,
);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
@@ -1861,6 +1903,52 @@ function legacyStatusCheck(row: Record<string, unknown>): {
};
}
/**
* A host's plugin protocol logins from an export. The file's credential ids
* belong to the server that wrote it, so a login keeps one only when it is
* a credential the importing user can use.
*/
async function importHostProtocolLogins(
importDb: Database.Database,
host: Record<string, unknown>,
hostId: number,
userId: string,
): Promise<void> {
let rows: Array<Record<string, unknown>>;
try {
rows = importDb
.prepare("SELECT * FROM host_protocol_auth WHERE host_id = ?")
.all(host.id) as Array<Record<string, unknown>>;
} catch {
// An export from before 2.9.0 has no protocol logins.
return;
}
const protocolAuth: Record<string, unknown> = {};
for (const row of rows) {
let fields: unknown = {};
try {
fields = JSON.parse(String(row.fields ?? "{}"));
} catch {
fields = {};
}
protocolAuth[String(row.protocol)] = {
authType: row.auth_type,
credentialId: row.credential_id,
username: row.username,
password: row.password,
fields,
};
}
const patch = readProtocolAuthPayload({ protocolAuth });
if (!patch) return;
await writeProtocolAuth(
userId,
hostId,
await keepUsableProtocolCredentials(patch, userId),
{ isOwner: true },
);
}
/**
* What an exported host carries for plugins: the plugin_settings rows a 2.9
* export writes, plus the whole row in camelCase so a plugin's import
+32 -47
View File
@@ -1185,7 +1185,6 @@ const migrateSchema = () => {
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
{ column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" },
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
@@ -1196,19 +1195,6 @@ const migrateSchema = () => {
{ column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
{ column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" },
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
{ column: "rdp_user", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_user TEXT" },
{ column: "rdp_password", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_password TEXT" },
{ column: "rdp_domain", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_domain TEXT" },
{ column: "vnc_password", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_password TEXT" },
{ column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" },
{ column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" },
{ column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" },
{ column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
];
@@ -1262,39 +1248,6 @@ const migrateSchema = () => {
});
}
// Copy unencrypted username/domain into protocol-specific columns for old guac hosts.
// Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
const usernameDomainBackfills = [
{
protocol: "rdp",
sql: "UPDATE ssh_data SET rdp_user = username, rdp_password = password, rdp_domain = domain WHERE connection_type = 'rdp' AND rdp_user IS NULL AND rdp_password IS NULL",
},
{
protocol: "vnc",
sql: "UPDATE ssh_data SET vnc_user = username, vnc_password = password WHERE connection_type = 'vnc' AND vnc_user IS NULL AND vnc_password IS NULL",
},
{
protocol: "telnet",
sql: "UPDATE ssh_data SET telnet_user = username, telnet_password = password WHERE connection_type = 'telnet' AND telnet_user IS NULL AND telnet_password IS NULL",
},
];
for (const backfill of usernameDomainBackfills) {
try {
const result = sqlite.prepare(backfill.sql).run();
if (result.changes > 0) {
databaseLogger.info(
`Backfilled ${result.changes} ${backfill.protocol} host credential(s)`,
{ operation: "guac_credential_backfill" },
);
}
} catch (e) {
databaseLogger.warn(`Failed to backfill ${backfill.protocol} host credentials`, {
operation: "guac_credential_backfill",
error: e,
});
}
}
try {
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
} catch {
@@ -1330,6 +1283,38 @@ const migrateSchema = () => {
}
}
addColumnIfNotExists("shared_host_secrets", "encrypted_fields", "TEXT");
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS host_protocol_auth (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
protocol TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'direct',
credential_id INTEGER,
username TEXT,
password TEXT,
fields TEXT,
secret_fields TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_protocol_auth_host_protocol ON host_protocol_auth (host_id, protocol);
CREATE INDEX IF NOT EXISTS idx_host_protocol_auth_user ON host_protocol_auth (user_id);
CREATE INDEX IF NOT EXISTS idx_host_protocol_auth_credential ON host_protocol_auth (credential_id);
`);
} catch (createError) {
databaseLogger.warn("Failed to create host_protocol_auth table", {
operation: "schema_migration",
error: createError,
});
}
try {
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
sqlite.exec(
+42 -20
View File
@@ -210,25 +210,6 @@ export const hosts = mysqlTable(
sshPort: int("ssh_port").default(22),
rdpCredentialId: int("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpUser: text("rdp_user"),
rdpPassword: text("rdp_password"),
rdpDomain: text("rdp_domain"),
vncCredentialId: int("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
vncPassword: text("vnc_password"),
vncUser: text("vnc_user"),
telnetUser: text("telnet_user"),
telnetPassword: text("telnet_password"),
telnetCredentialId: int("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpAuthType: text("rdp_auth_type"),
vncAuthType: text("vnc_auth_type"),
telnetAuthType: text("telnet_auth_type"),
domain: text("domain"),
useSocks5: boolean("use_socks5"),
socks5Host: text("socks5_host"),
socks5Port: int("socks5_port"),
@@ -239,7 +220,7 @@ export const hosts = mysqlTable(
// null = use the desktop app's global default; "local" | "remote" pins
// this specific host's SSH/Docker-console/Serial connections to originate
// from the embedded local backend or a connected remote sync server.
// Ignored for rdp/vnc/telnet, which always require the remote server.
// Ignored for plugin protocols, which always need the remote server.
connectionOrigin: text("connection_origin"),
portKnockSequence: text("port_knock_sequence"),
@@ -346,6 +327,45 @@ export const sshCredentialUsage = mysqlTable(
],
);
// A host's login for a protocol a plugin declares (contributes.protocols),
// owned and encrypted like the host itself. `fields` holds the protocol's
// non-secret declared fields as JSON, `secret_fields` its secret ones.
export const hostProtocolAuth = mysqlTable(
"host_protocol_auth",
{
id: int("id").autoincrement().primaryKey(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
protocol: varchar("protocol", { length: 255 }).notNull(),
authType: text("auth_type").notNull().default("direct"),
credentialId: int("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
username: text("username"),
password: text("password"),
fields: text("fields"),
secretFields: text("secret_fields"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_host_protocol_auth_host_protocol").on(
table.hostId,
table.protocol,
),
index("idx_host_protocol_auth_user").on(table.userId),
index("idx_host_protocol_auth_credential").on(table.credentialId),
],
);
export const sshFolders = mysqlTable(
"ssh_folders",
{
@@ -493,6 +513,8 @@ export const sharedHostSecrets = mysqlTable(
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
// A plugin protocol's declared credential fields, JSON, encrypted.
encryptedFields: text("encrypted_fields"),
createdAt: text("created_at")
.notNull()
+42 -20
View File
@@ -210,25 +210,6 @@ export const hosts = pgTable(
sshPort: integer("ssh_port").default(22),
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpUser: text("rdp_user"),
rdpPassword: text("rdp_password"),
rdpDomain: text("rdp_domain"),
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
vncPassword: text("vnc_password"),
vncUser: text("vnc_user"),
telnetUser: text("telnet_user"),
telnetPassword: text("telnet_password"),
telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpAuthType: text("rdp_auth_type"),
vncAuthType: text("vnc_auth_type"),
telnetAuthType: text("telnet_auth_type"),
domain: text("domain"),
useSocks5: boolean("use_socks5"),
socks5Host: text("socks5_host"),
socks5Port: integer("socks5_port"),
@@ -239,7 +220,7 @@ export const hosts = pgTable(
// null = use the desktop app's global default; "local" | "remote" pins
// this specific host's SSH/Docker-console/Serial connections to originate
// from the embedded local backend or a connected remote sync server.
// Ignored for rdp/vnc/telnet, which always require the remote server.
// Ignored for plugin protocols, which always need the remote server.
connectionOrigin: text("connection_origin"),
portKnockSequence: text("port_knock_sequence"),
@@ -346,6 +327,45 @@ export const sshCredentialUsage = pgTable(
],
);
// A host's login for a protocol a plugin declares (contributes.protocols),
// owned and encrypted like the host itself. `fields` holds the protocol's
// non-secret declared fields as JSON, `secret_fields` its secret ones.
export const hostProtocolAuth = pgTable(
"host_protocol_auth",
{
id: serial("id").primaryKey(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
protocol: varchar("protocol", { length: 255 }).notNull(),
authType: text("auth_type").notNull().default("direct"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
username: text("username"),
password: text("password"),
fields: text("fields"),
secretFields: text("secret_fields"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_host_protocol_auth_host_protocol").on(
table.hostId,
table.protocol,
),
index("idx_host_protocol_auth_user").on(table.userId),
index("idx_host_protocol_auth_credential").on(table.credentialId),
],
);
export const sshFolders = pgTable(
"ssh_folders",
{
@@ -493,6 +513,8 @@ export const sharedHostSecrets = pgTable(
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
// A plugin protocol's declared credential fields, JSON, encrypted.
encryptedFields: text("encrypted_fields"),
createdAt: text("created_at")
.notNull()
+42 -20
View File
@@ -202,25 +202,6 @@ export const hosts = sqliteTable(
sshPort: integer("ssh_port").default(22),
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpUser: text("rdp_user"),
rdpPassword: text("rdp_password"),
rdpDomain: text("rdp_domain"),
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
vncPassword: text("vnc_password"),
vncUser: text("vnc_user"),
telnetUser: text("telnet_user"),
telnetPassword: text("telnet_password"),
telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpAuthType: text("rdp_auth_type"),
vncAuthType: text("vnc_auth_type"),
telnetAuthType: text("telnet_auth_type"),
domain: text("domain"),
useSocks5: integer("use_socks5", { mode: "boolean" }),
socks5Host: text("socks5_host"),
socks5Port: integer("socks5_port"),
@@ -231,7 +212,7 @@ export const hosts = sqliteTable(
// null = use the desktop app's global default; "local" | "remote" pins
// this specific host's SSH/Docker-console/Serial connections to originate
// from the embedded local backend or a connected remote sync server.
// Ignored for rdp/vnc/telnet, which always require the remote server.
// Ignored for plugin protocols, which always need the remote server.
connectionOrigin: text("connection_origin"),
portKnockSequence: text("port_knock_sequence"),
@@ -338,6 +319,45 @@ export const sshCredentialUsage = sqliteTable(
],
);
// A host's login for a protocol a plugin declares (contributes.protocols),
// owned and encrypted like the host itself. `fields` holds the protocol's
// non-secret declared fields as JSON, `secret_fields` its secret ones.
export const hostProtocolAuth = sqliteTable(
"host_protocol_auth",
{
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
protocol: text("protocol").notNull(),
authType: text("auth_type").notNull().default("direct"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
username: text("username"),
password: text("password"),
fields: text("fields"),
secretFields: text("secret_fields"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_host_protocol_auth_host_protocol").on(
table.hostId,
table.protocol,
),
index("idx_host_protocol_auth_user").on(table.userId),
index("idx_host_protocol_auth_credential").on(table.credentialId),
],
);
export const sshFolders = sqliteTable(
"ssh_folders",
{
@@ -485,6 +505,8 @@ export const sharedHostSecrets = sqliteTable(
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
// A plugin protocol's declared credential fields, JSON, encrypted.
encryptedFields: text("encrypted_fields"),
createdAt: text("created_at")
.notNull()
@@ -28,6 +28,7 @@ import { CredentialAccessRepository } from "./credential-access-repository.js";
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
@@ -286,6 +287,13 @@ export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRep
);
}
export function createCurrentHostProtocolAuthRepository(): HostProtocolAuthRepository {
return new HostProtocolAuthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_protocol_auth_repository_write"),
);
}
export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthOverrideRepository {
return new SharedHostAuthOverrideRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,243 @@
import { and, eq, inArray } from "drizzle-orm";
import { hostProtocolAuth } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
export type HostProtocolAuthRecord = typeof hostProtocolAuth.$inferSelect;
/** A host's login for one plugin protocol, decrypted. */
export interface HostProtocolLogin {
protocol: string;
authType: string;
credentialId: number | null;
username: string | null;
password: string | null;
/** Non-secret declared fields. */
fields: Record<string, string>;
/** Secret declared fields. */
secretFields: Record<string, string>;
}
/** The encryption context of a login's secret columns. */
export function protocolAuthRecordId(hostId: number, protocol: string): string {
return `host-protocol-auth-${hostId}-${protocol}`;
}
function parseStringMap(value: string | null): Record<string, string> {
if (!value) return {};
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
const out: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") out[key] = entry;
}
return out;
} catch {
return {};
}
}
function openField(
value: string | null,
dek: Buffer,
recordId: string,
fieldName: string,
): string | null {
if (!value) return null;
if (!FieldCrypto.isEncrypted(value)) return value;
try {
return FieldCrypto.decryptField(value, dek, recordId, fieldName);
} catch (error) {
// A value that does not open is treated as missing, never passed on.
databaseLogger.warn("A host protocol login could not be decrypted", {
operation: "host_protocol_auth_decrypt",
recordId,
fieldName,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
function sealField(
value: string | null,
dek: Buffer,
recordId: string,
fieldName: string,
): string | null {
if (!value) return null;
return FieldCrypto.encryptField(value, dek, recordId, fieldName);
}
export function decryptProtocolLogin(
row: HostProtocolAuthRecord,
dek: Buffer,
): HostProtocolLogin {
const recordId = protocolAuthRecordId(row.hostId, row.protocol);
const secrets = openField(row.secretFields, dek, recordId, "secretFields");
return {
protocol: row.protocol,
authType: row.authType || "direct",
credentialId: row.credentialId ?? null,
username: row.username ?? null,
password: openField(row.password, dek, recordId, "password"),
fields: parseStringMap(row.fields),
secretFields: parseStringMap(secrets),
};
}
export function encryptProtocolLogin(
hostId: number,
login: HostProtocolLogin,
dek: Buffer,
): Pick<
HostProtocolAuthRecord,
| "authType"
| "credentialId"
| "username"
| "password"
| "fields"
| "secretFields"
> {
const recordId = protocolAuthRecordId(hostId, login.protocol);
const secretFields = Object.keys(login.secretFields).length
? JSON.stringify(login.secretFields)
: null;
return {
authType: login.authType || "direct",
credentialId: login.credentialId ?? null,
username: login.username || null,
password: sealField(login.password, dek, recordId, "password"),
fields: Object.keys(login.fields).length
? JSON.stringify(login.fields)
: null,
secretFields: sealField(secretFields, dek, recordId, "secretFields"),
};
}
/**
* host_protocol_auth: one login per host and plugin protocol, encrypted with
* the host owner's data key.
*/
export class HostProtocolAuthRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listRowsForHosts(hostIds: number[]): Promise<HostProtocolAuthRecord[]> {
const unique = Array.from(new Set(hostIds));
if (unique.length === 0) return [];
return this.context.drizzle
.select()
.from(hostProtocolAuth)
.where(inArray(hostProtocolAuth.hostId, unique));
}
async listRowsForHost(hostId: number): Promise<HostProtocolAuthRecord[]> {
return this.listRowsForHosts([hostId]);
}
async findRow(
hostId: number,
protocol: string,
): Promise<HostProtocolAuthRecord | null> {
const rows = await this.context.drizzle
.select()
.from(hostProtocolAuth)
.where(
and(
eq(hostProtocolAuth.hostId, hostId),
eq(hostProtocolAuth.protocol, protocol),
),
)
.limit(1);
return rows[0] ?? null;
}
async listForHost(hostId: number, dek: Buffer): Promise<HostProtocolLogin[]> {
return (await this.listRowsForHost(hostId)).map((row) =>
decryptProtocolLogin(row, dek),
);
}
async find(
hostId: number,
protocol: string,
dek: Buffer,
): Promise<HostProtocolLogin | null> {
const row = await this.findRow(hostId, protocol);
return row ? decryptProtocolLogin(row, dek) : null;
}
async upsert(
ownerId: string,
hostId: number,
login: HostProtocolLogin,
dek: Buffer,
): Promise<void> {
const values = encryptProtocolLogin(hostId, login, dek);
const existing = await this.findRow(hostId, login.protocol);
if (existing) {
await this.context.drizzle
.update(hostProtocolAuth)
.set({
...values,
userId: ownerId,
updatedAt: new Date().toISOString(),
})
.where(eq(hostProtocolAuth.id, existing.id));
} else {
await this.context.drizzle.insert(hostProtocolAuth).values({
...values,
hostId,
userId: ownerId,
protocol: login.protocol,
});
}
await this.afterWrite();
}
async delete(hostId: number, protocol: string): Promise<boolean> {
const existing = await this.findRow(hostId, protocol);
if (!existing) return false;
await this.context.drizzle
.delete(hostProtocolAuth)
.where(eq(hostProtocolAuth.id, existing.id));
await this.afterWrite();
return true;
}
/** The owner's hosts whose login for some protocol uses the credential. */
async listHostIdsForCredential(
ownerId: string,
credentialId: number,
): Promise<number[]> {
const rows = await this.context.drizzle
.select({ hostId: hostProtocolAuth.hostId })
.from(hostProtocolAuth)
.where(
and(
eq(hostProtocolAuth.userId, ownerId),
eq(hostProtocolAuth.credentialId, credentialId),
),
);
return Array.from(new Set(rows.map((row) => row.hostId)));
}
/** Every login a user owns, for an export. */
async listRowsForUser(userId: string): Promise<HostProtocolAuthRecord[]> {
return this.context.drizzle
.select()
.from(hostProtocolAuth)
.where(eq(hostProtocolAuth.userId, userId));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -15,9 +15,6 @@ export interface HostKeyVerificationRecord {
export interface HostUpdateStateRecord {
userId: string;
credentialId: number | null;
rdpCredentialId: number | null;
vncCredentialId: number | null;
telnetCredentialId: number | null;
authType: string;
parentHostId: number | null;
folder: string | null;
@@ -142,9 +139,6 @@ export class HostResolutionRepository {
.select({
userId: hosts.userId,
credentialId: hosts.credentialId,
rdpCredentialId: hosts.rdpCredentialId,
vncCredentialId: hosts.vncCredentialId,
telnetCredentialId: hosts.telnetCredentialId,
authType: hosts.authType,
parentHostId: hosts.parentHostId,
folder: hosts.folder,
@@ -44,9 +44,6 @@ export interface RbacSharedHost {
export interface RbacRoleHostAccessCredentialSource {
hostAccessId: number;
credentialId: number | null;
rdpCredentialId: number | null;
vncCredentialId: number | null;
telnetCredentialId: number | null;
hostId: number;
hostOwnerId: string;
}
@@ -328,9 +325,6 @@ export class RbacAccessRepository {
.select({
hostAccessId: hostAccess.id,
credentialId: hosts.credentialId,
rdpCredentialId: hosts.rdpCredentialId,
vncCredentialId: hosts.vncCredentialId,
telnetCredentialId: hosts.telnetCredentialId,
hostId: hosts.id,
hostOwnerId: hosts.userId,
})
@@ -1,12 +1,18 @@
import { and, eq, inArray, or } from "drizzle-orm";
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
import { and, eq, inArray } from "drizzle-orm";
import {
hostAccess,
hostProtocolAuth,
hosts,
sharedHostSecrets,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
/** "ssh", or a protocol id a plugin declares. */
export type ShareProtocol = string;
export class SharedHostSecretsRepository {
constructor(
@@ -178,19 +184,20 @@ export class SharedHostSecretsRepository {
const rows = await this.context.drizzle
.select({ id: hosts.id })
.from(hosts)
.where(
and(eq(hosts.userId, ownerId), eq(hosts.credentialId, credentialId)),
);
const protocolRows = await this.context.drizzle
.select({ id: hostProtocolAuth.hostId })
.from(hostProtocolAuth)
.where(
and(
eq(hosts.userId, ownerId),
or(
eq(hosts.credentialId, credentialId),
eq(hosts.rdpCredentialId, credentialId),
eq(hosts.vncCredentialId, credentialId),
eq(hosts.telnetCredentialId, credentialId),
),
eq(hostProtocolAuth.userId, ownerId),
eq(hostProtocolAuth.credentialId, credentialId),
),
);
return rows.map((row) => row.id);
return Array.from(new Set([...rows, ...protocolRows].map((row) => row.id)));
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,11 @@ import {
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
@@ -707,13 +712,6 @@ export function registerHostBulkRoutes(
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
sshDataObj.rdpUser = hostData.rdpUser || null;
sshDataObj.rdpPassword = hostData.rdpPassword || null;
sshDataObj.rdpDomain = hostData.rdpDomain || null;
sshDataObj.vncUser = hostData.vncUser || null;
sshDataObj.vncPassword = hostData.vncPassword || null;
sshDataObj.telnetUser = hostData.telnetUser || null;
sshDataObj.telnetPassword = hostData.telnetPassword || null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
@@ -725,7 +723,6 @@ export function registerHostBulkRoutes(
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
sshDataObj.domain = null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
@@ -753,6 +750,18 @@ export function registerHostBulkRoutes(
results.success++;
}
const protocolAuth = readProtocolAuthPayload(
hostData as Record<string, unknown>,
);
if (protocolAuth) {
await writeProtocolAuth(
userId,
savedHostId,
await keepUsableProtocolCredentials(protocolAuth, userId),
{ isOwner: true },
);
}
// Every enabled plugin that declares host-scope settings and
// registered a hostImportNormalizer validates and writes its own
// fields here, so this loop does not need to know which plugins
+38 -45
View File
@@ -1,8 +1,9 @@
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import {
parseSshOptions,
type HostSshOptions,
} from "../../hosts/ssh-options.js";
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -41,6 +42,10 @@ export function normalizeProtocolEnableFields(
);
}
/**
* The owner's SSH authentication, which a shared editor may not change. A
* plugin protocol's login is guarded in writeProtocolAuth instead.
*/
export const OWNER_PRIVATE_AUTH_FIELDS = {
ssh: [
"authType",
@@ -54,21 +59,7 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
"keyType",
"sudoPassword",
],
rdp: [
"rdpAuthType",
"rdpCredentialId",
"rdpUser",
"rdpPassword",
"rdpDomain",
],
vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"],
telnet: [
"telnetAuthType",
"telnetCredentialId",
"telnetUser",
"telnetPassword",
],
} as const satisfies Record<AuthOverrideProtocol, readonly string[]>;
} as const;
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"sudoPassword",
@@ -80,7 +71,7 @@ export const OWNER_PRIVATE_SSH_OPTION_FIELDS = ["agentSocketPath"] as const;
export function containsOwnerPrivateAuthUpdate(
hostData: Record<string, unknown>,
protocol: AuthOverrideProtocol,
protocol: keyof typeof OWNER_PRIVATE_AUTH_FIELDS,
): boolean {
return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) =>
Object.prototype.hasOwnProperty.call(hostData, field),
@@ -193,34 +184,41 @@ export type NormalizedImportedHost = Record<string, unknown> & {
socks5ProxyChain?: unknown;
portKnockSequence?: unknown;
overrideCredentialUsername?: unknown;
domain?: unknown;
enableSsh: boolean;
};
function legacyPrefix(protocolId: string): string {
return protocolId.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
}
function legacyKey(protocolId: string): string {
const prefix = legacyPrefix(protocolId);
return prefix.charAt(0).toUpperCase() + prefix.slice(1);
}
export function normalizeImportedHost(
hostData: Record<string, unknown>,
): NormalizedImportedHost {
const credentialAlias =
asString(hostData.credentialAlias) || asString(hostData.credentialName);
// A row with no connectionType names its protocol the way a 2.8 export
// did: an "enable<Protocol>" flag and a "<protocol>Port".
const flagged = listHostProtocols().find((protocol) =>
asBoolean(hostData[`enable${legacyKey(protocol.id)}`]),
);
const connectionType =
asString(hostData.connectionType) ||
(asBoolean(hostData.enableRdp)
? "rdp"
: asBoolean(hostData.enableVnc)
? "vnc"
: asBoolean(hostData.enableTelnet)
? "telnet"
: "ssh");
asString(hostData.connectionType) || flagged?.id || "ssh";
const protocol = listHostProtocols().find(
(entry) => entry.id === connectionType,
);
const port =
asPort(hostData.port) ||
(connectionType === "rdp"
? asPort(hostData.rdpPort) || 3389
: connectionType === "vnc"
? asPort(hostData.vncPort) || 5900
: connectionType === "telnet"
? asPort(hostData.telnetPort) || 23
: asPort(hostData.sshPort) || 22);
(protocol
? asPort(hostData[`${legacyPrefix(protocol.id)}Port`]) ||
protocol.defaultPort ||
22
: asPort(hostData.sshPort) || 22);
return {
...hostData,
@@ -260,9 +258,6 @@ const SENSITIVE_FIELDS = [
"password",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"autostartPassword",
];
@@ -282,9 +277,6 @@ export function stripSensitiveFields(
// 2.8 editors kept the sudo password inside terminal_config.
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasRdpPassword = !!host.rdpPassword;
result.hasVncPassword = !!host.vncPassword;
result.hasTelnetPassword = !!host.telnetPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
@@ -326,7 +318,7 @@ const CONNECT_LEVEL_FIELDS = new Set([
"statusCheckInterval",
"enableSsh",
"sshPort",
"rdpAuthType",
"protocolAuth",
"jumpHosts",
"createdAt",
"updatedAt",
@@ -368,6 +360,12 @@ export function sanitizeHostForRecipient(
!Array.isArray(authOverrides.ssh)
? (authOverrides.ssh as Record<string, unknown>)
: undefined;
if (stripped.protocolAuth !== undefined) {
stripped.protocolAuth = sanitizeProtocolAuthForRecipient(
stripped.protocolAuth,
permissionLevel,
);
}
if (!sshOverride?.credentialId) {
stripped.hasPassword = false;
stripped.hasKey = false;
@@ -468,10 +466,6 @@ export function transformHostResponse(
shareSshAuth: !!host.shareSshAuth,
enableSsh: !!host.enableSsh,
sshPort: host.sshPort ?? host.port ?? 22,
rdpUser: host.rdpUser || undefined,
rdpDomain: host.rdpDomain || undefined,
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
// Moved into a plugin's host settings; its hostPayloadLegacy puts it
// back for 2.8 clients.
@@ -490,6 +484,5 @@ export function transformHostResponse(
portKnockSequence: host.portKnockSequence
? JSON.parse(host.portKnockSequence as string)
: [],
domain: host.domain || undefined,
};
}
+139 -146
View File
@@ -71,6 +71,24 @@ import {
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyProtocolAuthPlan,
attachProtocolAuth,
firstProtocolUsername,
listProtocolLogins,
loadProtocolAuthSummaries,
planProtocolAuthWrite,
ProtocolAuthWriteError,
readProtocolAuthPayload,
toPortableLogins,
withProtocolAuth,
writeProtocolAuth,
type PlannedProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import {
findHostProtocol,
listHostProtocols,
} from "../../hosts/protocol-auth/registry.js";
import {
mergeStoredTerminalFields,
parseTerminalConfig,
@@ -199,7 +217,6 @@ router.post(
terminalConfig,
sshOptions,
forceKeyboardInteractive,
domain,
notes,
useSocks5,
socks5Host,
@@ -213,20 +230,8 @@ router.post(
overrideCredentialUsername,
enableSsh,
sshPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
vncAuthType,
vncCredentialId,
vncPassword,
vncUser,
telnetAuthType,
telnetCredentialId,
telnetUser,
telnetPassword,
} = hostData;
const protocolAuthPatch = readProtocolAuthPayload(hostData);
databaseLogger.info("Creating SSH host", {
operation: "host_create",
userId,
@@ -274,7 +279,7 @@ router.post(
authMethod ||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "";
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
const sshDataObj: Record<string, unknown> = {
@@ -304,7 +309,6 @@ router.post(
: null,
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -325,28 +329,9 @@ router.post(
: null,
...normalizeProtocolEnableFields(hostData),
sshPort: sshPort || port || 22,
rdpAuthType: rdpAuthType || null,
rdpCredentialId:
rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
vncAuthType: vncAuthType || null,
vncCredentialId:
vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: telnetAuthType || null,
telnetCredentialId:
telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
};
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
// A host whose main protocol is a plugin's keeps any password it is given.
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = password || null;
sshDataObj.key = null;
@@ -399,10 +384,6 @@ router.post(
sshDataObj.keyType = null;
}
sshDataObj.rdpPassword = rdpPassword || null;
sshDataObj.vncPassword = vncPassword || null;
sshDataObj.telnetPassword = telnetPassword || null;
try {
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
@@ -421,6 +402,14 @@ router.post(
}
const createdHost = result;
if (protocolAuthPatch) {
await writeProtocolAuth(
userId,
createdHost.id as number,
protocolAuthPatch,
{ isOwner: true },
);
}
// Standing folder shares apply to the newcomer.
try {
@@ -464,7 +453,7 @@ router.post(
name: String(name ?? ip),
});
res.json(stripSensitiveFields(resolvedHost));
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -794,7 +783,6 @@ router.put(
terminalConfig,
sshOptions,
forceKeyboardInteractive,
domain,
notes,
useSocks5,
socks5Host,
@@ -808,20 +796,8 @@ router.put(
overrideCredentialUsername,
enableSsh,
sshPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
vncAuthType,
vncCredentialId,
vncPassword,
vncUser,
telnetAuthType,
telnetCredentialId,
telnetUser,
telnetPassword,
} = hostData;
const protocolAuthPatch = readProtocolAuthPayload(hostData);
databaseLogger.info("Updating SSH host", {
operation: "host_update",
userId,
@@ -871,7 +847,7 @@ router.put(
const effectiveAuthType = authType || authMethod;
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "";
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
const sshDataObj: Record<string, unknown> = {
@@ -899,7 +875,6 @@ router.put(
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
notes: notes || null,
useSocks5: useSocks5 ? 1 : 0,
socks5Host: socks5Host || null,
@@ -919,25 +894,6 @@ router.put(
: null,
...normalizeProtocolEnableFields(hostData),
sshPort: sshPort || port || 22,
rdpAuthType: rdpAuthType || null,
rdpCredentialId:
rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
vncAuthType: vncAuthType || null,
vncCredentialId:
vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: telnetAuthType || null,
telnetCredentialId:
telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
};
const nextSshOptions = sshOptionsForWrite({ sshOptions, terminalConfig });
@@ -947,7 +903,7 @@ router.put(
sshDataObj.sudoPassword = sudoPassword || null;
}
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
// A host whose main protocol is a plugin's keeps any password it is given.
if ((connectionType || "ssh") !== "ssh") {
if (password) {
sshDataObj.password = password;
@@ -1007,10 +963,6 @@ router.put(
sshDataObj.keyType = null;
}
if (rdpPassword) sshDataObj.rdpPassword = rdpPassword;
if (vncPassword) sshDataObj.vncPassword = vncPassword;
if (telnetPassword) sshDataObj.telnetPassword = telnetPassword;
if (validatedParentHostId !== undefined) {
sshDataObj.parentHostId = validatedParentHostId;
} else if (folder !== undefined) {
@@ -1087,37 +1039,28 @@ router.put(
});
}
const referenceViolations: Array<[unknown, number | null, string]> = [
[
hostData.rdpCredentialId,
hostRecord.rdpCredentialId,
"RDP credential",
],
[
hostData.vncCredentialId,
hostRecord.vncCredentialId,
"VNC credential",
],
[
hostData.telnetCredentialId,
hostRecord.telnetCredentialId,
"Telnet credential",
],
];
for (const [incoming, current, label] of referenceViolations) {
if (incoming !== undefined && (incoming ?? null) !== current) {
return res.status(403).json({
error: `Only the host owner can change the ${label}`,
});
}
}
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
delete sshDataObj[field];
}
}
let protocolAuthPlan: PlannedProtocolAuth | null = null;
if (protocolAuthPatch) {
try {
protocolAuthPlan = await planProtocolAuthWrite(
ownerId,
Number(hostId),
protocolAuthPatch,
{ isOwner: accessInfo.isOwner },
);
} catch (error) {
if (error instanceof ProtocolAuthWriteError) {
return res.status(error.status).json({ error: error.message });
}
throw error;
}
}
const terminalFieldsError = await mergeStoredTerminalFields(
sshDataObj,
hostData,
@@ -1134,6 +1077,9 @@ router.put(
Number(hostId),
sshDataObj,
);
if (protocolAuthPlan) {
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
}
// A host that moved into a folder inherits that folder's standing shares.
try {
@@ -1202,7 +1148,7 @@ router.put(
success: true,
});
res.json(stripSensitiveFields(resolvedHost));
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
notifyStatsHostUpdated(parseInt(hostId), userId, "host_update");
} catch (err) {
sshLogger.error("Failed to update SSH host in database", err, {
@@ -1338,6 +1284,7 @@ router.get(
}),
);
attachProtocolAuth(result, await loadProtocolAuthSummaries(result));
const sanitized = result.map((host) =>
host.isShared
? sanitizeHostForRecipient(
@@ -1425,7 +1372,9 @@ router.get(
(await resolveHostCredentials(result, userId)) || result;
return res.json(
await withHostPluginSettings(stripSensitiveFields(resolved)),
await withHostPluginSettings(
await withProtocolAuth(stripSensitiveFields(resolved)),
),
);
}
@@ -1473,8 +1422,9 @@ router.get(
sharedExpiresAt: accessInfo.expiresAt || undefined,
ownerUsername,
};
const resolvedSharedResult =
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
const resolvedSharedResult = await withProtocolAuth(
(await resolveHostCredentials(sharedResult, userId)) || sharedResult,
);
res.json(
await withHostPluginSettings(
@@ -1585,7 +1535,12 @@ router.get(
* name: field
* schema:
* type: string
* enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword]
* enum: [password, sudoPassword, key, keyPassword]
* - in: query
* name: protocol
* schema:
* type: string
* description: A plugin protocol id; returns that protocol's saved password instead of field.
* responses:
* 200:
* description: The requested password value.
@@ -1600,18 +1555,22 @@ router.get(
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
const field = (req.query.field as string) || "password";
const coreField = [
"password",
"sudoPassword",
"key",
"keyPassword",
].includes(field);
// 2.8 clients ask for "<protocol>Password".
const protocol =
typeof req.query.protocol === "string"
? req.query.protocol
: coreField
? undefined
: /^([a-z][a-zA-Z0-9]*)Password$/.exec(field)?.[1];
const protocolId = protocol ? findHostProtocol(protocol)?.id : undefined;
if (
![
"password",
"sudoPassword",
"rdpPassword",
"vncPassword",
"telnetPassword",
"key",
"keyPassword",
].includes(field)
) {
if (!protocolId && (protocol || !coreField)) {
return res.status(400).json({ error: "Invalid field" });
}
@@ -1626,6 +1585,16 @@ router.get(
return res.status(404).json({ error: "Host not found" });
}
if (protocolId) {
const login = (await listProtocolLogins(hostId, userId)).find(
(entry) => entry.protocol === protocolId,
);
if (!login?.password) {
return res.status(404).json({ error: "No password set" });
}
return res.json({ value: login.password });
}
const resolved = (await resolveHostCredentials(host, userId)) || host;
let value = resolved[field];
@@ -1724,25 +1693,14 @@ router.get(
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
protocolAuth: toPortableLogins(
await listProtocolLogins(Number(hostId), userId),
),
};
const exportData = isRemoteDesktop
? {
...baseExportData,
rdpAuthType: resolvedHost.rdpAuthType || null,
rdpCredentialId: resolvedHost.rdpCredentialId || null,
rdpUser: resolvedHost.rdpUser || null,
rdpPassword: resolvedHost.rdpPassword || null,
rdpDomain: resolvedHost.rdpDomain || null,
vncAuthType: resolvedHost.vncAuthType || null,
vncCredentialId: resolvedHost.vncCredentialId || null,
vncUser: resolvedHost.vncUser || null,
vncPassword: resolvedHost.vncPassword || null,
telnetAuthType: resolvedHost.telnetAuthType || null,
telnetCredentialId: resolvedHost.telnetCredentialId || null,
telnetUser: resolvedHost.telnetUser || null,
telnetPassword: resolvedHost.telnetPassword || null,
}
? baseExportData
: {
...baseExportData,
authType: resolvedHost.authType,
@@ -1867,13 +1825,16 @@ router.get(
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
protocolAuth: shareableLogins(
toPortableLogins(
await listProtocolLogins(host.id as number, userId),
),
shareMode,
),
};
const exportData = isRemoteDesktop
? {
...baseExportData,
domain: resolvedHost.domain || null,
}
? baseExportData
: {
...baseExportData,
authType: resolvedHost.authType,
@@ -2091,6 +2052,38 @@ router.delete(
// the file-manager plugin, under /plugin-api/file-manager/, and command
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
/**
* A share export leaves out every secret and credential link, the same as
* it does for SSH.
*/
function shareableLogins(
logins: ReturnType<typeof toPortableLogins>,
shareMode: boolean,
): ReturnType<typeof toPortableLogins> {
if (!shareMode) return logins;
const out: ReturnType<typeof toPortableLogins> = {};
for (const [protocol, login] of Object.entries(logins)) {
const declared = findHostProtocol(protocol);
const secret = new Set(
(declared?.credentialFields ?? [])
.filter((field) => field.secret)
.map((field) => field.key),
);
out[protocol] = {
authType: login.authType === "credential" ? "direct" : login.authType,
credentialId: null,
username: login.username,
password: null,
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([key]) => declared && !secret.has(key),
),
),
};
}
return out;
}
async function resolveHostCredentials(
host: Record<string, unknown>,
requestingUserId?: string,
@@ -2128,9 +2121,9 @@ async function resolveHostCredentials(
host.id,
requestingUserId,
);
// Whether a protocol is on is the remote-desktop plugin's host
// setting; the client only offers an override for one that is.
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
// Whether a plugin protocol is on is its plugin's host setting; the
// client only offers an override for one that is.
for (const { id: protocol } of listHostProtocols()) {
authOverrides[protocol] = {
credentialId: overrideCredentialIds[protocol],
required: false,
+2 -2
View File
@@ -8,7 +8,7 @@ import {
getAuditUsername,
logAudit,
} from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
SharedHostAuthOverrideService,
SharedHostAuthOverrideServiceError,
@@ -1945,7 +1945,7 @@ router.delete(
* /rbac/host-access/{hostId}/auth/{protocol}:
* put:
* summary: Set personal authentication for a shared host protocol
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. Only SSH is currently supported.
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares.
* tags: [RBAC]
* security:
* - bearerAuth: []
@@ -346,7 +346,7 @@ export function registerUserSettingsRoutes(
* /users/session-sharing-enabled:
* get:
* summary: Get session sharing globally enabled setting
* description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
* description: Returns whether live session sharing (terminal and remote desktop share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
* tags:
* - Users
* responses:
-3
View File
@@ -20,9 +20,6 @@ const SECRET_FIELDS = [
"keyPassword",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
] as const;
const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i;
@@ -0,0 +1,660 @@
/**
* Per-host logins for the protocols plugins declare (contributes.protocols).
*
* Core stores them in host_protocol_auth, encrypted with the host owner's
* data key, and knows each protocol only by its declaration: which extra
* fields a login carries and which of those are secret. Writes, the host
* payload, sharing, export and sync all go through here, so a new protocol
* needs no core change.
*/
import {
createCurrentHostProtocolAuthRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../../database/repositories/factory.js";
import {
decryptProtocolLogin,
type HostProtocolAuthRecord,
type HostProtocolLogin,
} from "../../database/repositories/host-protocol-auth-repository.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { findUsableCredential } from "../usable-credential.js";
import {
isSecretReference,
resolveSecretReference,
} from "../external-secrets.js";
import {
findHostProtocol,
listHostProtocols,
plainFieldKeys,
secretFieldKeys,
type DeclaredHostProtocol,
} from "./registry.js";
import type { ProtocolAuthSummary } from "./summary.js";
export type { ProtocolAuthSummary } from "./summary.js";
export const PROTOCOL_AUTH_TYPES = ["direct", "credential", "none"] as const;
export type ProtocolAuthType = (typeof PROTOCOL_AUTH_TYPES)[number];
/** One protocol's login as a client sends it. Absent keys keep what is stored. */
export interface ProtocolAuthInput {
authType?: unknown;
credentialId?: unknown;
username?: unknown;
password?: unknown;
fields?: Record<string, unknown>;
}
/** Protocol id to its new login, or null to remove it. */
export type ProtocolAuthPatch = Map<string, ProtocolAuthInput | null>;
/** A login ready to hand to the program that connects. */
export interface ResolvedProtocolLogin {
authType: string;
username: string;
password: string;
fields: Record<string, string>;
}
export class ProtocolAuthWriteError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "ProtocolAuthWriteError";
}
}
/** The owner's data key; throws while it is locked. */
function ownerKey(ownerId: string): Buffer {
const dek = DataCrypto.getUserDataKey(ownerId);
if (!dek) throw new Error(`The data key of ${ownerId} is locked`);
return dek;
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function str(value: unknown): string {
return typeof value === "string" ? value : "";
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function asCredentialId(value: unknown): number | null {
const id = typeof value === "string" ? Number(value) : value;
return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
}
function asAuthType(value: unknown): ProtocolAuthType | undefined {
return PROTOCOL_AUTH_TYPES.includes(value as ProtocolAuthType)
? (value as ProtocolAuthType)
: undefined;
}
/**
* The flat keys a 2.8 client or export used for a protocol:
* `<id>User`, `<id>Password`, `<id>CredentialId`, `<id>AuthType` and
* `<id><Field>` for each declared field.
*/
function legacyInput(
hostData: Record<string, unknown>,
protocol: DeclaredHostProtocol,
): ProtocolAuthInput | null | undefined {
const prefix = protocol.id.replace(/-([a-z0-9])/g, (_, c: string) =>
c.toUpperCase(),
);
const key = (suffix: string) => `${prefix}${suffix}`;
const has = (name: string) =>
Object.prototype.hasOwnProperty.call(hostData, name);
const fieldKeys = (protocol.credentialFields ?? []).map((field) => field.key);
const names = [
key("User"),
key("Password"),
key("CredentialId"),
key("AuthType"),
...fieldKeys.map((field) => key(capitalize(field))),
];
if (!names.some(has)) return undefined;
const input: ProtocolAuthInput = {};
if (has(key("AuthType"))) input.authType = hostData[key("AuthType")];
if (has(key("CredentialId"))) {
input.credentialId = hostData[key("CredentialId")];
}
if (has(key("User"))) input.username = hostData[key("User")];
if (has(key("Password"))) {
// A 2.8 editor sent null for "leave the saved password alone".
const password = hostData[key("Password")];
if (password) input.password = password;
}
const fields: Record<string, unknown> = {};
for (const field of fieldKeys) {
const name = key(capitalize(field));
if (has(name)) fields[field] = hostData[name];
}
if (Object.keys(fields).length) input.fields = fields;
// A 2.8 editor cleared a protocol it switched off by sending nulls.
if (
has(key("AuthType")) &&
input.authType == null &&
!input.username &&
!input.password &&
!input.credentialId &&
Object.values(fields).every((value) => !value)
) {
return null;
}
return input;
}
/**
* The protocol logins a host write carries: `protocolAuth` keyed by
* protocol id, or the flat fields a 2.8 client sends. Null when it carries
* none, which leaves every stored login alone.
*/
export function readProtocolAuthPayload(
hostData: Record<string, unknown>,
): ProtocolAuthPatch | null {
const patch: ProtocolAuthPatch = new Map();
if (isObject(hostData.protocolAuth)) {
for (const [id, value] of Object.entries(hostData.protocolAuth)) {
if (!findHostProtocol(id)) continue;
if (value === null) patch.set(id, null);
else if (isObject(value)) patch.set(id, value as ProtocolAuthInput);
}
} else {
for (const protocol of listHostProtocols()) {
const input = legacyInput(hostData, protocol);
if (input !== undefined) patch.set(protocol.id, input);
}
}
return patch.size ? patch : null;
}
/** Folds a client's input over the stored login. Pure. */
export function mergeProtocolLogin(
protocolId: string,
declared: DeclaredHostProtocol | undefined,
current: HostProtocolLogin | null,
input: ProtocolAuthInput,
): HostProtocolLogin {
const inputCredential =
input.credentialId === undefined
? undefined
: asCredentialId(input.credentialId);
const authType: ProtocolAuthType =
asAuthType(input.authType) ??
(input.authType === undefined
? asAuthType(current?.authType)
: undefined) ??
(inputCredential ? "credential" : "direct");
const credentialId =
authType === "credential"
? inputCredential !== undefined
? inputCredential
: (current?.credentialId ?? null)
: null;
const direct = authType === "direct";
const username = direct
? input.username !== undefined
? str(input.username) || null
: (current?.username ?? null)
: null;
// An empty password keeps the saved one, as 2.8 did: the editor never
// shows it, and a shared editor cannot see whether one is set.
const password = direct
? str(input.password) || (current?.password ?? null)
: null;
const incoming = isObject(input.fields) ? input.fields : {};
const pick = (
keys: string[],
stored: Record<string, string>,
keepEmpty: boolean,
): Record<string, string> => {
const out: Record<string, string> = {};
for (const key of keys) {
const given = Object.prototype.hasOwnProperty.call(incoming, key)
? str(incoming[key])
: "";
const value =
given ||
(keepEmpty || !Object.prototype.hasOwnProperty.call(incoming, key)
? (stored[key] ?? "")
: "");
if (value) out[key] = value;
}
return out;
};
return {
protocol: protocolId,
authType,
credentialId,
username,
password,
fields: declared
? pick(plainFieldKeys(declared), current?.fields ?? {}, false)
: {},
secretFields: declared
? pick(secretFieldKeys(declared), current?.secretFields ?? {}, true)
: {},
};
}
function isEmptyLogin(login: HostProtocolLogin): boolean {
return (
login.authType === "direct" &&
!login.username &&
!login.password &&
Object.keys(login.fields).length === 0 &&
Object.keys(login.secretFields).length === 0
);
}
export type PlannedProtocolAuth = Array<[string, HostProtocolLogin | null]>;
/**
* Works out a host write's protocol logins without writing them. A shared
* editor works on the owner's record but may not point a login at a
* different credential, which throws a 403 before anything is written.
*/
export async function planProtocolAuthWrite(
ownerId: string,
hostId: number,
patch: ProtocolAuthPatch,
options: { isOwner: boolean },
): Promise<PlannedProtocolAuth> {
const repository = createCurrentHostProtocolAuthRepository();
const dek = ownerKey(ownerId);
const planned: PlannedProtocolAuth = [];
for (const [protocolId, input] of patch) {
const current = await repository.find(hostId, protocolId, dek);
const next =
input === null
? null
: mergeProtocolLogin(
protocolId,
findHostProtocol(protocolId),
current,
input,
);
if (
!options.isOwner &&
(next?.credentialId ?? null) !== (current?.credentialId ?? null)
) {
throw new ProtocolAuthWriteError(
`Only the host owner can change the ${protocolId} credential`,
403,
);
}
planned.push([protocolId, next && isEmptyLogin(next) ? null : next]);
}
return planned;
}
export async function applyProtocolAuthPlan(
ownerId: string,
hostId: number,
planned: PlannedProtocolAuth,
): Promise<void> {
const repository = createCurrentHostProtocolAuthRepository();
const dek = ownerKey(ownerId);
for (const [protocolId, next] of planned) {
if (next) await repository.upsert(ownerId, hostId, next, dek);
else await repository.delete(hostId, protocolId);
}
}
export async function writeProtocolAuth(
ownerId: string,
hostId: number,
patch: ProtocolAuthPatch,
options: { isOwner: boolean },
): Promise<void> {
await applyProtocolAuthPlan(
ownerId,
hostId,
await planProtocolAuthWrite(ownerId, hostId, patch, options),
);
}
/**
* An import names credentials by another server's ids. Any the importing
* user cannot use falls back to a direct login.
*/
export async function keepUsableProtocolCredentials(
patch: ProtocolAuthPatch,
userId: string,
): Promise<ProtocolAuthPatch> {
const out: ProtocolAuthPatch = new Map();
for (const [protocol, input] of patch) {
const credentialId = asCredentialId(input?.credentialId);
if (
input &&
credentialId &&
!(await findUsableCredential(credentialId, userId))
) {
out.set(protocol, {
...input,
credentialId: null,
authType: input.authType === "credential" ? "direct" : input.authType,
});
} else {
out.set(protocol, input);
}
}
return out;
}
/** The first username a write's logins carry, for a host with no SSH username. */
export function firstProtocolUsername(patch: ProtocolAuthPatch | null): string {
for (const input of patch?.values() ?? []) {
const username = str(input?.username);
if (username) return username;
}
return "";
}
/** Replaces every login of a host with the given ones, as a sync or import does. */
export async function replaceProtocolLogins(
ownerId: string,
hostId: number,
logins: HostProtocolLogin[],
): Promise<void> {
const repository = createCurrentHostProtocolAuthRepository();
const dek = ownerKey(ownerId);
const keep = new Set<string>();
for (const login of logins) {
keep.add(login.protocol);
if (isEmptyLogin(login)) await repository.delete(hostId, login.protocol);
else await repository.upsert(ownerId, hostId, login, dek);
}
for (const row of await repository.listRowsForHost(hostId)) {
if (!keep.has(row.protocol)) await repository.delete(hostId, row.protocol);
}
}
/** Every login of a host, decrypted with its owner's key. */
export async function listProtocolLogins(
hostId: number,
ownerId: string,
): Promise<HostProtocolLogin[]> {
const dek = ownerKey(ownerId);
return createCurrentHostProtocolAuthRepository().listForHost(hostId, dek);
}
function summarize(
row: HostProtocolAuthRecord,
dek: Buffer | null,
): ProtocolAuthSummary {
const login = dek ? decryptProtocolLogin(row, dek) : null;
let fields: Record<string, string> = {};
try {
const parsed = row.fields ? JSON.parse(row.fields) : {};
if (isObject(parsed)) {
fields = Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
} catch {
fields = {};
}
return {
authType: row.authType || "direct",
credentialId: row.credentialId ?? null,
username: row.username ?? null,
fields,
hasPassword: !!row.password,
secretFieldKeys: login ? Object.keys(login.secretFields) : [],
};
}
/** Browser-safe logins for a list of hosts, keyed by host id then protocol. */
export async function loadProtocolAuthSummaries(
hosts: Array<Record<string, unknown>>,
): Promise<Map<number, Record<string, ProtocolAuthSummary>>> {
const ids = hosts
.map((host) => Number(host.id))
.filter((id) => Number.isInteger(id) && id > 0);
const result = new Map<number, Record<string, ProtocolAuthSummary>>();
if (ids.length === 0) return result;
const rows =
await createCurrentHostProtocolAuthRepository().listRowsForHosts(ids);
const keys = new Map<string, Buffer | null>();
for (const row of rows) {
if (!keys.has(row.userId)) {
keys.set(row.userId, DataCrypto.getUserDataKey(row.userId));
}
const own = result.get(row.hostId) ?? {};
own[row.protocol] = summarize(row, keys.get(row.userId) ?? null);
result.set(row.hostId, own);
}
return result;
}
/** Sets `protocolAuth` on each host from the loaded summaries. */
export function attachProtocolAuth(
hosts: Array<Record<string, unknown>>,
summaries: Map<number, Record<string, ProtocolAuthSummary>>,
): void {
for (const host of hosts) {
host.protocolAuth = summaries.get(Number(host.id)) ?? {};
}
}
export async function withProtocolAuth<T extends Record<string, unknown>>(
host: T,
): Promise<T> {
attachProtocolAuth([host], await loadProtocolAuthSummaries([host]));
return host;
}
async function openReference(userId: string, value: string): Promise<string> {
return isSecretReference(value)
? resolveSecretReference(userId, value)
: value;
}
function withDeclaredFields(
declared: DeclaredHostProtocol,
...sources: Array<Record<string, string> | undefined>
): Record<string, string> {
const out: Record<string, string> = {};
for (const field of declared.credentialFields ?? []) {
out[field.key] = "";
for (const source of sources) {
if (source?.[field.key]) {
out[field.key] = source[field.key];
break;
}
}
}
return out;
}
/** The owner's own login: the stored one, or the credential it points at. */
export async function resolveOwnerProtocolLogin(
host: Record<string, unknown>,
declared: DeclaredHostProtocol,
): Promise<ResolvedProtocolLogin> {
const hostId = Number(host.id);
const ownerId = String(host.userId);
const dek = ownerKey(ownerId);
const login = await createCurrentHostProtocolAuthRepository().find(
hostId,
declared.id,
dek,
);
const authType = login?.authType || "direct";
let username = login?.username ?? "";
let password = login?.password ?? "";
if (authType === "credential" && login?.credentialId) {
const credential = await findUsableCredential(login.credentialId, ownerId);
// Only the login itself comes from a stored credential, never the fields.
if (credential?.username) username = credential.username;
if (credential?.password) password = credential.password;
}
const fallback = declared.hostLoginFallback ?? [];
if (fallback.includes("username")) username ||= str(host.username);
if (fallback.includes("password")) password ||= str(host.password);
const secretFields: Record<string, string> = {};
for (const [key, value] of Object.entries(login?.secretFields ?? {})) {
secretFields[key] = await openReference(ownerId, value);
}
return {
authType,
username,
password: password ? await openReference(ownerId, password) : "",
fields: withDeclaredFields(declared, secretFields, login?.fields),
};
}
/**
* A shared recipient's login: their own override credential, the owner's
* shared snapshot, or nothing. Never the owner's stored secret.
*/
export async function resolveRecipientProtocolLogin(
host: Record<string, unknown>,
userId: string,
declared: DeclaredHostProtocol,
): Promise<ResolvedProtocolLogin> {
const hostId = Number(host.id);
const { resolveRecipientSharedHostAuthentication } =
await import("../../utils/shared-host-auth-resolver.js");
const row = await createCurrentHostProtocolAuthRepository().findRow(
hostId,
declared.id,
);
const ownerFields = row ? summarize(row, null).fields : {};
let resolution: Awaited<
ReturnType<typeof resolveRecipientSharedHostAuthentication>
>;
try {
resolution = await resolveRecipientSharedHostAuthentication(
{ ...host, password: null } as never,
hostId,
userId,
declared.id,
);
} catch {
resolution = { source: "required" };
}
if (resolution.source === "personal-override") {
return {
authType: "credential",
username: str(resolution.credential.username),
password: str(resolution.credential.password),
fields: withDeclaredFields(declared, ownerFields),
};
}
if (resolution.source === "owner-shared") {
return {
authType: resolution.authType,
username: str(resolution.secret?.username),
password: str(resolution.secret?.password),
fields: withDeclaredFields(
declared,
resolution.secret?.fields,
ownerFields,
),
};
}
return {
authType:
resolution.source === "secretless" && row?.authType === "none"
? "none"
: "direct",
username: "",
password: "",
fields: withDeclaredFields(declared, ownerFields),
};
}
/** Credential ids a recipient chose for this host, per protocol. */
export async function listRecipientOverrideIds(
hostId: number,
userId: string,
): Promise<Record<string, number>> {
return (await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
hostId,
userId,
)) as Record<string, number>;
}
/** A login in an export or on the sync wire: plaintext, keyed by protocol. */
export interface PortableProtocolLogin {
authType: string;
credentialId: number | null;
username: string | null;
password: string | null;
fields: Record<string, string>;
}
export function toPortableLogins(
logins: HostProtocolLogin[],
): Record<string, PortableProtocolLogin> {
const out: Record<string, PortableProtocolLogin> = {};
for (const login of logins) {
out[login.protocol] = {
authType: login.authType,
credentialId: login.credentialId,
username: login.username,
password: login.password,
fields: { ...login.fields, ...login.secretFields },
};
}
return out;
}
/**
* Reads a login back from an export or the wire. Fields are split by the
* protocol's declaration; an undeclared protocol keeps every field secret.
*/
export function fromPortableLogin(
protocolId: string,
value: unknown,
credentialId: number | null,
): HostProtocolLogin | null {
if (!isObject(value)) return null;
const declared = findHostProtocol(protocolId);
const secret = new Set(declared ? secretFieldKeys(declared) : []);
const plain = new Set(declared ? plainFieldKeys(declared) : []);
const fields: Record<string, string> = {};
const secretFields: Record<string, string> = {};
if (isObject(value.fields)) {
for (const [key, entry] of Object.entries(value.fields)) {
if (typeof entry !== "string" || !entry) continue;
if (plain.has(key)) fields[key] = entry;
else if (secret.has(key) || !declared) secretFields[key] = entry;
}
}
const authType = asAuthType(value.authType) ?? "direct";
return {
protocol: protocolId,
authType,
credentialId: authType === "credential" ? credentialId : null,
username: str(value.username) || null,
password: str(value.password) || null,
fields,
secretFields,
};
}
@@ -0,0 +1,63 @@
/**
* The host protocols plugins declare in contributes.protocols, read from
* every installed plugin's manifest (enabled or not), so core can store,
* share and sync a protocol's login without the plugin's code running.
*/
import type { PluginProtocolContribution } from "@termix/plugin-sdk/manifest";
import { SSH_AUTH_PROTOCOL } from "../../../types/auth-protocols.js";
export interface DeclaredHostProtocol extends PluginProtocolContribution {
pluginId: string;
pluginName: string;
}
let source: () => DeclaredHostProtocol[] = () => [];
export function setHostProtocolSource(
next: () => DeclaredHostProtocol[],
): void {
source = next;
}
/** One entry per protocol id; the first plugin to declare an id keeps it. */
export function listHostProtocols(): DeclaredHostProtocol[] {
const seen = new Set<string>();
const out: DeclaredHostProtocol[] = [];
for (const protocol of source()) {
if (seen.has(protocol.id)) continue;
seen.add(protocol.id);
out.push(protocol);
}
return out;
}
export function findHostProtocol(
id: unknown,
): DeclaredHostProtocol | undefined {
if (typeof id !== "string") return undefined;
return listHostProtocols().find((protocol) => protocol.id === id);
}
export function isHostProtocol(id: unknown): id is string {
return !!findHostProtocol(id);
}
export function secretFieldKeys(
protocol: PluginProtocolContribution,
): string[] {
return (protocol.credentialFields ?? [])
.filter((field) => field.secret === true)
.map((field) => field.key);
}
export function plainFieldKeys(protocol: PluginProtocolContribution): string[] {
return (protocol.credentialFields ?? [])
.filter((field) => field.secret !== true)
.map((field) => field.key);
}
/** "ssh" or a declared protocol: what a shared recipient may override. */
export function isAuthOverrideProtocol(value: unknown): value is string {
return value === SSH_AUTH_PROTOCOL || isHostProtocol(value);
}
@@ -0,0 +1,43 @@
/**
* The browser-facing shape of a host's protocol logins. No database access,
* so the host normalizers can use it.
*/
/** What a browser sees of a login: no secret values, only whether they are set. */
export interface ProtocolAuthSummary {
authType: string;
credentialId: number | null;
username: string | null;
fields: Record<string, string>;
hasPassword: boolean;
secretFieldKeys: string[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* What a shared recipient may see of each login: never whether secrets are
* set, and at connect level only the auth type.
*/
export function sanitizeProtocolAuthForRecipient(
value: unknown,
permissionLevel: string | undefined,
): Record<string, unknown> {
if (!isObject(value)) return {};
const out: Record<string, unknown> = {};
for (const [protocol, summary] of Object.entries(value)) {
if (!isObject(summary)) continue;
out[protocol] =
permissionLevel === "connect"
? { authType: summary.authType }
: {
authType: summary.authType,
credentialId: summary.credentialId ?? null,
username: summary.username ?? null,
fields: summary.fields ?? {},
};
}
return out;
}
+19 -105
View File
@@ -4,9 +4,10 @@
* to guacd). Needs credentials:read, the one critical capability, and every
* call is audited whether it succeeds or not.
*
* The protocol credentials themselves stay in core, next to the host, because
* core's sharing model (per-recipient secret snapshots, personal overrides)
* is what decides which ones a shared recipient may use.
* The logins themselves stay in core, next to the host, because core's
* sharing model (per-recipient secret snapshots, personal overrides) decides
* which ones a shared recipient may use. A plugin reads only the protocols
* its own manifest declares in contributes.protocols.
*
* registerSecretResolver is the other half: a plugin that resolves
* "<scheme>://..." references in a host's secret fields (secret-sources and
@@ -21,8 +22,6 @@
import type {
PluginCredentials,
PluginHostProtocol,
PluginProtocolTarget,
PluginSshKeyCredential,
} from "@termix/plugin-sdk/backend";
import ssh2 from "ssh2";
@@ -31,6 +30,7 @@ import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
import { findHostProtocol } from "../hosts/protocol-auth/registry.js";
type AuditFn = (
action: string,
@@ -44,8 +44,6 @@ interface Deps {
audit: AuditFn;
}
const PROTOCOLS: PluginHostProtocol[] = ["rdp", "vnc", "telnet"];
type HostRow = Record<string, unknown>;
function str(value: unknown): string {
@@ -68,100 +66,6 @@ function parseJumpHosts(value: unknown): Array<{ hostId: number }> {
.map((hostId) => ({ hostId }));
}
async function resolveOwnerAuth(
host: HostRow,
protocol: PluginHostProtocol,
): Promise<PluginProtocolTarget["auth"]> {
const credentialId = host[`${protocol}CredentialId`] as number | null;
const authType =
str(host[`${protocol}AuthType`]) ||
(credentialId ? "credential" : "direct");
let username = str(host[`${protocol}User`]);
let password = str(host[`${protocol}Password`]);
if (authType === "credential" && credentialId) {
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
host.userId as string,
);
// The domain never comes from a stored credential.
if (credential?.username) username = credential.username;
if (credential?.password) password = credential.password;
}
if (protocol === "rdp") username ||= str(host.username);
password ||= str(host.password);
return {
authType,
username,
password,
domain: str(host.rdpDomain) || str(host.domain),
};
}
async function resolveRecipientAuth(
host: HostRow,
hostId: number,
userId: string,
protocol: PluginHostProtocol,
): Promise<PluginProtocolTarget["auth"]> {
const { resolveRecipientSharedHostAuthentication } =
await import("../utils/shared-host-auth-resolver.js");
// Recipients never read the owner's raw secrets, so start from nothing.
const recipientHost = {
...host,
password: null,
rdpUser: null,
rdpPassword: null,
vncUser: null,
vncPassword: null,
telnetUser: null,
telnetPassword: null,
};
let resolution: Awaited<
ReturnType<typeof resolveRecipientSharedHostAuthentication>
>;
try {
resolution = await resolveRecipientSharedHostAuthentication(
recipientHost as never,
hostId,
userId,
protocol,
);
} catch {
// Same as before the move: connect without stored credentials.
resolution = { source: "required" };
}
const auth =
resolution.source === "personal-override"
? { ...resolution.credential, domain: null }
: resolution.source === "owner-shared"
? resolution.secret
: null;
let authType = "direct";
if (resolution.source === "personal-override") authType = "credential";
else if (resolution.source === "owner-shared") authType = resolution.authType;
else if (
resolution.source === "secretless" &&
str(host[`${protocol}AuthType`]) === "none"
) {
authType = "none";
}
return {
authType,
username: str(auth?.username),
password: str(auth?.password),
domain: str(auth?.domain) || str(host.rdpDomain) || str(host.domain),
};
}
function derivePublicKey(
privateKey: string | null | undefined,
passphrase: string | null | undefined,
@@ -326,8 +230,15 @@ export function createPluginCredentials({
const details = `${protocol} credentials for host ${hostId}`;
try {
await assertCapability(pluginId, "credentials:read", declared);
if (!PROTOCOLS.includes(protocol)) {
throw new Error(`Unsupported protocol: ${String(protocol)}`);
// Another plugin that declared the same id first owns it.
const own =
manifest.contributes?.protocols?.some(
(entry) => entry.id === protocol,
) && findHostProtocol(protocol)?.pluginId === pluginId;
if (!own) {
throw new Error(
`Plugin ${pluginId} does not declare protocol "${String(protocol)}" in contributes.protocols`,
);
}
} catch (error) {
await audit("credentials_read", details, {
@@ -378,9 +289,12 @@ export function createPluginCredentials({
}
}
const declaredProtocol = findHostProtocol(protocol)!;
const { resolveOwnerProtocolLogin, resolveRecipientProtocolLogin } =
await import("../hosts/protocol-auth/protocol-auth.js");
const auth = shared
? await resolveRecipientAuth(host, hostId, userId, protocol)
: await resolveOwnerAuth(host, protocol);
? await resolveRecipientProtocolLogin(host, userId, declaredProtocol)
: await resolveOwnerProtocolLogin(host, declaredProtocol);
await audit("credentials_read", details, { success: true });
return {
-3
View File
@@ -76,9 +76,6 @@ export const HOST_SECRET_FIELDS = [
"passphrase",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
+12
View File
@@ -19,6 +19,7 @@ import { PluginLoader, type LoadedPlugin } from "./loader.js";
import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
@@ -53,6 +54,17 @@ export function getPluginRuntime(): { loader: PluginLoader } {
),
),
);
// Protocol logins stay readable, shareable and syncable while their
// plugin is off.
setHostProtocolSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.protocols ?? []).map((protocol) => ({
...protocol,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
})),
),
);
// Lets a host whose auth type belongs to a disabled plugin name it.
setSshAuthTypeOwnerSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
+16 -20
View File
@@ -27,6 +27,10 @@ import {
exportHostPluginSettings,
importHostPluginSettings,
} from "./host-plugin-settings.js";
import {
exportProtocolLogins,
importProtocolLogins,
} from "./host-protocol-auth.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
@@ -36,21 +40,6 @@ const CREDENTIAL_REFERENCE = {
const HOST_REFERENCES = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
@@ -161,16 +150,25 @@ export function registerCoreSyncEntities(): void {
update: "hosts.edit",
delete: "hosts.delete",
},
serialize: async (row) => ({
serialize: async (row, resolveSyncId) => ({
...row,
pluginSettings:
typeof row.id === "number"
? await exportHostPluginSettings(row.id)
: {},
protocolAuth:
typeof row.id === "number"
? await exportProtocolLogins(
row.id,
String(row.userId),
resolveSyncId,
)
: {},
}),
afterWrite: async ({ id, userId, wire }) => {
afterWrite: async ({ id, userId, wire, resolveId }) => {
if (id === null) return;
await importHostPluginSettings(id, wire.pluginSettings);
await importProtocolLogins(id, userId, wire.protocolAuth, resolveId);
await dropInvalidParent(userId, id);
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
@@ -439,11 +437,9 @@ const SHARED_HOST_DROP = [
"id",
"userId",
"credentialId",
"rdpCredentialId",
"vncCredentialId",
"telnetCredentialId",
"parentHostId",
"pluginSettings",
"protocolAuth",
"quickActions",
"createdAt",
"updatedAt",
+84
View File
@@ -0,0 +1,84 @@
/**
* A host's plugin protocol logins over sync. They travel on the host row as
* `protocolAuth`, keyed by protocol id, with the owner's secrets decrypted
* for the wire like the host's own and a credential named by its syncId.
*/
import type { SyncRow } from "@termix/plugin-sdk/backend";
import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
import {
fromPortableLogin,
listProtocolLogins,
replaceProtocolLogins,
toPortableLogins,
} from "../hosts/protocol-auth/protocol-auth.js";
import { syncLogger } from "../utils/logger.js";
type ResolveSyncId = (entityType: string, id: number) => Promise<string | null>;
type ResolveId = (entityType: string, syncId: string) => Promise<number | null>;
const CREDENTIALS = "sshCredentials";
export async function exportProtocolLogins(
hostId: number,
ownerId: string,
resolveSyncId: ResolveSyncId,
): Promise<SyncRow> {
const out: SyncRow = {};
const portable = toPortableLogins(await listProtocolLogins(hostId, ownerId));
for (const [protocol, login] of Object.entries(portable)) {
const { credentialId, ...rest } = login;
out[protocol] = {
...rest,
credentialSyncId: credentialId
? await resolveSyncId(CREDENTIALS, credentialId)
: null,
};
}
return out;
}
/**
* Writes what a synced host carried. A row from a peer that sends no
* protocolAuth leaves the stored logins alone.
*/
export async function importProtocolLogins(
hostId: number,
userId: string,
carried: unknown,
resolveId: ResolveId | undefined,
): Promise<void> {
if (!carried || typeof carried !== "object" || Array.isArray(carried)) {
return;
}
try {
const current = new Map(
(await listProtocolLogins(hostId, userId)).map((login) => [
login.protocol,
login,
]),
);
const logins: HostProtocolLogin[] = [];
for (const [protocol, value] of Object.entries(carried)) {
const syncId = (value as { credentialSyncId?: unknown })
?.credentialSyncId;
let credentialId: number | null = null;
if (typeof syncId === "string" && syncId) {
// A credential not here yet keeps whatever the login pointed at.
credentialId =
(resolveId ? await resolveId(CREDENTIALS, syncId) : null) ??
current.get(protocol)?.credentialId ??
null;
}
const login = fromPortableLogin(protocol, value, credentialId);
if (login) logins.push(login);
}
await replaceProtocolLogins(userId, hostId, logins);
} catch (error) {
syncLogger.warn("Could not apply a synced host's protocol logins", {
operation: "sync_host_protocol_auth",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
+7 -1
View File
@@ -331,7 +331,13 @@ export async function writeWireRow(
const written = await findStoredRow(entity, userId, syncId);
const id = typeof written?.id === "number" ? written.id : null;
if (entity.afterWrite) {
await entity.afterWrite({ id, userId, wire, created: !current });
await entity.afterWrite({
id,
userId,
wire,
created: !current,
resolveId: resolvers.resolveId,
});
}
DatabaseSaveTrigger.triggerSave("sync_write");
return { id, created: !current };
@@ -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,
Binary file not shown.
+10
View File
@@ -117,6 +117,8 @@ export const ENCRYPTED_COLUMNS: Array<[string, string, string, string]> = [
["ssh_data", "key_password", "keyPassword", "user_id"],
["ssh_data", "sudo_password", "sudoPassword", "user_id"],
["ssh_data", "rdp_password", "rdpPassword", "user_id"],
["ssh_data", "vnc_password", "vncPassword", "user_id"],
["ssh_data", "telnet_password", "telnetPassword", "user_id"],
["ssh_credentials", "password", "password", "user_id"],
["ssh_credentials", "private_key", "privateKey", "user_id"],
["ssh_credentials", "key", "key", "user_id"],
@@ -251,6 +253,14 @@ const HOST_ROWS: Row[] = [
telnet_port: 2323,
rdp_user: "d4-rdp-user",
rdp_password: "d4-rdp-password",
rdp_domain: "D4-DOMAIN",
rdp_auth_type: "direct",
vnc_user: "d4-vnc-user",
vnc_password: "d4-vnc-password",
vnc_auth_type: "direct",
// Telnet through the saved credential, which has no password of its own.
telnet_auth_type: "credential",
telnet_credential_id: 1,
rdp_security: "nla",
rdp_ignore_cert: 1,
guacamole_config: JSON.stringify({ colorDepth: 24 }),
+28
View File
@@ -415,6 +415,34 @@ ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host','
-- ==== core 0062_smooth_speed ====
ALTER TABLE `ssh_data` ADD `ssh_options` text;
-- ==== core 0063_needy_radioactive_man ====
-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth.
-- A boot migration copies them after this runs, so the old ssh_data columns
-- and their foreign keys stay in place, unused, until 3.0.0.
CREATE TABLE `host_protocol_auth` (
`id` int AUTO_INCREMENT NOT NULL,
`host_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`protocol` varchar(255) NOT NULL,
`auth_type` text NOT NULL DEFAULT ('direct'),
`credential_id` int,
`username` text,
`password` text,
`fields` text,
`secret_fields` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `host_protocol_auth_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_host_protocol_auth_host_protocol` UNIQUE(`host_id`,`protocol`)
);
--> statement-breakpoint
ALTER TABLE `shared_host_secrets` ADD `encrypted_fields` text;--> statement-breakpoint
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`);
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
+27
View File
@@ -435,6 +435,33 @@ DROP TABLE "sync_tombstones" CASCADE;
-- ==== core 0062_curvy_killmonger ====
ALTER TABLE "ssh_data" ADD COLUMN "ssh_options" text;
-- ==== core 0063_giant_starfox ====
-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth.
-- A boot migration copies them after this runs, so the old ssh_data columns
-- and their foreign keys stay in place, unused, until 3.0.0.
CREATE TABLE "host_protocol_auth" (
"id" serial PRIMARY KEY NOT NULL,
"host_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"protocol" varchar(255) NOT NULL,
"auth_type" text DEFAULT 'direct' NOT NULL,
"credential_id" integer,
"username" text,
"password" text,
"fields" text,
"secret_fields" text,
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "shared_host_secrets" ADD COLUMN "encrypted_fields" text;--> statement-breakpoint
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_host_protocol_auth_host_protocol" ON "host_protocol_auth" USING btree ("host_id","protocol");--> statement-breakpoint
CREATE INDEX "idx_host_protocol_auth_user" ON "host_protocol_auth" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING btree ("credential_id");
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number, userId: string) =>
id === 5 && userId === "importer" ? { id, username: "u" } : null,
}));
import {
fromPortableLogin,
keepUsableProtocolCredentials,
mergeProtocolLogin,
readProtocolAuthPayload,
toPortableLogins,
} from "../../../hosts/protocol-auth/protocol-auth.js";
import {
setHostProtocolSource,
type DeclaredHostProtocol,
} from "../../../hosts/protocol-auth/registry.js";
import type { HostProtocolLogin } from "../../../database/repositories/host-protocol-auth-repository.js";
const SPICE: DeclaredHostProtocol = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
pluginId: "spice-plugin",
pluginName: "Spice",
};
const REMOTE_X: DeclaredHostProtocol = {
id: "remote-x",
credentialFields: [{ key: "domain" }],
pluginId: "x-plugin",
pluginName: "X",
};
const stored: HostProtocolLogin = {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "viewer",
password: "saved",
fields: { display: "2" },
secretFields: { ticket: "t-saved" },
};
beforeEach(() => setHostProtocolSource(() => [SPICE, REMOTE_X]));
afterEach(() => setHostProtocolSource(() => []));
describe("readProtocolAuthPayload", () => {
it("reads protocolAuth for declared protocols only", () => {
const patch = readProtocolAuthPayload({
protocolAuth: {
spice: { username: "a" },
"remote-x": null,
stranger: { username: "b" },
},
});
expect(patch && Object.fromEntries(patch)).toEqual({
spice: { username: "a" },
"remote-x": null,
});
});
it("reads the flat fields a 2.8 client sends, named after the protocol", () => {
const patch = readProtocolAuthPayload({
spiceUser: "viewer",
spicePassword: "pw",
spiceDisplay: "3",
spiceAuthType: "direct",
remoteXUser: "x-user",
remoteXDomain: "CORP",
remoteXCredentialId: 9,
});
expect(patch && Object.fromEntries(patch)).toEqual({
spice: {
authType: "direct",
username: "viewer",
password: "pw",
fields: { display: "3" },
},
"remote-x": {
credentialId: 9,
username: "x-user",
fields: { domain: "CORP" },
},
});
});
it("treats a 2.8 editor's null password as leaving it alone", () => {
const patch = readProtocolAuthPayload({
spiceAuthType: "direct",
spiceUser: "viewer",
spicePassword: null,
});
expect(patch?.get("spice")).toEqual({
authType: "direct",
username: "viewer",
});
});
it("reads a 2.8 editor's all-null protocol as removed", () => {
const patch = readProtocolAuthPayload({
spiceAuthType: null,
spiceUser: null,
spicePassword: null,
spiceCredentialId: null,
});
expect(patch?.get("spice")).toBeNull();
});
it("answers null when a write carries no logins", () => {
expect(readProtocolAuthPayload({ name: "host" })).toBeNull();
});
});
describe("mergeProtocolLogin", () => {
it("keeps what the input leaves out, including secrets", () => {
expect(mergeProtocolLogin("spice", SPICE, stored, {})).toEqual(stored);
});
it("keeps a saved password and secret for an empty value", () => {
const next = mergeProtocolLogin("spice", SPICE, stored, {
password: "",
fields: { ticket: "", display: "" },
});
expect(next.password).toBe("saved");
expect(next.secretFields).toEqual({ ticket: "t-saved" });
// A plain field can be cleared.
expect(next.fields).toEqual({});
});
it("drops the direct login and undeclared fields for a credential", () => {
const next = mergeProtocolLogin("spice", SPICE, stored, {
authType: "credential",
credentialId: "12",
fields: { unknown: "x" },
});
expect(next).toMatchObject({
authType: "credential",
credentialId: 12,
username: null,
password: null,
fields: { display: "2" },
});
});
it("starts a new login from the input", () => {
expect(
mergeProtocolLogin("spice", SPICE, null, {
username: "new",
password: "pw",
fields: { ticket: "t" },
}),
).toEqual({
protocol: "spice",
authType: "direct",
credentialId: null,
username: "new",
password: "pw",
fields: {},
secretFields: { ticket: "t" },
});
});
it("falls back to direct for an auth type it does not know", () => {
expect(
mergeProtocolLogin("spice", SPICE, null, { authType: "magic" }).authType,
).toBe("direct");
});
});
describe("portable logins", () => {
it("round-trips through an export, splitting fields by the declaration", () => {
const portable = toPortableLogins([stored]);
expect(portable.spice.fields).toEqual({ display: "2", ticket: "t-saved" });
expect(fromPortableLogin("spice", portable.spice, null)).toEqual(stored);
});
it("keeps every field of an undeclared protocol secret", () => {
expect(
fromPortableLogin("gone", { fields: { display: "1" } }, null)
?.secretFields,
).toEqual({ display: "1" });
});
it("drops a credential link a user cannot use", async () => {
const patch = await keepUsableProtocolCredentials(
new Map([
["spice", { authType: "credential", credentialId: 5 }],
["remote-x", { authType: "credential", credentialId: 6 }],
]),
"importer",
);
expect(patch.get("spice")).toEqual({
authType: "credential",
credentialId: 5,
});
expect(patch.get("remote-x")).toEqual({
authType: "direct",
credentialId: null,
});
});
});
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from "vitest";
import {
findHostProtocol,
isAuthOverrideProtocol,
listHostProtocols,
plainFieldKeys,
secretFieldKeys,
setHostProtocolSource,
type DeclaredHostProtocol,
} from "../../../hosts/protocol-auth/registry.js";
const SPICE: DeclaredHostProtocol = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
pluginId: "spice-plugin",
pluginName: "Spice",
};
afterEach(() => setHostProtocolSource(() => []));
describe("the host protocol registry", () => {
it("knows nothing until a manifest declares it", () => {
expect(listHostProtocols()).toEqual([]);
expect(findHostProtocol("spice")).toBeUndefined();
});
it("keeps the first plugin to declare an id", () => {
setHostProtocolSource(() => [
SPICE,
{ ...SPICE, pluginId: "late", pluginName: "Late" },
]);
expect(listHostProtocols()).toHaveLength(1);
expect(findHostProtocol("spice")?.pluginId).toBe("spice-plugin");
});
it("allows overrides for ssh and declared protocols only", () => {
setHostProtocolSource(() => [SPICE]);
expect(isAuthOverrideProtocol("ssh")).toBe(true);
expect(isAuthOverrideProtocol("spice")).toBe(true);
expect(isAuthOverrideProtocol("rdp")).toBe(false);
expect(isAuthOverrideProtocol(7)).toBe(false);
});
it("splits declared fields into plain and secret", () => {
expect(plainFieldKeys(SPICE)).toEqual(["display"]);
expect(secretFieldKeys(SPICE)).toEqual(["ticket"]);
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { sanitizeProtocolAuthForRecipient } from "../../../hosts/protocol-auth/summary.js";
const summaries = {
spice: {
authType: "credential",
credentialId: 4,
username: null,
fields: { display: "2" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
};
describe("sanitizeProtocolAuthForRecipient", () => {
it("keeps only the auth type at connect level", () => {
expect(sanitizeProtocolAuthForRecipient(summaries, "connect")).toEqual({
spice: { authType: "credential" },
});
});
it("never says whether secrets are set, at any level", () => {
for (const level of ["view", "edit", "manage"]) {
expect(sanitizeProtocolAuthForRecipient(summaries, level)).toEqual({
spice: {
authType: "credential",
credentialId: 4,
username: null,
fields: { display: "2" },
},
});
}
});
it("answers an empty map for anything else", () => {
expect(sanitizeProtocolAuthForRecipient(null, "view")).toEqual({});
expect(sanitizeProtocolAuthForRecipient({ spice: 3 }, "view")).toEqual({});
});
});
+147 -34
View File
@@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({
savedCredentials: [] as Array<Record<string, unknown>>,
userCredentials: new Map<string, Array<Record<string, unknown>>>(),
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
logins: new Map<string, Record<string, unknown>>(),
ownerDecrypts: 0,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -40,6 +42,30 @@ vi.mock("../../database/repositories/factory.js", () => ({
return row;
},
}),
createCurrentHostProtocolAuthRepository: () => ({
find: async (hostId: number, protocol: string) => {
state.ownerDecrypts++;
return state.logins.get(`${hostId}:${protocol}`) ?? null;
},
findRow: async (hostId: number, protocol: string) => {
const login = state.logins.get(`${hostId}:${protocol}`);
return login
? {
hostId,
protocol,
authType: login.authType,
credentialId: login.credentialId ?? null,
username: login.username ?? null,
password: "sealed",
fields: JSON.stringify(login.fields ?? {}),
secretFields: "sealed",
}
: null;
},
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
listCredentialIds: async () => ({}),
}),
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null,
@@ -49,6 +75,17 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
validateUserAccess: () => Buffer.alloc(32),
getUserDataKey: () => Buffer.alloc(32),
},
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) => state.credentials.get(id) ?? null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
@@ -84,6 +121,13 @@ import {
} from "../../hosts/connect/secret-resolver-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import ssh2 from "ssh2";
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
const SPICE = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
hostLoginFallback: ["password" as const],
};
function contextFor(
capabilities: string[],
@@ -114,13 +158,13 @@ const baseHost = {
username: "sshuser",
password: "ssh-secret",
jumpHosts: JSON.stringify([{ hostId: 3 }]),
rdpUser: "admin",
rdpPassword: "rdp-secret",
rdpDomain: "CORP",
rdpAuthType: null,
rdpCredentialId: null,
};
/** The demo plugin declares SPICE; nothing in core knows it. */
function spiceContext(capabilities: string[]) {
return contextFor(capabilities, { protocols: [SPICE] });
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
@@ -134,17 +178,32 @@ beforeEach(() => {
state.userCredentials.clear();
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
state.hosts.set(7, { ...baseHost });
state.logins.clear();
state.ownerDecrypts = 0;
state.logins.set("7:spice", {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "admin",
password: "spice-secret",
fields: { display: "2" },
secretFields: { ticket: "t-123" },
});
setHostProtocolSource(() => [
{ ...SPICE, pluginId: "demo", pluginName: "demo" },
{ id: "rival", pluginId: "other", pluginName: "other" },
]);
resetSecretResolverRegistryForTests();
});
describe("ctx.credentials.resolveHostProtocol", () => {
it("refuses without credentials:read and audits the refusal", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
const ctx = spiceContext([]);
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
),
).rejects.toThrow(/credentials:read/);
expect(auditEntries.at(-1)).toMatchObject({
@@ -153,12 +212,31 @@ describe("ctx.credentials.resolveHostProtocol", () => {
});
});
it("refuses a protocol the plugin does not declare, or another plugin owns", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = contextFor(["credentials:read"], {
protocols: [SPICE, { id: "rival" }],
});
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "vnc"),
),
).rejects.toThrow(/does not declare/);
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rival"),
),
).rejects.toThrow(/does not declare/);
expect(state.ownerDecrypts).toBe(0);
});
it("returns the owner's stored login and audits it", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target).toEqual({
host: {
@@ -173,8 +251,8 @@ describe("ctx.credentials.resolveHostProtocol", () => {
auth: {
authType: "direct",
username: "admin",
password: "rdp-secret",
domain: "CORP",
password: "spice-secret",
fields: { display: "2", ticket: "t-123" },
},
});
expect(auditEntries.at(-1)).toMatchObject({
@@ -183,23 +261,52 @@ describe("ctx.credentials.resolveHostProtocol", () => {
});
});
it("uses the stored credential in credential mode", async () => {
it("falls back to the host's own password as declared", async () => {
grants.set("demo", ["credentials:read"]);
state.hosts.set(7, {
...baseHost,
vncCredentialId: 11,
vncAuthType: "credential",
state.logins.set("7:spice", {
protocol: "spice",
authType: "direct",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
});
state.credentials.set(11, { username: "viewer", password: "vnc-pass" });
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "vnc"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth).toEqual({
authType: "direct",
username: "",
password: "ssh-secret",
fields: { display: "", ticket: "" },
});
});
it("uses the stored credential in credential mode", async () => {
grants.set("demo", ["credentials:read"]);
state.logins.set("7:spice", {
protocol: "spice",
authType: "credential",
credentialId: 11,
username: null,
password: null,
fields: { display: "1" },
secretFields: {},
});
state.credentials.set(11, { username: "viewer", password: "cred-pass" });
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth).toMatchObject({
authType: "credential",
username: "viewer",
password: "vnc-pass",
password: "cred-pass",
fields: { display: "1" },
});
});
@@ -208,50 +315,56 @@ describe("ctx.credentials.resolveHostProtocol", () => {
state.resolution = {
source: "owner-shared",
authType: "direct",
secret: { username: "shared", password: "snap", domain: null },
secret: {
username: "shared",
password: "snap",
fields: { ticket: "snap-ticket" },
},
};
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.shared).toBe(true);
expect(target?.auth).toMatchObject({
expect(target?.auth).toEqual({
authType: "direct",
username: "shared",
password: "snap",
fields: { display: "2", ticket: "snap-ticket" },
});
expect(state.resolverCalls[0]).toMatchObject({
password: null,
rdpUser: null,
rdpPassword: null,
});
expect(state.resolverCalls[0]).toMatchObject({ password: null });
// The owner's login is never decrypted for a recipient.
expect(state.ownerDecrypts).toBe(0);
});
it("gives a recipient with nothing shared an empty login", async () => {
grants.set("demo", ["credentials:read"]);
state.resolution = { source: "required" };
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "telnet"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth.username).toBe("");
expect(target?.auth.password).toBe("");
expect(target?.auth.fields.ticket).toBe("");
expect(state.ownerDecrypts).toBe(0);
});
it("returns null without connect access or for a missing host", async () => {
grants.set("demo", ["credentials:read"]);
state.canConnect = false;
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
await expect(
runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
),
).resolves.toBeNull();
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(99, "rdp"),
ctx.credentials.resolveHostProtocol(99, "spice"),
),
).resolves.toBeNull();
expect(auditEntries.at(-1)).toMatchObject({ success: false });
@@ -759,3 +759,67 @@ describe("contributes.keybindingActions", () => {
expect(errors).toMatch(/d/);
});
});
describe("contributes.protocols", () => {
const withProtocols = (protocols: unknown) =>
validateManifest(base({ contributes: { protocols } }));
it("accepts protocols with credential fields", () => {
expect(
withProtocols([
{
id: "spice",
credentialFields: [
{ key: "display" },
{ key: "ticket", secret: true },
],
defaultPort: 5930,
hostLoginFallback: ["username", "password"],
},
{ id: "x2go" },
]),
).toEqual([]);
});
it("refuses core's protocol, duplicates and bad ids", () => {
const errors = withProtocols([
{ id: "ssh" },
{ id: "spice" },
{ id: "spice" },
{ id: "Bad_Id" },
]);
expect(errors.join("\n")).toMatch(/"ssh" belongs to core/);
expect(errors.join("\n")).toMatch(/duplicates "spice"/);
expect(errors.join("\n")).toMatch(/\[3\]\.id must be lowercase/);
});
it("refuses field keys every login already has, and duplicate keys", () => {
const errors = withProtocols([
{
id: "spice",
credentialFields: [
{ key: "password" },
{ key: "display" },
{ key: "display", secret: "yes" },
],
},
]);
expect(errors.join("\n")).toMatch(/"password" is part of every login/);
expect(errors.join("\n")).toMatch(/duplicates "display"/);
expect(errors.join("\n")).toMatch(/secret must be a boolean/);
});
it("refuses unknown keys, bad ports and bad fallbacks", () => {
const errors = withProtocols([
{
id: "spice",
defaultPort: 70000,
hostLoginFallback: ["key"],
extra: true,
},
]);
expect(errors.join("\n")).toMatch(/defaultPort must be a port number/);
expect(errors.join("\n")).toMatch(/hostLoginFallback/);
expect(errors.join("\n")).toMatch(/extra/);
});
});
@@ -0,0 +1,387 @@
/**
* A fixture plugin declares a protocol core has never heard of ("spice") and
* gets a per-host login stored, encrypted, shared with a recipient (and
* overridden by them) and resolved, with no core change: only the manifest
* declaration. Runs on a real database with real repositories, sharing
* manager and override service; only keys, permissions and audit are faked.
*/
import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
db: null as null | { drizzle: unknown },
keys: new Map<string, Buffer>(),
levels: new Map<string, string>(),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.db!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: async (id: string) => id,
getRequestMeta: () => ({}),
}));
vi.mock("../../utils/data-crypto.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../utils/data-crypto.js")>();
const DataCrypto = actual.DataCrypto as unknown as Record<string, unknown>;
DataCrypto.getUserDataKey = (userId: string) =>
state.keys.get(userId) ?? null;
DataCrypto.validateUserAccess = (userId: string) => {
const key = state.keys.get(userId);
if (!key) throw new Error(`User ${userId} has no data encryption key`);
return key;
};
return actual;
});
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string, _hostId: number) => {
const level = state.levels.get(userId);
return level
? { hasAccess: true, isShared: true, permissionLevel: level }
: { hasAccess: false };
},
hasPermission: async () => true,
}),
},
}));
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
import {
loadProtocolAuthSummaries,
ProtocolAuthWriteError,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
import { SharedHostAuthOverrideService } from "../../utils/shared-host-auth-override-service.js";
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import { sanitizeHostForRecipient } from "../../database/routes/host-normalizers.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
const HOST_ID = 10;
const GRANT_ID = 1;
const manifest = {
id: "fixture-spice",
name: "Fixture Spice",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["credentials:read"],
contributes: {
protocols: [
{
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
},
],
},
} as PluginManifest;
let database: TestSqliteDatabase | null = null;
let context: DatabaseContext;
async function exec(query: string): Promise<void> {
await context.drizzle.run(sql.raw(query));
}
beforeEach(async () => {
database = new TestSqliteDatabase("sqlite");
context = await database.connect();
state.db = context;
state.keys = new Map([
["owner", crypto.randomBytes(32)],
["recipient", crypto.randomBytes(32)],
]);
state.levels = new Map([["recipient", "connect"]]);
for (const statement of [
`INSERT INTO users (id, username, password_hash) VALUES
('owner', 'owner', 'x'), ('recipient', 'recipient', 'x')`,
`INSERT INTO ssh_credentials (id, user_id, name, username, password, auth_type)
VALUES (5, 'recipient', 'mine', 'own-user', 'own-pass', 'password'),
(6, 'owner', 'owners', 'cred-user', 'cred-pass', 'password')`,
`INSERT INTO ssh_data (id, user_id, name, ip, port, username, password, auth_type)
VALUES (${HOST_ID}, 'owner', 'desk', '10.0.0.10', 22, 'root', 'ssh-pass', 'password')`,
`INSERT INTO host_access (id, host_id, user_id, granted_by, permission_level)
VALUES (${GRANT_ID}, ${HOST_ID}, 'recipient', 'owner', 'connect')`,
`INSERT INTO plugins (id, name, version, state, manifest_json)
VALUES ('fixture-spice', 'Fixture Spice', '1.0.0', 'enabled', '{}')`,
`INSERT INTO plugin_permission_grants (plugin_id, capability, source)
VALUES ('fixture-spice', 'credentials:read', 'bundled')`,
]) {
await exec(statement);
}
invalidatePluginPermissionCache();
// What the loader does with every installed manifest.
setHostProtocolSource(() =>
manifest.contributes!.protocols!.map((protocol) => ({
...protocol,
pluginId: manifest.id,
pluginName: manifest.name,
})),
);
});
afterEach(async () => {
setHostProtocolSource(() => []);
await database?.close();
database = null;
});
function pluginCtx() {
return createPluginContext(
manifest,
createPluginHandle(manifest.id, { activate: () => {} }),
);
}
async function storeLogin(): Promise<void> {
const patch = readProtocolAuthPayload({
protocolAuth: {
spice: {
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
},
unknown: { username: "ignored" },
},
});
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
}
async function storedRow(): Promise<Record<string, unknown>> {
const rows = (await context.drizzle.all(
sql.raw(`SELECT * FROM host_protocol_auth WHERE host_id = ${HOST_ID}`),
)) as Record<string, unknown>[];
expect(rows).toHaveLength(1);
return rows[0];
}
describe("a plugin protocol core does not know", () => {
it("stores the login encrypted with the owner's key, secrets apart", async () => {
await storeLogin();
const row = await storedRow();
expect(row.protocol).toBe("spice");
expect(row.username).toBe("viewer");
expect(JSON.parse(row.fields as string)).toEqual({ display: "2" });
expect(String(row.password)).not.toContain("spice-pass");
expect(String(row.secret_fields)).not.toContain("t-secret");
expect(
FieldCrypto.decryptField(
row.password as string,
state.keys.get("owner")!,
"",
"password",
),
).toBe("spice-pass");
expect(() =>
FieldCrypto.decryptField(
row.password as string,
state.keys.get("recipient")!,
"",
"password",
),
).toThrow();
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
expect(summaries.get(HOST_ID)).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "2" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
});
});
it("resolves the owner's login for the declaring plugin", async () => {
await storeLogin();
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.shared).toBe(false);
expect(target?.auth).toEqual({
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
});
});
it("treats a tampered password as missing, never as a value", async () => {
await storeLogin();
const row = await storedRow();
const envelope = JSON.parse(row.password as string);
envelope.tag = "0".repeat(envelope.tag.length);
await exec(
`UPDATE host_protocol_auth SET password = '${JSON.stringify(envelope)}' WHERE host_id = ${HOST_ID}`,
);
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth.password).toBe("");
expect(target?.auth.fields.ticket).toBe("t-secret");
});
it("gives a recipient the shared snapshot, not the owner's secret", async () => {
await storeLogin();
await SharedHostSecretsManager.getInstance().snapshotForUser(
GRANT_ID,
HOST_ID,
"recipient",
"owner",
);
// The owner's row loses its password without a resync: the recipient
// keeps reading their own copy, never the owner's row.
await exec(
`UPDATE host_protocol_auth SET password = NULL WHERE host_id = ${HOST_ID}`,
);
const target = await runAsActor("recipient", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.shared).toBe(true);
expect(target?.auth).toEqual({
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
});
});
it("uses the recipient's own override over the shared snapshot", async () => {
await storeLogin();
await SharedHostSecretsManager.getInstance().snapshotForUser(
GRANT_ID,
HOST_ID,
"recipient",
"owner",
);
await SharedHostAuthOverrideService.getInstance().setCredentialId(
HOST_ID,
"recipient",
"spice",
5,
);
const target = await runAsActor("recipient", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth).toEqual({
authType: "credential",
username: "own-user",
password: "own-pass",
// The owner's plain fields, never their secret ones.
fields: { display: "2", ticket: "" },
});
});
it("refuses an override for a protocol nobody declares", async () => {
await expect(
SharedHostAuthOverrideService.getInstance().setCredentialId(
HOST_ID,
"recipient",
"made-up",
5,
),
).rejects.toThrow(/No plugin declares/);
});
it("follows a credential the owner points the login at", async () => {
const patch = readProtocolAuthPayload({
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
});
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth).toMatchObject({
authType: "credential",
username: "cred-user",
password: "cred-pass",
});
});
it("lets a shared editor change the login but not its credential", async () => {
await storeLogin();
const rename = readProtocolAuthPayload({
protocolAuth: { spice: { username: "renamed", password: "" } },
});
await writeProtocolAuth("owner", HOST_ID, rename!, { isOwner: false });
expect((await storedRow()).username).toBe("renamed");
// An empty password from an editor who cannot see it keeps it.
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth.password).toBe("spice-pass");
const repoint = readProtocolAuthPayload({
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
});
await expect(
writeProtocolAuth("owner", HOST_ID, repoint!, { isOwner: false }),
).rejects.toBeInstanceOf(ProtocolAuthWriteError);
const remove = readProtocolAuthPayload({ protocolAuth: { spice: null } });
await writeProtocolAuth("owner", HOST_ID, remove!, { isOwner: false });
expect(
(await context.drizzle.all(
sql.raw(`SELECT id FROM host_protocol_auth`),
)) as unknown[],
).toHaveLength(0);
});
it("shows each share level only what it may see", async () => {
await storeLogin();
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
const host = {
id: HOST_ID,
name: "desk",
ip: "10.0.0.10",
protocolAuth: summaries.get(HOST_ID),
};
expect(
sanitizeHostForRecipient({ ...host }, "connect").protocolAuth,
).toEqual({ spice: { authType: "direct" } });
for (const level of ["view", "edit", "manage"]) {
expect(
sanitizeHostForRecipient({ ...host }, level).protocolAuth,
level,
).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "2" },
},
});
}
});
});
@@ -31,9 +31,6 @@ function secretHost(id: number): Record<string, unknown> {
keyPassword: "s3cret-value",
sudoPassword: "s3cret-value",
socks5Password: "s3cret-value",
rdpPassword: "s3cret-value",
vncPassword: "s3cret-value",
telnetPassword: "s3cret-value",
autostartPassword: "s3cret-value",
autostartKey: "s3cret-value",
autostartKeyPassword: "s3cret-value",
@@ -420,11 +420,45 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
environmentVariables: [{ key: "D4", value: "yes" }],
});
// Every 2.8 RDP, VNC and Telnet login reads back through
// host_protocol_auth, the only place core reads them from now.
const ctx = await pluginCtx("remote-desktop");
const target = await asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"),
const resolve = (hostId: number, protocol: string) =>
asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(hostId, protocol),
);
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
});
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
fields: { domain: "D4-DOMAIN" },
});
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
authType: "direct",
username: "d4-vnc-user",
password: "d4-vnc-password",
});
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
authType: "credential",
username: "deploy",
});
const logins = await selectRows<{ host_id: number; protocol: string }>(
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
);
expect(target?.auth.password).toBe("d4-rdp-password");
expect(
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
).toEqual([
`${HOSTS.rdpOnly}:rdp`,
`${HOSTS.desktop}:rdp`,
`${HOSTS.desktop}:telnet`,
`${HOSTS.desktop}:vnc`,
]);
});
it("registers an SSH auth provider for every 2.8 auth type", async () => {
@@ -0,0 +1,131 @@
/**
* A host's plugin protocol logins over sync: out with the credential named
* by syncId, back in with it translated to the local id.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
logins: [] as Array<Record<string, unknown>>,
replaced: null as null | { ownerId: string; hostId: number; logins: unknown },
}));
vi.mock(
"../../hosts/protocol-auth/protocol-auth.js",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../hosts/protocol-auth/protocol-auth.js")
>();
return {
...actual,
listProtocolLogins: async () => state.logins,
replaceProtocolLogins: async (
ownerId: string,
hostId: number,
logins: unknown,
) => {
state.replaced = { ownerId, hostId, logins };
},
};
},
);
vi.mock("../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
}));
import {
exportProtocolLogins,
importProtocolLogins,
} from "../../sync/host-protocol-auth.js";
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
beforeEach(() => {
state.replaced = null;
state.logins = [
{
protocol: "spice",
authType: "credential",
credentialId: 4,
username: null,
password: null,
fields: { display: "2" },
secretFields: { ticket: "t" },
},
];
setHostProtocolSource(() => [
{
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
pluginId: "p",
pluginName: "P",
},
]);
});
describe("host protocol logins over sync", () => {
it("sends each login with its credential as a syncId", async () => {
const wire = await exportProtocolLogins(1, "owner", async (type, id) =>
type === "sshCredentials" && id === 4 ? "cred-sync-4" : null,
);
expect(wire).toEqual({
spice: {
authType: "credential",
username: null,
password: null,
fields: { display: "2", ticket: "t" },
credentialSyncId: "cred-sync-4",
},
});
});
it("writes what arrived with the credential's local id", async () => {
await importProtocolLogins(
9,
"owner",
{
spice: {
authType: "credential",
fields: { display: "3", ticket: "t2" },
credentialSyncId: "cred-sync-4",
},
},
async (type, syncId) =>
type === "sshCredentials" && syncId === "cred-sync-4" ? 40 : null,
);
expect(state.replaced).toEqual({
ownerId: "owner",
hostId: 9,
logins: [
{
protocol: "spice",
authType: "credential",
credentialId: 40,
username: null,
password: null,
fields: { display: "3" },
secretFields: { ticket: "t2" },
},
],
});
});
it("keeps the current credential while its target has not arrived", async () => {
await importProtocolLogins(
9,
"owner",
{ spice: { authType: "credential", credentialSyncId: "later" } },
async () => null,
);
expect(
(state.replaced?.logins as Array<{ credentialId: number }>)[0]
.credentialId,
).toBe(4);
});
it("leaves the logins alone for a peer that sends none", async () => {
await importProtocolLogins(9, "owner", undefined, async () => null);
expect(state.replaced).toBeNull();
});
});
@@ -0,0 +1,223 @@
/**
* The 2.8 RDP, VNC and Telnet logins move out of the ssh_data columns into
* host_protocol_auth. Every shape a 2.8 install could hold must come across
* (encrypted under the column's name, the blob 2.8 copied from `password`,
* plaintext, credential mode, the pre-protocol-columns hosts), a second run
* must change nothing, and a login the user removed afterwards must not come
* back. Runs on SQLite, or on TEST_DIALECT's server.
*/
import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
const state = vi.hoisted(() => ({
db: null as null | { drizzle: unknown },
keys: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.db!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) => state.keys.get(userId) ?? null,
},
}));
import {
copiedMarker,
runProtocolAuthMigration,
} from "../../upgrade/protocol-auth-migration.js";
import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
const ownerKey = crypto.randomBytes(32);
let database: TestSqliteDatabase | null = null;
function sealed(value: string, hostId: number, field: string): string {
return FieldCrypto.encryptField(value, ownerKey, String(hostId), field);
}
function quote(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
beforeEach(async () => {
database = new TestSqliteDatabase();
state.db = await database.connect();
state.keys = new Map([["owner", ownerKey]]);
await database.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('owner', 'owner', 'x'), ('locked', 'locked', 'x');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(5, 'owner', 'vnc-cred', 'viewer', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user, rdp_password, rdp_domain, rdp_auth_type)
VALUES (1, 'owner', 'ssh', 'win', '10.0.0.1', 22, 'root', 'password',
'admin', ${quote(sealed("rdp-pass", 1, "rdpPassword"))}, 'CORP', 'direct');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
vnc_auth_type, vnc_credential_id)
VALUES (2, 'owner', 'ssh', 'vnc', '10.0.0.2', 22, 'root', 'password', 'credential', 5);
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
telnet_user, telnet_password)
VALUES (3, 'owner', 'ssh', 'switch', '10.0.0.3', 22, 'root', 'password', 'tu', 'tel-plain');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, password,
domain, auth_type)
VALUES (4, 'owner', 'rdp', 'old', '10.0.0.4', 3389, 'old-user',
${quote(sealed("old-pass", 4, "password"))}, 'OLDDOM', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user, rdp_password, rdp_auth_type)
VALUES (5, 'owner', 'ssh', 'copied', '10.0.0.5', 22, 'root', 'password',
'copied', ${quote(sealed("copied-pass", 5, "password"))}, 'none');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type)
VALUES (6, 'owner', 'ssh', 'plain-ssh', '10.0.0.6', 22, 'root', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user)
VALUES (7, 'locked', 'ssh', 'later', '10.0.0.7', 22, 'root', 'password', 'waits');
`);
});
afterEach(async () => {
await database?.close();
database = null;
});
async function logins() {
const repository = createCurrentHostProtocolAuthRepository();
const out: Record<string, unknown> = {};
for (const hostId of [1, 2, 3, 4, 5, 6]) {
for (const login of await repository.listForHost(hostId, ownerKey)) {
out[`${hostId}:${login.protocol}`] = login;
}
}
return out;
}
const EXPECTED = {
"1:rdp": {
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "admin",
password: "rdp-pass",
fields: { domain: "CORP" },
secretFields: {},
},
"2:vnc": {
protocol: "vnc",
authType: "credential",
credentialId: 5,
username: null,
password: null,
fields: {},
secretFields: {},
},
"3:telnet": {
protocol: "telnet",
authType: "direct",
credentialId: null,
username: "tu",
password: "tel-plain",
fields: {},
secretFields: {},
},
"4:rdp": {
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "old-user",
password: "old-pass",
fields: { domain: "OLDDOM" },
secretFields: {},
},
"5:rdp": {
protocol: "rdp",
authType: "none",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
},
};
describe("runProtocolAuthMigration", () => {
it("copies every 2.8 login shape, and a second run changes nothing", async () => {
const first = await runProtocolAuthMigration();
expect(first).toEqual({ copied: 5, pendingUsers: 1 });
expect(await logins()).toEqual(EXPECTED);
const second = await runProtocolAuthMigration();
expect(second).toEqual({ copied: 0, pendingUsers: 1 });
expect(await logins()).toEqual(EXPECTED);
});
it("leaves the legacy columns in place", async () => {
await runProtocolAuthMigration();
const [row] = await database!.query<{ rdp_user: string }>(
sql`SELECT rdp_user FROM ssh_data WHERE id = 1`,
);
expect(row.rdp_user).toBe("admin");
});
it("does not bring back a login the user removed", async () => {
await runProtocolAuthMigration();
await createCurrentHostProtocolAuthRepository().delete(1, "rdp");
await runProtocolAuthMigration();
expect(await logins()).not.toHaveProperty("1:rdp");
});
it("waits for a locked data key, then copies at that user's login", async () => {
await runProtocolAuthMigration();
const repository = createCurrentHostProtocolAuthRepository();
expect(await repository.listRowsForHost(7)).toEqual([]);
const lockedKey = crypto.randomBytes(32);
state.keys.set("locked", lockedKey);
expect(await runProtocolAuthMigration("locked")).toEqual({
copied: 1,
pendingUsers: 0,
});
expect(await repository.find(7, "rdp", lockedKey)).toMatchObject({
username: "waits",
});
const [marker] = await database!.query<{ value: string }>(
sql`SELECT value FROM settings WHERE key = ${copiedMarker("locked")}`,
);
expect(marker.value).toBe("done");
});
it("skips a host that already has a login, as after a partial run", async () => {
await createCurrentHostProtocolAuthRepository().upsert(
"owner",
1,
{
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "edited",
password: null,
fields: {},
secretFields: {},
},
ownerKey,
);
await runProtocolAuthMigration();
expect((await logins())["1:rdp"]).toMatchObject({ username: "edited" });
expect(await logins()).toHaveProperty("3:telnet");
});
});
@@ -12,9 +12,19 @@ const state = vi.hoisted(() => ({
accessToHost: new Map<number, number>(),
grants: [] as Array<Record<string, unknown>>,
roleMembers: new Map<number, string[]>(),
// hostId -> that host's plugin protocol logins, decrypted
logins: new Map<number, Array<Record<string, unknown>>>(),
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostProtocolAuthRepository: () => ({
listForHost: async (hostId: number) => state.logins.get(hostId) ?? [],
}),
createCurrentHostResolutionRepository: () => ({
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
findHostOwnerId: async (hostId: number) =>
@@ -137,22 +147,20 @@ function baseHost(overrides: Record<string, unknown> = {}) {
credentialId: null,
shareSshAuth: false,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
rdpAuthType: null,
vncAuthType: null,
telnetAuthType: null,
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
rdpUser: null,
rdpPassword: null,
rdpDomain: null,
vncUser: null,
vncPassword: null,
telnetUser: null,
telnetPassword: null,
...overrides,
};
}
/** A plugin protocol login as the repository hands it back, decrypted. */
function login(protocol: string, overrides: Record<string, unknown> = {}) {
return {
protocol,
authType: "direct",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
...overrides,
};
}
@@ -164,6 +172,7 @@ beforeEach(() => {
state.accessToHost = new Map([[7, 42]]);
state.grants = [];
state.roleMembers.clear();
state.logins.clear();
});
describe("SharedHostSecretsManager", () => {
@@ -189,7 +198,7 @@ describe("SharedHostSecretsManager", () => {
});
});
it("snapshots opted-in SSH credential auth alongside enabled non-SSH protocols", async () => {
it("snapshots opted-in SSH credential auth alongside plugin protocol logins", async () => {
state.credentials.set(123, {
id: 123,
userId: "owner",
@@ -208,22 +217,24 @@ describe("SharedHostSecretsManager", () => {
credentialId: 123,
shareSshAuth: true,
password: null,
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
rdpDomain: "CORP",
enableTelnet: true,
telnetUser: "tel-user",
telnetPassword: "tel-pass",
}),
);
state.logins.set(42, [
login("spice", {
username: "spice-admin",
password: "spice-pass",
fields: { display: "2" },
secretFields: { ticket: "t-1" },
}),
login("x2go", { username: "x-user", password: "x-pass" }),
]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([
"rdp",
"spice",
"ssh",
"telnet",
"x2go",
]);
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
@@ -234,21 +245,101 @@ describe("SharedHostSecretsManager", () => {
keyType: "ssh-ed25519",
});
const rdp = await manager.getSecretForUser(42, "target", "rdp");
expect(rdp).toMatchObject({
username: "rdp-admin",
password: "rdp-pass",
domain: "CORP",
const spice = await manager.getSecretForUser(42, "target", "spice");
expect(spice).toMatchObject({
username: "spice-admin",
password: "spice-pass",
fields: { display: "2", ticket: "t-1" },
authType: "direct",
});
const telnet = await manager.getSecretForUser(42, "target", "telnet");
expect(telnet).toMatchObject({
username: "tel-user",
password: "tel-pass",
const x2go = await manager.getSecretForUser(42, "target", "x2go");
expect(x2go).toMatchObject({
username: "x-user",
password: "x-pass",
});
});
it("snapshots a credential-backed protocol login from the credential", async () => {
state.credentials.set(55, {
id: 55,
userId: "owner",
username: "cred-viewer",
password: "cred-pass",
});
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", {
authType: "credential",
credentialId: 55,
fields: { display: "1" },
}),
]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows[0]).toMatchObject({
protocol: "spice",
sourceType: "credential",
originalCredentialId: 55,
});
expect(await manager.getSecretForUser(42, "target", "spice")).toMatchObject(
{
authType: "credential",
username: "cred-viewer",
password: "cred-pass",
fields: { display: "1" },
},
);
});
it("takes no snapshot of a login that asks at connect time", async () => {
state.hosts.set(42, baseHost());
state.logins.set(42, [login("spice", { authType: "none" })]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows).toHaveLength(0);
});
it("reads a snapshot taken before 2.9.0 with its domain column", async () => {
const recordId = "shared-7-target-legacy";
state.secretRows.push({
id: 1,
hostAccessId: 7,
targetUserId: "target",
protocol: "legacy",
encryptedUsername: FieldCrypto.encryptField(
"admin",
targetDEK,
recordId,
"username",
),
encryptedAuthType: "direct",
encryptedDomain: FieldCrypto.encryptField(
"CORP",
targetDEK,
recordId,
"domain",
),
});
expect(
await manager.getSecretForUser(42, "target", "legacy"),
).toMatchObject({ username: "admin", fields: { domain: "CORP" } });
});
it("drops a protocol's snapshot once its login is gone", async () => {
state.hosts.set(42, baseHost());
state.logins.set(42, [login("spice", { username: "u", password: "p" })]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows).toHaveLength(1);
state.logins.set(42, []);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows).toHaveLength(0);
});
it("produces no snapshot rows for secret-less auth types", async () => {
state.hosts.set(
42,
@@ -283,36 +374,54 @@ describe("SharedHostSecretsManager", () => {
});
it("cannot be decrypted with the wrong DEK", async () => {
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", {
username: "spice-admin",
password: "spice-pass",
secretFields: { ticket: "t-1" },
}),
);
]);
await manager.snapshotForUser(7, 42, "target", "owner");
const row = state.secretRows[0];
expect(() =>
FieldCrypto.decryptField(
row.encryptedPassword as string,
ownerDEK,
"shared-7-target-rdp",
"password",
),
).toThrow();
for (const [column, field] of [
["encryptedPassword", "password"],
["encryptedFields", "fields"],
]) {
expect(() =>
FieldCrypto.decryptField(
row[column] as string,
ownerDEK,
"shared-7-target-spice",
field,
),
).toThrow();
}
});
it("refuses a tampered snapshot rather than handing it out", async () => {
state.hosts.set(42, baseHost());
state.logins.set(42, [login("spice", { password: "spice-pass" })]);
await manager.snapshotForUser(7, 42, "target", "owner");
const row = state.secretRows[0];
const envelope = JSON.parse(row.encryptedPassword as string);
envelope.data = envelope.data.replace(/^./, (c: string) =>
c === "0" ? "1" : "0",
);
row.encryptedPassword = JSON.stringify(envelope);
await expect(
manager.getSecretForUser(42, "target", "spice"),
).rejects.toThrow();
});
it("resyncHost re-snapshots direct grants and role members", async () => {
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
}),
);
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", { username: "spice-admin", password: "spice-pass" }),
]);
state.accessToHost = new Map([
[1, 42],
[2, 42],
@@ -334,29 +443,20 @@ describe("SharedHostSecretsManager", () => {
]);
// Owner rotates the non-SSH password; resync updates those copies.
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rotated",
}),
);
state.logins.set(42, [
login("spice", { username: "spice-admin", password: "rotated" }),
]);
await manager.resyncHost(42);
const secret = await manager.getSecretForUser(42, "target", "rdp");
const secret = await manager.getSecretForUser(42, "target", "spice");
expect(secret?.password).toBe("rotated");
});
it("snapshotForRoleMember fans out from role grants", async () => {
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
}),
);
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", { username: "spice-admin", password: "spice-pass" }),
]);
state.accessToHost = new Map([[2, 42]]);
state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }];
@@ -366,7 +466,7 @@ describe("SharedHostSecretsManager", () => {
expect(state.secretRows[0]).toMatchObject({
hostAccessId: 2,
targetUserId: "member-1",
protocol: "rdp",
protocol: "spice",
});
});
});
@@ -0,0 +1,241 @@
/**
* Copies each host's 2.8 RDP, VNC and Telnet logins out of the ssh_data
* columns into host_protocol_auth, re-encrypted with the owner's data key
* under the new table's context.
*
* The columns stay in the database, unused, until 3.0.0. A user's hosts are
* copied once their data key opens (at boot for a system-wrapped key, at the
* next password login for a 2.8 key that only the password opens), and a
* per-user marker then stops a later boot from bringing back a login the
* user removed. A host that already has a login for a protocol is skipped,
* so a run that stopped half way is safe to repeat.
*
* Very old installs kept a remote desktop host's login in username,
* password and domain; 2.8 copied those into the protocol columns on every
* boot, and this does the same for a host whose protocol columns are empty.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import {
createCurrentHostProtocolAuthRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
import {
selectLegacyRows,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
interface LegacyProtocol {
id: string;
user: string;
password: string;
passwordField: string;
credential: string;
authType: string;
domain?: string;
}
const PROTOCOLS: LegacyProtocol[] = [
{
id: "rdp",
user: "rdp_user",
password: "rdp_password",
passwordField: "rdpPassword",
credential: "rdp_credential_id",
authType: "rdp_auth_type",
domain: "rdp_domain",
},
{
id: "vnc",
user: "vnc_user",
password: "vnc_password",
passwordField: "vncPassword",
credential: "vnc_credential_id",
authType: "vnc_auth_type",
},
{
id: "telnet",
user: "telnet_user",
password: "telnet_password",
passwordField: "telnetPassword",
credential: "telnet_credential_id",
authType: "telnet_auth_type",
},
];
const AUTH_TYPES = new Set(["direct", "credential", "none"]);
export function copiedMarker(userId: string): string {
return `protocol_auth_copied_v1:${userId}`;
}
type LegacyRow = Record<string, unknown> & { id: number; user_id: string };
function text(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/**
* A 2.8 secret column read raw: plaintext, or an envelope written under the
* field's own name, its column name, or "password" when 2.8 copied the
* host's password blob into it.
*/
export function openLegacySecret(
value: unknown,
dek: Buffer,
recordId: string,
fieldNames: string[],
): string | null {
const raw = typeof value === "string" ? value : "";
if (!raw) return null;
if (!FieldCrypto.isEncrypted(raw)) return raw;
for (const fieldName of fieldNames) {
try {
return FieldCrypto.decryptField(raw, dek, recordId, fieldName);
} catch {
// Try the next name.
}
}
return null;
}
/** The login a 2.8 host row held for one protocol, or null for none. */
export function legacyLogin(
row: LegacyRow,
protocol: LegacyProtocol,
dek: Buffer,
credentialExists: (id: number) => boolean,
): HostProtocolLogin | null {
const recordId = String(row.id);
const storedType = text(row[protocol.authType]);
const credentialValue = Number(row[protocol.credential]);
const credentialId =
Number.isInteger(credentialValue) &&
credentialValue > 0 &&
credentialExists(credentialValue)
? credentialValue
: null;
let username = text(row[protocol.user]);
let password = openLegacySecret(row[protocol.password], dek, recordId, [
protocol.passwordField,
protocol.password,
"password",
]);
let domain = protocol.domain ? text(row[protocol.domain]) : "";
const hasOwn =
!!storedType ||
!!row[protocol.credential] ||
!!username ||
!!row[protocol.password] ||
!!domain;
if (!hasOwn) {
if (text(row.connection_type) !== protocol.id) return null;
username = text(row.username);
password = openLegacySecret(row.password, dek, recordId, ["password"]);
domain = protocol.domain ? text(row.domain) : "";
if (!username && !password && !domain) return null;
} else if (protocol.domain && !domain) {
domain = text(row.domain);
}
const authType = AUTH_TYPES.has(storedType)
? storedType
: credentialId
? "credential"
: "direct";
const direct = authType === "direct";
return {
protocol: protocol.id,
authType,
credentialId: authType === "credential" ? credentialId : null,
username: direct ? username || null : null,
password: direct ? password : null,
fields: domain ? { domain } : {},
secretFields: {},
};
}
export interface ProtocolAuthMigrationResult {
copied: number;
pendingUsers: number;
}
/** Copies every user's logins, or only `userId`'s after a login opened their key. */
export async function runProtocolAuthMigration(
userId?: string,
): Promise<ProtocolAuthMigrationResult> {
const result: ProtocolAuthMigrationResult = { copied: 0, pendingUsers: 0 };
const rows = await selectLegacyRows<LegacyRow>(sql`
SELECT id, user_id, connection_type, username, password, domain,
rdp_user, rdp_password, rdp_domain, rdp_credential_id, rdp_auth_type,
vnc_user, vnc_password, vnc_credential_id, vnc_auth_type,
telnet_user, telnet_password, telnet_credential_id, telnet_auth_type
FROM ssh_data
`);
if (rows.length === 0) return result;
let credentialIds: Set<number>;
try {
credentialIds = new Set(
(
await selectRows<{ id: number | string }>(
sql`SELECT id FROM ssh_credentials`,
)
).map((row) => Number(row.id)),
);
} catch {
return result;
}
const settings = createCurrentSettingsRepository();
const repository = createCurrentHostProtocolAuthRepository();
const byUser = new Map<string, LegacyRow[]>();
for (const row of rows) {
const owner = String(row.user_id);
if (userId && owner !== userId) continue;
byUser.set(owner, [...(byUser.get(owner) ?? []), row]);
}
for (const [owner, hosts] of byUser) {
try {
if ((await settings.get(copiedMarker(owner))) === "done") continue;
const dek = DataCrypto.getUserDataKey(owner);
if (!dek) {
result.pendingUsers++;
continue;
}
for (const host of hosts) {
const hostId = Number(host.id);
for (const protocol of PROTOCOLS) {
const login = legacyLogin(host, protocol, dek, (id) =>
credentialIds.has(id),
);
if (!login) continue;
if (await repository.findRow(hostId, protocol.id)) continue;
await repository.upsert(owner, hostId, login, dek);
result.copied++;
}
}
await settings.set(copiedMarker(owner), "done");
} catch (error) {
databaseLogger.warn("Protocol login copy failed for a user", {
operation: "protocol_auth_migration",
userId: owner,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (result.copied > 0) {
databaseLogger.info(
`Moved ${result.copied} remote desktop login(s) into host_protocol_auth`,
{ operation: "protocol_auth_migration" },
);
}
return result;
}
-3
View File
@@ -25,9 +25,6 @@ class FieldCrypto {
"autostartKey",
"autostartKeyPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
]),
ssh_credentials: new Set([
"password",
@@ -11,9 +11,6 @@ export class LazyFieldEncryption {
autostart_key: "autostartKey",
autostart_key_password: "autostartKeyPassword",
socks5_password: "socks5Password",
rdp_password: "rdpPassword",
vnc_password: "vncPassword",
telnet_password: "telnetPassword",
private_key: "privateKey",
public_key: "publicKey",
password_hash: "passwordHash",
@@ -28,9 +25,6 @@ export class LazyFieldEncryption {
autostartKey: "autostart_key",
autostartKeyPassword: "autostart_key_password",
socks5Password: "socks5_password",
rdpPassword: "rdp_password",
vncPassword: "vnc_password",
telnetPassword: "telnet_password",
privateKey: "private_key",
publicKey: "public_key",
passwordHash: "password_hash",
@@ -97,27 +91,6 @@ export class LazyFieldEncryption {
}
}
// Guac hosts migrated from single-protocol: rdpPassword/vncPassword/telnetPassword
// columns were populated by copying the encrypted `password` blob. Try decrypting
// under the original field name before giving up.
if (
fieldName === "rdpPassword" ||
fieldName === "vncPassword" ||
fieldName === "telnetPassword"
) {
try {
const decrypted = FieldCrypto.decryptField(
fieldValue,
userKEK,
recordId,
"password",
);
return decrypted;
} catch {
// not encrypted as "password" either
}
}
const sensitiveFields = [
"totpSecret",
"totpBackupCodes",
@@ -129,9 +102,6 @@ export class LazyFieldEncryption {
"autostartKey",
"autostartKeyPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"privateKey",
"publicKey",
"clientSecret",
@@ -283,9 +253,6 @@ export class LazyFieldEncryption {
autostartKey: "autostart_key",
autostartKeyPassword: "autostart_key_password",
socks5Password: "socks5_password",
rdpPassword: "rdp_password",
vncPassword: "vnc_password",
telnetPassword: "telnet_password",
clientSecret: "client_secret",
oidcIdentifier: "oidc_identifier",
};
@@ -301,9 +268,6 @@ export class LazyFieldEncryption {
"autostartKey",
"autostartKeyPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
],
ssh_credentials: [
"password",
@@ -1,8 +1,5 @@
import {
AUTH_PROTOCOL_METADATA,
isSupportedAuthOverrideProtocol,
type AuthOverrideProtocol,
} from "../../types/auth-protocols.js";
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
import {
createCurrentCredentialRepository,
createCurrentSharedHostAuthOverrideRepository,
@@ -109,9 +106,9 @@ export class SharedHostAuthOverrideService {
}
private requireSupportedProtocol(protocol: AuthOverrideProtocol): void {
if (!isSupportedAuthOverrideProtocol(protocol)) {
if (!isAuthOverrideProtocol(protocol)) {
throw new SharedHostAuthOverrideServiceError(
`${AUTH_PROTOCOL_METADATA[protocol].label} authentication overrides are not supported yet`,
`No plugin declares the ${protocol} protocol`,
400,
);
}
+6 -10
View File
@@ -1,7 +1,5 @@
import {
isSupportedAuthOverrideProtocol,
type AuthOverrideProtocol,
} from "../../types/auth-protocols.js";
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
@@ -37,8 +35,8 @@ export function requiresPersonalHostAuthentication(
host: Pick<HostResolutionHostRecord, "credentialId" | "authType">,
protocol: AuthOverrideProtocol,
): boolean {
// Owner auth for RDP/VNC/Telnet is snapshotted for every recipient, so only
// SSH, which sits behind shareSshAuth, can leave a recipient without auth.
// Owner auth for a plugin protocol is snapshotted for every recipient, so
// only SSH, which sits behind shareSshAuth, can leave one without auth.
if (protocol !== "ssh") return false;
if (host.credentialId) return true;
ensureCoreSshAuthProviders();
@@ -64,10 +62,8 @@ export async function resolveRecipientSharedHostAuthentication(
userId: string,
protocol: AuthOverrideProtocol,
): Promise<RecipientSharedHostAuthResolution> {
if (!isSupportedAuthOverrideProtocol(protocol)) {
throw new Error(
`${protocol.toUpperCase()} shared-host authentication is not implemented`,
);
if (!isAuthOverrideProtocol(protocol)) {
throw new Error(`No plugin declares the ${String(protocol)} protocol`);
}
const repository = createCurrentHostResolutionRepository();
@@ -13,6 +13,8 @@ import type { HostResolutionHostRecord } from "../database/repositories/host-res
import { DataCrypto } from "./data-crypto.js";
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import { listProtocolLogins } from "../hosts/protocol-auth/protocol-auth.js";
export interface SharedSecretData {
username?: string;
@@ -22,6 +24,8 @@ export interface SharedSecretData {
keyPassword?: string;
keyType?: string;
domain?: string;
/** A plugin protocol's declared credential fields. */
fields?: Record<string, string>;
}
interface ProtocolSnapshot {
@@ -39,25 +43,38 @@ function snapshotRecordId(
return `shared-${hostAccessId}-${targetUserId}-${protocol}`;
}
// SSH follows its own switch. The remote desktop protocols' switches are the
// remote-desktop plugin's host settings, and the host editor clears a
// protocol's login when it is switched off, so a snapshot is taken whenever
// login data is present (see collectProtocolSnapshots).
function enabledProtocols(
host: HostResolutionHostRecord,
): Record<ShareProtocol, boolean> {
// SSH follows its own switch. A plugin protocol's switch is that plugin's
// host setting, and the editor removes a protocol's login when it is
// switched off, so a snapshot is taken whenever a login is stored.
function sshEnabled(host: HostResolutionHostRecord): boolean {
const ct = host.connectionType;
return {
ssh: host.enableSsh !== false && (!ct || ct === "ssh"),
rdp: true,
vnc: true,
telnet: true,
};
return host.enableSsh !== false && (!ct || ct === "ssh");
}
// Per-recipient copies of connection secrets, re-encrypted under the
// recipient's DEK. SSH authentication is copied only when the host owner
// explicitly opts in; recipient-owned credential overrides remain separate.
/**
* A snapshot's protocol fields. Snapshots taken before 2.9.0 kept a domain
* in its own column and nothing else.
*/
function parseFields(
value: string | undefined,
legacyDomain: string | undefined,
): Record<string, string> | undefined {
if (value) {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, string>;
}
} catch {
// Unreadable fields are left out.
}
}
return legacyDomain ? { domain: legacyDomain } : undefined;
}
class SharedHostSecretsManager {
private static instance: SharedHostSecretsManager;
@@ -120,6 +137,10 @@ class SharedHostSecretsManager {
),
encryptedKeyType: snapshot.data.keyType || null,
encryptedDomain: encrypt(snapshot.data.domain, "domain"),
encryptedFields: encrypt(
snapshot.data.fields ? JSON.stringify(snapshot.data.fields) : "",
"fields",
),
});
}
@@ -361,10 +382,9 @@ class SharedHostSecretsManager {
ownerId: string,
): Promise<ProtocolSnapshot[]> {
const repository = createCurrentHostResolutionRepository();
const enabled = enabledProtocols(host);
const snapshots: ProtocolSnapshot[] = [];
if (enabled.ssh && host.shareSshAuth) {
if (sshEnabled(host) && host.shareSshAuth) {
if (host.credentialId) {
const credential = await repository.findCredentialByIdForUser(
host.credentialId,
@@ -405,106 +425,40 @@ class SharedHostSecretsManager {
}
}
if (enabled.rdp) {
const rdpAuthType =
host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct");
if (rdpAuthType === "credential" && host.rdpCredentialId) {
const credential = await repository.findCredentialByIdForUser(
host.rdpCredentialId,
for (const login of await listProtocolLogins(host.id, ownerId)) {
const fields = { ...login.fields, ...login.secretFields };
const hasFields = Object.keys(fields).length > 0;
if (login.authType === "credential" && login.credentialId) {
const credential = await findUsableCredential(
login.credentialId,
ownerId,
);
if (credential) {
snapshots.push({
protocol: "rdp",
protocol: login.protocol,
sourceType: "credential",
originalCredentialId: host.rdpCredentialId,
originalCredentialId: login.credentialId,
data: {
username: credential.username || undefined,
authType: "credential",
password: credential.password || undefined,
domain: host.rdpDomain || undefined,
fields: hasFields ? fields : undefined,
},
});
}
} else if (host.rdpUser || host.rdpPassword) {
} else if (
login.authType === "direct" &&
(login.username || login.password || hasFields)
) {
snapshots.push({
protocol: "rdp",
protocol: login.protocol,
sourceType: "inline",
originalCredentialId: null,
data: {
username: host.rdpUser || undefined,
username: login.username || undefined,
authType: "direct",
password: host.rdpPassword || undefined,
domain: host.rdpDomain || undefined,
},
});
}
}
if (enabled.vnc) {
const vncAuthType =
host.vncAuthType || (host.vncCredentialId ? "credential" : "direct");
if (vncAuthType === "credential" && host.vncCredentialId) {
const credential = await repository.findCredentialByIdForUser(
host.vncCredentialId,
ownerId,
);
if (credential) {
snapshots.push({
protocol: "vnc",
sourceType: "credential",
originalCredentialId: host.vncCredentialId,
data: {
username: credential.username || undefined,
authType: "credential",
password: credential.password || undefined,
},
});
}
} else if (host.vncUser || host.vncPassword) {
snapshots.push({
protocol: "vnc",
sourceType: "inline",
originalCredentialId: null,
data: {
username: host.vncUser || undefined,
authType: "direct",
password: host.vncPassword || undefined,
},
});
}
}
if (enabled.telnet) {
const telnetAuthType =
host.telnetAuthType ||
(host.telnetCredentialId ? "credential" : "direct");
if (telnetAuthType === "credential" && host.telnetCredentialId) {
const credential = await repository.findCredentialByIdForUser(
host.telnetCredentialId,
ownerId,
);
if (credential) {
snapshots.push({
protocol: "telnet",
sourceType: "credential",
originalCredentialId: host.telnetCredentialId,
data: {
username: credential.username || undefined,
authType: "credential",
password: credential.password || undefined,
},
});
}
} else if (host.telnetUser || host.telnetPassword) {
snapshots.push({
protocol: "telnet",
sourceType: "inline",
originalCredentialId: null,
data: {
username: host.telnetUser || undefined,
authType: "direct",
password: host.telnetPassword || undefined,
password: login.password || undefined,
fields: hasFields ? fields : undefined,
},
});
}
@@ -527,6 +481,7 @@ class SharedHostSecretsManager {
? FieldCrypto.decryptField(value, userDEK, recordId, fieldName)
: undefined;
const domain = decrypt(secret.encryptedDomain, "domain");
return {
username: decrypt(secret.encryptedUsername, "username"),
authType: secret.encryptedAuthType || "password",
@@ -534,7 +489,8 @@ class SharedHostSecretsManager {
key: decrypt(secret.encryptedKey, "key"),
keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"),
keyType: secret.encryptedKeyType || undefined,
domain: decrypt(secret.encryptedDomain, "domain"),
domain,
fields: parseFields(decrypt(secret.encryptedFields, "fields"), domain),
};
}
}
+1 -1
View File
@@ -121,7 +121,7 @@ const swaggerOptions: SwaggerJSDocOptions = {
},
{
name: "Guacamole",
description: "RDP, VNC, and Telnet remote desktop sessions",
description: "Remote desktop sessions",
},
{
name: "Proxmox",
+21 -2
View File
@@ -1,7 +1,10 @@
import {
createCurrentHostProtocolAuthRepository,
createCurrentUserDataExportRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { decryptProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
import { toPortableLogins } from "../hosts/protocol-auth/protocol-auth.js";
import { readUserPluginRows } from "../plugins/user-data.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
@@ -59,12 +62,28 @@ class UserDataExport {
const exportRepository = createCurrentUserDataExportRepository();
const sshHosts = await exportRepository.listHostsByUserId(userId);
const processedSshHosts =
// Each host's plugin protocol logins ride on it, sealed like the
// host's own secrets unless the export is plaintext.
const logins = new Map<number, Record<string, unknown>>();
for (const row of await createCurrentHostProtocolAuthRepository().listRowsForUser(
userId,
)) {
const own = logins.get(row.hostId) ?? {};
own[row.protocol] =
format === "plaintext" && userDataKey
? toPortableLogins([decryptProtocolLogin(row, userDataKey)])[
row.protocol
]
: row;
logins.set(row.hostId, own);
}
const processedSshHosts = (
format === "plaintext" && userDataKey
? sshHosts.map((host) =>
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
)
: sshHosts;
: sshHosts
).map((host) => ({ ...host, protocolAuth: logins.get(host.id) ?? {} }));
let sshCredentialsData: unknown[] = [];
if (includeCredentials) {
+4 -51
View File
@@ -1,55 +1,8 @@
export const AUTH_OVERRIDE_PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const;
/** Core's own protocol. Every other one is declared by a plugin. */
export const SSH_AUTH_PROTOCOL = "ssh";
export type AuthOverrideProtocol = (typeof AUTH_OVERRIDE_PROTOCOLS)[number];
const SUPPORTED_AUTH_OVERRIDE_PROTOCOLS =
AUTH_OVERRIDE_PROTOCOLS satisfies readonly AuthOverrideProtocol[];
export const AUTH_PROTOCOL_METADATA = {
ssh: {
label: "SSH",
credentialField: "credentialId",
},
rdp: {
label: "RDP",
credentialField: "rdpCredentialId",
},
vnc: {
label: "VNC",
credentialField: "vncCredentialId",
},
telnet: {
label: "Telnet",
credentialField: "telnetCredentialId",
},
} as const satisfies Record<
AuthOverrideProtocol,
{
label: string;
credentialField:
| "credentialId"
| "rdpCredentialId"
| "vncCredentialId"
| "telnetCredentialId";
}
>;
export function isAuthOverrideProtocol(
value: unknown,
): value is AuthOverrideProtocol {
return (
typeof value === "string" &&
AUTH_OVERRIDE_PROTOCOLS.includes(value as AuthOverrideProtocol)
);
}
export function isSupportedAuthOverrideProtocol(
protocol: AuthOverrideProtocol,
): boolean {
return SUPPORTED_AUTH_OVERRIDE_PROTOCOLS.includes(
protocol as (typeof SUPPORTED_AUTH_OVERRIDE_PROTOCOLS)[number],
);
}
/** "ssh", or a protocol id a plugin declares in contributes.protocols. */
export type AuthOverrideProtocol = string;
export interface HostAuthOverrideState<
CredentialId extends number | string = number,
+25 -32
View File
@@ -1,4 +1,8 @@
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
import type {
HostProtocolAuthSummary,
HostProtocolAuthType,
HostSshOptions,
} from "@termix/plugin-sdk/frontend";
import type { Request } from "express";
import type { RefObject } from "react";
import type { HostAuthOverrides } from "./auth-protocols.js";
@@ -15,6 +19,19 @@ export type {
export type SSHAuthType =
"password" | "key" | "credential" | "none" | "agent" | (string & {});
export type { HostProtocolAuthSummary, HostProtocolAuthType };
/** One plugin protocol's login in a host write. */
export interface HostProtocolAuthInput {
authType?: HostProtocolAuthType;
credentialId?: number | null;
username?: string | null;
/** Left out to keep the saved password. */
password?: string | null;
/** Declared credential fields; one left out keeps its value. */
fields?: Record<string, string | null>;
}
export type WebEndpointAccess = "direct" | "tunnel";
export type WebEndpointRender = "external" | "embedded";
@@ -109,23 +126,11 @@ export type Host = {
/** "ssh", or the id of the plugin protocol a host without SSH uses. */
connectionType?: string;
domain?: string;
enableSsh?: boolean;
sshPort?: number;
rdpCredentialId?: number | null;
rdpUser?: string;
rdpPassword?: string;
rdpDomain?: string;
vncCredentialId?: number | null;
vncPassword?: string;
vncUser?: string;
telnetUser?: string;
telnetPassword?: string;
telnetCredentialId?: number | null;
rdpAuthType?: "direct" | "credential" | "none" | null;
vncAuthType?: "direct" | "credential" | null;
telnetAuthType?: "direct" | "credential" | null;
/** Each plugin protocol's login, secrets left out. */
protocolAuth?: Record<string, HostProtocolAuthSummary>;
/**
* Stable identity across a desktop/server sync pair. `id` is an
* autoincrement local to whichever database produced the row, so it cannot
@@ -148,9 +153,6 @@ export type Host = {
// tell a stored secret from an empty one without receiving it.
hasPassword?: boolean;
hasSudoPassword?: boolean;
hasRdpPassword?: boolean;
hasVncPassword?: boolean;
hasTelnetPassword?: boolean;
isShared?: boolean;
authOverrides?: HostAuthOverrides;
@@ -228,23 +230,14 @@ export interface HostData {
/** "ssh", or the id of the plugin protocol a host without SSH uses. */
connectionType?: string;
domain?: string;
enableSsh?: boolean;
sshPort?: number;
rdpCredentialId?: number | null;
rdpUser?: string;
rdpPassword?: string;
rdpDomain?: string;
vncCredentialId?: number | null;
vncPassword?: string;
vncUser?: string;
telnetUser?: string;
telnetPassword?: string;
telnetCredentialId?: number | null;
rdpAuthType?: "direct" | "credential" | "none" | null;
vncAuthType?: "direct" | "credential" | null;
telnetAuthType?: "direct" | "credential" | null;
/**
* Plugin protocol logins to write, keyed by protocol id. A key left out
* keeps its login, null removes it, and a field left out keeps its value.
*/
protocolAuth?: Record<string, HostProtocolAuthInput | null>;
/** Desktop only: kept on this device, never synced to the server. */
localOnly?: boolean;
}
+6 -18
View File
@@ -1,9 +1,11 @@
import type {
HostProtocolAuthSummary,
HostSshOptions,
HostTerminalConfig,
SSHAuthType,
} from "./index.js";
import type { HostAuthOverrides } from "./auth-protocols.js";
import type { QuickConnectLogin } from "@termix/plugin-sdk/frontend";
export type Host = {
id: string;
@@ -44,6 +46,8 @@ export type Host = {
pin?: boolean;
/** Quick connect only: core can save this host as-is. */
quickConnectSavable?: boolean;
/** A Quick Connect host's plugin protocol login, never saved. */
quickConnectLogin?: QuickConnectLogin;
sortOrder?: number | null;
/** Stable identity across a desktop/server sync pair. */
@@ -80,24 +84,8 @@ export type Host = {
sshPort: number;
rdpAuthType?: "direct" | "credential" | "none";
rdpCredentialId?: string;
rdpUser?: string;
rdpPassword?: string;
hasRdpPassword?: boolean;
domain?: string;
vncAuthType?: "direct" | "credential";
vncCredentialId?: string;
vncPassword?: string;
hasVncPassword?: boolean;
vncUser?: string;
telnetAuthType?: "direct" | "credential";
telnetCredentialId?: string;
telnetUser?: string;
telnetPassword?: string;
hasTelnetPassword?: boolean;
/** Each plugin protocol's login, secrets left out. */
protocolAuth?: Record<string, HostProtocolAuthSummary>;
/** Host-scope plugin settings, keyed by plugin id. Secrets are redacted. */
pluginSettings?: Record<string, Record<string, unknown>>;
+1 -8
View File
@@ -99,14 +99,7 @@ export async function adminDeleteUserHost(
export async function adminGetHostPassword(
targetUserId: string,
hostId: number,
field:
| "password"
| "sudoPassword"
| "rdpPassword"
| "vncPassword"
| "telnetPassword"
| "key"
| "keyPassword" = "password",
field: "password" | "sudoPassword" | "key" | "keyPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
+1 -8
View File
@@ -116,14 +116,7 @@ export async function getSSHHostWithCredentials(
export async function getHostPassword(
hostId: number,
field:
| "password"
| "sudoPassword"
| "rdpPassword"
| "vncPassword"
| "telnetPassword"
| "key"
| "keyPassword" = "password",
field: "password" | "sudoPassword" | "key" | "keyPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
+1 -1
View File
@@ -10,7 +10,7 @@ interface OriginResolvableHost {
/**
* Resolves which backend a given host's interactive connection (SSH, Docker
* console, RDP/VNC/Telnet) should dial: the desktop app's embedded local
* console, remote desktop) should dial: the desktop app's embedded local
* backend, or the server it is linked to. A plugin whose connection is
* always local regardless of this setting (serial: the hardware is
* physically attached to this desktop machine) skips this helper entirely
+2 -2
View File
@@ -6,8 +6,8 @@ import {
} from "@/sidebar/host-contributions";
/**
* Which tab a click on a host opens. The ways to connect (SSH terminal, RDP,
* VNC, Telnet) are host actions that plugins register, so the answer is the
* Which tab a click on a host opens. The ways to connect (the SSH terminal,
* remote desktop) are host actions that plugins register, so the answer is the
* highest-priority connect action the host allows, or null when no running
* plugin can connect to it.
*/
+2 -2
View File
@@ -664,7 +664,7 @@ export function getRemoteCoreApi(): AxiosInstance {
}
function initializeApiInstances() {
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
// Host Management API (port 30001) - SSH and plugin protocols
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
sshHostApi = hostApi;
@@ -675,7 +675,7 @@ function initializeApiInstances() {
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
}
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
// Host Management API (port 30001) - SSH and plugin protocols
export let hostApi: AxiosInstance;
// Backward compatibility
export let sshHostApi: AxiosInstance;
+4 -3
View File
@@ -17,9 +17,10 @@ import {
} from "@/main-axios";
import type { Credential, Host } from "@/types/ui-types";
import {
AUTH_PROTOCOL_METADATA,
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import { authProtocolLabel } from "./host-permissions";
import { mapCredentials } from "./HostManagerData";
import { getConnectedRemoteApi } from "@/lib/remote-server-api";
@@ -44,7 +45,7 @@ export function HostAuthOverrideModal({
const overrideState = host.authOverrides?.[protocol];
const ownerAuthShared =
overrideState?.ownerAuthShared ??
(protocol === "ssh" ? !!host.shareSshAuth : false);
(protocol === SSH_AUTH_PROTOCOL ? !!host.shareSshAuth : false);
// A shared host's copy on a linked desktop: its override lives on the
// server, against the server's own credentials.
const sharedCopySyncId = host.sharedCopy ? (host.syncId ?? null) : null;
@@ -127,7 +128,7 @@ export function HostAuthOverrideModal({
<DialogHeader>
<DialogTitle>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
protocol: authProtocolLabel(protocol, t),
})}
</DialogTitle>
<DialogDescription>
-19
View File
@@ -145,25 +145,6 @@ export function HostEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [host]);
useEffect(() => {
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
let cancelled = false;
const loadVncPassword = adminTargetUserId
? adminGetHostPassword(adminTargetUserId, Number(host.id), "vncPassword")
: getHostPassword(Number(host.id), "vncPassword");
loadVncPassword.then((password) => {
if (cancelled || !password) return;
setForm((prev) =>
prev.vncPassword ? prev : { ...prev, vncPassword: password },
);
});
return () => {
cancelled = true;
};
}, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]);
/**
* Writes each plugin's host-scope values through its own route.
*
+70 -80
View File
@@ -1,6 +1,11 @@
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { HostProtocolAuthInput, SSHHostData } from "@/types";
import {
HOST_PROTOCOL_SECRET_KEPT,
type HostDraft,
type HostProtocolAuthForm,
type HostProtocolAuthSummary,
} from "@termix/plugin-sdk/frontend";
import type { HostDefaults } from "@/api/settings-api";
import {
listHostProtocols,
@@ -44,6 +49,66 @@ export function applyHostDraft(
};
}
/** A saved login as the editor form holds it; saved secrets stay behind a placeholder. */
export function protocolAuthForm(
summaries: Record<string, HostProtocolAuthSummary> | undefined,
): Record<string, HostProtocolAuthForm> {
const form: Record<string, HostProtocolAuthForm> = {};
for (const [protocol, summary] of Object.entries(summaries ?? {})) {
const fields: Record<string, string> = { ...(summary.fields ?? {}) };
for (const key of summary.secretFieldKeys ?? []) {
fields[key] = HOST_PROTOCOL_SECRET_KEPT;
}
form[protocol] = {
authType:
summary.authType ?? (summary.credentialId ? "credential" : "direct"),
credentialId:
summary.credentialId != null ? String(summary.credentialId) : "",
username: summary.username ?? "",
password: summary.hasPassword ? HOST_PROTOCOL_SECRET_KEPT : "",
fields,
};
}
return form;
}
/**
* The logins a host save sends: one per plugin protocol the editor knows,
* removed when its switch is off. A saved secret left alone is not sent.
*/
export function protocolAuthPayload(
form: Record<string, HostProtocolAuthForm>,
protocols: HostProtocols,
): Record<string, HostProtocolAuthInput | null> {
const payload: Record<string, HostProtocolAuthInput | null> = {};
for (const protocol of listHostProtocols()) {
if (!protocols[protocol.settingKey]) {
payload[protocol.id] = null;
continue;
}
const login = form[protocol.id];
if (!login) continue;
const direct = login.authType === "direct";
payload[protocol.id] = {
authType: login.authType,
credentialId:
login.authType === "credential" && login.credentialId
? Number(login.credentialId)
: null,
username: direct ? login.username || null : null,
...(direct && login.password !== HOST_PROTOCOL_SECRET_KEPT
? { password: login.password || null }
: {}),
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([, value]) => value !== HOST_PROTOCOL_SECRET_KEPT,
),
),
};
}
return payload;
}
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
@@ -107,32 +172,8 @@ export function createHostEditorForm(
portKnockSequence:
host?.portKnockSequence ??
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
rdpCredentialId: host?.rdpCredentialId ?? "",
rdpUser: host?.rdpUser ?? "",
rdpPassword: host?.hasRdpPassword
? "existing_rdp_password"
: (host?.rdpPassword ?? ""),
domain: host?.domain ?? "",
vncCredentialId: host?.vncCredentialId ?? "",
vncPassword: host?.hasVncPassword
? "existing_vnc_password"
: (host?.vncPassword ?? ""),
vncUser: host?.vncUser ?? "",
telnetUser: host?.telnetUser ?? "",
telnetPassword: host?.hasTelnetPassword
? "existing_telnet_password"
: (host?.telnetPassword ?? ""),
telnetCredentialId:
host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "",
rdpAuthType: (host?.rdpAuthType ??
(host?.rdpCredentialId ? "credential" : "direct")) as
"direct" | "credential" | "none",
vncAuthType: (host?.vncAuthType ??
(host?.vncCredentialId ? "credential" : "direct")) as
"direct" | "credential",
telnetAuthType: (host?.telnetAuthType ??
(host?.telnetCredentialId ? "credential" : "direct")) as
"direct" | "credential",
// Each plugin protocol's login, edited by that plugin's host editor tab.
protocolAuth: protocolAuthForm(host?.protocolAuth),
statusCheckEnabled:
host?.statusCheckEnabled ?? d?.statusCheckEnabled ?? true,
statusCheckInterval: (host?.statusCheckInterval ?? null) as number | null,
@@ -242,58 +283,7 @@ export function buildHostEditorPayload(
enableSsh: protocols.enableSsh,
sshPort: Number(form.sshPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null,
rdpCredentialId:
protocols.enableRdp &&
form.rdpAuthType === "credential" &&
form.rdpCredentialId
? Number(form.rdpCredentialId)
: null,
rdpUser:
protocols.enableRdp && form.rdpAuthType === "direct"
? form.rdpUser || null
: null,
rdpPassword:
protocols.enableRdp &&
form.rdpAuthType === "direct" &&
form.rdpPassword !== "existing_rdp_password"
? form.rdpPassword || null
: null,
rdpDomain: form.domain || null,
vncAuthType: protocols.enableVnc ? form.vncAuthType : null,
vncCredentialId:
protocols.enableVnc &&
form.vncAuthType === "credential" &&
form.vncCredentialId
? Number(form.vncCredentialId)
: null,
vncPassword:
protocols.enableVnc &&
form.vncAuthType === "direct" &&
form.vncPassword !== "existing_vnc_password"
? form.vncPassword || null
: null,
vncUser:
protocols.enableVnc && form.vncAuthType === "direct"
? form.vncUser || null
: null,
telnetAuthType: protocols.enableTelnet ? form.telnetAuthType : null,
telnetCredentialId:
protocols.enableTelnet &&
form.telnetAuthType === "credential" &&
form.telnetCredentialId
? Number(form.telnetCredentialId)
: null,
telnetUser:
protocols.enableTelnet && form.telnetAuthType === "direct"
? form.telnetUser || null
: null,
telnetPassword:
protocols.enableTelnet &&
form.telnetAuthType === "direct" &&
form.telnetPassword !== "existing_telnet_password"
? form.telnetPassword || null
: null,
protocolAuth: protocolAuthPayload(form.protocolAuth, protocols),
// The editor keeps ids as strings; the API and every backend lookup take
// a number, and a string id does not compare equal on Postgres/MySQL.
jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })),
+1 -1
View File
@@ -417,7 +417,7 @@ export function HostManager({
: makeCredentialTabs(t);
// Collapsing while on a now-hidden tab would leave nothing selected. The
// SSH group collapses to its own "ssh" tab, everything else to General.
// The top-level strip only lists general/ssh/rdp/vnc/telnet -- the SSH
// The top-level strip only lists general, ssh and protocol tabs -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const sshSubTabs = makeHostSshSubTabs(t, protocols);
+16 -39
View File
@@ -6,9 +6,6 @@ type RawSSHHost = SSHHostWithStatus & {
hasKey?: boolean;
hasKeyPassword?: boolean;
hasSudoPassword?: boolean;
hasRdpPassword?: boolean;
hasVncPassword?: boolean;
hasTelnetPassword?: boolean;
};
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
type RawCredential = {
@@ -81,31 +78,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
sshPort:
h.sshPort ??
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
rdpAuthType:
(h.rdpAuthType as "direct" | "credential") ??
(h.rdpCredentialId ? "credential" : "direct"),
rdpCredentialId:
h.rdpCredentialId != null ? String(h.rdpCredentialId) : undefined,
rdpUser: h.rdpUser,
rdpPassword: h.rdpPassword ?? "",
hasRdpPassword: !!host.hasRdpPassword || !!h.rdpPassword,
domain: h.rdpDomain,
vncAuthType:
(h.vncAuthType as "direct" | "credential") ??
(h.vncCredentialId ? "credential" : "direct"),
vncCredentialId:
h.vncCredentialId != null ? String(h.vncCredentialId) : undefined,
vncPassword: h.vncPassword ?? "",
hasVncPassword: !!host.hasVncPassword || !!h.vncPassword,
vncUser: h.vncUser,
telnetAuthType:
(h.telnetAuthType as "direct" | "credential") ??
(h.telnetCredentialId ? "credential" : "direct"),
telnetCredentialId:
h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined,
telnetUser: h.telnetUser,
telnetPassword: h.telnetPassword ?? "",
hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword,
protocolAuth: h.protocolAuth ?? {},
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
hostId: String(j.hostId ?? j.hostid ?? j),
})),
@@ -126,18 +99,22 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
isShared: h.isShared ?? false,
authOverrides: h.authOverrides
? Object.fromEntries(
Object.entries(h.authOverrides).map(([protocol, state]) => [
protocol,
Object.entries(h.authOverrides).flatMap(([protocol, state]) =>
state
? {
...state,
credentialId:
state.credentialId != null
? String(state.credentialId)
: undefined,
}
: state,
]),
? [
[
protocol,
{
...state,
credentialId:
state.credentialId != null
? String(state.credentialId)
: undefined,
},
],
]
: [],
),
)
: undefined,
permissionLevel: h.permissionLevel,
+26 -6
View File
@@ -1,9 +1,13 @@
import type { Host, SharePermissionLevel } from "@/types/ui-types";
import {
isSupportedAuthOverrideProtocol,
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import { listHostProtocols, protocolEnabled } from "./host-protocols";
import {
listHostProtocols,
protocolEnabled,
type HostProtocolDef,
} from "./host-protocols";
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
connect: 1,
@@ -32,14 +36,30 @@ export function canDeleteHost(host: Host): boolean {
return !host.isShared;
}
/** SSH, then every protocol a running plugin registered. */
export function authOverrideProtocols(
list: HostProtocolDef[] = listHostProtocols(),
): AuthOverrideProtocol[] {
return [SSH_AUTH_PROTOCOL, ...list.map((protocol) => protocol.id)];
}
/** How a protocol is named in the override menu and dialog. */
export function authProtocolLabel(
protocol: AuthOverrideProtocol,
t: (key: string) => string,
list: HostProtocolDef[] = listHostProtocols(),
): string {
if (protocol === SSH_AUTH_PROTOCOL) return "SSH";
const def = list.find((entry) => entry.id === protocol);
return def ? t(def.titleKey) : protocol;
}
export function canOverrideHostAuth(
host: Host,
protocol: AuthOverrideProtocol,
): boolean {
if (!host.isShared || !isSupportedAuthOverrideProtocol(protocol)) {
return false;
}
if (protocol === "ssh") return !!host.enableSsh;
if (!host.isShared) return false;
if (protocol === SSH_AUTH_PROTOCOL) return !!host.enableSsh;
// Other protocols are switched on in their plugin's host settings.
const plugin = listHostProtocols().find((entry) => entry.id === protocol);
return !!plugin && protocolEnabled(host.pluginSettings, plugin);
+1 -1
View File
@@ -4,7 +4,7 @@ import { byOrderThenId, createRegistry } from "@/lib/registry";
type Icon = ComponentType<{ className?: string; size?: number | string }>;
/**
* A connection protocol a plugin adds next to SSH (RDP, VNC). Its on/off
* A connection protocol a plugin adds next to SSH. Its on/off
* switch and port are the plugin's own host settings, so core reads them
* from host.pluginSettings[pluginId] and never knows the protocol by name.
*/
+8 -9
View File
@@ -33,19 +33,18 @@ export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
export function createQuickConnectHost(input: QuickConnectInput): Host {
const protocol = input.protocol;
if (protocol) {
// Protocol logins are core host fields named after the protocol.
const login = {
[`${protocol.id}AuthType`]: "direct",
[`${protocol.id}User`]: input.username,
[`${protocol.id}Password`]: input.password,
};
return {
...createQuickConnectHost({ ...input, protocol: undefined, port: 22 }),
...login,
// Never saved, so the login travels in plain text to the protocol's tab.
quickConnectLogin: {
protocol: protocol.id,
username: input.username,
password: input.password,
fields: input.domain ? { domain: input.domain } : {},
},
port: input.port,
enableSsh: false,
quickConnectSavable: false,
domain: input.domain,
pluginSettings: {
[protocol.pluginId]: {
[protocol.settingKey]: true,
@@ -100,7 +99,7 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
}
/**
* Every way to reach the address: connect actions (terminal, RDP, VNC...)
* Every way to reach the address: connect actions (terminal, remote desktop)
* by priority, then tools that opted into Quick Connect. Only actions that
* open a tab work here, since the host is never saved.
*/
+5 -7
View File
@@ -50,13 +50,11 @@ import {
canEditHost,
canOverrideHostAuth,
canShareHost,
authOverrideProtocols as listAuthOverrideProtocols,
authProtocolLabel,
} from "@/sidebar/host-permissions";
import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal";
import {
AUTH_OVERRIDE_PROTOCOLS,
AUTH_PROTOCOL_METADATA,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import type { AuthOverrideProtocol } from "@/types/auth-protocols";
import {
useStatusColorScheme,
getStatusClasses,
@@ -323,7 +321,7 @@ export function HostItem({
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
const authOverrideProtocols = AUTH_OVERRIDE_PROTOCOLS.filter((protocol) =>
const authOverrideProtocols = listAuthOverrideProtocols().filter((protocol) =>
canOverrideHostAuth(host, protocol),
);
const [authOverrideProtocol, setAuthOverrideProtocol] =
@@ -808,7 +806,7 @@ export function HostItem({
>
<KeyRound className="size-3.5 mr-2" />
{t("hosts.sharing.authOverrideActionProtocol", {
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
protocol: authProtocolLabel(protocol, t),
})}
</DropdownMenuItem>
))}
+12 -11
View File
@@ -614,17 +614,18 @@ export function SidebarTree({
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
enableSsh: host.enableSsh,
sshPort: host.sshPort,
rdpUser: host.rdpUser ?? null,
rdpPassword: host.rdpPassword ?? null,
rdpDomain: host.domain ?? null,
vncAuthType: host.vncAuthType ?? null,
vncCredentialId: host.vncCredentialId
? Number(host.vncCredentialId)
: null,
vncPassword: host.vncPassword ?? null,
vncUser: host.vncUser ?? null,
telnetUser: host.telnetUser ?? null,
telnetPassword: host.telnetPassword ?? null,
// Saved passwords never reach the browser, so a copy starts without them.
protocolAuth: Object.fromEntries(
Object.entries(host.protocolAuth ?? {}).map(([protocol, login]) => [
protocol,
{
authType: login.authType,
credentialId: login.credentialId ?? null,
username: login.username ?? null,
fields: login.fields ?? {},
},
]),
),
forceKeyboardInteractive: host.forceKeyboardInteractive ?? false,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host ?? null,
@@ -49,7 +49,7 @@ const host = {
ownerAuthShared: false,
},
},
} as Host;
} as unknown as Host;
beforeEach(() => {
api.getCredentials.mockReset();
@@ -115,7 +115,9 @@ describe("HostCredentialList credential usage", () => {
name: "rdp-only",
authType: "password",
credentialId: undefined,
rdpCredentialId: "1",
protocolAuth: {
rdp: { authType: "credential", credentialId: 1 },
},
enableSsh: false,
}),
],
+95 -71
View File
@@ -9,6 +9,7 @@ import {
type HostProtocols,
} from "../../sidebar/HostEditorData";
import type { Host } from "@/types/ui-types";
import { HOST_PROTOCOL_SECRET_KEPT } from "@termix/plugin-sdk/frontend";
const sshOnly: HostProtocols = {
enableSsh: true,
@@ -24,20 +25,6 @@ const rdpOnly: HostProtocols = {
enableTelnet: false,
};
const vncOnly: HostProtocols = {
enableSsh: false,
enableRdp: false,
enableVnc: true,
enableTelnet: false,
};
const telnetOnly: HostProtocols = {
enableSsh: false,
enableRdp: false,
enableVnc: false,
enableTelnet: true,
};
describe("omitOwnerSshAuthFromSharedEdit", () => {
it("keeps editable host settings but removes all owner SSH authentication fields", () => {
const form = createHostEditorForm(null);
@@ -308,75 +295,112 @@ describe("plugin host settings", () => {
});
});
describe("RDP/VNC/Telnet password persistence indicator", () => {
it("seeds a sentinel value when the host reports a saved rdp password", () => {
const host = {
hasRdpPassword: true,
rdpAuthType: "direct",
} as Host;
describe("plugin protocol logins", () => {
const spice = {
id: "spice",
pluginId: "spice-plugin",
settingKey: "enableSpice",
defaultPort: 5930,
titleKey: "spice",
icon: () => null,
};
const spiceOn: HostProtocols = { enableSsh: false, enableSpice: true };
const spiceOff: HostProtocols = { enableSsh: true, enableSpice: false };
const saved = {
protocolAuth: {
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "0" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
},
} as unknown as Host;
const form = createHostEditorForm(host);
it("holds a saved password and secret fields behind the placeholder", () => {
const form = createHostEditorForm(saved);
expect(form.rdpPassword).toBe("existing_rdp_password");
expect(form.protocolAuth.spice).toEqual({
authType: "direct",
credentialId: "",
username: "viewer",
password: HOST_PROTOCOL_SECRET_KEPT,
fields: { display: "0", ticket: HOST_PROTOCOL_SECRET_KEPT },
});
});
it("does not send the rdp sentinel back to the backend unchanged", () => {
const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host;
const form = { ...createHostEditorForm(host) };
const payload = buildHostEditorPayload(form, rdpOnly);
expect(payload.rdpPassword).toBeNull();
it("leaves unchanged secrets out of the payload", () => {
const dispose = registerHostProtocol(spice);
try {
const payload = buildHostEditorPayload(
createHostEditorForm(saved),
spiceOn,
);
expect(payload.protocolAuth?.spice).toEqual({
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "0" },
});
} finally {
dispose();
}
});
it("sends a newly typed rdp password", () => {
const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host;
const form = {
...createHostEditorForm(host),
rdpPassword: "new-rdp-pass",
};
const payload = buildHostEditorPayload(form, rdpOnly);
expect(payload.rdpPassword).toBe("new-rdp-pass");
it("sends a newly typed password", () => {
const dispose = registerHostProtocol(spice);
try {
const form = createHostEditorForm(saved);
form.protocolAuth.spice.password = "new-pass";
const payload = buildHostEditorPayload(form, spiceOn);
expect(payload.protocolAuth?.spice?.password).toBe("new-pass");
} finally {
dispose();
}
});
it("seeds a sentinel value when the host reports a saved vnc password", () => {
const host = { hasVncPassword: true, vncAuthType: "direct" } as Host;
const form = createHostEditorForm(host);
expect(form.vncPassword).toBe("existing_vnc_password");
it("sends only the credential for a credential login", () => {
const dispose = registerHostProtocol(spice);
try {
const form = createHostEditorForm(saved);
form.protocolAuth.spice = {
...form.protocolAuth.spice,
authType: "credential",
credentialId: "7",
};
const login = buildHostEditorPayload(form, spiceOn).protocolAuth?.spice;
expect(login).toMatchObject({
authType: "credential",
credentialId: 7,
username: null,
});
expect(login).not.toHaveProperty("password");
} finally {
dispose();
}
});
it("does not send the vnc sentinel back to the backend unchanged", () => {
const host = { hasVncPassword: true, vncAuthType: "direct" } as Host;
const form = { ...createHostEditorForm(host) };
const payload = buildHostEditorPayload(form, vncOnly);
expect(payload.vncPassword).toBeNull();
it("removes the login of a protocol switched off", () => {
const dispose = registerHostProtocol(spice);
try {
const payload = buildHostEditorPayload(
createHostEditorForm(saved),
spiceOff,
);
expect(payload.protocolAuth).toEqual({ spice: null });
} finally {
dispose();
}
});
it("seeds a sentinel value when the host reports a saved telnet password", () => {
const host = {
hasTelnetPassword: true,
telnetAuthType: "direct",
} as Host;
const form = createHostEditorForm(host);
expect(form.telnetPassword).toBe("existing_telnet_password");
});
it("does not send the telnet sentinel back to the backend unchanged", () => {
const host = {
hasTelnetPassword: true,
telnetAuthType: "direct",
} as Host;
const form = { ...createHostEditorForm(host) };
const payload = buildHostEditorPayload(form, telnetOnly);
expect(payload.telnetPassword).toBeNull();
it("leaves a protocol no running plugin registered alone", () => {
const payload = buildHostEditorPayload(
createHostEditorForm(saved),
spiceOn,
);
expect(payload.protocolAuth).toEqual({});
});
});
@@ -91,12 +91,13 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
expect(host).toMatchObject({
enableSsh: false,
pluginSettings: { demo: { enableDemo: true, demoPort: 3390 } },
domain: "CORP",
});
// The login sits on the core fields named after the protocol.
expect(host).toMatchObject({
"demo-desktopUser": "admin",
"demo-desktopPassword": "pw",
// The unsaved login travels to the protocol's tab as it is.
expect(host.quickConnectLogin).toEqual({
protocol: "demo-desktop",
username: "admin",
password: "pw",
fields: { domain: "CORP" },
});
});