mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: store rdp, vnc and telnet logins in a generic per-protocol table
This commit is contained in:
1 parent
e0c30a4e6f
commit
bba06f7ff6
107 files changed
+18750
-1224
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user