mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the remote-desktop plugin (B14)
This commit is contained in:
1 parent
0af2c94983
commit
14eb558b85
215 files changed
+30029
-3921
No files matched your search
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* ctx.credentials: plaintext protocol credentials for a host, for a plugin
|
||||
* that hands them to a program core does not run (remote desktop gives them
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginCredentials,
|
||||
PluginHostProtocol,
|
||||
PluginProtocolTarget,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
details: string,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
) => Promise<void>;
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
const PROTOCOLS: PluginHostProtocol[] = ["rdp", "vnc", "telnet"];
|
||||
|
||||
type HostRow = Record<string, unknown>;
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseJumpHosts(value: unknown): Array<{ hostId: number }> {
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((hop) => Number((hop as { hostId?: unknown })?.hostId))
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
.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),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginCredentials({
|
||||
manifest,
|
||||
audit,
|
||||
}: Deps): PluginCredentials {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
|
||||
return {
|
||||
resolveHostProtocol: async (hostId, protocol) => {
|
||||
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)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const userId = getActor();
|
||||
if (!userId) {
|
||||
throw new Error(
|
||||
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
|
||||
);
|
||||
}
|
||||
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
// Shared hosts carry fields encrypted under the owner's key.
|
||||
const ownerId = await repository.findHostOwnerId(hostId);
|
||||
const host = ownerId
|
||||
? ((await repository.findHostById(hostId, ownerId)) as HostRow | null)
|
||||
: null;
|
||||
if (!host) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: "Host not found",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const shared = host.userId !== userId;
|
||||
if (shared) {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: "No connect access",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const auth = shared
|
||||
? await resolveRecipientAuth(host, hostId, userId, protocol)
|
||||
: await resolveOwnerAuth(host, protocol);
|
||||
|
||||
await audit("credentials_read", details, { success: true });
|
||||
return {
|
||||
host: {
|
||||
id: hostId,
|
||||
name: (host.name as string | null) ?? null,
|
||||
ip: str(host.ip),
|
||||
port: Number(host.port) || 0,
|
||||
ownerUserId: host.userId as string,
|
||||
jumpHosts: parseJumpHosts(host.jumpHosts),
|
||||
},
|
||||
shared,
|
||||
auth,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -78,7 +78,6 @@ function toRecord(host: Record<string, unknown>): PluginHostRecord {
|
||||
folder: (host.folder as string | null) ?? null,
|
||||
jumpHosts: host.jumpHosts,
|
||||
enableSsh: (host.enableSsh as boolean | null) ?? null,
|
||||
enableRdp: (host.enableRdp as boolean | null) ?? null,
|
||||
enableTerminal: (host.enableTerminal as boolean | null) ?? null,
|
||||
enableFileManager: (host.enableFileManager as boolean | null) ?? null,
|
||||
enableTunnel: (host.enableTunnel as boolean | null) ?? null,
|
||||
@@ -205,6 +204,14 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
|
||||
...host,
|
||||
userId,
|
||||
});
|
||||
// Fields another plugin owns (a remote desktop switch) go to that
|
||||
// plugin's host settings, the same way a bulk import row's do.
|
||||
const { applyPluginHostImportSettings } =
|
||||
await import("../database/routes/host-plugin-settings.js");
|
||||
await applyPluginHostImportSettings(
|
||||
created.id,
|
||||
host as Record<string, unknown>,
|
||||
);
|
||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||
return toRecord(created as unknown as Record<string, unknown>);
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type PluginContext,
|
||||
type PluginModule,
|
||||
type PluginOpenIsolatedWindowRequest,
|
||||
type PluginNativeRdpRequest,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
} from "../database/db/dialect.js";
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
import { createPluginHosts } from "./ctx-hosts.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
export type { PluginModule };
|
||||
|
||||
@@ -336,6 +339,28 @@ export function createPluginContext(
|
||||
},
|
||||
);
|
||||
|
||||
const desktopLaunchNativeRdp = guarded(
|
||||
manifest,
|
||||
"desktop:window",
|
||||
async (request: PluginNativeRdpRequest) => {
|
||||
const { isElectronIpcAvailable, requestFromElectronMain } =
|
||||
await import("../utils/electron-ipc-bridge.js");
|
||||
if (!isElectronIpcAvailable()) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} tried to open the native RDP client outside the desktop app`,
|
||||
);
|
||||
}
|
||||
return requestFromElectronMain<{ success: boolean; error?: string }>(
|
||||
"launch-native-rdp",
|
||||
request,
|
||||
);
|
||||
},
|
||||
{
|
||||
action: "desktop_launch_native_rdp",
|
||||
details: () => "opened the native RDP client",
|
||||
},
|
||||
);
|
||||
|
||||
const capabilitiesRequire = async (capability: string) => {
|
||||
try {
|
||||
await assertCapability(pluginId, capability, declared);
|
||||
@@ -696,8 +721,12 @@ export function createPluginContext(
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
launchNativeRdp: (request) => desktopLaunchNativeRdp(request),
|
||||
available: () => isElectronIpcAvailable(),
|
||||
},
|
||||
|
||||
credentials: createPluginCredentials({ manifest, audit: auditCall }),
|
||||
|
||||
audit: {
|
||||
// Attribution comes from the runtime: the actor, never a plugin value.
|
||||
record: async (entry) => {
|
||||
|
||||
@@ -40,7 +40,6 @@ export type { PluginSettingsScope };
|
||||
*/
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
|
||||
"app_name",
|
||||
"guac_enabled",
|
||||
"global_metrics_interval",
|
||||
"global_status_check_interval",
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user