mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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 {
|
||||
|
||||
@@ -76,9 +76,6 @@ export const HOST_SECRET_FIELDS = [
|
||||
"passphrase",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user