mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
1154 lines
37 KiB
TypeScript
1154 lines
37 KiB
TypeScript
/**
|
|
* ctx.hosts: the hosts a plugin's acting user can see, and the sharing
|
|
* operations that need core's RBAC and secret-snapshot machinery.
|
|
*
|
|
* list/get/checkAccess need hosts:read. share and the share-target pickers
|
|
* need hosts:write, because granting access to a host is a write on that
|
|
* host even though the plugin owns neither the host nor the grant.
|
|
*/
|
|
|
|
import type {
|
|
PluginHosts,
|
|
PluginHostSummary,
|
|
PluginHostRecord,
|
|
PluginHostCreateInput,
|
|
PluginHostUpdateInput,
|
|
PluginHostAccess,
|
|
PluginHostShareLevel,
|
|
PluginHostShareResult,
|
|
PluginShareTarget,
|
|
PluginShareableUser,
|
|
PluginShareableRole,
|
|
PluginHostJumpHost,
|
|
PluginHostShareGrant,
|
|
} from "@termix-ssh/plugin-sdk/backend";
|
|
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
|
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
|
import { assertCapability, capabilityRefused } from "./permissions.js";
|
|
import { hostSessionStatus } from "../hosts/host-session-status.js";
|
|
import { getActor } from "./actor.js";
|
|
import { hostStatus, provideHostStatus } from "./host-status-provider.js";
|
|
import { provideActivity, recentActivity } from "./activity-provider.js";
|
|
import { loadProbeTargets } from "./ctx-probe.js";
|
|
import { createPluginHostImport } from "./ctx-host-import.js";
|
|
import { parseSshOptions } from "../hosts/ssh-options.js";
|
|
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
|
import type { DisposableBag } from "./disposables.js";
|
|
import { pluginEvents, TOPICS } from "./events.js";
|
|
import { pluginLogger } from "../utils/logger.js";
|
|
import {
|
|
assertHostsUnlocked,
|
|
assertManualCreateAllowed,
|
|
provideHostLocks,
|
|
} from "../hosts/locks.js";
|
|
|
|
type AuditFn = (
|
|
action: string,
|
|
details: string,
|
|
outcome: { success: boolean; errorMessage?: string },
|
|
) => Promise<void>;
|
|
|
|
interface Deps {
|
|
manifest: PluginManifest;
|
|
/** Where registerPort entries go, so deactivate removes them. */
|
|
bag?: DisposableBag;
|
|
audit: AuditFn;
|
|
}
|
|
|
|
function toSummary(host: {
|
|
id: number;
|
|
userId: string;
|
|
name: string | null;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
tags: string | null;
|
|
folder: string | null;
|
|
authType: string;
|
|
}): PluginHostSummary {
|
|
return {
|
|
id: host.id,
|
|
userId: host.userId,
|
|
name: host.name,
|
|
ip: host.ip,
|
|
port: host.port,
|
|
username: host.username,
|
|
tags: host.tags,
|
|
folder: host.folder,
|
|
authType: host.authType,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Host fields that carry secret material. Stripped from anything ctx.hosts or
|
|
* ctx.ssh hands a plugin, unless the plugin holds credentials:read.
|
|
*/
|
|
export const HOST_SECRET_FIELDS = [
|
|
"password",
|
|
"key",
|
|
"keyPassword",
|
|
"privateKey",
|
|
"passphrase",
|
|
"sudoPassword",
|
|
"socks5Password",
|
|
] as const;
|
|
|
|
/** A copy of a host with every secret field removed, nested ones included. */
|
|
export function redactHostSecrets<T extends Record<string, unknown>>(
|
|
host: T,
|
|
): T {
|
|
const copy: Record<string, unknown> = { ...host };
|
|
for (const field of HOST_SECRET_FIELDS) delete copy[field];
|
|
const terminalConfig = copy.terminalConfig;
|
|
if (terminalConfig && typeof terminalConfig === "object") {
|
|
const { sudoPassword: _sudo, ...rest } = terminalConfig as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
copy.terminalConfig = rest;
|
|
}
|
|
if (Array.isArray(copy.socks5ProxyChain)) {
|
|
copy.socks5ProxyChain = copy.socks5ProxyChain.map((hop) =>
|
|
hop && typeof hop === "object"
|
|
? redactHostSecrets(hop as Record<string, unknown>)
|
|
: hop,
|
|
);
|
|
}
|
|
return copy as T;
|
|
}
|
|
|
|
function asNumber(value: unknown): number | null {
|
|
if (value === null || value === undefined || value === "") return null;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function asFlag(value: unknown, fallback: boolean): boolean {
|
|
if (value === null || value === undefined) return fallback;
|
|
return value === true || value === 1 || value === "1" || value === "true";
|
|
}
|
|
|
|
function parseJumpHosts(value: unknown): PluginHostJumpHost[] {
|
|
let list = value;
|
|
if (typeof list === "string") {
|
|
try {
|
|
list = JSON.parse(list);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
if (!Array.isArray(list)) return [];
|
|
return list
|
|
.map((entry) => asNumber((entry as { hostId?: unknown })?.hostId))
|
|
.filter((hostId): hostId is number => hostId !== null)
|
|
.map((hostId) => ({ hostId }));
|
|
}
|
|
|
|
/**
|
|
* A decrypted ssh_data row as PluginHostRecord. Built field by field: a
|
|
* column reaches plugins only once the SDK types it, and no secret column is
|
|
* ever copied.
|
|
*/
|
|
export function toRecord(
|
|
host: Record<string, unknown>,
|
|
pluginSettings: Record<string, unknown>,
|
|
): PluginHostRecord {
|
|
const id = Number(host.id);
|
|
const origin = host.connectionOrigin;
|
|
return {
|
|
id,
|
|
userId: String(host.userId),
|
|
syncId: (host.syncId as string | null) ?? null,
|
|
name: (host.name as string | null) ?? null,
|
|
ip: String(host.ip ?? ""),
|
|
port: asNumber(host.port) ?? 22,
|
|
username: String(host.username ?? ""),
|
|
authType: String(host.authType ?? ""),
|
|
credentialId: asNumber(host.credentialId),
|
|
overrideCredentialUsername: asFlag(host.overrideCredentialUsername, false),
|
|
connectionType: (host.connectionType as string | null) || "ssh",
|
|
tags: (host.tags as string | null) ?? null,
|
|
folder: (host.folder as string | null) ?? null,
|
|
parentHostId: asNumber(host.parentHostId),
|
|
pin: asFlag(host.pin, false),
|
|
notes: (host.notes as string | null) ?? null,
|
|
jumpHosts: parseJumpHosts(host.jumpHosts),
|
|
enableSsh: asFlag(host.enableSsh, true),
|
|
sshPort: asNumber(host.sshPort),
|
|
statusCheckEnabled: asFlag(host.statusCheckEnabled, true),
|
|
statusCheckInterval: asNumber(host.statusCheckInterval),
|
|
connectionOrigin: origin === "local" || origin === "remote" ? origin : null,
|
|
sshOptions: hostTerminalExport(host).sshOptions,
|
|
pluginSettings,
|
|
status: Number.isInteger(id) ? hostStatus.get(id) : null,
|
|
localOnly: asFlag(host.localOnly, false),
|
|
createdAt: (host.createdAt as string | null) ?? null,
|
|
updatedAt: (host.updatedAt as string | null) ?? null,
|
|
};
|
|
}
|
|
|
|
/** Thrown when a plugin's host write names a field the SDK does not take. */
|
|
export class PluginHostInputError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "PluginHostInputError";
|
|
}
|
|
}
|
|
|
|
const INPUT_KEYS = new Set<string>(PLUGIN_HOST_INPUT_KEYS);
|
|
|
|
// Stops compiling when PluginHostCreateInput gains a key the list misses.
|
|
const inputKeysComplete: [
|
|
Exclude<keyof PluginHostCreateInput, (typeof PLUGIN_HOST_INPUT_KEYS)[number]>,
|
|
] extends [never]
|
|
? true
|
|
: never = true;
|
|
void inputKeysComplete;
|
|
|
|
/**
|
|
* The ssh_data columns for a create or update, plus the host settings to
|
|
* write afterwards. Refuses any key PluginHostCreateInput does not list, so a
|
|
* field that moved into a plugin cannot be written into nothing.
|
|
*/
|
|
export function toHostWrite(input: PluginHostUpdateInput): {
|
|
row: Record<string, unknown>;
|
|
settings: Record<string, Record<string, unknown>>;
|
|
protocolAuth: Record<string, unknown> | null;
|
|
pluginEnable: Record<string, boolean>;
|
|
} {
|
|
const unknown = Object.keys(input ?? {}).filter(
|
|
(key) => !INPUT_KEYS.has(key),
|
|
);
|
|
if (unknown.length > 0) {
|
|
throw new PluginHostInputError(
|
|
`ctx.hosts does not take ${unknown.join(", ")}; host settings go in pluginSettings, keyed by the plugin that declares them`,
|
|
);
|
|
}
|
|
const row: Record<string, unknown> = {};
|
|
const set = (key: string, value: unknown) => {
|
|
if (value !== undefined) row[key] = value;
|
|
};
|
|
set("name", input.name);
|
|
set("ip", input.ip);
|
|
set("port", input.port);
|
|
set("username", input.username);
|
|
set("authType", input.authType);
|
|
set("credentialId", input.credentialId);
|
|
set("overrideCredentialUsername", input.overrideCredentialUsername);
|
|
set("connectionType", input.connectionType);
|
|
set(
|
|
"tags",
|
|
Array.isArray(input.tags)
|
|
? input.tags
|
|
.map((tag) => String(tag).trim())
|
|
.filter(Boolean)
|
|
.join(",")
|
|
: input.tags,
|
|
);
|
|
set("folder", input.folder);
|
|
set("pin", input.pin);
|
|
set("notes", input.notes);
|
|
set(
|
|
"jumpHosts",
|
|
input.jumpHosts === undefined
|
|
? undefined
|
|
: input.jumpHosts === null
|
|
? null
|
|
: JSON.stringify(parseJumpHosts(input.jumpHosts)),
|
|
);
|
|
set("enableSsh", input.enableSsh);
|
|
set("sshPort", input.sshPort);
|
|
set("statusCheckEnabled", input.statusCheckEnabled);
|
|
set("statusCheckInterval", input.statusCheckInterval);
|
|
set(
|
|
"forceKeyboardInteractive",
|
|
input.forceKeyboardInteractive === undefined
|
|
? undefined
|
|
: input.forceKeyboardInteractive
|
|
? "true"
|
|
: "false",
|
|
);
|
|
set(
|
|
"sshOptions",
|
|
input.sshOptions === undefined
|
|
? undefined
|
|
: input.sshOptions === null
|
|
? null
|
|
: JSON.stringify(parseSshOptions(input.sshOptions)),
|
|
);
|
|
for (const key of [
|
|
"password",
|
|
"key",
|
|
"keyPassword",
|
|
"keyType",
|
|
"sudoPassword",
|
|
] as const) {
|
|
const value = input[key];
|
|
if (value !== undefined && value !== null && typeof value !== "string") {
|
|
throw new PluginHostInputError(`${key} must be a string`);
|
|
}
|
|
set(key, value === undefined ? undefined : value || null);
|
|
}
|
|
|
|
let protocolAuth: Record<string, unknown> | null = null;
|
|
if (input.protocolAuth !== undefined && input.protocolAuth !== null) {
|
|
if (
|
|
typeof input.protocolAuth !== "object" ||
|
|
Array.isArray(input.protocolAuth)
|
|
) {
|
|
throw new PluginHostInputError(
|
|
"protocolAuth must map protocol ids to their logins",
|
|
);
|
|
}
|
|
protocolAuth = input.protocolAuth as Record<string, unknown>;
|
|
}
|
|
|
|
const settings: Record<string, Record<string, unknown>> = {};
|
|
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
|
|
if (
|
|
typeof input.pluginSettings !== "object" ||
|
|
Array.isArray(input.pluginSettings)
|
|
) {
|
|
throw new PluginHostInputError(
|
|
"pluginSettings must map plugin ids to their host settings",
|
|
);
|
|
}
|
|
for (const [pluginId, values] of Object.entries(input.pluginSettings)) {
|
|
if (!values || typeof values !== "object" || Array.isArray(values)) {
|
|
throw new PluginHostInputError(
|
|
`pluginSettings.${pluginId} must be an object`,
|
|
);
|
|
}
|
|
settings[pluginId] = values;
|
|
}
|
|
}
|
|
const pluginEnable: Record<string, boolean> = {};
|
|
if (input.pluginEnable !== undefined && input.pluginEnable !== null) {
|
|
if (
|
|
typeof input.pluginEnable !== "object" ||
|
|
Array.isArray(input.pluginEnable)
|
|
) {
|
|
throw new PluginHostInputError(
|
|
"pluginEnable must map plugin ids to true or false",
|
|
);
|
|
}
|
|
for (const [pluginId, enabled] of Object.entries(input.pluginEnable)) {
|
|
if (typeof enabled !== "boolean") {
|
|
throw new PluginHostInputError(
|
|
`pluginEnable.${pluginId} must be true or false`,
|
|
);
|
|
}
|
|
pluginEnable[pluginId] = enabled;
|
|
}
|
|
}
|
|
return { row, settings, protocolAuth, pluginEnable };
|
|
}
|
|
|
|
/** Folds pluginEnable into each plugin's settings under its enableKey. */
|
|
async function foldPluginEnable(
|
|
settings: Record<string, Record<string, unknown>>,
|
|
pluginEnable: Record<string, boolean>,
|
|
): Promise<Record<string, Record<string, unknown>>> {
|
|
const ids = Object.keys(pluginEnable);
|
|
if (ids.length === 0) return settings;
|
|
const { hostSettingsPlugins } =
|
|
await import("../database/routes/host-plugin-settings.js");
|
|
const running = hostSettingsPlugins();
|
|
const out = { ...settings };
|
|
for (const pluginId of ids) {
|
|
const manifest = running.find((m) => m.id === pluginId);
|
|
if (!manifest) {
|
|
// checkHostPluginSettingsInput skips and logs a plugin that is not running.
|
|
out[pluginId] = { ...out[pluginId] };
|
|
continue;
|
|
}
|
|
const enableKey = manifest.contributes?.settings?.host?.enableKey;
|
|
if (!enableKey) {
|
|
throw new PluginHostInputError(`${pluginId} has no host switch`);
|
|
}
|
|
out[pluginId] = { ...out[pluginId], [enableKey]: pluginEnable[pluginId] };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Writes a host's protocol logins; a credential the owner can't use is refused. */
|
|
async function writeHostProtocolAuth(
|
|
ownerId: string,
|
|
hostId: number,
|
|
protocolAuth: Record<string, unknown> | null,
|
|
): Promise<void> {
|
|
if (!protocolAuth) return;
|
|
const { readProtocolAuthPayload, writeProtocolAuth } =
|
|
await import("../hosts/protocol-auth/protocol-auth.js");
|
|
const patch = readProtocolAuthPayload({ protocolAuth });
|
|
if (!patch) return;
|
|
const { findUsableCredential } =
|
|
await import("../hosts/usable-credential.js");
|
|
for (const [protocol, login] of patch) {
|
|
const credentialId = Number(login?.credentialId);
|
|
if (
|
|
Number.isInteger(credentialId) &&
|
|
credentialId > 0 &&
|
|
!(await findUsableCredential(credentialId, ownerId))
|
|
) {
|
|
throw new PluginHostInputError(
|
|
`protocolAuth.${protocol}: credential ${credentialId} not found`,
|
|
);
|
|
}
|
|
}
|
|
await writeProtocolAuth(ownerId, hostId, patch, { isOwner: true });
|
|
}
|
|
|
|
const SHARE_LEVELS: readonly string[] = ["connect", "view", "edit", "manage"];
|
|
|
|
/** Upserts one grant and snapshots the host's secrets for it. */
|
|
async function grantHostShare(
|
|
hostId: number,
|
|
ownerId: string,
|
|
grantedBy: string,
|
|
target: PluginShareTarget,
|
|
permissionLevel: PluginHostShareLevel,
|
|
expiresAt: string | null,
|
|
): Promise<number> {
|
|
const { createCurrentRbacAccessRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const grant = await createCurrentRbacAccessRepository().upsertHostAccess({
|
|
hostId,
|
|
grantedBy,
|
|
permissionLevel,
|
|
expiresAt,
|
|
...(target.type === "user"
|
|
? { targetType: "user" as const, targetUserId: target.id as string }
|
|
: { targetType: "role" as const, targetRoleId: target.id as number }),
|
|
});
|
|
|
|
const { SharedHostSecretsManager } =
|
|
await import("../utils/shared-host-secrets-manager.js");
|
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
|
try {
|
|
if (target.type === "user") {
|
|
await secretsManager.snapshotForUser(
|
|
grant.id,
|
|
hostId,
|
|
target.id as string,
|
|
ownerId,
|
|
);
|
|
} else {
|
|
await secretsManager.snapshotForRole(
|
|
grant.id,
|
|
hostId,
|
|
target.id as number,
|
|
ownerId,
|
|
);
|
|
}
|
|
} catch {
|
|
// A snapshot failure never blocks the grant: the recipient can
|
|
// still be prompted, or the owner can retry sharing later.
|
|
}
|
|
return grant.id;
|
|
}
|
|
|
|
function hoursFromNow(durationHours: number | undefined): string | null {
|
|
return durationHours && durationHours > 0
|
|
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
|
|
: null;
|
|
}
|
|
|
|
function actingUser(): string {
|
|
const actor = getActor();
|
|
if (!actor) {
|
|
throw new Error(
|
|
"ctx.hosts needs an acting user: call it inside a request or ctx.asUser",
|
|
);
|
|
}
|
|
return actor;
|
|
}
|
|
|
|
export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|
const pluginId = manifest.id;
|
|
const declared = manifest.capabilities;
|
|
|
|
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
|
|
const requireReadSync = () => {
|
|
if (!declared.includes("hosts:read")) {
|
|
throw capabilityRefused(pluginId, "hosts:read");
|
|
}
|
|
};
|
|
const requireWrite = () =>
|
|
assertCapability(pluginId, "hosts:write", declared);
|
|
|
|
// With an actor, a host the actor cannot reach is out of bounds. With none,
|
|
// it is the plugin's own background work (a poller, a guacd session).
|
|
const actorMayReach = async (hostId: number): Promise<boolean> => {
|
|
const actor = getActor();
|
|
if (!actor) return true;
|
|
const { PermissionManager } =
|
|
await import("../utils/permission-manager.js");
|
|
const access = await PermissionManager.getInstance().canAccessHost(
|
|
actor,
|
|
hostId,
|
|
"connect",
|
|
);
|
|
return access.hasAccess;
|
|
};
|
|
|
|
// The calling plugin's own host settings, one query for the whole list.
|
|
const toRecords = async (
|
|
rows: Record<string, unknown>[],
|
|
): Promise<PluginHostRecord[]> => {
|
|
const { loadHostPluginSettings } =
|
|
await import("../database/routes/host-plugin-settings.js");
|
|
const settings = await loadHostPluginSettings(
|
|
rows.map((row) => Number(row.id)),
|
|
);
|
|
return rows.map((row) =>
|
|
toRecord(row, settings.get(Number(row.id))?.[pluginId] ?? {}),
|
|
);
|
|
};
|
|
|
|
const checkSettings = async (
|
|
settings: Record<string, Record<string, unknown>>,
|
|
) => {
|
|
if (Object.keys(settings).length === 0) return [];
|
|
const { checkHostPluginSettingsInput } =
|
|
await import("../database/routes/host-plugin-settings.js");
|
|
const { writes, skipped, errors } = checkHostPluginSettingsInput(settings);
|
|
if (errors.length > 0) throw new PluginHostInputError(errors.join("; "));
|
|
if (skipped.length > 0) {
|
|
pluginLogger.warn(
|
|
"Host settings for plugins that are not running were skipped",
|
|
{
|
|
operation: "plugin_hosts_write",
|
|
pluginId,
|
|
skipped,
|
|
},
|
|
);
|
|
}
|
|
return writes;
|
|
};
|
|
|
|
const writeSettings = async (
|
|
hostId: number,
|
|
writes: Awaited<ReturnType<typeof checkSettings>>,
|
|
) => {
|
|
if (writes.length === 0) return;
|
|
const { writeHostPluginSettings } =
|
|
await import("../database/routes/host-plugin-settings.js");
|
|
for (const { manifest: owner, values } of writes) {
|
|
await writeHostPluginSettings(owner, hostId, values);
|
|
}
|
|
};
|
|
|
|
/** Host settings a plugin writes are the host's own, not its defaults. */
|
|
const markSettingsOwn = async (
|
|
hostId: number,
|
|
writes: Awaited<ReturnType<typeof checkSettings>>,
|
|
) => {
|
|
if (writes.length === 0) return;
|
|
const { changeHostOverrides } =
|
|
await import("../hosts/defaults/overrides.js");
|
|
await changeHostOverrides([hostId], {
|
|
own: writes.flatMap(({ manifest: owner, values }) =>
|
|
Object.keys(values).map((key): [string, string] => [owner.id, key]),
|
|
),
|
|
});
|
|
};
|
|
|
|
return {
|
|
...createPluginHostImport({ manifest, bag, audit }),
|
|
|
|
list: async () => {
|
|
await requireRead();
|
|
const userId = actingUser();
|
|
const {
|
|
createCurrentHostResolutionRepository,
|
|
createCurrentRoleRepository,
|
|
createCurrentRbacAccessRepository,
|
|
} = await import("../database/repositories/factory.js");
|
|
const repository = createCurrentHostResolutionRepository();
|
|
|
|
const owned = await repository.findHostsByUserId(userId);
|
|
const roleIds =
|
|
await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
const grants =
|
|
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
|
userId,
|
|
roleIds,
|
|
);
|
|
const sharedRows = await repository.listHostRowsForAccessList(
|
|
userId,
|
|
grants,
|
|
);
|
|
|
|
const byId = new Map<number, PluginHostSummary>();
|
|
for (const host of owned) byId.set(host.id, toSummary(host));
|
|
for (const host of sharedRows) {
|
|
if (!byId.has(host.id)) byId.set(host.id, toSummary(host));
|
|
}
|
|
return [...byId.values()];
|
|
},
|
|
|
|
get: async (hostId) => {
|
|
await requireRead();
|
|
const userId = actingUser();
|
|
const { PermissionManager } =
|
|
await import("../utils/permission-manager.js");
|
|
const access = await PermissionManager.getInstance().canAccessHost(
|
|
userId,
|
|
hostId,
|
|
"connect",
|
|
);
|
|
if (!access.hasAccess) return null;
|
|
|
|
const { createCurrentHostResolutionRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const ownerId =
|
|
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
|
hostId,
|
|
)) ?? userId;
|
|
const host = await createCurrentHostResolutionRepository().findHostById(
|
|
hostId,
|
|
ownerId,
|
|
);
|
|
return host ? toSummary(host) : null;
|
|
},
|
|
|
|
checkAccess: async (
|
|
hostId: number,
|
|
level: PluginHostShareLevel,
|
|
): Promise<PluginHostAccess> => {
|
|
await requireRead();
|
|
const userId = actingUser();
|
|
const { PermissionManager } =
|
|
await import("../utils/permission-manager.js");
|
|
const access = await PermissionManager.getInstance().canAccessHost(
|
|
userId,
|
|
hostId,
|
|
level,
|
|
);
|
|
return {
|
|
hasAccess: access.hasAccess,
|
|
isOwner: access.isOwner,
|
|
isShared: access.isShared,
|
|
permissionLevel: access.permissionLevel as
|
|
PluginHostShareLevel | undefined,
|
|
expiresAt: access.expiresAt,
|
|
};
|
|
},
|
|
|
|
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
|
|
try {
|
|
await requireWrite();
|
|
assertManualCreateAllowed(pluginId);
|
|
} catch (error) {
|
|
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
const userId = actingUser();
|
|
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host);
|
|
const writes = await checkSettings(
|
|
await foldPluginEnable(settings, pluginEnable),
|
|
);
|
|
const { createCurrentHostRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
|
await import("../hosts/defaults/index.js");
|
|
const columns: Record<string, unknown> = { ...row, userId };
|
|
columns.defaultOverrides = JSON.stringify(
|
|
await applyHostDefaultsToWrite({
|
|
ownerId: userId,
|
|
hostId: null,
|
|
columns,
|
|
body: host as unknown as Record<string, unknown>,
|
|
}),
|
|
);
|
|
const created =
|
|
await createCurrentHostRepository().createEncryptedForUser(
|
|
userId,
|
|
columns,
|
|
);
|
|
await writeHostProtocolAuth(userId, created.id, protocolAuth);
|
|
await applyDefaultsAfterHostWrite(created.id);
|
|
await writeSettings(created.id, writes);
|
|
await markSettingsOwn(created.id, writes);
|
|
await audit("hosts_create", `host ${created.id}`, { success: true });
|
|
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
|
|
return (
|
|
await toRecords([created as unknown as Record<string, unknown>])
|
|
)[0];
|
|
},
|
|
|
|
update: async (
|
|
hostId: number,
|
|
patch: PluginHostUpdateInput,
|
|
): Promise<PluginHostRecord | null> => {
|
|
try {
|
|
await requireWrite();
|
|
assertHostsUnlocked([hostId], pluginId);
|
|
} catch (error) {
|
|
await audit("hosts_update", `host ${hostId}`, {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
const userId = actingUser();
|
|
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch);
|
|
const writes = await checkSettings(
|
|
await foldPluginEnable(settings, pluginEnable),
|
|
);
|
|
const { createCurrentHostRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
|
await import("../hosts/defaults/index.js");
|
|
const { createCurrentHostDefaultsRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const stored = (
|
|
await createCurrentHostDefaultsRepository().listHosts({
|
|
hostIds: [hostId],
|
|
})
|
|
)[0];
|
|
const columns: Record<string, unknown> = { ...row };
|
|
if (stored && stored.userId === userId) {
|
|
columns.defaultOverrides = JSON.stringify(
|
|
await applyHostDefaultsToWrite({
|
|
ownerId: userId,
|
|
hostId,
|
|
columns,
|
|
body: patch as unknown as Record<string, unknown>,
|
|
stored,
|
|
}),
|
|
);
|
|
}
|
|
const updated =
|
|
await createCurrentHostRepository().updateEncryptedForUser(
|
|
userId,
|
|
hostId,
|
|
columns,
|
|
);
|
|
if (updated) {
|
|
await writeHostProtocolAuth(userId, hostId, protocolAuth);
|
|
await writeSettings(hostId, writes);
|
|
await markSettingsOwn(hostId, writes);
|
|
await applyDefaultsAfterHostWrite(hostId, {
|
|
moved:
|
|
!!stored &&
|
|
((columns.folder !== undefined &&
|
|
(columns.folder ?? null) !== (stored.folder ?? null)) ||
|
|
(columns.parentHostId !== undefined &&
|
|
(columns.parentHostId ?? null) !==
|
|
(stored.parentHostId ?? null))),
|
|
ownerId: userId,
|
|
});
|
|
}
|
|
await audit("hosts_update", `host ${hostId}`, {
|
|
success: updated !== null,
|
|
});
|
|
return updated
|
|
? (await toRecords([updated as unknown as Record<string, unknown>]))[0]
|
|
: null;
|
|
},
|
|
|
|
delete: async (hostId: number): Promise<boolean> => {
|
|
try {
|
|
await requireWrite();
|
|
assertHostsUnlocked([hostId], pluginId);
|
|
const userId = actingUser();
|
|
const { PermissionManager } =
|
|
await import("../utils/permission-manager.js");
|
|
if (
|
|
!(await PermissionManager.getInstance().hasPermission(
|
|
userId,
|
|
"hosts.delete",
|
|
))
|
|
) {
|
|
throw new Error("The acting user may not delete hosts");
|
|
}
|
|
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
|
const deleted = await deleteOwnedHost(userId, hostId);
|
|
await audit("hosts_delete", `host ${hostId}`, {
|
|
success: deleted !== null,
|
|
});
|
|
return deleted !== null;
|
|
} catch (error) {
|
|
await audit("hosts_delete", `host ${hostId}`, {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
listOwned: async (): Promise<PluginHostRecord[]> => {
|
|
await requireWrite();
|
|
const userId = actingUser();
|
|
const { createCurrentHostRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const rows =
|
|
await createCurrentHostRepository().listDecryptedByUserId(userId);
|
|
await audit("hosts_list_owned", `${rows.length} host(s)`, {
|
|
success: true,
|
|
});
|
|
return toRecords(rows as unknown as Record<string, unknown>[]);
|
|
},
|
|
|
|
share: async (
|
|
hostId: number,
|
|
targets: PluginShareTarget[],
|
|
permissionLevel: PluginHostShareLevel,
|
|
durationHours?: number,
|
|
): Promise<PluginHostShareResult> => {
|
|
try {
|
|
await requireWrite();
|
|
assertHostsUnlocked([hostId], pluginId);
|
|
} catch (error) {
|
|
await audit("hosts_share", `host ${hostId}`, {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
const userId = actingUser();
|
|
const { PermissionManager } =
|
|
await import("../utils/permission-manager.js");
|
|
const access = await PermissionManager.getInstance().canAccessHost(
|
|
userId,
|
|
hostId,
|
|
"manage",
|
|
);
|
|
if (!access.hasAccess) {
|
|
const result: PluginHostShareResult = {
|
|
hostId,
|
|
shared: false,
|
|
reason: "forbidden",
|
|
};
|
|
await audit("hosts_share", `host ${hostId}`, {
|
|
success: false,
|
|
errorMessage: "forbidden",
|
|
});
|
|
return result;
|
|
}
|
|
|
|
const { createCurrentHostResolutionRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const ownerId =
|
|
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
|
hostId,
|
|
)) ?? userId;
|
|
|
|
if (targets.some((t) => t.type === "user" && t.id === ownerId)) {
|
|
await audit("hosts_share", `host ${hostId}`, {
|
|
success: false,
|
|
errorMessage: "owner",
|
|
});
|
|
return { hostId, shared: false, reason: "owner" };
|
|
}
|
|
|
|
const expiresAt = hoursFromNow(durationHours);
|
|
for (const target of targets) {
|
|
await grantHostShare(
|
|
hostId,
|
|
ownerId,
|
|
userId,
|
|
target,
|
|
permissionLevel,
|
|
expiresAt,
|
|
);
|
|
}
|
|
|
|
await audit("hosts_share", `host ${hostId}`, { success: true });
|
|
return { hostId, shared: true };
|
|
},
|
|
|
|
setShares: async (
|
|
hostId: number,
|
|
grants: PluginHostShareGrant[],
|
|
): Promise<PluginHostShareResult> => {
|
|
const fail = async (reason: string): Promise<PluginHostShareResult> => {
|
|
await audit("hosts_set_shares", `host ${hostId}`, {
|
|
success: false,
|
|
errorMessage: reason,
|
|
});
|
|
return { hostId, shared: false, reason };
|
|
};
|
|
try {
|
|
await requireWrite();
|
|
assertHostsUnlocked([hostId], pluginId);
|
|
} catch (error) {
|
|
await fail(error instanceof Error ? error.message : String(error));
|
|
throw error;
|
|
}
|
|
if (!Array.isArray(grants)) {
|
|
throw new PluginHostInputError("grants must be a list");
|
|
}
|
|
for (const grant of grants) {
|
|
const type = grant?.target?.type;
|
|
if (
|
|
(type !== "user" && type !== "role") ||
|
|
!SHARE_LEVELS.includes(grant.permissionLevel)
|
|
) {
|
|
throw new PluginHostInputError(
|
|
"Each grant needs a user or role target and a share level",
|
|
);
|
|
}
|
|
}
|
|
|
|
const userId = actingUser();
|
|
const {
|
|
createCurrentHostResolutionRepository,
|
|
createCurrentRbacAccessRepository,
|
|
} = await import("../database/repositories/factory.js");
|
|
const ownerId =
|
|
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
|
|
if (ownerId !== userId) return fail("forbidden");
|
|
if (
|
|
grants.some((g) => g.target.type === "user" && g.target.id === userId)
|
|
) {
|
|
return fail("owner");
|
|
}
|
|
|
|
const kept = new Set<number>();
|
|
for (const grant of grants) {
|
|
kept.add(
|
|
await grantHostShare(
|
|
hostId,
|
|
ownerId,
|
|
userId,
|
|
grant.target,
|
|
grant.permissionLevel,
|
|
hoursFromNow(grant.durationHours),
|
|
),
|
|
);
|
|
}
|
|
const repository = createCurrentRbacAccessRepository();
|
|
for (const existing of await repository.listHostAccess(hostId)) {
|
|
if (!kept.has(existing.id)) {
|
|
await repository.revokeHostAccess(existing.id, hostId);
|
|
}
|
|
}
|
|
const { pruneHostGrants } = await import("../database/routes/rbac.js");
|
|
await pruneHostGrants(hostId);
|
|
|
|
await audit("hosts_set_shares", `host ${hostId}`, { success: true });
|
|
return { hostId, shared: true };
|
|
},
|
|
|
|
listUsers: async (): Promise<PluginShareableUser[]> => {
|
|
try {
|
|
await requireWrite();
|
|
} catch (error) {
|
|
await audit("hosts_list_users", "share target picker", {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
const { createCurrentUserRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const users = await createCurrentUserRepository().listAll();
|
|
await audit("hosts_list_users", "share target picker", {
|
|
success: true,
|
|
});
|
|
return users.map((u) => ({ id: u.id, username: u.username }));
|
|
},
|
|
|
|
listRoles: async (): Promise<PluginShareableRole[]> => {
|
|
try {
|
|
await requireWrite();
|
|
} catch (error) {
|
|
await audit("hosts_list_roles", "share target picker", {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
const { createCurrentRoleRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const roles = await createCurrentRoleRepository().listRoles();
|
|
await audit("hosts_list_roles", "share target picker", {
|
|
success: true,
|
|
});
|
|
return roles
|
|
.filter((r) => !r.isSystem)
|
|
.map((r) => ({ id: r.id, name: r.name, displayName: r.displayName }));
|
|
},
|
|
|
|
// Synchronous so a transport can call it from an ssh2 "ready" handler, so
|
|
// only the declaration is checked up front, like ctx.http.router. The
|
|
// session only counts once the actor's access to the host is confirmed.
|
|
trackSession: (hostId: number) => {
|
|
if (!declared.includes("hosts:read")) {
|
|
throw capabilityRefused(pluginId, "hosts:read");
|
|
}
|
|
let release: (() => void) | null = null;
|
|
let stopped = false;
|
|
void actorMayReach(hostId)
|
|
.then((allowed) => {
|
|
if (allowed && !stopped) release = hostSessionStatus.register(hostId);
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
stopped = true;
|
|
release?.();
|
|
release = null;
|
|
};
|
|
},
|
|
|
|
recordActivity: async (hostId, type, hostName) => {
|
|
await requireRead();
|
|
const userId = actingUser();
|
|
if (!(await actorMayReach(hostId))) return;
|
|
await recentActivity.record(userId, { type, hostId, hostName });
|
|
},
|
|
|
|
probeTargets: async (filter) => {
|
|
await requireRead();
|
|
const actor = getActor();
|
|
if (!actor) {
|
|
throw new Error("ctx.hosts.probeTargets needs an acting user");
|
|
}
|
|
const ids = filter?.hostIds?.filter((id) => Number.isInteger(id));
|
|
return loadProbeTargets(actor, ids);
|
|
},
|
|
|
|
sessionActive: (hostId) => {
|
|
requireReadSync();
|
|
return hostSessionStatus.hasActiveSession(hostId);
|
|
},
|
|
|
|
locks: {
|
|
provide: (provider) => {
|
|
if (!declared.includes("hosts:lock")) {
|
|
throw capabilityRefused(pluginId, "hosts:lock");
|
|
}
|
|
const remove = provideHostLocks(
|
|
pluginId,
|
|
manifest.name || pluginId,
|
|
provider,
|
|
);
|
|
void audit("host_locks_provide", `${pluginId} manages hosts`, {
|
|
success: true,
|
|
});
|
|
const drop = bag?.add(remove, "host lock provider");
|
|
return () => {
|
|
drop?.();
|
|
remove();
|
|
};
|
|
},
|
|
},
|
|
|
|
folders: {
|
|
ensure: async (name, style) => {
|
|
await requireWrite();
|
|
const userId = actingUser();
|
|
const { createCurrentHostFolderRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
await createCurrentHostFolderRepository().upsertMetadata(
|
|
userId,
|
|
name,
|
|
style?.color ?? null,
|
|
style?.icon ?? null,
|
|
);
|
|
},
|
|
removeEmpty: async (name) => {
|
|
await requireWrite();
|
|
const userId = actingUser();
|
|
const { createCurrentHostFolderRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
return createCurrentHostFolderRepository().deleteEmptyFolderRecords(
|
|
userId,
|
|
name,
|
|
);
|
|
},
|
|
},
|
|
|
|
activity: {
|
|
provide: (provider) => {
|
|
if (!declared.includes("hosts:activity-provide")) {
|
|
throw capabilityRefused(pluginId, "hosts:activity-provide");
|
|
}
|
|
const remove = provideActivity(
|
|
pluginId,
|
|
provider as Parameters<typeof provideActivity>[1],
|
|
);
|
|
void audit(
|
|
"host_activity_provide",
|
|
`${pluginId} stores recent activity`,
|
|
{
|
|
success: true,
|
|
},
|
|
);
|
|
const drop = bag?.add(remove, "activity provider");
|
|
return () => {
|
|
drop?.();
|
|
remove();
|
|
};
|
|
},
|
|
},
|
|
|
|
// Not audited: pollers call these on every sample.
|
|
status: {
|
|
provide: (provider) => {
|
|
requireReadSync();
|
|
if (!declared.includes("hosts:status-provide")) {
|
|
throw capabilityRefused(pluginId, "hosts:status-provide");
|
|
}
|
|
const remove = provideHostStatus(pluginId, {
|
|
start: () => provider.start(),
|
|
stop: () => provider.stop(),
|
|
get: (hostId) => provider.get(hostId),
|
|
statusesFor: (userId, requested) =>
|
|
provider.statusesFor(userId, requested ? [...requested] : null),
|
|
refresh: (userId) => provider.refresh(userId),
|
|
check: (hostId) => provider.check(hostId),
|
|
reportLogin: (hostId, outcome) =>
|
|
provider.reportLogin(hostId, outcome),
|
|
registerPort: (type, resolve) => provider.registerPort(type, resolve),
|
|
getIntervalSeconds: () => provider.getIntervalSeconds(),
|
|
setIntervalSeconds: (seconds) => provider.setIntervalSeconds(seconds),
|
|
});
|
|
void audit("host_status_provide", `${pluginId} provides host status`, {
|
|
success: true,
|
|
});
|
|
const drop = bag?.add(remove, "host status provider");
|
|
return () => {
|
|
drop?.();
|
|
remove();
|
|
};
|
|
},
|
|
get: async (hostId) => {
|
|
await requireRead();
|
|
if (!(await actorMayReach(hostId))) return null;
|
|
return hostStatus.get(hostId);
|
|
},
|
|
check: async (hostId) => {
|
|
await requireRead();
|
|
if (!(await actorMayReach(hostId))) return null;
|
|
return hostStatus.check(hostId);
|
|
},
|
|
// Synchronous for the same reason as trackSession.
|
|
reportLogin: (hostId, outcome) => {
|
|
requireReadSync();
|
|
void actorMayReach(hostId)
|
|
.then((allowed) => {
|
|
if (allowed) hostStatus.reportLogin(hostId, outcome);
|
|
})
|
|
.catch(() => {});
|
|
},
|
|
registerPort: (connectionType, resolve) => {
|
|
requireReadSync();
|
|
const unregister = hostStatus.registerPort(connectionType, resolve);
|
|
const drop = bag?.add(unregister, `status port for ${connectionType}`);
|
|
return () => {
|
|
drop?.();
|
|
unregister();
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|