feat: move desktop, status, import, audit and branding features into plugins

This commit is contained in:
LukeGus committed 2026-10-18 21:17:54 -05:00
1 parent 9d93fb0893
commit 1cdbfc674c
166 files changed
+21298 -12041

No files matched your search

-11
View File
@@ -370,17 +370,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS recent_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
host_id INTEGER NOT NULL,
host_name TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
@@ -98,12 +98,7 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Per-user history and file manager surfaces.
{
name: "idx_recent_activity_user_ts",
table: "recent_activity",
columns: "user_id, timestamp",
},
// Per-user open tabs.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
-22
View File
@@ -414,28 +414,6 @@ export const hostDefaults = mysqlTable(
],
);
export const recentActivity = mysqlTable(
"recent_activity",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: varchar("timestamp", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Always read newest-first for one user, so timestamp follows user_id.
(table) => [
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
],
);
export const hostAccess = mysqlTable(
"host_access",
{
-22
View File
@@ -414,28 +414,6 @@ export const hostDefaults = pgTable(
],
);
export const recentActivity = pgTable(
"recent_activity",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: varchar("timestamp", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Always read newest-first for one user, so timestamp follows user_id.
(table) => [
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
],
);
export const hostAccess = pgTable(
"host_access",
{
-22
View File
@@ -404,28 +404,6 @@ export const hostDefaults = sqliteTable(
],
);
export const recentActivity = sqliteTable(
"recent_activity",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: text("timestamp")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Always read newest-first for one user, so timestamp follows user_id.
(table) => [
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
],
);
export const hostAccess = sqliteTable(
"host_access",
{
@@ -23,7 +23,6 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos
import { UserAuthRepository } from "./user-auth-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRepository } from "./session-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
@@ -269,13 +268,6 @@ export function createCurrentRbacAccessRepository(): RbacAccessRepository {
);
}
export function createCurrentRecentActivityRepository(): RecentActivityRepository {
return new RecentActivityRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("recent_activity_repository_write"),
);
}
export function createCurrentRoleRepository(): RoleRepository {
return new RoleRepository(
createCurrentRepositoryContext(),
@@ -1,107 +0,0 @@
import { desc, eq, inArray } from "drizzle-orm";
import { recentActivity } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
export class RecentActivityRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(
userId: string,
limit: number,
): Promise<RecentActivityRecord[]> {
return this.context.drizzle
.select()
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp))
.limit(limit);
}
async create(
activity: NewRecentActivityRecord,
): Promise<RecentActivityRecord> {
const rows = await insertReturning(this.context, recentActivity, activity);
await this.afterWrite();
return rows[0];
}
async trimUserActivity(userId: string, keepCount: number): Promise<number> {
const rows = await this.context.drizzle
.select({ id: recentActivity.id })
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp));
const idsToDelete = rows
.slice(keepCount)
.map((row) => row.id)
.filter((id) => typeof id === "number");
if (idsToDelete.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.id, idsToDelete));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.hostId, hostId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -5,12 +5,6 @@ import {
createCurrentUserRepository,
} from "../repositories/factory.js";
import { apiLogger } from "../../utils/logger.js";
import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
async function isAdminUser(userId: string | undefined): Promise<boolean> {
if (!userId) return false;
@@ -138,125 +132,4 @@ export function registerAuditLogRoutes(
.json({ error: "Failed to fetch audit log actions" });
}
});
/**
* @openapi
* /audit-logs/export:
* get:
* summary: Export audit logs
* description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited.
* tags:
* - Audit
* parameters:
* - in: query
* name: format
* schema: { type: string, enum: [csv, ndjson], default: csv }
* - in: query
* name: userId
* schema: { type: string }
* - in: query
* name: action
* schema: { type: string }
* - in: query
* name: resourceType
* schema: { type: string }
* - in: query
* name: success
* schema: { type: string, enum: [true, false] }
* - in: query
* name: startDate
* schema: { type: string, format: date-time }
* - in: query
* name: endDate
* schema: { type: string, format: date-time }
* responses:
* 200:
* description: Audit log file.
* 403:
* description: Not authorized.
* 500:
* description: Failed to export audit logs.
*/
router.get("/audit-logs/export", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
try {
if (!(await isAdminUser(authReq.userId))) {
return res.status(403).json({ error: "Not authorized" });
}
const format = req.query.format === "ndjson" ? "ndjson" : "csv";
const { userId, action, resourceType, success, startDate, endDate } =
req.query as Record<string, string | undefined>;
const filters = {
userId,
action,
resourceType,
success:
success !== undefined && success !== ""
? success === "true"
: undefined,
startDate,
endDate,
};
res.setHeader(
"Content-Type",
format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson",
);
res.setHeader(
"Content-Disposition",
`attachment; filename="${exportFilename(format, new Date())}"`,
);
// Streamed in batches: an export is unbounded by definition, and the
// whole point is to move data out before retention drops it.
const BATCH = 500;
let offset = 0;
let exported = 0;
for (;;) {
const rows = await createCurrentAuditLogRepository().listForExport({
filters,
limit: BATCH,
offset,
});
if (rows.length === 0) break;
if (format === "csv") {
// Header only on the first batch.
const chunk = toCsv(rows);
res.write(
offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1),
);
} else {
res.write(toNdjson(rows));
}
exported += rows.length;
offset += rows.length;
if (rows.length < BATCH) break;
}
res.end();
// Reading the whole trail is itself worth recording.
const { ipAddress, userAgent } = getRequestMeta(req);
void logAudit({
userId: authReq.userId!,
username: await getAuditUsername(authReq.userId!),
action: "export_audit_logs",
resourceType: "audit_log",
details: JSON.stringify({ format, exported, filters }),
ipAddress,
userAgent,
success: true,
});
} catch (err) {
apiLogger.error("Failed to export audit logs", err);
if (!res.headersSent) {
return res.status(500).json({ error: "Failed to export audit logs" });
}
res.end();
}
});
}
+3 -136
View File
@@ -1,98 +1,9 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { Request, Response, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
BRANDING_KEYS,
parseBrandingAppName,
parseBrandingLogoDataUrl,
parseBrandingTagline,
resolveBrandingSettings,
} from "./branding-settings.js";
import { resolveBrandingSettings } from "./branding-settings.js";
const PATCHABLE_FIELDS = ["appName", "tagline", "logo"] as const;
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
function invalidField(res: Response, field: string): void {
res.status(400).json({
error: `Invalid value for ${field}`,
code: "BRANDING_SETTINGS_INVALID",
field,
});
}
/**
* Validates a PATCH body and returns the settings-table writes/deletes it
* implies, or null after the response has already been failed with a 400.
* `logo: null` clears the custom logo (falls back to the bundled default).
*/
async function buildBrandingWrites(
body: unknown,
res: Response,
): Promise<{
writes: Array<{ key: string; value: string }>;
deletes: string[];
} | null> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
res.status(400).json({
error: "Invalid request body",
code: "BRANDING_SETTINGS_INVALID",
});
return null;
}
for (const field of Object.keys(body)) {
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
res.status(400).json({
error: `Unknown setting field: ${field}`,
code: "BRANDING_SETTINGS_UNKNOWN_FIELD",
field,
});
return null;
}
}
const input = body as Partial<Record<PatchableField, unknown>>;
const writes: Array<{ key: string; value: string }> = [];
const deletes: string[] = [];
if (input.appName !== undefined) {
const appName = parseBrandingAppName(input.appName);
if (appName === null) {
invalidField(res, "appName");
return null;
}
writes.push({ key: BRANDING_KEYS.appName, value: appName });
}
if (input.tagline !== undefined) {
const tagline = parseBrandingTagline(input.tagline);
if (tagline === null) {
invalidField(res, "tagline");
return null;
}
writes.push({ key: BRANDING_KEYS.tagline, value: tagline });
}
if (input.logo !== undefined) {
if (input.logo === null) {
deletes.push(BRANDING_KEYS.logo);
} else {
const logo = await parseBrandingLogoDataUrl(input.logo);
if (logo === null) {
invalidField(res, "logo");
return null;
}
writes.push({ key: BRANDING_KEYS.logo, value: logo });
}
}
return { writes, deletes };
}
export function registerBrandingRoutes(
router: Router,
requireAdmin: RequestHandler,
): void {
export function registerBrandingRoutes(router: Router): void {
/**
* @openapi
* /users/branding:
@@ -118,48 +29,4 @@ export function registerBrandingRoutes(
res.status(500).json({ error: "Failed to load branding settings" });
}
});
/**
* @openapi
* /users/branding:
* patch:
* summary: Update white-label branding (admin only)
* description: Persists a partial update to appName, tagline and/or logo (a PNG/JPEG/WEBP data URL; pass null to clear the logo). Invalid values are rejected with a 400.
* tags:
* - Users
* responses:
* 200:
* description: Updated effective branding settings.
* 400:
* description: Invalid or unknown setting field.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to save branding settings.
*/
router.patch(
"/branding",
requireAdmin,
async (req: Request, res: Response) => {
const result = await buildBrandingWrites(req.body, res);
if (result === null) return;
try {
const settings = createCurrentSettingsRepository();
if (result.writes.length > 0) {
await settings.setMany(result.writes);
}
for (const key of result.deletes) {
await settings.delete(key);
}
const resolved = await resolveBrandingSettings(settings);
res.json(resolved);
} catch (err) {
databaseLogger.error("Failed to save branding settings", err);
res.status(500).json({ error: "Failed to save branding settings" });
}
},
);
}
@@ -1,5 +1,4 @@
import sharp from "sharp";
import { databaseLogger } from "../../utils/logger.js";
import { consumeByName } from "../../plugins/registry.js";
/**
* White-label branding: admin-configurable app name, logo and tagline shown
@@ -20,25 +19,33 @@ export const BRANDING_KEYS = {
} as const;
export const BRANDING_DEFAULT_APP_NAME = "Termix";
const APP_NAME_MAX_LENGTH = 60;
const TAGLINE_MAX_LENGTH = 160;
/**
* Cap applies to the decoded image bytes, not the base64 text. The settings
* table is fully cached in memory per backend process (see
* settings-cache.ts), so an oversized logo would be permanently resident RAM
* on every instance, not just a one-time upload cost.
*/
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
const BRANDING_LOGO_MAX_INPUT_PIXELS = 20_000_000;
interface SettingSource {
get(key: string): Promise<string | null>;
}
export const BRANDING_PROVIDER_NAME = "brandingProvider";
/** A plugin supplies branding by providing "<its id>.brandingProvider". */
export type BrandingProvider = () =>
Promise<BrandingSettings> | BrandingSettings;
export async function resolveBrandingSettings(
settings: SettingSource,
): Promise<BrandingSettings> {
const provider = consumeByName<BrandingProvider>(BRANDING_PROVIDER_NAME);
if (provider) {
try {
const provided = await provider();
return {
appName: provided.appName?.trim() || BRANDING_DEFAULT_APP_NAME,
tagline: provided.tagline ?? "",
logo: provided.logo || null,
};
} catch {
// A broken provider falls back to the stored settings.
}
}
const [appName, tagline, logo] = await Promise.all([
settings.get(BRANDING_KEYS.appName),
settings.get(BRANDING_KEYS.tagline),
@@ -53,74 +60,3 @@ export async function resolveBrandingSettings(
}
/** Trimmed, 1-60 chars. Falls back to the default when cleared. */
export function parseBrandingAppName(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length === 0) return BRANDING_DEFAULT_APP_NAME;
if (trimmed.length > APP_NAME_MAX_LENGTH) return null;
return trimmed;
}
/** Trimmed, 0-160 chars. An empty string means "use the built-in default". */
export function parseBrandingTagline(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length > TAGLINE_MAX_LENGTH) return null;
return trimmed;
}
/**
* Validates and re-encodes an uploaded logo. Only raster formats are
* accepted (no SVG), sidestepping SVG-script XSS entirely rather than trying
* to sanitize markup. The image is always re-encoded to PNG through sharp:
* this both proves the payload is a genuine image (not a disguised file
* behind a spoofed data-URL prefix) and strips any embedded metadata.
*
* Returns null on any validation failure; the caller is expected to reject
* the request with a 400 without echoing back details of what was rejected.
*/
export async function parseBrandingLogoDataUrl(
value: unknown,
): Promise<string | null> {
if (typeof value !== "string") return null;
const match = value.match(
/^data:image\/(png|jpe?g|webp);base64,([a-zA-Z0-9+/]+=*)$/,
);
if (!match) return null;
const [, , base64] = match;
let buffer: Buffer;
try {
buffer = Buffer.from(base64, "base64");
} catch {
return null;
}
if (buffer.length === 0 || buffer.length > BRANDING_LOGO_MAX_BYTES * 4) {
// Reject wildly oversized input before decoding it with sharp; the final
// size check happens below on the re-encoded PNG.
return null;
}
try {
const source = sharp(buffer, {
failOn: "error",
limitInputPixels: BRANDING_LOGO_MAX_INPUT_PIXELS,
});
const metadata = await source.metadata();
if (
!metadata.format ||
!["png", "jpeg", "webp"].includes(metadata.format)
) {
return null;
}
const normalized = await source.rotate().png().toBuffer();
if (normalized.length > BRANDING_LOGO_MAX_BYTES) return null;
return `data:image/png;base64,${normalized.toString("base64")}`;
} catch (err) {
databaseLogger.warn("Rejected invalid branding logo upload", {
operation: "branding_logo_invalid",
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
@@ -1,436 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type {
AuthenticatedRequest,
CredentialBackend,
} from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import type { Client as SshClient } from "ssh2";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { connectHost } from "../../hosts/connect/connect-host.js";
import type { SshConnectHost } from "../../hosts/connect/types.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { isSecretReference } from "../../hosts/external-secrets.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
function describeDeployError(err: unknown): string {
const message = getErrorMessage(err, "Connection failed");
if (message.includes("All configured authentication methods failed")) {
return "Authentication failed. Please check your credentials and ensure the SSH service is running.";
}
if (message.includes("ENOTFOUND") || message.includes("ENOENT")) {
return "Could not resolve hostname or connect to server.";
}
if (message.includes("ECONNREFUSED")) {
return "Connection refused. The server may not be running or the port may be incorrect.";
}
if (message.includes("ETIMEDOUT")) {
return "Connection timed out. Check your network connection and server availability.";
}
if (
message.includes("authentication failed") ||
message.includes("Permission denied")
) {
return "Authentication failed. Please check your username and password/key.";
}
if (message === "SSH connection timeout") return "Connection timeout";
return message;
}
async function deploySSHKeyToHost(
host: SshConnectHost,
userId: string,
credData: CredentialBackend,
): Promise<{ success: boolean; message?: string; error?: string }> {
const publicKey = credData.publicKey as string;
let conn: SshClient;
try {
({ client: conn } = await connectHost(host, {
userId,
purpose: "credential-deploy",
timeoutMs: 120000,
}));
} catch (error) {
return { success: false, error: describeDeployError(error) };
}
return new Promise((resolve) => {
void (async () => {
try {
await new Promise<void>((resolveCmd, rejectCmd) => {
const cmdTimeout = setTimeout(() => {
rejectCmd(new Error("mkdir command timeout"));
}, 10000);
conn.exec(
"test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh",
(err, stream) => {
if (err) {
clearTimeout(cmdTimeout);
return rejectCmd(err);
}
stream.on("close", (code) => {
clearTimeout(cmdTimeout);
if (code === 0) {
resolveCmd();
} else {
rejectCmd(
new Error(`mkdir command failed with code ${code}`),
);
}
});
stream.on("data", () => {
// Ignore output
});
},
);
});
const keyExists = await new Promise<boolean>(
(resolveCheck, rejectCheck) => {
const checkTimeout = setTimeout(() => {
rejectCheck(new Error("Key check timeout"));
}, 5000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
clearTimeout(checkTimeout);
return rejectCheck(
new Error(
"Invalid public key format - must contain at least 2 parts",
),
);
}
const keyPattern = keyParts[1];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
clearTimeout(checkTimeout);
return rejectCheck(new Error("Invalid public key data"));
}
conn.exec(
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
(err, stream) => {
if (err) {
clearTimeout(checkTimeout);
return rejectCheck(err);
}
let output = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.on("close", () => {
clearTimeout(checkTimeout);
const exists = output.trim() === "0";
resolveCheck(exists);
});
},
);
},
);
if (keyExists) {
conn.end();
resolve({ success: true, message: "SSH key already deployed" });
return;
}
await new Promise<void>((resolveAdd, rejectAdd) => {
const addTimeout = setTimeout(() => {
rejectAdd(new Error("Key add timeout"));
}, 30000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const escapedKey = actualPublicKey
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''");
const escapedName = credData.name
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''");
conn.exec(
`printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
(err, stream) => {
if (err) {
clearTimeout(addTimeout);
return rejectAdd(err);
}
stream.on("data", () => {
// Consume output
});
stream.on("close", (code) => {
clearTimeout(addTimeout);
if (code === 0) {
resolveAdd();
} else {
rejectAdd(
new Error(`Key deployment failed with code ${code}`),
);
}
});
},
);
});
const verifySuccess = await new Promise<boolean>(
(resolveVerify, rejectVerify) => {
const verifyTimeout = setTimeout(() => {
rejectVerify(new Error("Key verification timeout"));
}, 5000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
clearTimeout(verifyTimeout);
return rejectVerify(
new Error(
"Invalid public key format - must contain at least 2 parts",
),
);
}
const keyPattern = keyParts[1];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
clearTimeout(verifyTimeout);
return rejectVerify(new Error("Invalid public key data"));
}
conn.exec(
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
(err, stream) => {
if (err) {
clearTimeout(verifyTimeout);
return rejectVerify(err);
}
let output = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.on("close", () => {
clearTimeout(verifyTimeout);
const verified = output.trim() === "0";
resolveVerify(verified);
});
},
);
},
);
conn.end();
if (verifySuccess) {
resolve({ success: true, message: "SSH key deployed successfully" });
} else {
resolve({
success: false,
error: "Key deployment verification failed",
});
}
} catch (error) {
conn.end();
resolve({
success: false,
error: getErrorMessage(error, "Deployment failed"),
});
}
})();
});
}
export function registerCredentialDeployRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireHostEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /credentials/{id}/deploy-to-host:
* post:
* summary: Deploy SSH key to a host
* description: Deploys an SSH public key to a target host's authorized_keys file.
* tags:
* - Credentials
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* targetHostId:
* type: integer
* responses:
* 200:
* description: SSH key deployed successfully.
* 400:
* description: Credential ID and target host ID are required.
* 401:
* description: Authentication required.
* 404:
* description: Credential or target host not found.
* 500:
* description: Failed to deploy SSH key.
*/
router.post(
"/:id/deploy-to-host",
authenticateJWT,
requireCredentialViewPermission,
requireHostEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const credentialId = parseInt(id);
const { targetHostId } = req.body;
if (!credentialId || !targetHostId) {
return res.status(400).json({
success: false,
error: "Credential ID and target host ID are required",
});
}
try {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({
success: false,
error: "Authentication required",
});
}
const repository = createCurrentHostResolutionRepository();
const credential = await repository.findCredentialByIdForUser(
credentialId,
userId,
);
if (!credential) {
return res.status(404).json({
success: false,
error: "Credential not found",
});
}
const credData = credential as unknown as CredentialBackend;
if (credData.authType !== "key") {
return res.status(400).json({
success: false,
error: "Only SSH key-based credentials can be deployed",
});
}
let publicKey = credData.publicKey;
const storedKey = credData.privateKey || credData.key;
if (!publicKey && isSecretReference(storedKey)) {
// A key kept in a secret source has no stored public key; derive
// it from the resolved key.
const resolved = await resolveKeyReferences(
userId,
storedKey,
(credData as { keyPassword?: string | null }).keyPassword,
);
publicKey =
parseSSHKey(resolved.key, resolved.passphrase).publicKey ||
undefined;
}
if (!publicKey) {
return res.status(400).json({
success: false,
error: "Public key is required for deployment",
});
}
// Writing authorized_keys is an owner action, so ownership is checked
// before the shared resolver fills in the credentials.
const hostData = await repository.findHostByIdForUser(
targetHostId,
userId,
);
if (!hostData) {
return res.status(404).json({
success: false,
error: "Target host not found",
});
}
const resolvedHost = await resolveHostById(targetHostId, userId);
if (!resolvedHost) {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
const deployResult = await deploySSHKeyToHost(
resolvedHost as unknown as SshConnectHost,
userId,
credData,
);
if (deployResult.success) {
res.json({
success: true,
message: deployResult.message || "SSH key deployed successfully",
});
} else {
res.status(500).json({
success: false,
error: deployResult.error || "Deployment failed",
});
}
} catch (error) {
res.status(500).json({
success: false,
error: getErrorMessage(error, "Failed to deploy SSH key"),
});
}
},
);
}
@@ -1,519 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Response, Router } from "express";
import crypto from "crypto";
import ssh2Pkg from "ssh2";
import { authLogger } from "../../utils/logger.js";
import {
parsePublicKey,
parseSSHKey,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
const { utils: ssh2Utils } = ssh2Pkg;
function generateSSHKeyPair(
keyType: string,
keySize?: number,
passphrase?: string,
): {
success: boolean;
privateKey?: string;
publicKey?: string;
error?: string;
} {
try {
let ssh2Type = keyType;
const options: {
bits?: number;
passphrase?: string;
cipher?: string;
} = {};
if (keyType === "ssh-rsa") {
ssh2Type = "rsa";
options.bits = keySize || 2048;
} else if (keyType === "ssh-ed25519") {
ssh2Type = "ed25519";
} else if (keyType === "ecdsa-sha2-nistp256") {
ssh2Type = "ecdsa";
options.bits = 256;
}
if (passphrase && passphrase.trim()) {
options.passphrase = passphrase;
options.cipher = "aes128-cbc";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return {
success: true,
privateKey: keyPair.private,
publicKey: keyPair.public,
};
} catch (error) {
return {
success: false,
error: getErrorMessage(error, "SSH key generation failed"),
};
}
}
export function registerCredentialKeyRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /credentials/detect-key-type:
* post:
* summary: Detect SSH key type
* description: Detects the type of an SSH private key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: Key type detection result.
* 400:
* description: Private key is required.
* 500:
* description: Failed to detect key type.
*/
router.post(
"/detect-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
const keyInfo = parseSSHKey(privateKey, keyPassword);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
hasPublicKey: !!keyInfo.publicKey,
error: keyInfo.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to detect key type", error);
res.status(500).json({
error: getErrorMessage(error, "Failed to detect key type"),
});
}
},
);
/**
* @openapi
* /credentials/detect-public-key-type:
* post:
* summary: Detect SSH public key type
* description: Detects the type of an SSH public key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* publicKey:
* type: string
* responses:
* 200:
* description: Key type detection result.
* 400:
* description: Public key is required.
* 500:
* description: Failed to detect public key type.
*/
router.post(
"/detect-public-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { publicKey } = req.body;
if (!publicKey || typeof publicKey !== "string") {
return res.status(400).json({ error: "Public key is required" });
}
try {
const keyInfo = parsePublicKey(publicKey);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
error: keyInfo.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to detect public key type", error);
res.status(500).json({
error: getErrorMessage(error, "Failed to detect public key type"),
});
}
},
);
/**
* @openapi
* /credentials/validate-key-pair:
* post:
* summary: Validate SSH key pair
* description: Validates if a given SSH private key and public key match.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* publicKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: Key pair validation result.
* 400:
* description: Private key and public key are required.
* 500:
* description: Failed to validate key pair.
*/
router.post(
"/validate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
if (!publicKey || typeof publicKey !== "string") {
return res.status(400).json({ error: "Public key is required" });
}
try {
const validationResult = validateKeyPair(
privateKey,
publicKey,
keyPassword,
);
const response = {
isValid: validationResult.isValid,
privateKeyType: validationResult.privateKeyType,
publicKeyType: validationResult.publicKeyType,
generatedPublicKey: validationResult.generatedPublicKey,
error: validationResult.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to validate key pair", error);
res.status(500).json({
error: getErrorMessage(error, "Failed to validate key pair"),
});
}
},
);
/**
* @openapi
* /credentials/generate-key-pair:
* post:
* summary: Generate new SSH key pair
* description: Generates a new SSH key pair.
* tags:
* - Credentials
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* keyType:
* type: string
* keySize:
* type: integer
* passphrase:
* type: string
* responses:
* 200:
* description: The new key pair.
* 500:
* description: Failed to generate SSH key pair.
*/
router.post(
"/generate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
try {
const result = generateSSHKeyPair(keyType, keySize, passphrase);
if (result.success && result.privateKey && result.publicKey) {
const response = {
success: true,
privateKey: result.privateKey,
publicKey: result.publicKey,
keyType: keyType,
format: "ssh",
algorithm: keyType,
keySize: keyType === "ssh-rsa" ? keySize : undefined,
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
};
res.json(response);
} else {
res.status(500).json({
success: false,
error: result.error || "Failed to generate SSH key pair",
});
}
} catch (error) {
authLogger.error("Failed to generate key pair", error);
res.status(500).json({
success: false,
error: getErrorMessage(error, "Failed to generate key pair"),
});
}
},
);
/**
* @openapi
* /credentials/generate-public-key:
* post:
* summary: Generate public key from private key
* description: Generates a public key from a given private key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: The generated public key.
* 400:
* description: Private key is required.
* 500:
* description: Failed to generate public key.
*/
router.post(
"/generate-public-key",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
let privateKeyObj;
const parseAttempts = [];
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
passphrase: keyPassword,
});
} catch (error) {
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
}
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey(privateKey);
} catch (error) {
parseAttempts.push(
`Method 2 (without passphrase): ${error.message}`,
);
}
}
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs8",
});
} catch (error) {
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
}
}
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs1",
});
} catch (error) {
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
}
}
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "sec1",
});
} catch (error) {
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
}
}
if (!privateKeyObj) {
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
if (keyInfo.success && keyInfo.publicKey) {
const publicKeyString = String(keyInfo.publicKey);
return res.json({
success: true,
publicKey: publicKeyString,
keyType: keyInfo.keyType,
});
} else {
parseAttempts.push(
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
);
}
} catch (error) {
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
}
}
if (!privateKeyObj) {
return res.status(400).json({
success: false,
error: "Unable to parse private key. Tried multiple formats.",
details: parseAttempts,
});
}
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
const publicKeyPem = publicKeyObj.export({
type: "spki",
format: "pem",
});
const publicKeyString =
typeof publicKeyPem === "string"
? publicKeyPem
: (publicKeyPem as Buffer).toString("utf8");
let keyType = "unknown";
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
if (asymmetricKeyType === "rsa") {
keyType = "ssh-rsa";
} else if (asymmetricKeyType === "ed25519") {
keyType = "ssh-ed25519";
} else if (asymmetricKeyType === "ec") {
keyType = "ecdsa-sha2-nistp256";
}
let finalPublicKey = publicKeyString;
let formatType = "pem";
try {
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
if (!(ssh2PrivateKey instanceof Error)) {
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
const base64Data = publicKeyBuffer.toString("base64");
finalPublicKey = `${keyType} ${base64Data}`;
formatType = "ssh";
}
} catch {
// Ignore validation errors
}
const response = {
success: true,
publicKey: finalPublicKey,
keyType: keyType,
format: formatType,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to generate public key", error);
res.status(500).json({
success: false,
error: getErrorMessage(error, "Failed to generate public key"),
});
}
},
);
}
@@ -8,8 +8,6 @@ import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
import {
logAudit,
@@ -1211,19 +1209,4 @@ router.put(
},
);
registerCredentialKeyRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
);
registerCredentialDeployRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
export default router;
@@ -3,8 +3,7 @@ import express, { type Request, type Response } from "express";
import { dashboardLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { createCurrentRecentActivityRepository } from "../repositories/factory.js";
import { recordRecentActivity } from "../../services/recent-activity.js";
import { recentActivity } from "../../plugins/activity-provider.js";
const router = express.Router();
@@ -99,11 +98,7 @@ router.get(
const limit = Number(req.query.limit) || 20;
const activities =
await createCurrentRecentActivityRepository().listByUserId(
userId,
limit,
);
const activities = await recentActivity.list(userId, limit);
res.json(activities);
} catch (err) {
@@ -169,7 +164,7 @@ router.post(
});
}
const result = await recordRecentActivity(userId, {
const result = await recentActivity.record(userId, {
type,
hostId,
hostName,
@@ -185,6 +180,11 @@ router.post(
message: "Activity already logged recently (rate limited)",
});
}
if (result.status === "unavailable") {
return res
.status(404)
.json({ error: "Recent activity is not available" });
}
if (result.status === "denied") {
return res
.status(404)
@@ -222,7 +222,7 @@ router.delete(
try {
const userId = (req as AuthenticatedRequest).userId;
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await recentActivity.deleteByUserId(userId);
dashboardLogger.success("Recent activity cleared", {
operation: "reset_recent_activity",
@@ -1,4 +1,5 @@
import { authLogger } from "../../utils/logger.js";
import { recentActivity } from "../../plugins/activity-provider.js";
import {
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
@@ -9,7 +10,6 @@ import {
createCurrentCredentialSidebarPreferenceRepository,
createCurrentUiPreferenceRepository,
createCurrentOpenTabRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSessionRepository,
@@ -64,7 +64,7 @@ export async function deleteUserAndRelatedData(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await recentActivity.deleteByUserId(userId);
await createCurrentHostFolderRepository().deleteByUserId(userId);
+3 -821
View File
@@ -1,172 +1,22 @@
import {
prepareHostImports,
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import {
changeHostOverrides,
type OverrideChange,
} from "../../hosts/defaults/overrides.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { splitDefaultKey } from "../../../types/host-defaults.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";
import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../../hosts/connect/auth-provider-registry.js";
import {
applyPluginHostImportSettings,
setHostPluginEnabled,
} from "./host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
} from "./host-normalizers.js";
type SSHConfigHost = {
name: string;
hostname?: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
};
type ShareCredential = {
alias?: unknown;
name?: unknown;
description?: unknown;
folder?: unknown;
tags?: unknown;
authType?: unknown;
username?: unknown;
keyType?: unknown;
};
function textValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function tagString(value: unknown): string {
if (Array.isArray(value)) {
return value
.map((tag) => textValue(tag))
.filter((tag): tag is string => !!tag)
.join(",");
}
return textValue(value) || "";
}
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
return value === "key" ? "key" : "password";
}
export function parseSSHConfig(content: string): SSHConfigHost[] {
const results: SSHConfigHost[] = [];
let current: SSHConfigHost | null = null;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
if (!match) continue;
const key = match[1].toLowerCase();
const value = match[2];
if (key === "host") {
if (current && current.hostname) results.push(current);
// Skip wildcard patterns
if (value === "*" || value.includes("*") || value.includes("?")) {
current = null;
} else {
current = { name: value };
}
continue;
}
if (!current) continue;
switch (key) {
case "hostname":
current.hostname = value;
break;
case "user":
current.user = value;
break;
case "port": {
const p = Number.parseInt(value, 10);
if (p > 0 && p <= 65535) current.port = p;
break;
}
case "identityfile":
if (!current.identityFile) current.identityFile = value;
break;
case "proxyjump":
current.proxyJump = value;
break;
}
}
if (current && current.hostname) results.push(current);
return results;
}
/**
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
username: unknown,
): string | null {
if (isNonEmptyString(username)) return username;
if (connectionType !== "ssh" || authType === "credential") return "";
return null;
}
import { setHostPluginEnabled } from "./host-plugin-settings.js";
export function registerHostBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCreatePermission: RequestHandler,
requireEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
@@ -467,674 +317,6 @@ export function registerHostBulkRoutes(
}
},
);
router.post(
"/bulk-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
hosts: hostsToImport,
overwrite,
credentials: credentialsToImport,
} = req.body;
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
return res
.status(400)
.json({ error: "Hosts array is required and must not be empty" });
}
if (hostsToImport.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
}
let orderedHosts: ReturnType<typeof prepareHostImports>;
try {
orderedHosts = prepareHostImports(hostsToImport);
} catch (error) {
return res.status(400).json({ error: getErrorMessage(error) });
}
const importedIds = new Map<unknown, number>();
const results = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [] as string[],
};
const credentialAliasMap = new Map<string, number>();
const addCredentialAlias = (alias: unknown, id: number) => {
const key = textValue(alias);
if (key) credentialAliasMap.set(key.toLowerCase(), id);
};
try {
const credentialRepository = createCurrentCredentialRepository();
const existingCredentials =
await credentialRepository.listDecryptedByUserId(userId);
for (const credential of existingCredentials) {
addCredentialAlias(credential.name, credential.id as number);
}
if (Array.isArray(credentialsToImport)) {
for (const rawCredential of credentialsToImport as ShareCredential[]) {
const alias = textValue(rawCredential.alias);
const name = textValue(rawCredential.name) || alias;
if (!alias || !name) continue;
const existingId = credentialAliasMap.get(name.toLowerCase());
if (existingId) {
addCredentialAlias(alias, existingId);
continue;
}
const now = new Date().toISOString();
const created = await credentialRepository.createEncryptedForUser(
userId,
{
userId,
name,
description:
textValue(rawCredential.description) ||
"Imported placeholder. Add the secret before connecting.",
folder: textValue(rawCredential.folder),
tags: tagString(rawCredential.tags),
authType: normalizeCredentialAuthType(rawCredential.authType),
username: textValue(rawCredential.username),
password: null,
key: null,
privateKey: null,
publicKey: null,
keyPassword: null,
keyType: textValue(rawCredential.keyType),
detectedKeyType: null,
usageCount: 0,
lastUsed: null,
createdAt: now,
updatedAt: now,
},
);
const createdCredential = created as Record<string, unknown>;
addCredentialAlias(alias, createdCredential.id as number);
addCredentialAlias(name, createdCredential.id as number);
}
}
} catch (error) {
results.errors.push(
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
);
}
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId &&
hostData.credentialAlias
) {
hostData.credentialId = credentialAliasMap.get(
hostData.credentialAlias.toLowerCase(),
);
}
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Missing required fields (ip, port)`,
);
continue;
}
const username = importedHostUsername(
effectiveConnectionType,
hostData.authType,
hostData.username,
);
if (username === null) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Username required for SSH connections`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
// Hosts that jump through this one still resolve to the kept host.
if (exportId !== undefined) importedIds.set(exportId, existing.id);
results.skipped++;
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
!knownAuthTypes.has(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "password" &&
!isNonEmptyString(hostData.password)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Password required for password authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "key" &&
!isNonEmptyString(hostData.key)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Key required for key authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId required for credential authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
hostData.credentialId
) {
const credentialRepository = createCurrentCredentialRepository();
const cred = await credentialRepository.findByIdForUser(
userId,
hostData.credentialId,
);
if (!cred) {
const fallback = await credentialRepository.listByUserId(userId);
if (fallback.length > 0) {
hostData.credentialId = fallback[0].id;
} else if (isNonEmptyString(hostData.key)) {
hostData.authType = "key";
hostData.credentialId = undefined;
} else if (isNonEmptyString(hostData.password)) {
hostData.authType = "password";
hostData.credentialId = undefined;
} else {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
);
continue;
}
}
}
const jumpHosts = remapImportedJumpHosts(
hostData.jumpHosts,
importedIds,
);
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
name: hostData.name || `${username}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
username,
pin: hostData.pin || false,
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
...importedStatusCheck(hostData as Record<string, unknown>),
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
sshOptions: hostData.sshOptions,
terminalConfig: hostData.terminalConfig,
}) ?? null,
forceKeyboardInteractive: hostData.forceKeyboardInteractive
? "true"
: "false",
notes: hostData.notes || null,
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain
? JSON.stringify(hostData.socks5ProxyChain)
: null,
portKnockSequence: hostData.portKnockSequence
? JSON.stringify(hostData.portKnockSequence)
: null,
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
updatedAt: new Date().toISOString(),
};
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = hostData.password || null;
sshDataObj.authType = "password";
sshDataObj.credentialId = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
sshDataObj.authType = hostData.authType || "password";
sshDataObj.credentialId =
hostData.authType === "credential" ? hostData.credentialId : null;
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
sshDataObj.keyPassword =
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
}
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as Record<string, unknown>,
);
let savedHostId: number;
if (existing) {
const saved = await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
if (!saved) throw new Error("Host no longer exists");
if (exportId !== undefined) importedIds.set(exportId, existing.id);
savedHostId = existing.id;
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
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
// exist. See host-plugin-settings.ts.
await applyPluginHostImportSettings(
savedHostId,
hostData as Record<string, unknown>,
);
writtenHostIds.push(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
});
}),
);
/**
* @openapi
* /host/ssh-config-import:
* post:
* summary: Import hosts from an OpenSSH config file
* description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - content
* properties:
* content:
* type: string
* description: Raw text content of the SSH config file.
* overwrite:
* type: boolean
* responses:
* 200:
* description: Import completed.
* 400:
* description: Invalid request body.
*/
router.post(
"/ssh-config-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { content, overwrite } = req.body;
if (!isNonEmptyString(content)) {
return res.status(400).json({
error: "content is required and must be a non-empty string",
});
}
let parsed: SSHConfigHost[];
try {
parsed = parseSSHConfig(content);
} catch {
return res
.status(400)
.json({ error: "Failed to parse SSH config file" });
}
if (parsed.length === 0) {
return res.status(400).json({
error: "No valid Host entries found in the SSH config file",
});
}
if (parsed.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
}
const hostsToImport = parsed.map((h) => ({
name: h.name,
ip: h.hostname,
port: h.port ?? 22,
username: h.user,
authType: h.identityFile ? "key" : undefined,
connectionType: "ssh",
enableSsh: true,
...(h.proxyJump
? {
jumpHosts: [{ host: h.proxyJump, port: 22 }],
}
: {}),
}));
const results = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [] as string[],
};
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
);
try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
results.skipped++;
continue;
}
const sshDataObj: Record<string, unknown> = {
userId,
connectionType: "ssh",
name: hostData.name || hostData.ip,
folder: "Default",
tags: "",
ip: hostData.ip,
port: hostData.port,
username: hostData.username || null,
authType: hostData.authType || "none",
password: null,
key: null,
keyPassword: null,
keyType: null,
credentialId: null,
pin: false,
sudoPassword: null,
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
statusCheckEnabled: true,
statusCheckInterval: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
overrideCredentialUsername: 0,
enableSsh: true,
updatedAt: new Date().toISOString(),
};
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as unknown as Record<string, unknown>,
);
if (existing) {
await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
writtenHostIds.push(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
);
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
});
}),
);
}
/**
* Status check fields from an import row. Exports from before 2.9.0 carried
* them inside statsConfig.
*/
function importedStatusCheck(raw: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: {};
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
const useGlobal = legacy.useGlobalStatusInterval !== false;
const interval =
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
const seconds = Number(interval);
return {
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
interval != null && Number.isInteger(seconds) && seconds >= 5
? seconds
: null,
};
}
const BUILTIN_SSH_AUTH_TYPES = [
"password",
"key",
"credential",
"agent",
"none",
];
/** Core's own types plus every type a plugin registers or declares. */
function listKnownAuthTypes(): Set<string> {
return new Set([
...BUILTIN_SSH_AUTH_TYPES,
...listSshAuthProviders().map((provider) => provider.type),
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
/** Fills an imported host's inherited keys and records what it sets itself. */
async function withHostDefaults(
userId: string,
hostId: number | null,
columns: Record<string, unknown>,
body: Record<string, unknown>,
): Promise<void> {
const stored =
hostId === null
? null
: ((
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0] ?? null);
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body,
stored,
}),
);
}
/** A resetDefaults body as an override change, or null when there is none. */
@@ -2,11 +2,11 @@ import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { recentActivity } from "../../plugins/activity-provider.js";
import {
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
} from "../repositories/factory.js";
@@ -432,9 +432,7 @@ export function registerHostFolderRoutes(
hostIds,
);
await createCurrentRecentActivityRepository().deleteByHostIds(
hostIds,
);
await recentActivity.deleteByHostIds(hostIds);
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds,
@@ -3,13 +3,10 @@ import type { Request, RequestHandler, Response, Router } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
DEFAULT_STATUS_INTERVAL,
GLOBAL_STATUS_INTERVAL_KEY,
hostStatusService,
hostStatus,
type HostStatusEntry,
} from "../../hosts/status/host-status-service.js";
} from "../../plugins/host-status-provider.js";
interface HostStatusRoutesDeps {
authenticateJWT: RequestHandler;
@@ -71,7 +68,7 @@ export function registerHostStatusRoutes(
return sessionExpired(res);
const requested = parseStatusHostIds(req.query.hostIds);
const statuses = await hostStatusService.statusesFor(userId, requested);
const statuses = await hostStatus.statusesFor(userId, requested);
// One batched permission check: this is polled every few seconds, and a
// per-host check made it linear in host count against the database.
@@ -110,15 +107,7 @@ export function registerHostStatusRoutes(
authenticateJWT,
requireAdmin,
async (_req: Request, res: Response) => {
const value = Number(
await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY),
);
res.json({
statusCheckInterval:
Number.isInteger(value) && value >= 5
? value
: DEFAULT_STATUS_INTERVAL,
});
res.json({ statusCheckInterval: await hostStatus.getIntervalSeconds() });
},
);
@@ -161,11 +150,7 @@ export function registerHostStatusRoutes(
.json({ error: "Status check interval must be 5 to 3600 seconds" });
}
try {
await createCurrentSettingsRepository().set(
GLOBAL_STATUS_INTERVAL_KEY,
String(seconds),
);
hostStatusService.retimeAll();
await hostStatus.setIntervalSeconds(seconds);
res.json({ statusCheckInterval: seconds });
} catch (error) {
sshLogger.error("Failed to save status check settings", error, {
@@ -198,7 +183,7 @@ export function registerHostStatusRoutes(
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
await hostStatusService.refresh(userId);
await hostStatus.refresh(userId);
res.json({ message: "Status checks restarted" });
},
);
@@ -247,9 +232,8 @@ export function registerHostStatusRoutes(
return res.status(404).json({ error: "Status not available" });
}
await hostStatusService.statusesFor(userId, null);
const entry =
hostStatusService.get(id) ?? (await hostStatusService.check(id));
await hostStatus.statusesFor(userId, null);
const entry = hostStatus.get(id) ?? (await hostStatus.check(id));
if (!entry) {
return res.status(404).json({ error: "Status not available" });
}
+5 -580
View File
@@ -7,25 +7,21 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import multer from "multer";
import { sshLogger, databaseLogger } from "../../utils/logger.js";
import { resolveHostCredentials } from "../../hosts/resolve-host-credentials.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../hosts/credential-username.js";
import { pickResolvedPassword } from "../../hosts/credential-username.js";
import { emitInternalEvent } from "../../hosts/internal-events.js";
import { deleteOwnedHost } from "../../hosts/delete-host.js";
import {
createCurrentCredentialRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSharedHostAuthOverrideRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import {
@@ -41,7 +37,6 @@ import {
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
hostTerminalExport,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
@@ -67,14 +62,8 @@ import {
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../../utils/shared-host-auth-resolver.js";
import type { HostResolutionCredentialRecord } from "../repositories/host-resolution-repository.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
@@ -91,15 +80,11 @@ import {
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 { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
import {
mergeStoredSshOptions,
parseTerminalConfig,
@@ -1688,340 +1673,6 @@ router.get(
},
);
/**
* @openapi
* /host/db/host/{id}/export:
* get:
* summary: Export SSH host
* description: Exports a specific SSH host with decrypted credentials.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The exported SSH host.
* 400:
* description: Invalid userId or hostId.
* 404:
* description: SSH host not found.
* 500:
* description: Failed to export SSH host.
*/
router.get(
"/db/host/:id/export",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId) || !hostId) {
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const host =
await createCurrentHostResolutionRepository().findHostByIdForUser(
Number(hostId),
userId,
);
if (!host) {
return res.status(404).json({ error: "SSH host not found" });
}
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
const hostPluginSettings = (
await loadHostPluginSettings([Number(hostId)])
).get(Number(hostId));
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = exportedConnectionType !== "ssh";
const baseExportData = {
exportId: resolvedHost.id,
connectionType: exportedConnectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
password: resolvedHost.password || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
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
: {
...baseExportData,
authType: resolvedHost.authType,
key: resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
portKnockSequence: resolvedHost.portKnockSequence
? JSON.parse(resolvedHost.portKnockSequence as string)
: null,
};
sshLogger.success("Host exported with decrypted credentials", {
operation: "host_export",
hostId: parseInt(hostId),
userId,
});
res.json(exportData);
} catch (err) {
sshLogger.error("Failed to export SSH host", err, {
operation: "host_export",
hostId: parseInt(hostId),
userId,
});
res.status(500).json({ error: "Failed to export SSH host" });
}
},
);
/**
* @openapi
* /host/db/hosts/export:
* get:
* summary: Export all SSH hosts
* description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
* tags:
* - SSH
* parameters:
* - in: query
* name: share
* required: false
* schema:
* type: string
* description: Set to "1" to export without embedded secrets.
* responses:
* 200:
* description: All exported SSH hosts.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to export SSH hosts.
*/
router.get(
"/db/hosts/export",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const shareMode = req.query.share === "1" || req.query.share === "true";
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const pluginSettingsByHost = await loadHostPluginSettings(
allHosts.map((h) => h.id as number),
);
const exportedHosts = [];
const usedCredentialIds = new Set<number>();
for (const host of allHosts) {
const resolvedHost = shareMode
? host
: (await resolveHostCredentials(host, userId)) || host;
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = exportedConnectionType !== "ssh";
const baseExportData = {
exportId: resolvedHost.id,
connectionType: exportedConnectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
password: shareMode ? null : resolvedHost.password || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
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
: {
...baseExportData,
authType: resolvedHost.authType,
key: shareMode ? null : resolvedHost.key || null,
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: shareMode
? null
: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
};
if (
shareMode &&
!isRemoteDesktop &&
resolvedHost.authType === "credential" &&
resolvedHost.credentialId
) {
usedCredentialIds.add(resolvedHost.credentialId as number);
}
exportedHosts.push(exportData);
}
if (!shareMode) {
sshLogger.success("All hosts exported with decrypted credentials", {
operation: "hosts_export_all",
count: exportedHosts.length,
userId,
});
return res.json({ hosts: exportedHosts });
}
const exportedCredentials: Record<string, unknown>[] = [];
if (usedCredentialIds.size > 0) {
const credentialRepository = createCurrentCredentialRepository();
const ownedCredentials =
await credentialRepository.listDecryptedByUserId(userId);
const credentialById = new Map(
ownedCredentials.map((credential) => [credential.id, credential]),
);
for (const host of exportedHosts as Record<string, unknown>[]) {
const credentialId = host.credentialId as number | null;
if (!credentialId) continue;
const credential = credentialById.get(credentialId);
if (!credential) continue;
host.credentialAlias = credential.name;
if (
!exportedCredentials.some(
(entry) => entry.alias === credential.name,
)
) {
exportedCredentials.push({
alias: credential.name,
name: credential.name,
description: credential.description || null,
folder: credential.folder || null,
tags:
typeof credential.tags === "string"
? credential.tags.split(",").filter(Boolean)
: [],
authType: credential.authType,
username: credential.username || null,
keyType: credential.keyType || null,
});
}
}
}
for (const host of exportedHosts as Record<string, unknown>[]) {
delete host.credentialId;
}
sshLogger.success("All hosts exported for sharing without secrets", {
operation: "hosts_export_all_share",
count: exportedHosts.length,
credentialCount: exportedCredentials.length,
userId,
});
res.json({
version: "1",
exportedAt: new Date().toISOString(),
credentials: exportedCredentials,
hosts: exportedHosts,
});
} catch (err) {
sshLogger.error("Failed to export all SSH hosts", err, {
operation: "hosts_export_all",
userId,
});
res.status(500).json({ error: "Failed to export SSH hosts" });
}
},
);
/**
* @openapi
* /host/db/host/{id}:
@@ -2117,231 +1768,6 @@ 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,
/**
* Credentials already fetched for this request, keyed by id. The host list
* preloads them in one query; single-host callers omit it and fall back to
* fetching the one credential they need.
*/
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
): Promise<Record<string, unknown>> {
try {
const ownerId = (host.ownerId || host.userId) as string | undefined;
if (
requestingUserId &&
ownerId &&
requestingUserId !== ownerId &&
typeof host.id === "number"
) {
const authHost = host as unknown as HostResolutionHostRecord;
const needsPersonalCredential = requiresPersonalHostAuthentication(
authHost,
"ssh",
);
const baseSshOverrideState = {
required: needsPersonalCredential,
ownerAuthShared: !!host.shareSshAuth,
};
// Owner auth for the remote desktop protocols is always snapshotted
// for recipients; only their own override credential varies per user.
const authOverrides: Record<string, unknown> = {
ssh: baseSshOverrideState,
};
const overrideCredentialIds =
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
host.id,
requestingUserId,
);
// 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,
ownerAuthShared: true,
};
}
const recipientHost: Record<string, unknown> = {
...host,
credentialId: null,
password: null,
key: null,
keyPassword: null,
keyType: null,
authOverrides,
};
try {
const resolution = await resolveRecipientSharedHostAuthentication(
authHost,
host.id,
requestingUserId,
"ssh",
);
if (resolution.source === "personal-override") {
const credential = resolution.credential;
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
authType:
credential.key || credential.privateKey
? "key"
: credential.password
? "password"
: "none",
username: credential.username || recipientHost.username,
password: credential.password,
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
}
if (resolution.source === "owner-shared") {
if (resolution.authType === "agent") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: "agent",
};
}
const sharedAuth = resolution.secret;
if (sharedAuth) {
const resolvedUsername = pickResolvedUsername(
recipientHost.username,
sharedAuth.username,
host.overrideCredentialUsername,
);
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: sharedAuth.key
? "key"
: sharedAuth.password
? "password"
: "none",
username: resolvedUsername,
password: sharedAuth.password,
key: sharedAuth.key,
keyPassword: sharedAuth.keyPassword,
keyType: sharedAuth.keyType,
};
}
}
if (resolution.source === "secretless") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
};
}
} catch {
// A missing/deleted override or snapshot behaves like unavailable auth.
}
return recipientHost;
}
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const credentialOwnerId = (host.ownerId || host.userId) as string;
const credential =
preloadedCredentials?.get(credentialId) ??
(await findUsableCredential(credentialId, credentialOwnerId));
if (credential) {
const resolvedHost: Record<string, unknown> = {
...host,
password: pickResolvedPassword(host.password, credential.password),
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
const resolvedUsername = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
}
return resolvedHost;
}
}
return { ...host };
} catch (error) {
sshLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
);
return host;
}
}
registerHostFolderRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
@@ -2355,7 +1781,6 @@ registerHostFolderRoutes(router, {
registerHostBulkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.create"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
+9 -2
View File
@@ -13,6 +13,7 @@ import {
createCurrentPluginRepository,
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
@@ -297,7 +298,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
let desktopMode: unknown = loaded?.manifest.desktop;
let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest)
: undefined;
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
@@ -313,8 +316,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
desktop?: unknown;
runtime?: unknown;
};
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ??
(manifest?.runtime === "desktop" ? "local" : undefined));
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
@@ -2,13 +2,13 @@ import type { Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { recentActivity } from "../../plugins/activity-provider.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
@@ -68,7 +68,7 @@ export async function resetUserPassword(
await userRepository.update(userId, { passwordHash });
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await recentActivity.deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (anything keyed by
@@ -6,11 +6,6 @@ import {
setGlobalLogLevel,
} from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
AUDIT_FORWARD_TOKEN_SETTING,
AUDIT_FORWARD_URL_ENV,
AUDIT_FORWARD_URL_SETTING,
} from "../../utils/audit-forwarder.js";
import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist,
@@ -192,125 +187,6 @@ export function registerUserSettingsRoutes(
}
});
/**
* @openapi
* /users/audit-forwarding:
* get:
* summary: Get audit log forwarding settings (admin only)
* tags:
* - Users
* responses:
* 200:
* description: Forwarding target. The token itself is never returned.
*/
router.get("/audit-forwarding", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
if (!(await getAdminActor(userId))) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsRepository = createCurrentSettingsRepository();
const url =
(await settingsRepository.get(AUDIT_FORWARD_URL_SETTING)) ?? "";
const hasToken = !!(await settingsRepository.get(
AUDIT_FORWARD_TOKEN_SETTING,
));
res.json({
url,
hasToken,
envConfigured: !!process.env[AUDIT_FORWARD_URL_ENV]?.trim(),
});
} catch (err) {
authLogger.error("Failed to get audit forwarding settings", err);
res
.status(500)
.json({ error: "Failed to get audit forwarding settings" });
}
});
/**
* @openapi
* /users/audit-forwarding:
* patch:
* summary: Update audit log forwarding settings (admin only)
* description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* url:
* type: string
* token:
* type: string
* responses:
* 200:
* description: Setting updated.
* 403:
* description: Not authorized.
*/
router.patch("/audit-forwarding", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { url, token } = req.body ?? {};
if (
typeof url !== "string" ||
(token !== undefined && typeof token !== "string")
) {
return res.status(400).json({ error: "url must be a string" });
}
const trimmedUrl = url.trim();
if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
return res.status(400).json({ error: "url must be http(s)" });
}
const settingsRepository = createCurrentSettingsRepository();
if (!trimmedUrl) {
await settingsRepository.delete(AUDIT_FORWARD_URL_SETTING);
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
} else {
await settingsRepository.set(AUDIT_FORWARD_URL_SETTING, trimmedUrl);
if (token !== undefined) {
if (token.trim()) {
await settingsRepository.set(
AUDIT_FORWARD_TOKEN_SETTING,
token.trim(),
);
} else {
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
}
}
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_audit_forwarding",
resourceType: "setting",
details: JSON.stringify({ enabled: !!trimmedUrl }),
ipAddress,
userAgent,
success: true,
});
res.json({ url: trimmedUrl, hasToken: !!token?.trim() });
} catch (err) {
authLogger.error("Failed to update audit forwarding settings", err);
res
.status(500)
.json({ error: "Failed to update audit forwarding settings" });
}
});
/**
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
* One route pair per private endpoint allowlist, each with its own setting key.
+1 -1
View File
@@ -1543,7 +1543,7 @@ registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerBrandingRoutes(router, requireAdmin);
registerBrandingRoutes(router);
registerAuthRoutes(router);
registerAuthCompatRoutes(router);
+2 -2
View File
@@ -2,10 +2,10 @@ import {
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
} from "../database/repositories/factory.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { recentActivity } from "../plugins/activity-provider.js";
import { sshLogger } from "../utils/logger.js";
import { emitInternalEvent } from "./internal-events.js";
@@ -33,7 +33,7 @@ export async function deleteOwnedHost(
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
await recentActivity.deleteByHostId(hostId);
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
await createCurrentHostRepository().deleteForUser(userId, hostId);
+213
View File
@@ -0,0 +1,213 @@
import {
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
} from "../../database/repositories/factory.js";
import { hostTerminalExport } from "../../database/routes/host-normalizers.js";
import { loadHostPluginSettings } from "../../database/routes/host-plugin-settings.js";
import {
listProtocolLogins,
toPortableLogins,
} from "../protocol-auth/protocol-auth.js";
import { findHostProtocol } from "../protocol-auth/registry.js";
import { resolveHostCredentials } from "../resolve-host-credentials.js";
/**
* 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;
}
type Row = Record<string, unknown>;
/** One host as the Termix JSON export carries it. */
async function exportRow(
host: Row,
userId: string,
shareMode: boolean,
pluginSettings: Awaited<ReturnType<typeof loadHostPluginSettings>>,
): Promise<{ data: Row; isRemoteDesktop: boolean; resolved: Row }> {
const resolvedHost = shareMode
? host
: (await resolveHostCredentials(host, userId)) || host;
const connectionType = (resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = connectionType !== "ssh";
const base = {
exportId: resolvedHost.id,
connectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
password: shareMode ? null : resolvedHost.password || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
protocolAuth: shareableLogins(
toPortableLogins(await listProtocolLogins(host.id as number, userId)),
shareMode,
),
};
if (isRemoteDesktop) {
return { data: base, isRemoteDesktop, resolved: resolvedHost };
}
const terminal = hostTerminalExport(resolvedHost);
const data = {
...base,
authType: resolvedHost.authType,
key: shareMode ? null : resolvedHost.key || null,
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode ? null : resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
terminalConfig: terminal.terminalConfig ?? null,
sshOptions: terminal.sshOptions,
forceKeyboardInteractive: resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: shareMode ? null : resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
portKnockSequence: resolvedHost.portKnockSequence
? JSON.parse(resolvedHost.portKnockSequence as string)
: null,
};
return { data, isRemoteDesktop, resolved: resolvedHost };
}
export interface HostExport {
hosts: Row[];
/** Share mode only. */
version?: string;
exportedAt?: string;
credentials?: Row[];
}
/**
* All of the user's own hosts. Without shareMode credentials are decrypted and
* embedded. With it every secret is left out and credential logins point at a
* scrubbed credentials list by alias.
*/
export async function exportAllHosts(
userId: string,
shareMode: boolean,
hostIds?: number[],
): Promise<HostExport> {
const owned =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const wanted = hostIds ? new Set(hostIds) : null;
const allHosts = wanted
? owned.filter((host) => wanted.has(host.id as number))
: owned;
const settings = await loadHostPluginSettings(
allHosts.map((h) => h.id as number),
);
const hosts: Row[] = [];
const usedCredentialIds = new Set<number>();
for (const host of allHosts) {
const { data, isRemoteDesktop, resolved } = await exportRow(
host as unknown as Row,
userId,
shareMode,
settings,
);
if (
shareMode &&
!isRemoteDesktop &&
resolved.authType === "credential" &&
resolved.credentialId
) {
usedCredentialIds.add(resolved.credentialId as number);
}
hosts.push(data);
}
if (!shareMode) return { hosts };
const credentials: Row[] = [];
if (usedCredentialIds.size > 0) {
const owned =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const byId = new Map(
owned.map((credential) => [credential.id, credential]),
);
for (const host of hosts) {
const credentialId = host.credentialId as number | null;
if (!credentialId) continue;
const credential = byId.get(credentialId);
if (!credential) continue;
host.credentialAlias = credential.name;
if (!credentials.some((entry) => entry.alias === credential.name)) {
credentials.push({
alias: credential.name,
name: credential.name,
description: credential.description || null,
folder: credential.folder || null,
tags:
typeof credential.tags === "string"
? credential.tags.split(",").filter(Boolean)
: [],
authType: credential.authType,
username: credential.username || null,
keyType: credential.keyType || null,
});
}
}
}
for (const host of hosts) delete host.credentialId;
return {
version: "1",
exportedAt: new Date().toISOString(),
credentials,
hosts,
};
}
+829
View File
@@ -0,0 +1,829 @@
import {
prepareHostImports,
remapImportedJumpHosts,
} from "../../database/routes/host-import-order.js";
import { sshOptionsForWrite } from "../ssh-options.js";
import {
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../defaults/index.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../protocol-auth/protocol-auth.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentHostDefaultsRepository,
} from "../../database/repositories/factory.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../connect/auth-provider-registry.js";
import { applyPluginHostImportSettings } from "../../database/routes/host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
} from "../../database/routes/host-normalizers.js";
/** A request the caller got wrong. Routes answer it with a 400. */
export class ImportError extends Error {
constructor(message: string) {
super(message);
this.name = "ImportError";
}
}
export interface ImportResults {
success: number;
updated: number;
skipped: number;
failed: number;
errors: string[];
/** Hosts this call created. */
createdHostIds: number[];
/** True when atomic was set and a failure undid the creates. */
rolledBack?: boolean;
}
export interface BulkImportInput {
hosts: unknown;
overwrite?: unknown;
credentials?: unknown;
/** All or nothing: any failure deletes what this call created. */
atomic?: boolean;
}
type SSHConfigHost = {
name: string;
hostname?: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
};
type ShareCredential = {
alias?: unknown;
name?: unknown;
description?: unknown;
folder?: unknown;
tags?: unknown;
authType?: unknown;
username?: unknown;
keyType?: unknown;
};
function textValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function tagString(value: unknown): string {
if (Array.isArray(value)) {
return value
.map((tag) => textValue(tag))
.filter((tag): tag is string => !!tag)
.join(",");
}
return textValue(value) || "";
}
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
return value === "key" ? "key" : "password";
}
export function parseSSHConfig(content: string): SSHConfigHost[] {
const results: SSHConfigHost[] = [];
let current: SSHConfigHost | null = null;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
if (!match) continue;
const key = match[1].toLowerCase();
const value = match[2];
if (key === "host") {
if (current && current.hostname) results.push(current);
// Skip wildcard patterns
if (value === "*" || value.includes("*") || value.includes("?")) {
current = null;
} else {
current = { name: value };
}
continue;
}
if (!current) continue;
switch (key) {
case "hostname":
current.hostname = value;
break;
case "user":
current.user = value;
break;
case "port": {
const p = Number.parseInt(value, 10);
if (p > 0 && p <= 65535) current.port = p;
break;
}
case "identityfile":
if (!current.identityFile) current.identityFile = value;
break;
case "proxyjump":
current.proxyJump = value;
break;
}
}
if (current && current.hostname) results.push(current);
return results;
}
/**
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
username: unknown,
): string | null {
if (isNonEmptyString(username)) return username;
if (connectionType !== "ssh" || authType === "credential") return "";
return null;
}
/**
* Status check fields from an import row. Exports from before 2.9.0 carried
* them inside statsConfig.
*/
function importedStatusCheck(raw: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: {};
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
const useGlobal = legacy.useGlobalStatusInterval !== false;
const interval =
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
const seconds = Number(interval);
return {
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
interval != null && Number.isInteger(seconds) && seconds >= 5
? seconds
: null,
};
}
const BUILTIN_SSH_AUTH_TYPES = [
"password",
"key",
"credential",
"agent",
"none",
];
/** Core's own types plus every type a plugin registers or declares. */
function listKnownAuthTypes(): Set<string> {
return new Set([
...BUILTIN_SSH_AUTH_TYPES,
...listSshAuthProviders().map((provider) => provider.type),
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
/** Fills an imported host's inherited keys and records what it sets itself. */
async function withHostDefaults(
userId: string,
hostId: number | null,
columns: Record<string, unknown>,
body: Record<string, unknown>,
): Promise<void> {
const stored =
hostId === null
? null
: ((
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0] ?? null);
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body,
stored,
}),
);
}
export function importSummary(results: ImportResults): string {
return `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`;
}
export function importResponse(results: ImportResults) {
return {
message: importSummary(results),
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
};
}
export const MAX_IMPORT_HOSTS = 100;
/** Deletes what an atomic import created, newest first. */
async function rollBackCreated(
userId: string,
results: ImportResults,
): Promise<void> {
const repository = createCurrentHostRepository();
for (const hostId of [...results.createdHostIds].reverse()) {
await repository.deleteForUser(userId, hostId).catch(() => {});
}
results.rolledBack = true;
results.success = 0;
results.createdHostIds = [];
}
/**
* The Termix JSON import: shared by POST /host/bulk-import and
* ctx.hosts.importMany. Callers wrap it in DatabaseSaveTrigger.batched.
*/
export async function runBulkImport(
userId: string,
input: BulkImportInput,
): Promise<ImportResults> {
const {
hosts: hostsToImport,
overwrite,
credentials: credentialsToImport,
} = input;
if (input.atomic && overwrite) {
throw new ImportError("An atomic import cannot overwrite existing hosts");
}
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
throw new ImportError("Hosts array is required and must not be empty");
}
if (hostsToImport.length > 100) {
throw new ImportError("Maximum 100 hosts allowed per import");
}
let orderedHosts: ReturnType<typeof prepareHostImports>;
try {
orderedHosts = prepareHostImports(hostsToImport);
} catch (error) {
throw new ImportError(getErrorMessage(error));
}
const importedIds = new Map<unknown, number>();
const results: ImportResults = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [],
};
const credentialAliasMap = new Map<string, number>();
const addCredentialAlias = (alias: unknown, id: number) => {
const key = textValue(alias);
if (key) credentialAliasMap.set(key.toLowerCase(), id);
};
try {
const credentialRepository = createCurrentCredentialRepository();
const existingCredentials =
await credentialRepository.listDecryptedByUserId(userId);
for (const credential of existingCredentials) {
addCredentialAlias(credential.name, credential.id as number);
}
if (Array.isArray(credentialsToImport)) {
for (const rawCredential of credentialsToImport as ShareCredential[]) {
const alias = textValue(rawCredential.alias);
const name = textValue(rawCredential.name) || alias;
if (!alias || !name) continue;
const existingId = credentialAliasMap.get(name.toLowerCase());
if (existingId) {
addCredentialAlias(alias, existingId);
continue;
}
const now = new Date().toISOString();
const created = await credentialRepository.createEncryptedForUser(
userId,
{
userId,
name,
description:
textValue(rawCredential.description) ||
"Imported placeholder. Add the secret before connecting.",
folder: textValue(rawCredential.folder),
tags: tagString(rawCredential.tags),
authType: normalizeCredentialAuthType(rawCredential.authType),
username: textValue(rawCredential.username),
password: null,
key: null,
privateKey: null,
publicKey: null,
keyPassword: null,
keyType: textValue(rawCredential.keyType),
detectedKeyType: null,
usageCount: 0,
lastUsed: null,
createdAt: now,
updatedAt: now,
},
);
const createdCredential = created as Record<string, unknown>;
addCredentialAlias(alias, createdCredential.id as number);
addCredentialAlias(name, createdCredential.id as number);
}
}
} catch (error) {
results.errors.push(
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
);
}
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId &&
hostData.credentialAlias
) {
hostData.credentialId = credentialAliasMap.get(
hostData.credentialAlias.toLowerCase(),
);
}
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Missing required fields (ip, port)`,
);
continue;
}
const username = importedHostUsername(
effectiveConnectionType,
hostData.authType,
hostData.username,
);
if (username === null) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Username required for SSH connections`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
// Hosts that jump through this one still resolve to the kept host.
if (exportId !== undefined) importedIds.set(exportId, existing.id);
results.skipped++;
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
!knownAuthTypes.has(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "password" &&
!isNonEmptyString(hostData.password)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Password required for password authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "key" &&
!isNonEmptyString(hostData.key)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Key required for key authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId required for credential authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
hostData.credentialId
) {
const credentialRepository = createCurrentCredentialRepository();
const cred = await credentialRepository.findByIdForUser(
userId,
hostData.credentialId,
);
if (!cred) {
const fallback = await credentialRepository.listByUserId(userId);
if (fallback.length > 0) {
hostData.credentialId = fallback[0].id;
} else if (isNonEmptyString(hostData.key)) {
hostData.authType = "key";
hostData.credentialId = undefined;
} else if (isNonEmptyString(hostData.password)) {
hostData.authType = "password";
hostData.credentialId = undefined;
} else {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
);
continue;
}
}
}
const jumpHosts = remapImportedJumpHosts(hostData.jumpHosts, importedIds);
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
name: hostData.name || `${username}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
username,
pin: hostData.pin || false,
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
...importedStatusCheck(hostData as Record<string, unknown>),
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
sshOptions: hostData.sshOptions,
terminalConfig: hostData.terminalConfig,
}) ?? null,
forceKeyboardInteractive: hostData.forceKeyboardInteractive
? "true"
: "false",
notes: hostData.notes || null,
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain
? JSON.stringify(hostData.socks5ProxyChain)
: null,
portKnockSequence: hostData.portKnockSequence
? JSON.stringify(hostData.portKnockSequence)
: null,
overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0,
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
updatedAt: new Date().toISOString(),
};
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = hostData.password || null;
sshDataObj.authType = "password";
sshDataObj.credentialId = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
sshDataObj.authType = hostData.authType || "password";
sshDataObj.credentialId =
hostData.authType === "credential" ? hostData.credentialId : null;
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
sshDataObj.keyPassword =
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
}
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as Record<string, unknown>,
);
let savedHostId: number;
if (existing) {
const saved = await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
if (!saved) throw new Error("Host no longer exists");
if (exportId !== undefined) importedIds.set(exportId, existing.id);
savedHostId = existing.id;
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
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
// exist. See host-plugin-settings.ts.
await applyPluginHostImportSettings(
savedHostId,
hostData as Record<string, unknown>,
);
writtenHostIds.push(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
}
}
results.createdHostIds = createdHostIds;
if (input.atomic && results.failed > 0) {
await rollBackCreated(userId, results);
return results;
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
results.createdHostIds = createdHostIds;
return results;
}
/** The OpenSSH config import, shared by the route and ctx.hosts. */
export async function runSshConfigImport(
userId: string,
input: { content?: unknown; overwrite?: unknown; atomic?: boolean },
): Promise<ImportResults> {
const { content, overwrite } = input;
if (input.atomic && overwrite) {
throw new ImportError("An atomic import cannot overwrite existing hosts");
}
if (!isNonEmptyString(content)) {
throw new ImportError("content is required and must be a non-empty string");
}
let parsed: SSHConfigHost[];
try {
parsed = parseSSHConfig(content);
} catch {
throw new ImportError("Failed to parse SSH config file");
}
if (parsed.length === 0) {
throw new ImportError("No valid Host entries found in the SSH config file");
}
if (parsed.length > 100) {
throw new ImportError("Maximum 100 hosts allowed per import");
}
const hostsToImport = parsed.map((h) => ({
name: h.name,
ip: h.hostname,
port: h.port ?? 22,
username: h.user,
authType: h.identityFile ? "key" : undefined,
connectionType: "ssh",
enableSsh: true,
...(h.proxyJump
? {
jumpHosts: [{ host: h.proxyJump, port: 22 }],
}
: {}),
}));
const results: ImportResults = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [],
};
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
);
try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
results.skipped++;
continue;
}
const sshDataObj: Record<string, unknown> = {
userId,
connectionType: "ssh",
name: hostData.name || hostData.ip,
folder: "Default",
tags: "",
ip: hostData.ip,
port: hostData.port,
username: hostData.username || null,
authType: hostData.authType || "none",
password: null,
key: null,
keyPassword: null,
keyType: null,
credentialId: null,
pin: false,
sudoPassword: null,
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
statusCheckEnabled: true,
statusCheckInterval: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
overrideCredentialUsername: 0,
enableSsh: true,
updatedAt: new Date().toISOString(),
};
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as unknown as Record<string, unknown>,
);
if (existing) {
await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
writtenHostIds.push(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
);
}
}
results.createdHostIds = createdHostIds;
if (input.atomic && results.failed > 0) {
await rollBackCreated(userId, results);
return results;
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
results.createdHostIds = createdHostIds;
return results;
}
@@ -0,0 +1,211 @@
import { getErrorMessage } from "../utils/error-message.js";
import { sshLogger } from "../utils/logger.js";
import { findUsableCredential } from "./usable-credential.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "./credential-username.js";
import { createCurrentSharedHostAuthOverrideRepository } from "../database/repositories/factory.js";
import { listHostProtocols } from "./protocol-auth/registry.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../utils/shared-host-auth-resolver.js";
import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../database/repositories/host-resolution-repository.js";
/** A host with its credential's secrets filled in for the asking user. */
export async function resolveHostCredentials(
host: Record<string, unknown>,
requestingUserId?: string,
/**
* Credentials already fetched for this request, keyed by id. The host list
* preloads them in one query; single-host callers omit it and fall back to
* fetching the one credential they need.
*/
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
): Promise<Record<string, unknown>> {
try {
const ownerId = (host.ownerId || host.userId) as string | undefined;
if (
requestingUserId &&
ownerId &&
requestingUserId !== ownerId &&
typeof host.id === "number"
) {
const authHost = host as unknown as HostResolutionHostRecord;
const needsPersonalCredential = requiresPersonalHostAuthentication(
authHost,
"ssh",
);
const baseSshOverrideState = {
required: needsPersonalCredential,
ownerAuthShared: !!host.shareSshAuth,
};
// Owner auth for the remote desktop protocols is always snapshotted
// for recipients; only their own override credential varies per user.
const authOverrides: Record<string, unknown> = {
ssh: baseSshOverrideState,
};
const overrideCredentialIds =
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
host.id,
requestingUserId,
);
// 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,
ownerAuthShared: true,
};
}
const recipientHost: Record<string, unknown> = {
...host,
credentialId: null,
password: null,
key: null,
keyPassword: null,
keyType: null,
authOverrides,
};
try {
const resolution = await resolveRecipientSharedHostAuthentication(
authHost,
host.id,
requestingUserId,
"ssh",
);
if (resolution.source === "personal-override") {
const credential = resolution.credential;
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
authType:
credential.key || credential.privateKey
? "key"
: credential.password
? "password"
: "none",
username: credential.username || recipientHost.username,
password: credential.password,
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
}
if (resolution.source === "owner-shared") {
if (resolution.authType === "agent") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: "agent",
};
}
const sharedAuth = resolution.secret;
if (sharedAuth) {
const resolvedUsername = pickResolvedUsername(
recipientHost.username,
sharedAuth.username,
host.overrideCredentialUsername,
);
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: sharedAuth.key
? "key"
: sharedAuth.password
? "password"
: "none",
username: resolvedUsername,
password: sharedAuth.password,
key: sharedAuth.key,
keyPassword: sharedAuth.keyPassword,
keyType: sharedAuth.keyType,
};
}
}
if (resolution.source === "secretless") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
};
}
} catch {
// A missing/deleted override or snapshot behaves like unavailable auth.
}
return recipientHost;
}
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const credentialOwnerId = (host.ownerId || host.userId) as string;
const credential =
preloadedCredentials?.get(credentialId) ??
(await findUsableCredential(credentialId, credentialOwnerId));
if (credential) {
const resolvedHost: Record<string, unknown> = {
...host,
password: pickResolvedPassword(host.password, credential.password),
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
const resolvedUsername = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
}
return resolvedHost;
}
}
return { ...host };
} catch (error) {
sshLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
);
return host;
}
}
@@ -1,491 +0,0 @@
/**
* The host list's status dot.
*
* Every host with status checks on gets a TCP connection to its port on a
* timer. A port that answers is "online", one that does not (twice in a row)
* is "offline". An open session or a working login also counts as online.
* Probing never logs in to the host itself: on RADIUS or Duo backed devices a
* login fires a real 2FA push, every interval. A host behind jump hosts is
* probed through a chain, which does log in to the hops, so hosts that share
* a hop list share one chain (jump-probe-pool.ts).
*
* Polling is demand driven. A user asking for statuses starts polling their
* own hosts; nothing is probed for a user who never opened the app. The first
* request waits briefly for those first checks so it is not empty.
*/
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { hostSessionStatus } from "../host-session-status.js";
import { sshLogger } from "../../utils/logger.js";
import {
createCurrentHostResolutionRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
import type { HostStatus } from "./host-status.js";
import { ConcurrentLimiter } from "./limiter.js";
import { tcpPing } from "./tcp-ping.js";
import { JumpProbePool } from "./jump-probe-pool.js";
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
export const DEFAULT_STATUS_INTERVAL = 30;
/** How long the first request for a user waits for the first checks. */
const FIRST_CHECK_WAIT_MS = 6_000;
/** A status younger than this is fresh enough for check(). */
const FRESH_MS = 30_000;
export interface HostStatusEntry {
status: HostStatus;
lastChecked: string;
}
export interface HostStatusPayload {
hostId: number;
ownerUserId: string;
status: HostStatus;
previous: HostStatus | null;
/** Anything but offline, the shape automations' trigger already reads. */
online: boolean;
}
export interface StatusTarget {
id: number;
userId: string;
ip: string;
port: number;
connectionType: string;
jumpHosts: Array<{ hostId: number }>;
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
}
type PortResolver = (
hostId: number,
) => number | undefined | Promise<number | undefined>;
export interface HostStatusDeps {
loadTargets: (filter: {
userId?: string;
hostIds?: number[];
}) => Promise<StatusTarget[]>;
/** Hosts other users shared with this one, directly or through a role. */
loadSharedHostIds?: (userId: string) => Promise<number[]>;
ping: (host: string, port: number) => Promise<boolean>;
pingThroughJumpHosts: (
target: StatusTarget,
port: number,
) => Promise<boolean>;
hasActiveSession?: (hostId: number) => boolean;
globalInterval: () => number;
emit: (payload: HostStatusPayload) => void;
/** Pause before a failed ping is tried again. */
retryDelayMs?: number;
}
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter(
(hop): hop is { hostId: number } =>
!!hop && Number.isInteger((hop as { hostId?: unknown }).hostId),
)
: [];
} catch {
return [];
}
}
export function toStatusTarget(row: HostStatusTargetRow): StatusTarget {
return {
id: row.id,
userId: row.userId,
ip: row.ip,
port: row.port,
connectionType: row.connectionType || "ssh",
jumpHosts: parseJumpHosts(row.jumpHosts),
statusCheckEnabled: row.statusCheckEnabled !== false,
statusCheckInterval: row.statusCheckInterval ?? null,
};
}
const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
return createJumpHostChain(jumpHosts, userId);
});
const defaultDeps: HostStatusDeps = {
loadTargets: async (filter) =>
(
await createCurrentHostResolutionRepository().listStatusTargets(filter)
).map(toStatusTarget),
loadSharedHostIds: async (userId) => {
const { createCurrentRbacAccessRepository, createCurrentRoleRepository } =
await import("../../database/repositories/factory.js");
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
return [...new Set(entries.map((entry) => entry.hostId))];
},
hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId),
ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: (target, port) =>
jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000),
globalInterval: () => {
const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY));
return Number.isInteger(value) && value >= 5
? value
: DEFAULT_STATUS_INTERVAL;
},
emit: (payload) => pluginEvents.emit(TOPICS.hostStatus, payload),
};
interface Polled {
target: StatusTarget;
timer?: NodeJS.Timeout;
}
export class HostStatusService {
private readonly polled = new Map<number, Polled>();
private readonly store = new Map<number, HostStatusEntry>();
private readonly owners = new Map<number, string>();
private readonly inFlight = new Map<number, Promise<void>>();
private readonly startedUsers = new Set<string>();
private readonly portResolvers = new Map<string, Set<PortResolver>>();
private readonly limiter = new ConcurrentLimiter(20);
private unsubscribers: Array<() => void> = [];
constructor(private readonly deps: HostStatusDeps = defaultDeps) {}
/** Subscribes to the core events the status depends on. */
start(): void {
if (this.unsubscribers.length > 0) return;
this.unsubscribers = [
hostSessionStatus.subscribe((hostId, online) => {
if (online) this.reportLogin(hostId, { ok: true });
}),
pluginEvents.on(TOPICS.hostUpdated, (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) void this.reload(hostId);
}),
pluginEvents.on(TOPICS.hostDeleted, (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) this.forget(hostId);
}),
];
}
stop(): void {
for (const unsubscribe of this.unsubscribers) unsubscribe();
this.unsubscribers = [];
for (const hostId of [...this.polled.keys()]) this.stopPolling(hostId);
this.startedUsers.clear();
}
get(hostId: number): HostStatusEntry | null {
return this.store.get(hostId) ?? null;
}
/**
* Statuses for a user's request. With hostIds, polling is brought in line
* with exactly those of their own hosts (the desktop app sends only the
* hosts that live on this backend). Without, their own hosts are started
* once.
*/
async statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>> {
const started: Promise<void>[] = [];
if (requestedHostIds !== null) {
await this.reconcile(userId, requestedHostIds, started);
} else if (!this.startedUsers.has(userId)) {
await this.startUser(userId, started);
}
if (started.length > 0) await this.waitFor(started);
return this.store;
}
/** Drops every timer for a user's hosts and starts them again. */
async refresh(userId: string): Promise<void> {
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) this.stopPolling(target.id);
for (const target of targets) this.startPolling(target);
this.startedUsers.add(userId);
}
/** Re-times every polled host, after the global interval changed. */
retimeAll(): void {
for (const { target } of [...this.polled.values()]) {
this.startPolling(target, false);
}
}
/** Checks now unless the last result is fresh. */
async check(hostId: number): Promise<HostStatusEntry | null> {
const current = this.store.get(hostId);
if (current && Date.now() - Date.parse(current.lastChecked) < FRESH_MS) {
return current;
}
const target =
this.polled.get(hostId)?.target ??
(await this.deps.loadTargets({ hostIds: [hostId] }))[0];
if (!target || !target.statusCheckEnabled) return current ?? null;
await this.probe(target);
return this.store.get(hostId) ?? null;
}
/**
* A working login proves the host is up. A failed one says nothing about
* reachability (a wrong password, another user's login), so it is ignored.
*/
reportLogin(hostId: number, outcome: { ok: boolean }): void {
if (outcome.ok) this.set(hostId, { status: "online" });
}
registerPort(connectionType: string, resolve: PortResolver): () => void {
let set = this.portResolvers.get(connectionType);
if (!set) {
set = new Set();
this.portResolvers.set(connectionType, set);
}
set.add(resolve);
return () => {
set!.delete(resolve);
if (set!.size === 0) this.portResolvers.delete(connectionType);
};
}
private async waitFor(started: Promise<void>[]): Promise<void> {
let timer: NodeJS.Timeout | undefined;
await Promise.race([
Promise.allSettled(started),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS);
timer.unref?.();
}),
]);
if (timer) clearTimeout(timer);
}
private track(started: Promise<void>[], target: StatusTarget): void {
const probe = this.startPolling(target);
if (probe) started.push(probe);
}
private async startUser(
userId: string,
started: Promise<void>[],
): Promise<void> {
this.startedUsers.add(userId);
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) {
if (!this.polled.has(target.id)) this.track(started, target);
}
await this.startShared(userId, null, started);
}
/**
* Hosts shared with the user are checked as their owners, even when no
* owner has asked for statuses since the server started.
*/
private async startShared(
userId: string,
allowed: Set<number> | null,
started: Promise<void>[],
): Promise<void> {
if (!this.deps.loadSharedHostIds) return;
try {
const shared = (await this.deps.loadSharedHostIds(userId)).filter(
(hostId) =>
!this.polled.has(hostId) && (allowed === null || allowed.has(hostId)),
);
if (shared.length === 0) return;
const targets = await this.deps.loadTargets({ hostIds: shared });
for (const target of targets) {
if (!this.polled.has(target.id)) this.track(started, target);
}
} catch (error) {
sshLogger.warn("Could not start status checks for shared hosts", {
operation: "host_status_shared",
userId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private async reconcile(
userId: string,
allowed: Set<number>,
started: Promise<void>[],
): Promise<void> {
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) {
if (!allowed.has(target.id)) {
this.stopPolling(target.id);
this.store.delete(target.id);
continue;
}
if (!this.polled.has(target.id)) this.track(started, target);
}
await this.startShared(userId, allowed, started);
}
private async reload(hostId: number): Promise<void> {
try {
const [target] = await this.deps.loadTargets({ hostIds: [hostId] });
if (!target) {
this.forget(hostId);
return;
}
if (this.polled.has(hostId) || this.startedUsers.has(target.userId)) {
this.startPolling(target);
}
} catch (error) {
sshLogger.warn("Could not reload host for status checks", {
operation: "host_status_reload",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private forget(hostId: number): void {
this.stopPolling(hostId);
this.store.delete(hostId);
this.owners.delete(hostId);
}
private intervalMs(target: StatusTarget): number {
const seconds = target.statusCheckInterval ?? this.deps.globalInterval();
const intervalMs = Math.max(5, seconds) * 1000;
// Spread hosts so a fleet does not fire on the same second.
const spread = Math.min(intervalMs * 0.2, 15_000);
return (
intervalMs + ((target.id * 1103515245) % Math.max(1, Math.floor(spread)))
);
}
/** Returns the first probe when one was started. */
private startPolling(
target: StatusTarget,
probeNow = true,
): Promise<void> | undefined {
this.stopPolling(target.id);
this.owners.set(target.id, target.userId);
if (!target.statusCheckEnabled) {
this.store.delete(target.id);
return undefined;
}
const polled: Polled = { target };
this.polled.set(target.id, polled);
const first = probeNow ? this.probe(target) : undefined;
polled.timer = setInterval(() => {
const latest = this.polled.get(target.id);
if (latest) void this.probe(latest.target);
}, this.intervalMs(target));
polled.timer.unref?.();
return first;
}
private stopPolling(hostId: number): void {
const polled = this.polled.get(hostId);
if (polled?.timer) clearInterval(polled.timer);
this.polled.delete(hostId);
}
private probe(target: StatusTarget): Promise<void> {
const running = this.inFlight.get(target.id);
if (running) return running;
const job = this.limiter
.run(() => this.probeNow(target))
.catch((error) => {
sshLogger.error("Status check failed", error, {
operation: "host_status_probe",
hostId: target.id,
});
})
.finally(() => this.inFlight.delete(target.id));
this.inFlight.set(target.id, job);
return job;
}
private async portFor(target: StatusTarget): Promise<number> {
if (target.connectionType !== "ssh") {
for (const resolve of this.portResolvers.get(target.connectionType) ??
[]) {
try {
const port = await resolve(target.id);
if (Number.isInteger(port) && port! > 0) return port!;
} catch {
// fall through to the next resolver
}
}
}
return target.port;
}
private async pingOnce(target: StatusTarget): Promise<boolean> {
try {
if (this.deps.hasActiveSession?.(target.id)) return true;
const port = await this.portFor(target);
return target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
} catch {
return false;
}
}
private async probeNow(target: StatusTarget): Promise<void> {
this.owners.set(target.id, target.userId);
let reachable = await this.pingOnce(target);
if (!reachable) {
// One lost packet should not flip the dot.
const delay = this.deps.retryDelayMs ?? 1000;
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
reachable = await this.pingOnce(target);
}
this.set(target.id, { status: reachable ? "online" : "offline" });
}
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
const previous = this.store.get(hostId)?.status ?? null;
const next: HostStatusEntry = {
...entry,
lastChecked: new Date().toISOString(),
};
this.store.set(hostId, next);
if (previous === next.status) return;
void this.emitChange(hostId, next, previous);
}
private async emitChange(
hostId: number,
entry: HostStatusEntry,
previous: HostStatus | null,
): Promise<void> {
let ownerUserId = this.owners.get(hostId);
if (!ownerUserId) {
const [target] = await this.deps
.loadTargets({ hostIds: [hostId] })
.catch(() => []);
if (!target) return;
ownerUserId = target.userId;
this.owners.set(hostId, ownerUserId);
}
this.deps.emit({
hostId,
ownerUserId,
status: entry.status,
previous,
online: entry.status === "online",
});
}
}
export const hostStatusService = new HostStatusService();
-9
View File
@@ -1,9 +0,0 @@
export type HostStatus = "online" | "offline";
export function isHostKeyVerificationError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("Host denied (verification failed)") ||
error.message.includes("Host key changed"))
);
}
-81
View File
@@ -1,81 +0,0 @@
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
get limit(): number {
return this.maxConcurrent;
}
/**
* Changes the ceiling at runtime.
*
* Raising it wakes the waiters the new headroom allows, so a queue that built
* up under the old limit drains at once instead of one job at a time as
* running work finishes. Lowering it never interrupts work already running;
* the new limit simply applies from the next release onward.
*/
setLimit(maxConcurrent: number): void {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
this.maxConcurrent = maxConcurrent;
this.releaseWaiters();
}
/**
* Wakes as many queued callers as there is now room for.
*
* `woken` counts callers that have been resumed but have not yet reached the
* `active += 1` on the far side of their await. Without it the occupancy
* looks lower than it really is for a microtask, and the loop would release
* past the ceiling.
*/
private releaseWaiters(): void {
while (
this.active + this.woken < this.maxConcurrent &&
this.waiters.length > 0
) {
this.woken += 1;
this.waiters.shift()!();
}
}
private woken = 0;
async run<T>(fn: () => Promise<T>): Promise<T> {
// Queue behind anything already woken, so a caller arriving mid-handoff
// cannot jump the queue and push occupancy past the ceiling.
if (this.active + this.woken >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
// Resumed by a slot handoff; that reservation is now consumed.
this.woken = Math.max(0, this.woken - 1);
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
this.releaseWaiters();
}
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Recent activity is a provider: core owns the callers (the dashboard routes,
* ctx.hosts.recordActivity, the cleanup when a user or host goes away) and one
* provider owns the storage. A plugin registers with
* ctx.hosts.activity.provide. With none, nothing is recorded and the list is
* empty. Core checks that the user may reach the host before it records.
*/
export interface ActivityEntry {
id: number;
userId: string;
type: string;
hostId: number;
hostName: string | null;
timestamp: string;
}
export type RecordActivityResult =
| { status: "logged"; id: number }
| { status: "rate_limited" }
| { status: "invalid_type" }
| { status: "denied" }
| { status: "unavailable" };
export interface ActivityProvider {
record(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult>;
list(userId: string, limit: number): Promise<ActivityEntry[]>;
deleteByUserId(userId: string): Promise<void>;
deleteByHostIds(hostIds: number[]): Promise<void>;
}
let plugged: { pluginId: string; provider: ActivityProvider } | null = null;
export function provideActivity(
pluginId: string,
provider: ActivityProvider,
): () => void {
if (plugged) {
throw new Error(
`Recent activity is already provided by plugin "${plugged.pluginId}"`,
);
}
const entry = { pluginId, provider };
plugged = entry;
return () => {
if (plugged === entry) plugged = null;
};
}
function active(): ActivityProvider | null {
return plugged?.provider ?? null;
}
async function canReachHost(userId: string, hostId: number): Promise<boolean> {
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
} = await import("../database/repositories/factory.js");
if (
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
)
) {
return true;
}
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const shared =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
return shared.some((access) => access.hostId === hostId);
}
/** What the rest of core calls. */
export const recentActivity = {
async record(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const provider = active();
if (!provider) return { status: "unavailable" };
if (!(await canReachHost(userId, entry.hostId))) {
return { status: "denied" };
}
return provider.record(userId, entry);
},
async list(userId: string, limit: number): Promise<ActivityEntry[]> {
return (await active()?.list(userId, limit)) ?? [];
},
async deleteByUserId(userId: string): Promise<void> {
await active()?.deleteByUserId(userId);
},
async deleteByHostId(hostId: number): Promise<void> {
await active()?.deleteByHostIds([hostId]);
},
async deleteByHostIds(hostIds: number[]): Promise<void> {
await active()?.deleteByHostIds(hostIds);
},
};
+143
View File
@@ -0,0 +1,143 @@
/**
* ctx.audit.subscribe, list and exportBatches: reading the audit trail,
* behind audit:read. Writing lines (record) stays in ctx.ts.
*/
import type {
PluginAudit,
PluginAuditEntry,
PluginAuditFilters,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
import { onAuditEntry } from "../utils/audit-hooks.js";
import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const MAX_PAGE = 200;
const MAX_BATCH = 1000;
function toEntry(row: AuditLogRecord): PluginAuditEntry {
return {
id: row.id,
timestamp: new Date(
row.timestamp as unknown as string | number,
).toISOString(),
userId: row.userId ?? null,
username: row.username,
action: row.action,
resourceType: row.resourceType,
resourceId: row.resourceId ?? null,
resourceName: row.resourceName ?? null,
details: row.details ?? null,
ipAddress: row.ipAddress ?? null,
userAgent: row.userAgent ?? null,
success: Boolean(row.success),
errorMessage: row.errorMessage ?? null,
};
}
export function createPluginAuditReader(
deps: Deps,
): Pick<PluginAudit, "subscribe" | "list" | "exportBatches"> {
const { manifest, bag, audit } = deps;
const require = (action: string) =>
assertCapability(manifest.id, "audit:read", manifest.capabilities, action);
return {
subscribe: (handler) => {
if (!manifest.capabilities.includes("audit:read")) {
throw capabilityRefused(manifest.id, "audit:read", "audit_subscribe");
}
let remove: (() => void) | null = null;
let forgetBag: (() => void) | null = null;
// Granted state is checked once, then every line is delivered until
// unsubscribed; a revoked grant stops the next subscribe.
void require("audit_subscribe")
.then(async () => {
await audit("audit_subscribe", "listened to the audit log", {
success: true,
});
remove = onAuditEntry((entry) => {
handler({
timestamp: entry.timestamp.toISOString(),
userId: entry.userId,
username: entry.username,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId ?? null,
resourceName: entry.resourceName ?? null,
details: entry.details ?? null,
ipAddress: entry.ipAddress ?? null,
userAgent: entry.userAgent ?? null,
success: entry.success,
errorMessage: entry.errorMessage ?? null,
});
});
})
.catch(() => {});
forgetBag = bag.add(() => remove?.(), "audit subscription");
return () => {
remove?.();
remove = null;
forgetBag?.();
};
},
list: async (options) => {
await require("audit_list");
const { createCurrentAuditLogRepository } =
await import("../database/repositories/factory.js");
const limit = Math.min(MAX_PAGE, Math.max(1, options?.limit ?? 50));
const page = await createCurrentAuditLogRepository().listPage({
filters: options?.filters ?? {},
limit,
offset: Math.max(0, options?.offset ?? 0),
});
await audit("audit_list", "read audit log entries", { success: true });
return { entries: page.logs.map(toEntry), total: page.total };
},
exportBatches: async function* (
filters?: PluginAuditFilters,
batchSize?: number,
) {
await require("audit_export");
const { createCurrentAuditLogRepository } =
await import("../database/repositories/factory.js");
const limit = Math.min(MAX_BATCH, Math.max(1, batchSize ?? 500));
let offset = 0;
let exported = 0;
try {
for (;;) {
const rows = await createCurrentAuditLogRepository().listForExport({
filters: filters ?? {},
limit,
offset,
});
if (rows.length === 0) break;
exported += rows.length;
offset += rows.length;
yield rows.map(toEntry);
if (rows.length < limit) break;
}
} finally {
await audit("audit_export", `exported ${exported} audit log entries`, {
success: true,
});
}
},
};
}
+68
View File
@@ -194,6 +194,74 @@ export function createPluginCredentials({
}
},
publicKey: async (credentialId) => {
const details = `public key of credential ${credentialId}`;
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
if (!Number.isInteger(credentialId) || credentialId <= 0) {
throw new Error("A credential id is required");
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
userId,
);
const row = credential as unknown as Record<string, unknown> | null;
if (!row || row.authType !== "key") {
await audit("credentials_public_key", details, { success: true });
return null;
}
let publicKey =
typeof row.publicKey === "string" && row.publicKey.trim()
? row.publicKey.trim()
: null;
if (!publicKey) {
const stored = str(row.privateKey) || str(row.key);
if (stored) {
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(
userId,
stored,
typeof row.keyPassword === "string" ? row.keyPassword : null,
);
publicKey = derivePublicKey(resolved.key, resolved.passphrase);
}
}
await audit("credentials_public_key", details, { success: true });
return publicKey;
} catch (error) {
await audit("credentials_public_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
resolveKeyRefs: async (key, passphrase) => {
const details = "resolved key references";
try {
await assertCapability(pluginId, "credentials:read", declared);
const userId = actingUser();
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(userId, key, passphrase);
await audit("credentials_resolve_key", details, { success: true });
return resolved;
} catch (error) {
await audit("credentials_resolve_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw capabilityRefused(pluginId, "auth:provide");
+259
View File
@@ -0,0 +1,259 @@
/**
* ctx.desktop.local: the user's own machine, behind desktop:local.
*
* Only works on the desktop app's embedded backend. On a server every call
* throws, and available() is false. Path rules live in desktop-local/local-fs.
*/
import type {
PluginDesktopLocal,
PluginLocalPty,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
import * as localFs from "./desktop-local/local-fs.js";
import { remoteJson, requestHeaders } from "../sync/client/http.js";
import { spawnLocalPty } from "./desktop-local/local-pty.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
function isEmbedded(): boolean {
return process.env.ELECTRON_EMBEDDED === "true";
}
export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
const { manifest, bag, audit } = deps;
const audited = new Set<string>();
const guard = async (action: string, details: string, every = false) => {
await assertCapability(
manifest.id,
"desktop:local",
manifest.capabilities,
action,
);
if (!isEmbedded()) {
throw new Error(
`Plugin ${manifest.id} used the local machine outside the desktop app`,
);
}
if (every || !audited.has(action)) {
audited.add(action);
await audit(action, details, { success: true });
}
};
const mainRequest = async (channel: string, path: string) => {
const { requestFromElectronMain } =
await import("../utils/electron-ipc-bridge.js");
return requestFromElectronMain<{ success: boolean; error?: string }>(
channel,
{ path },
);
};
return {
available: () =>
isEmbedded() && manifest.capabilities.includes("desktop:local"),
pluginFetch: async (origin, subPath, init) => {
await guard("desktop_local_fetch", "called its own routes");
if (
typeof subPath !== "string" ||
!subPath.startsWith("/") ||
subPath.startsWith("//") ||
subPath.includes("\\") ||
/(^|\/)\.\.?(\/|\?|$)/.test(subPath)
) {
throw new Error("Invalid plugin route path");
}
const route = `/plugin-api/${manifest.id}${subPath}`;
const headers: Record<string, string> = { ...init?.headers };
for (const name of Object.keys(headers)) {
if (/^(authorization|cookie|host)$/i.test(name)) delete headers[name];
}
if (origin === "remote") {
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link) throw new Error("This device is not linked to a server");
const { remoteStreamFetch } = await import("../sync/client/http.js");
return (await remoteStreamFetch(link, route, {
method: init?.method,
headers,
body: init?.body,
signal: init?.signal,
})) as never;
}
if (origin !== "local") throw new Error("Unknown origin");
const { getActor } = await import("./actor.js");
const actor = getActor();
if (!actor) throw new Error("No acting user");
const { AuthManager } = await import("../utils/auth-manager.js");
const session = await AuthManager.getInstance().issuePluginSession(
actor,
{
pluginId: manifest.id,
purpose: "desktop local transfer",
ttlSeconds: 900,
},
);
const port = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
const { fetch: undiciFetch } = await import("undici");
return (await undiciFetch(`http://127.0.0.1:${port}${route}`, {
method: init?.method ?? "GET",
headers: {
...headers,
"X-Electron-App": "true",
Authorization: `Bearer ${session.token}`,
},
body: init?.body as never,
duplex: "half",
signal: init?.signal,
} as never)) as never;
},
info: async () => {
await guard("desktop_local_info", "read local machine info");
const home = await localFs.homeInfo();
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
return {
...home,
userDataDir: process.env.ELECTRON_USER_DATA ?? "",
backendPort: Number(process.env.EMBEDDED_BACKEND_PORT) || 30001,
link: link
? {
serverUrl: link.serverUrl,
token: link.sessionToken,
headers: requestHeaders(link),
allowInvalidCertificate: !!link.allowInvalidCertificate,
}
: null,
};
},
linkJson: async (pathname) => {
await guard("desktop_local_link", "called the linked server");
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link)
throw new Error("This device is not linked to a Termix server");
if (!pathname.startsWith("/")) throw new Error("Invalid path");
return remoteJson(
{
serverUrl: link.serverUrl,
sessionToken: link.sessionToken,
customHeaders: link.customHeaders,
basicAuth: link.basicAuth,
allowInvalidCertificate: link.allowInvalidCertificate,
},
pathname,
);
},
fs: {
list: async (dirPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.listDirectory(dirPath);
},
mkdir: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.makeDirectory(parentPath, name);
},
createFile: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.createEmptyFile(parentPath, name);
},
rename: async (path, newName) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.renameEntry(path, newName);
},
ensureDir: async (dirPath, rootPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.ensureDirectory(dirPath, rootPath);
},
exists: async (paths) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.existingPaths(paths);
},
walk: async (rootPaths) => {
await guard("desktop_local_fs", "used the local file system");
if (!Array.isArray(rootPaths) || rootPaths.length === 0) {
throw new Error("No local paths provided");
}
return localFs.walkPaths(rootPaths);
},
trash: async (paths) => {
await guard(
"desktop_local_trash",
`moved ${Array.isArray(paths) ? paths.length : 0} local items to the trash`,
true,
);
if (!Array.isArray(paths) || paths.length === 0) {
throw new Error("No local paths provided");
}
const failed: Array<{ path: string; error: string }> = [];
for (const candidate of paths) {
const target = localFs.normalizeLocalPath(candidate);
try {
const result = await mainRequest("shell-trash-item", target);
if (!result?.success) throw new Error(result?.error ?? "Failed");
} catch (error) {
failed.push({
path: target,
error: error instanceof Error ? error.message : String(error),
});
}
}
return { trashed: paths.length - failed.length, failed };
},
reveal: async (path) => {
await guard("desktop_local_fs", "used the local file system");
await mainRequest("shell-show-item", localFs.normalizeLocalPath(path));
},
open: async (path) => {
await guard("desktop_local_open", "opened a local path", true);
const result = await mainRequest(
"shell-open-path",
localFs.normalizeLocalPath(path),
);
if (!result?.success) throw new Error(result?.error ?? "Failed");
},
openRead: async (path) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openRead(path);
},
openDownload: async (options) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openDownload(options);
},
},
pty: {
spawn: async (options) => {
await guard("desktop_local_pty", "started a local shell", true);
const child: PluginLocalPty = spawnLocalPty(options);
// A shell never outlives the plugin that started it.
bag.add(() => {
try {
child.kill();
} catch {
// already gone
}
});
return child;
},
},
};
}
+192
View File
@@ -0,0 +1,192 @@
/**
* ctx.hosts.importMany, importSshConfig, exportMany and registerImportNormalizer.
*
* The import and export logic is the same code the host routes run
* (hosts/import/), so a plugin gets the same validation, defaults, protocol
* logins and plugin settings. What this file adds is the capability checks,
* the core permission checks, one batched save and the audit lines.
*/
import type {
PluginHostExport,
PluginHostImportNormalizer,
PluginHostImportOptions,
PluginHostImportResult,
PluginHosts,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import * as registry from "./registry.js";
import type { DisposableBag } from "./disposables.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
import { PermissionManager } from "../utils/permission-manager.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag?: DisposableBag;
audit: AuditFn;
}
type ImportApi = Pick<
PluginHosts,
"importMany" | "importSshConfig" | "exportMany" | "registerImportNormalizer"
>;
function actingUser(): string {
const actor = getActor();
if (!actor) throw new Error("This call needs an acting user");
return actor;
}
async function requirePermissions(userId: string, names: string[]) {
const permissions = PermissionManager.getInstance();
for (const name of names) {
if (!(await permissions.hasPermission(userId, name))) {
throw new Error(`The acting user lacks the ${name} permission`);
}
}
}
function toResult(
results: Awaited<
ReturnType<typeof import("../hosts/import/host-import.js").runBulkImport>
>,
): PluginHostImportResult {
return {
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
createdHostIds: results.createdHostIds,
...(results.rolledBack ? { rolledBack: true } : {}),
};
}
export function createPluginHostImport({
manifest,
bag,
audit,
}: Deps): ImportApi {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const runImport = async (
action: string,
run: (userId: string) => Promise<PluginHostImportResult>,
needsCredentials: boolean,
): Promise<PluginHostImportResult> => {
try {
await assertCapability(pluginId, "hosts:write", declared, action);
if (needsCredentials) {
await assertCapability(pluginId, "credentials:write", declared, action);
}
const userId = actingUser();
await requirePermissions(userId, ["hosts.create", "hosts.edit"]);
const result = await DatabaseSaveTrigger.batched(() => run(userId))();
await audit(
action,
`${result.success} created, ${result.updated} updated, ${result.skipped} skipped, ${result.failed} failed`,
{ success: true },
);
return result;
} catch (error) {
await audit(action, "host import", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
return {
importMany: (hosts, options?: PluginHostImportOptions) =>
runImport(
"hosts_import",
async (userId) => {
const { runBulkImport } =
await import("../hosts/import/host-import.js");
return toResult(
await runBulkImport(userId, {
hosts,
overwrite: options?.overwrite,
credentials: options?.credentials,
atomic: options?.atomic,
}),
);
},
Array.isArray(options?.credentials) && options.credentials.length > 0,
),
importSshConfig: (content, options) =>
runImport(
"hosts_import_ssh_config",
async (userId) => {
const { runSshConfigImport } =
await import("../hosts/import/host-import.js");
return toResult(
await runSshConfigImport(userId, {
content,
overwrite: options?.overwrite,
atomic: options?.atomic,
}),
);
},
false,
),
exportMany: async (options): Promise<PluginHostExport> => {
const share = options?.share === true;
try {
await assertCapability(
pluginId,
"hosts:export",
declared,
"hosts_export",
);
const userId = actingUser();
const { exportAllHosts } =
await import("../hosts/import/host-export.js");
const exported = await exportAllHosts(userId, share, options?.hostIds);
await audit(
"export_hosts",
`exported ${exported.hosts.length} hosts${share ? " without secrets" : " with secrets"}`,
{ success: true },
);
return exported;
} catch (error) {
await audit("export_hosts", share ? "share export" : "full export", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
registerImportNormalizer: (normalizer: PluginHostImportNormalizer) => {
if (!declared.includes("hosts:write")) {
throw capabilityRefused(pluginId, "hosts:write");
}
// Same key the import path has always read, so the older
// ctx.registry.provide form keeps working.
const key = `${pluginId}.hostImportNormalizer`;
registry.provide(key, normalizer);
const remove = () => {
registry.revoke(key, normalizer);
};
const drop = bag?.add(remove, "host import normalizer");
return () => {
drop?.();
remove();
};
},
};
}
+79 -12
View File
@@ -26,7 +26,10 @@ 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 { hostStatusService } from "../hosts/status/host-status-service.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";
@@ -171,7 +174,7 @@ export function toRecord(
connectionOrigin: origin === "local" || origin === "remote" ? origin : null,
sshOptions: hostTerminalExport(host).sshOptions,
pluginSettings,
status: Number.isInteger(id) ? hostStatusService.get(id) : null,
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,
@@ -389,6 +392,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
};
return {
...createPluginHostImport({ manifest, bag, audit }),
list: async () => {
await requireRead();
const userId = actingUser();
@@ -787,38 +792,100 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await requireRead();
const userId = actingUser();
if (!(await actorMayReach(hostId))) return;
const { recordRecentActivity } =
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
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);
},
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 hostStatusService.get(hostId);
return hostStatus.get(hostId);
},
check: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.check(hostId);
return hostStatus.check(hostId);
},
// Synchronous for the same reason as trackSession.
reportLogin: (hostId, outcome) => {
requireReadSync();
void actorMayReach(hostId)
.then((allowed) => {
if (allowed) hostStatusService.reportLogin(hostId, outcome);
if (allowed) hostStatus.reportLogin(hostId, outcome);
})
.catch(() => {});
},
registerPort: (connectionType, resolve) => {
requireReadSync();
const unregister = hostStatusService.registerPort(
connectionType,
resolve,
);
const unregister = hostStatus.registerPort(connectionType, resolve);
const drop = bag?.add(unregister, `status port for ${connectionType}`);
return () => {
drop?.();
+172
View File
@@ -0,0 +1,172 @@
/**
* Reachability for status providers: ctx.network.tcpProbe, ctx.ssh.probe and
* ctx.hosts.probeTargets. Nothing here ever logs in to the probed host.
*/
import type {
PluginHostProbeTarget,
PluginNetwork,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
import { tcpPing } from "./probe/tcp-ping.js";
import { sharedJumpProbes } from "./probe/jump-probe-pool.js";
import type { HostStatusTargetRow } from "../database/repositories/host-resolution-repository.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
const MIN_TIMEOUT_MS = 500;
const MAX_TIMEOUT_MS = 15_000;
const DEFAULT_TIMEOUT_MS = 5000;
function clampTimeout(value?: number): number {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_TIMEOUT_MS;
return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, n));
}
function validPort(port: number): boolean {
return Number.isInteger(port) && port > 0 && port <= 65535;
}
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter(
(hop): hop is { hostId: number } =>
!!hop && Number.isInteger((hop as { hostId?: unknown }).hostId),
)
: [];
} catch {
return [];
}
}
function toTarget(row: HostStatusTargetRow): PluginHostProbeTarget {
return {
id: row.id,
ownerUserId: row.userId,
ip: row.ip,
port: row.port,
connectionType: row.connectionType || "ssh",
jumpHosts: parseJumpHosts(row.jumpHosts),
statusCheckEnabled: row.statusCheckEnabled !== false,
statusCheckInterval: row.statusCheckInterval ?? null,
};
}
export function createPluginNetwork(deps: {
manifest: PluginManifest;
audit: AuditFn;
}): PluginNetwork {
const { manifest, audit } = deps;
let audited = false;
return {
tcpProbe: async (host, port, options) => {
await assertCapability(
manifest.id,
"network:probe",
manifest.capabilities,
"network_tcp_probe",
);
if (typeof host !== "string" || !host.trim() || host.length > 255) {
throw new Error("A host name or address is required");
}
if (!validPort(port))
throw new Error("A port from 1 to 65535 is required");
if (!audited) {
audited = true;
await audit("network_tcp_probe", "checked if TCP ports answer", {
success: true,
});
}
return tcpPing(host.trim(), port, clampTimeout(options?.timeoutMs));
},
};
}
/** The hosts the actor owns or was shared, narrowed to ones they may connect to. */
export async function loadProbeTargets(
actor: string,
hostIds?: number[],
): Promise<PluginHostProbeTarget[]> {
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
} = await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const hosts = createCurrentHostResolutionRepository();
const own = await hosts.listStatusTargets({
userId: actor,
hostIds,
});
const ownIds = new Set(own.map((row) => row.id));
const roleIds = await createCurrentRoleRepository().listUserRoleIds(actor);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
actor,
roleIds,
);
let sharedIds = [...new Set(entries.map((entry) => entry.hostId))].filter(
(id) => !ownIds.has(id) && (!hostIds || hostIds.includes(id)),
);
if (sharedIds.length > 0) {
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
actor,
sharedIds,
);
sharedIds = sharedIds.filter((id) => allowed.has(id));
}
const shared =
sharedIds.length > 0
? await hosts.listStatusTargets({ hostIds: sharedIds })
: [];
return [...own, ...shared].map(toTarget);
}
export async function probeHost(
hostId: number,
options?: { port?: number; timeoutMs?: number },
): Promise<boolean> {
const actor = getActor();
if (actor) {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
actor,
hostId,
"connect",
);
if (!access.hasAccess) return false;
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const [row] = await createCurrentHostResolutionRepository().listStatusTargets(
{ hostIds: [hostId] },
);
if (!row) return false;
const target = toTarget(row);
const port = options?.port ?? target.port;
if (!validPort(port)) throw new Error("A port from 1 to 65535 is required");
const timeout = clampTimeout(options?.timeoutMs);
return target.jumpHosts.length > 0
? sharedJumpProbes.ping(
target.jumpHosts,
target.ownerUserId,
target.ip,
port,
timeout,
)
: tcpPing(target.ip, port, timeout);
}
+35 -5
View File
@@ -26,6 +26,8 @@ import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import { hostStatus } from "./host-status-provider.js";
import { probeHost } from "./ctx-probe.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -224,11 +226,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const reportLogin = (host: number | PluginSshHost) => {
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return;
void import("../hosts/status/host-status-service.js")
.then(({ hostStatusService }) =>
hostStatusService.reportLogin(hostId, { ok: true }),
)
.catch(() => {});
hostStatus.reportLogin(hostId, { ok: true });
};
/** Checks, connects and audits one new connection. */
@@ -296,6 +294,11 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
}`;
return {
probe: async (hostId, probeOptions) => {
await assertCapability(pluginId, "ssh:connect", declared);
if (!Number.isInteger(hostId) || hostId <= 0) return false;
return probeHost(hostId, probeOptions);
},
connect: async (host, options) => {
const connection = await connectOnce(host, options);
return {
@@ -306,6 +309,33 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
},
exec: async (host, command, execOptions) => {
const connection = await connectOnce(host, {
...execOptions,
purpose: execOptions?.purpose ?? "exec",
timeoutMs: undefined,
});
const details = `${describeHost(host)}: command run`;
try {
const { runExec } = await import("./ctx-ssh-exec.js");
const result = await runExec(
connection.client as never,
command,
execOptions,
);
await audit("ssh_exec", details, { success: true });
return result;
} catch (error) {
await audit("ssh_exec", details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
connection.dispose();
}
},
withConnection: async (host, options, fn) => {
await checkSsh(true);
const { resolveConnectHost } =
+123
View File
@@ -0,0 +1,123 @@
/**
* Runs one command on an open ssh2 client for ctx.ssh.exec, with a time limit
* and caps on how much output is kept.
*/
import type { PluginSshExecOptions } from "@termix-ssh/plugin-sdk/backend";
export const EXEC_DEFAULT_TIMEOUT_MS = 30_000;
export const EXEC_MAX_TIMEOUT_MS = 10 * 60_000;
export const EXEC_DEFAULT_MAX_OUTPUT = 256 * 1024;
export const EXEC_HARD_MAX_OUTPUT = 4 * 1024 * 1024;
export const EXEC_MAX_COMMAND_LENGTH = 64 * 1024;
interface ExecStream {
on: (event: string, listener: (...args: never[]) => void) => unknown;
stderr: { on: (event: string, listener: (chunk: Buffer) => void) => unknown };
write: (data: string) => unknown;
end: (data?: string) => unknown;
close: () => void;
}
export interface ExecClient {
exec: (
command: string,
callback: (error: Error | undefined, stream: ExecStream) => void,
) => unknown;
}
export interface ExecResult {
stdout: string;
stderr: string;
/** Null when the command was killed by a signal or hit the time limit. */
code: number | null;
truncated: boolean;
timedOut: boolean;
}
export function runExec(
client: ExecClient,
command: string,
options: PluginSshExecOptions = {},
): Promise<ExecResult> {
if (typeof command !== "string" || command.trim() === "") {
return Promise.reject(new Error("A command is required"));
}
if (command.length > EXEC_MAX_COMMAND_LENGTH) {
return Promise.reject(new Error("The command is too long"));
}
const timeoutMs = Math.min(
EXEC_MAX_TIMEOUT_MS,
Math.max(1000, options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS),
);
const maxOutput = Math.min(
EXEC_HARD_MAX_OUTPUT,
Math.max(1024, options.maxOutputBytes ?? EXEC_DEFAULT_MAX_OUTPUT),
);
return new Promise<ExecResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
let truncated = false;
let stdout = "";
let stderr = "";
let timer: NodeJS.Timeout | null = null;
const finish = (code: number | null) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve({ stdout, stderr, code, truncated, timedOut });
};
const append = (current: string, chunk: Buffer): string => {
const room = maxOutput - Buffer.byteLength(current);
if (room <= 0) {
truncated = true;
return current;
}
if (chunk.length > room) {
truncated = true;
return current + chunk.subarray(0, room).toString("utf8");
}
return current + chunk.toString("utf8");
};
client.exec(command, (error, stream) => {
if (error) {
if (!settled) {
settled = true;
reject(error);
}
return;
}
timer = setTimeout(() => {
timedOut = true;
try {
stream.close();
} catch {
// already closed
}
finish(null);
}, timeoutMs);
stream.on("data", ((chunk: Buffer) => {
stdout = append(stdout, chunk);
}) as never);
stream.stderr.on("data", (chunk: Buffer) => {
stderr = append(stderr, chunk);
});
stream.on("close", ((code: number | null | undefined) => {
finish(typeof code === "number" ? code : null);
}) as never);
stream.on("error", ((streamError: Error) => {
if (!settled) {
settled = true;
if (timer) clearTimeout(timer);
reject(streamError);
}
}) as never);
if (options.stdin !== undefined) stream.end(options.stdin);
});
});
}
+14
View File
@@ -22,6 +22,9 @@
import { pluginLogger } from "../utils/logger.js";
import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js";
import { pluginEvents } from "./events.js";
import { createPluginDesktopLocal } from "./ctx-desktop-local.js";
import { createPluginAuditReader } from "./ctx-audit.js";
import { createPluginNetwork } from "./ctx-probe.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
@@ -950,6 +953,7 @@ export function createPluginContext(
bag: handle.bag,
}),
fetch: createPluginFetch({ manifest, audit: auditCall }),
network: createPluginNetwork({ manifest, audit: auditCall }),
process: createPluginProcess({
manifest,
bag: handle.bag,
@@ -964,6 +968,11 @@ export function createPluginContext(
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
launchExternalClient: (request) => desktopLaunchExternalClient(request),
available: () => isElectronIpcAvailable(),
local: createPluginDesktopLocal({
manifest,
bag: handle.bag,
audit: auditCall,
}),
},
credentials: createPluginCredentials({
@@ -995,6 +1004,11 @@ export function createPluginContext(
// Auditing must never break the caller.
}
},
...createPluginAuditReader({
manifest,
bag: handle.bag,
audit: auditCall,
}),
},
/**
@@ -0,0 +1,572 @@
/**
* Local filesystem helpers for plugins on the desktop app's embedded backend.
* Ported from the old Electron local-files module. The path rules are the
* security part: a download can never leave the folder the user picked, links
* inside it are refused, and a download never replaces a file by accident.
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
PluginLocalDirEntry,
PluginLocalDownload,
PluginLocalWalkResult,
} from "@termix-ssh/plugin-sdk/backend";
export class LocalFileError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = "LocalFileError";
this.code = code;
}
}
type PathImpl = typeof path;
export function normalizeLocalPath(candidate: unknown): string {
if (typeof candidate !== "string" || !path.isAbsolute(candidate)) {
throw new Error("A local absolute path is required");
}
return path.normalize(candidate);
}
// A single path segment: no separators, not "." / "..", no NULs.
export function requireEntryName(name: unknown, what: string): string {
const safeName = String(name || "").trim();
if (
!safeName ||
safeName === "." ||
safeName === ".." ||
/[/\\\0]/.test(safeName)
) {
throw new Error(`Invalid ${what}`);
}
return safeName;
}
// pathImpl is injectable so the Windows rules can be tested on any OS.
export function assertWithinRoot(
rootPath: string,
candidate: string,
pathImpl: PathImpl = path,
): string {
if (typeof rootPath !== "string" || !rootPath.trim()) {
throw new LocalFileError("EINVAL", "A download folder is required");
}
const root = pathImpl.normalize(pathImpl.resolve(rootPath));
const target = pathImpl.normalize(pathImpl.resolve(candidate));
const rel = pathImpl.relative(root, target);
const escapes =
rel === "" ||
rel === ".." ||
rel.startsWith(`..${pathImpl.sep}`) ||
pathImpl.isAbsolute(rel) ||
rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === "");
if (escapes) {
throw new LocalFileError(
"EINVAL",
`"${pathImpl.basename(candidate)}" would be written outside the selected folder`,
);
}
return target;
}
// Resolve a deliberately selected root once, but never follow links supplied
// as descendants of a downloaded tree.
export async function prepareDownloadPath(
rootPath: string,
candidate: string,
directory = false,
): Promise<{ root: string; path: string }> {
const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate));
const selected = path.resolve(rootPath);
const realRoot = await fsp.realpath(selected);
if (!(await fsp.stat(realRoot)).isDirectory()) {
throw new LocalFileError("EINVAL", "The download root is not a directory");
}
const parts = path.relative(selected, target).split(path.sep);
let current = realRoot;
for (let i = 0; i < parts.length; i++) {
current = path.join(current, parts[i]);
const needsDirectory = directory || i < parts.length - 1;
if (needsDirectory) {
await fsp.mkdir(current).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
}
const stat = await fsp
.lstat(current)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT" && !needsDirectory) return null;
throw error;
});
if (stat?.isSymbolicLink()) {
throw new LocalFileError(
"EINVAL",
"Downloads cannot follow links inside the selected folder",
);
}
if (needsDirectory && !stat?.isDirectory()) {
throw new LocalFileError(
"ENOTDIR",
"A download parent is not a directory",
);
}
}
return { root: realRoot, path: current };
}
export async function pathExists(target: string): Promise<boolean> {
try {
await fsp.lstat(target);
return true;
} catch {
return false;
}
}
async function describeEntry(
dirPath: string,
dirent: fs.Dirent,
): Promise<PluginLocalDirEntry> {
const entryPath = path.join(dirPath, dirent.name);
let type: PluginLocalDirEntry["type"] = "file";
let size = 0;
let modifiedTimestamp: number | undefined;
let linkTarget: string | undefined;
try {
if (dirent.isSymbolicLink()) {
type = "link";
try {
linkTarget = await fsp.readlink(entryPath);
} catch {
// dangling or unreadable link
}
try {
const target = await fsp.stat(entryPath);
if (target.isDirectory()) type = "directory";
size = target.size;
modifiedTimestamp = target.mtimeMs;
} catch {
const own = await fsp.lstat(entryPath);
modifiedTimestamp = own.mtimeMs;
}
} else if (dirent.isDirectory()) {
type = "directory";
modifiedTimestamp = (await fsp.stat(entryPath)).mtimeMs;
} else {
const stat = await fsp.stat(entryPath);
size = stat.size;
modifiedTimestamp = stat.mtimeMs;
}
} catch {
// Unreadable entry: still list it so the user sees it exists.
}
return {
name: dirent.name,
path: entryPath,
type,
size,
modifiedTimestamp,
linkTarget,
hidden: dirent.name.startsWith("."),
};
}
export async function listDirectory(dirPath: string): Promise<{
path: string;
parent: string | null;
entries: PluginLocalDirEntry[];
}> {
const resolved = normalizeLocalPath(dirPath);
const dirents = await fsp.readdir(resolved, { withFileTypes: true });
const entries = await Promise.all(
dirents.map((dirent) => describeEntry(resolved, dirent)),
);
const parent = path.dirname(resolved);
return {
path: resolved,
parent: parent === resolved ? null : parent,
entries,
};
}
// Expands dropped local paths into a flat file list with paths relative to the
// drop root, plus empty directories.
export async function walkPaths(
rootPaths: string[],
): Promise<PluginLocalWalkResult> {
const files: PluginLocalWalkResult["files"] = [];
const emptyDirs: string[] = [];
let totalBytes = 0;
async function walkDir(absDir: string, relDir: string): Promise<void> {
const dirents = await fsp.readdir(absDir, { withFileTypes: true });
if (dirents.length === 0) {
emptyDirs.push(relDir);
return;
}
for (const dirent of dirents) {
const abs = path.join(absDir, dirent.name);
const rel = `${relDir}/${dirent.name}`;
if (dirent.isDirectory()) {
await walkDir(abs, rel);
} else if (dirent.isFile()) {
const stat = await fsp.stat(abs);
files.push({ localPath: abs, relativePath: rel, size: stat.size });
totalBytes += stat.size;
} else if (dirent.isSymbolicLink()) {
try {
const stat = await fsp.stat(abs);
if (stat.isFile()) {
files.push({ localPath: abs, relativePath: rel, size: stat.size });
totalBytes += stat.size;
}
} catch {
// dangling link: skip
}
}
}
}
for (const rootPath of rootPaths) {
const abs = normalizeLocalPath(rootPath);
const stat = await fsp.stat(abs);
const name = path.basename(abs);
if (stat.isDirectory()) {
await walkDir(abs, name);
} else if (stat.isFile()) {
files.push({ localPath: abs, relativePath: name, size: stat.size });
totalBytes += stat.size;
}
}
return { files, emptyDirs, totalBytes };
}
export async function homeInfo(): Promise<{
home: string;
separator: string;
platform: NodeJS.Platform;
}> {
return {
home: os.homedir(),
separator: path.sep,
platform: process.platform,
};
}
export async function makeDirectory(
parentPath: string,
name: string,
): Promise<string> {
const target = path.join(
normalizeLocalPath(parentPath),
requireEntryName(name, "folder name"),
);
await fsp.mkdir(target);
return target;
}
export async function createEmptyFile(
parentPath: string,
name: string,
): Promise<string> {
const target = path.join(
normalizeLocalPath(parentPath),
requireEntryName(name, "file name"),
);
// "wx" fails if the file already exists rather than truncating it.
await fsp.writeFile(target, "", { flag: "wx" });
return target;
}
// Exclusive rename: a destination that appeared meanwhile is never replaced.
export async function renameLocalPath(
source: string,
target: string,
): Promise<void> {
if (source === target) return;
try {
await fsp.lstat(target);
throw new LocalFileError(
"EEXIST",
"A file or folder with that name already exists",
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if ((await fsp.lstat(source)).isDirectory()) {
await fsp.rename(source, target);
return;
}
await fsp.link(source, target);
await fsp.unlink(source);
}
export async function renameEntry(
oldPath: string,
newName: string,
): Promise<string> {
const source = normalizeLocalPath(oldPath);
const safeName = requireEntryName(newName, "name");
const target = path.join(path.dirname(source), safeName);
if (target === source) return source;
if (await pathExists(target)) {
throw new LocalFileError("EEXIST", `"${safeName}" already exists`);
}
await renameLocalPath(source, target);
return target;
}
export async function ensureDirectory(
dirPath: string,
rootPath?: string,
): Promise<string> {
let target = normalizeLocalPath(dirPath);
if (rootPath !== undefined) {
target = (await prepareDownloadPath(rootPath, target, true)).path;
} else {
await fsp.mkdir(target, { recursive: true });
}
return target;
}
export async function existingPaths(paths: string[]): Promise<string[]> {
if (!Array.isArray(paths)) throw new Error("Expected a list of paths");
const existing: string[] = [];
for (const candidate of paths) {
const target = normalizeLocalPath(candidate);
if (await pathExists(target)) existing.push(target);
}
return existing;
}
export async function openRead(
localPath: string,
): Promise<{ stream: fs.ReadStream; size: number }> {
const absPath = normalizeLocalPath(localPath);
const stat = await fsp.stat(absPath);
if (!stat.isFile()) throw new Error("Only regular files can be uploaded");
return {
stream: fs.createReadStream(absPath, { highWaterMark: 1024 * 1024 }),
size: stat.size,
};
}
// Destinations with a download in flight, so two transfers never race onto
// the same file.
const activeDestinations = new Set<string>();
function transferToken(transferId: string): string {
return (
String(transferId)
.replace(/[^A-Za-z0-9_-]/g, "")
.slice(0, 48) || "transfer"
);
}
export interface PublishFs {
rename: (from: string, to: string) => Promise<void>;
link: (from: string, to: string) => Promise<void>;
copyFileExcl: (from: string, to: string) => Promise<void>;
rm: (target: string) => Promise<void>;
lstat: (target: string) => Promise<fs.Stats>;
}
export const defaultPublishFs: PublishFs = Object.freeze({
rename: (from, to) => fsp.rename(from, to),
link: (from, to) => fsp.link(from, to),
copyFileExcl: (from, to) =>
fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL),
rm: (target) => fsp.rm(target, { force: true }),
lstat: (target) => fsp.lstat(target),
});
// link() is atomic and fails with EEXIST if the name is taken; filesystems
// without hard links fall back to an exclusive copy.
async function publishExclusive(
io: PublishFs,
sourcePath: string,
absDest: string,
): Promise<void> {
const exists = () =>
new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`);
try {
await io.link(sourcePath, absDest);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "EEXIST") throw exists();
try {
await io.copyFileExcl(sourcePath, absDest);
} catch (copyError) {
if ((copyError as NodeJS.ErrnoException)?.code === "EEXIST") {
throw exists();
}
throw copyError;
}
}
await io.rm(sourcePath);
}
// rename() onto an existing file is not portable (it commonly fails on
// Windows), so move the old file aside, publish exclusively, then drop it.
async function replaceExisting(
io: PublishFs,
partialPath: string,
absDest: string,
transferId: string,
): Promise<void> {
let current: fs.Stats;
try {
current = await io.lstat(absDest);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
await publishExclusive(io, partialPath, absDest);
return;
}
throw error;
}
if (current.isDirectory()) {
throw new LocalFileError(
"EISDIR",
`"${path.basename(absDest)}" is a folder and cannot be replaced by a file`,
);
}
const asidePath = `${absDest}.${transferToken(transferId)}.termix-replaced`;
try {
await io.rename(absDest, asidePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "EPERM" || code === "EBUSY" || code === "EACCES") {
throw new LocalFileError(
"EBUSY",
`"${path.basename(absDest)}" is in use and could not be replaced`,
);
}
throw error;
}
try {
await publishExclusive(io, partialPath, absDest);
} catch (error) {
await io.rm(absDest).catch(() => {});
await io.rename(asidePath, absDest).catch(() => {});
throw error;
}
try {
await io.rm(asidePath);
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
await io.rm(asidePath).catch(() => {});
}
}
export async function publishDownload(
partialPath: string,
absDest: string,
overwrite: boolean,
transferId: string,
io: PublishFs = defaultPublishFs,
): Promise<void> {
if (overwrite) {
await replaceExisting(io, partialPath, absDest, transferId);
return;
}
await publishExclusive(io, partialPath, absDest);
}
// Bytes go to a transfer-unique temp sibling first and are published under the
// real name only on success, so a failed transfer never leaves a truncated file.
export async function openDownload(
options: {
rootPath: string;
destPath: string;
transferId: string;
overwrite?: boolean;
},
io: PublishFs = defaultPublishFs,
): Promise<PluginLocalDownload> {
const { rootPath, destPath, transferId } = options;
if (!transferId || !destPath) throw new Error("Missing download parameters");
const destination = await prepareDownloadPath(rootPath, destPath);
const absDest = destination.path;
if (activeDestinations.has(absDest)) {
throw new LocalFileError(
"EBUSY",
`"${path.basename(absDest)}" is already being downloaded`,
);
}
activeDestinations.add(absDest);
const partialPath = `${path.join(destination.root, path.basename(absDest))}.${transferToken(
transferId,
)}.termix-part`;
let released = false;
const release = () => {
if (released) return;
released = true;
activeDestinations.delete(absDest);
};
try {
if (options.overwrite !== true && (await pathExists(absDest))) {
throw new LocalFileError(
"EEXIST",
`"${path.basename(absDest)}" already exists`,
);
}
// "wx": the temp name is ours alone; refuse to reuse a stale one.
const stream = fs.createWriteStream(partialPath, { flags: "wx" });
// A write error must not crash the backend. publish() reports it.
let streamError: Error | null = null;
stream.on("error", (error) => {
streamError = error;
});
return {
path: absDest,
stream,
publish: async (publishOptions) => {
try {
if (streamError) throw streamError;
await new Promise<void>((resolve, reject) => {
if (stream.writableFinished) return resolve();
stream.once("error", reject);
stream.end(() => resolve());
});
if (streamError) throw streamError;
await prepareDownloadPath(destination.root, absDest);
await publishDownload(
partialPath,
absDest,
(publishOptions?.overwrite ?? options.overwrite) === true,
transferId,
io,
);
return absDest;
} catch (error) {
await fsp.rm(partialPath, { force: true }).catch(() => {});
throw error;
} finally {
release();
}
},
abort: async () => {
stream.destroy();
await fsp.rm(partialPath, { force: true }).catch(() => {});
release();
},
};
} catch (error) {
release();
throw error;
}
}
@@ -0,0 +1,167 @@
/**
* Local shells for plugins on the desktop app's embedded backend. Ported from
* the old Electron local terminal handlers.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import type { PluginLocalPty } from "@termix-ssh/plugin-sdk/backend";
export function resolveLocalShell(
platform: NodeJS.Platform,
requestedShell?: string,
env: NodeJS.ProcessEnv = process.env,
exists: (file: string) => boolean = fs.existsSync,
): { file: string; args: string[] } {
if (platform === "win32") {
if (requestedShell === "wsl") return { file: "wsl.exe", args: [] };
return {
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
args: ["-NoLogo"],
};
}
// A SHELL left pointing at an uninstalled shell makes the spawn fail.
const fallbacks =
platform === "darwin"
? ["/bin/zsh", "/bin/bash", "/bin/sh"]
: ["/bin/bash", "/bin/sh"];
const usable = (file?: string): file is string =>
!!file && (!path.isAbsolute(file) || exists(file));
const file =
[env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find(usable) ||
fallbacks[0];
return { file, args: ["-l"] };
}
/**
* node-pty starts every shell through its spawn-helper binary, and a packaged
* copy that lost its execute bit fails with "posix_spawnp failed". Returns how
* many helpers were fixed.
*/
export function repairSpawnHelpers(ptyDir: string, fsImpl = fs): number {
// A universal macOS build ships app-arm64.asar and app-x64.asar.
const dir = ptyDir.replace(
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
"$1.asar.unpacked",
);
const candidates = [
path.join(dir, "build", "Release", "spawn-helper"),
path.join(
dir,
"prebuilds",
`${process.platform}-${process.arch}`,
"spawn-helper",
),
];
let fixed = 0;
for (const helper of candidates) {
try {
const mode = fsImpl.statSync(helper).mode;
if ((mode & 0o111) === 0) {
fsImpl.chmodSync(helper, mode | 0o755);
fixed += 1;
}
} catch {
// Missing or read-only; nothing to fix here.
}
}
return fixed;
}
interface NodePty {
spawn: (
file: string,
args: string[],
options: Record<string, unknown>,
) => {
pid: number;
write: (data: string) => void;
resize: (cols: number, rows: number) => void;
kill: () => void;
onData: (cb: (data: string) => void) => { dispose: () => void };
onExit: (cb: (e: { exitCode: number }) => void) => { dispose: () => void };
};
}
const requireFromHere = createRequire(import.meta.url);
function loadNodePty(): NodePty {
try {
return requireFromHere("node-pty") as NodePty;
} catch {
throw new Error("The local terminal is not available in this build");
}
}
export const MAX_PTY_COLS = 500;
export const MAX_PTY_ROWS = 300;
export function spawnLocalPty(options?: {
cols?: number;
rows?: number;
shell?: string;
}): PluginLocalPty {
const pty = loadNodePty();
const cols = Math.min(MAX_PTY_COLS, Math.max(2, Number(options?.cols) || 80));
const rows = Math.min(MAX_PTY_ROWS, Math.max(1, Number(options?.rows) || 24));
const shellConfig = resolveLocalShell(process.platform, options?.shell);
const start = () =>
pty.spawn(shellConfig.file, shellConfig.args, {
name: "xterm-256color",
cols,
rows,
cwd: os.homedir(),
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
});
let child: ReturnType<NodePty["spawn"]>;
try {
child = start();
} catch (error) {
const ptyDir = path.dirname(
requireFromHere.resolve("node-pty/package.json"),
);
if (
!String((error as Error)?.message).includes("posix_spawnp") ||
repairSpawnHelpers(ptyDir) === 0
) {
throw error;
}
child = start();
}
return {
pid: child.pid,
shell: shellConfig.file,
write: (data) => {
if (typeof data === "string" && data.length <= 64 * 1024) {
child.write(data);
}
},
resize: (c, r) => {
if (
Number.isInteger(c) &&
Number.isInteger(r) &&
c >= 2 &&
c <= MAX_PTY_COLS &&
r >= 1 &&
r <= MAX_PTY_ROWS
) {
child.resize(c, r);
}
},
kill: () => child.kill(),
onData: (handler) => {
const sub = child.onData(handler);
return () => sub.dispose();
},
onExit: (handler) => {
const sub = child.onExit(({ exitCode }) => handler(exitCode));
return () => sub.dispose();
},
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const TOPICS = {
hostSessionStatus: "host.session.status",
/** A generic named internal event (host_deleted, user_login, ...). */
internalEvent: "internal.event",
/** A host's status dot changed (hosts/status/host-status-service.ts). */
/** A host's status dot changed (the host status provider). */
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
+149
View File
@@ -0,0 +1,149 @@
/**
* Host status is a provider: core owns the interface and the callers (the
* status routes, the sync feed, ctx.hosts.status), and one provider fills it.
* A plugin registers with ctx.hosts.status.provide. With none, every status
* is unknown.
*/
export type ProviderHostStatus = "online" | "reachable" | "offline";
export interface HostStatusEntry {
status: ProviderHostStatus;
lastChecked: string;
}
export type HostPortResolver = (
hostId: number,
) => number | undefined | Promise<number | undefined>;
export interface HostStatusProvider {
start(): void;
stop(): void;
get(hostId: number): HostStatusEntry | null;
/** All known statuses after bringing polling in line with the request. */
statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>>;
refresh(userId: string): Promise<void>;
check(hostId: number): Promise<HostStatusEntry | null>;
reportLogin(hostId: number, outcome: { ok: boolean }): void;
registerPort(connectionType: string, resolve: HostPortResolver): () => void;
getIntervalSeconds(): Promise<number>;
setIntervalSeconds(seconds: number): Promise<void>;
}
let builtin: HostStatusProvider | null = null;
let plugged: { pluginId: string; provider: HostStatusProvider } | null = null;
let running = false;
const ports = new Map<symbol, { type: string; resolve: HostPortResolver }>();
const portDrops = new Map<symbol, () => void>();
function active(): HostStatusProvider | null {
return plugged?.provider ?? builtin;
}
function attachPorts(provider: HostStatusProvider): void {
for (const [key, { type, resolve }] of ports) {
portDrops.set(key, provider.registerPort(type, resolve));
}
}
function detachPorts(): void {
for (const drop of portDrops.values()) drop();
portDrops.clear();
}
function swap(next: () => void): void {
const before = active();
if (running) before?.stop();
detachPorts();
next();
const after = active();
if (after) attachPorts(after);
if (running) after?.start();
}
/** A fallback provider. Core registers none; tests use it. */
export function registerBuiltinHostStatusProvider(
provider: HostStatusProvider | null,
): void {
swap(() => {
builtin = provider;
});
}
/** Returns the remover. A second provider is refused. */
export function provideHostStatus(
pluginId: string,
provider: HostStatusProvider,
): () => void {
if (plugged) {
throw new Error(
`Host status is already provided by plugin "${plugged.pluginId}"`,
);
}
const entry = { pluginId, provider };
swap(() => {
plugged = entry;
});
return () => {
if (plugged !== entry) return;
swap(() => {
plugged = null;
});
};
}
export function hostStatusProviderId(): string | null {
return plugged?.pluginId ?? null;
}
/** What the rest of core calls. */
export const hostStatus = {
start(): void {
running = true;
active()?.start();
},
stop(): void {
running = false;
active()?.stop();
},
get(hostId: number): HostStatusEntry | null {
return active()?.get(hostId) ?? null;
},
async statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>> {
return (await active()?.statusesFor(userId, requestedHostIds)) ?? new Map();
},
async refresh(userId: string): Promise<void> {
await active()?.refresh(userId);
},
async check(hostId: number): Promise<HostStatusEntry | null> {
return (await active()?.check(hostId)) ?? null;
},
reportLogin(hostId: number, outcome: { ok: boolean }): void {
active()?.reportLogin(hostId, outcome);
},
/** Kept across a provider change, so a plugin's port stays registered. */
registerPort(connectionType: string, resolve: HostPortResolver): () => void {
const key = Symbol(connectionType);
ports.set(key, { type: connectionType, resolve });
const provider = active();
if (provider)
portDrops.set(key, provider.registerPort(connectionType, resolve));
return () => {
ports.delete(key);
portDrops.get(key)?.();
portDrops.delete(key);
};
},
async getIntervalSeconds(): Promise<number> {
return (await active()?.getIntervalSeconds()) ?? 30;
},
async setIntervalSeconds(seconds: number): Promise<void> {
await active()?.setIntervalSeconds(seconds);
},
};
+15 -2
View File
@@ -656,6 +656,15 @@ export class PluginLoader {
if (plugin.state === "active" || plugin.state === "activating") return;
if (
plugin.manifest.runtime === "desktop" &&
process.env.ELECTRON_EMBEDDED !== "true"
) {
plugin.state = "blocked";
plugin.lastError = "only runs in the desktop app";
return;
}
plugin.state = "activating";
plugin.lastError = null;
@@ -719,8 +728,12 @@ export class PluginLoader {
bundled:
plugin.source === "bundled" || plugin.signedTier === "official",
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
// A new table, or a plugin that keeps its state in settings, may be
// where core data is waiting to move. Every move is idempotent.
if (
applied.length > 0 ||
plugin.manifest.contributes?.settings?.admin?.length
) {
const { runPluginDataMoves } =
await import("../upgrade/plugin-data-moves.js");
await runPluginDataMoves();
@@ -97,3 +97,10 @@ export class JumpProbePool {
if (this.entries.get(key) === entry) this.entries.delete(key);
}
}
/** One pool for core's checks and for plugins probing through ctx. */
export const sharedJumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
const { createJumpHostChain } =
await import("../../hosts/jump-host-chain.js");
return createJumpHostChain(jumpHosts, userId);
});
File renamed without changes.
+12
View File
@@ -41,6 +41,18 @@ export function revoke(key: string, value?: unknown): boolean {
return providers.delete(key);
}
/**
* The provider under "<some plugin id>.<name>", for a well-known name core
* reads without knowing which plugin supplies it. First match wins.
*/
export function consumeByName<T>(name: string): T | undefined {
const suffix = `.${name}`;
for (const [key, value] of providers) {
if (key.endsWith(suffix)) return value as T;
}
return undefined;
}
export function has(key: string): boolean {
return providers.has(key);
}
+4
View File
@@ -44,6 +44,10 @@ export type { PluginSettingsScope };
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"notification_private_endpoint_allowlist",
// Read once by the branding provider to keep a pre-plugin install's branding.
"branding_app_name",
"branding_tagline",
"branding_logo",
];
type ChangeListener = (value: unknown) => void;
-90
View File
@@ -1,90 +0,0 @@
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import { dashboardLogger } from "../utils/logger.js";
const RATE_LIMIT_MS = 1000;
const activityRateLimiter = new Map<string, number>();
/**
* Activity types are whatever the recording plugin calls them (each tab
* declares the types it reopens), so only the shape is checked here.
*/
function isActivityType(type: unknown): type is string {
return typeof type === "string" && /^[a-z][a-z0-9_]{0,31}$/.test(type);
}
export type RecordActivityResult =
| { status: "logged"; id: number }
| { status: "rate_limited" }
| { status: "invalid_type" }
| { status: "denied" };
/**
* Adds one recent activity entry: the dashboard's /activity/log route and
* ctx.hosts.recordActivity both come through here, so the rate limit and the
* access rule are the same for both.
*/
export async function recordRecentActivity(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const { type, hostId, hostName } = entry;
if (!isActivityType(type)) {
return { status: "invalid_type" };
}
const rateLimitKey = `${userId}:${hostId}:${type}`;
const now = Date.now();
const lastLogged = activityRateLimiter.get(rateLimitKey);
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
return { status: "rate_limited" };
}
activityRateLimiter.set(rateLimitKey, now);
if (activityRateLimiter.size > 10000) {
for (const [key, timestamp] of activityRateLimiter.entries()) {
if (now - timestamp > RATE_LIMIT_MS * 2) activityRateLimiter.delete(key);
}
}
const isOwnedHost =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isOwnedHost) {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
if (!sharedHosts.some((access) => access.hostId === hostId)) {
return { status: "denied" };
}
}
const result = await createCurrentRecentActivityRepository().create({
userId,
type,
hostId,
hostName,
});
// Best-effort trim; a failure here must not fail the log itself.
try {
await createCurrentRecentActivityRepository().trimUserActivity(userId, 100);
} catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity",
userId,
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
});
}
return { status: "logged", id: result.id };
}
+4 -4
View File
@@ -200,9 +200,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runCoreBootMigrations } = await import("./boot.js");
await runCoreBootMigrations();
const { hostStatusService } =
await import("./hosts/status/host-status-service.js");
hostStatusService.start();
// Status comes from whichever plugin provides it; none means unknown.
const { hostStatus } = await import("./plugins/host-status-provider.js");
hostStatus.start();
// Counted before the desktop user is provisioned, so a first desktop
// run counts as a fresh install too.
@@ -226,7 +226,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// Plugins serve their routes under /plugin-api/<id>/ and /plugin-ws/<id>/
// through ctx on activate, and start their own schedulers there, so
// disabling one answers 503 instead of leaving a dead import here. The
// dashboard's own uptime and recent-activity routes are core and are
// dashboard's uptime and recent-activity routes are core forwarders and are
// mounted on the main server in database.ts.
// Initialize log level from database settings
+32
View File
@@ -200,6 +200,38 @@ export async function remoteFetch(
return response;
}
export interface RemoteStreamRequest {
method?: string;
headers?: Record<string, string>;
/** A string, a buffer or a Node stream. */
body?: unknown;
signal?: AbortSignal;
}
/**
* Sends a request with the link's proxy headers and certificate rules and a
* body that may be a stream. The response body is left unread for the caller.
*/
export async function remoteStreamFetch(
target: RemoteTarget,
pathname: string,
request: RemoteStreamRequest = {},
) {
const headers = { ...request.headers, ...requestHeaders(target) };
try {
return await undiciFetch(remoteUrl(target.serverUrl, pathname), {
method: request.method ?? "GET",
headers,
body: request.body as never,
duplex: "half",
dispatcher: dispatcherFor(target),
signal: request.signal,
} as never);
} catch (error) {
throw describeNetworkError(error);
}
}
/** Sends a request and parses a JSON answer from Termix. */
export async function remoteJson<T>(
target: RemoteTarget,
+9 -4
View File
@@ -11,7 +11,10 @@
import fs from "fs";
import path from "path";
import semver from "semver";
import type { PluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import {
resolvePluginDesktopMode,
type PluginDesktopMode,
} from "@termix-ssh/plugin-sdk/manifest";
import { syncLogger } from "../../utils/logger.js";
import { sendCoreAlert } from "../../notify/core-notify.js";
import {
@@ -227,7 +230,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
}
const { loader } = getPluginRuntime();
let local = loader.get(plugin.id);
const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror";
const mode = local
? resolvePluginDesktopMode(local.manifest)
: (plugin.desktop ?? "mirror");
if (mode === "server") {
if (local && (await isEnabledLocally(plugin.id))) {
@@ -328,7 +333,7 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
// A plugin the server no longer has is switched off here. Its data stays.
for (const local of getPluginRuntime().loader.list()) {
if (remoteIds.has(local.id) || local.source !== "user") continue;
if ((local.manifest.desktop ?? "mirror") !== "mirror") continue;
if (resolvePluginDesktopMode(local.manifest) !== "mirror") continue;
if (await isEnabledLocally(local.id)) {
await setPluginEnabled(local.id, false).catch(() => {});
}
@@ -341,7 +346,7 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
*/
export function isServerManaged(pluginId: string): boolean {
const local = getPluginRuntime().loader.get(pluginId);
const mode = local?.manifest.desktop ?? "mirror";
const mode = local ? resolvePluginDesktopMode(local.manifest) : "mirror";
if (mode !== "mirror") return mode === "server";
return local?.source !== "user" || vouchedHere(local);
}
+5 -4
View File
@@ -9,6 +9,7 @@ import { syncLogger } from "../../utils/logger.js";
import { getLocalVersion } from "../../utils/app-version.js";
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import {
createCurrentSessionRepository,
createCurrentSettingsRepository,
@@ -447,13 +448,13 @@ router.get(
async (req: Request, res: Response) => {
const userId = userOf(req);
try {
const { hostStatusService } =
await import("../../hosts/status/host-status-service.js");
const { hostStatus } =
await import("../../plugins/host-status-provider.js");
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const { createCurrentHostRepository } =
await import("../../database/repositories/factory.js");
const statuses = await hostStatusService.statusesFor(userId, null);
const statuses = await hostStatus.statusesFor(userId, null);
const entries = [...statuses.entries()];
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
@@ -516,7 +517,7 @@ router.get(
artifact: !!plugin.artifact,
enabled: records.get(plugin.id)?.state === "enabled",
active: plugin.state === "active",
desktop: plugin.manifest.desktop ?? "mirror",
desktop: resolvePluginDesktopMode(plugin.manifest),
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
granted: (await grants.listByPlugin(plugin.id)).map(
(grant) => grant.capability,
@@ -1,99 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js";
describe("RecentActivityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<{
repository: RecentActivityRepository;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp)
VALUES
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
(2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'),
(3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z');
`);
return {
repository: new RecentActivityRepository(context, onWrite),
};
}
it("lists, creates, and trims recent activity", async () => {
let writeCount = 0;
const { repository } = await createRepository(() => {
writeCount += 1;
});
expect(
(await repository.listByUserId("user-1", 2)).map((row) => row.id),
).toEqual([2, 1]);
const created = await repository.create({
userId: "user-1",
type: "terminal",
hostId: 1,
hostName: "one",
timestamp: "2026-06-26T00:03:00.000Z",
});
expect(created).toMatchObject({
userId: "user-1",
type: "terminal",
hostId: 1,
});
expect(await repository.trimUserActivity("user-1", 2)).toBe(1);
expect(
await adapter!.query(
sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`,
),
).toEqual([{ id: created.id }, { id: 2 }]);
expect(writeCount).toBe(2);
});
it("deletes activity by user id and only triggers writes for changed rows", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(0);
expect(await repo.deleteByUserId("user-1")).toBe(2);
expect(writeCount).toBe(1);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(1);
});
it("deletes activity by host id and host id list", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
expect(writeCount).toBe(2);
});
});
@@ -1,5 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { beforeEach, describe, expect, it, vi } from "vitest";
import express, {
type NextFunction,
type Request,
@@ -19,15 +18,6 @@ vi.mock("../../../utils/logger.js", () => ({
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
setMany: async (writes: Array<{ key: string; value: string }>) => {
for (const write of writes) state.settings[write.key] = write.value;
},
delete: async (key: string) => {
delete state.settings[key];
},
}),
}));
@@ -42,25 +32,17 @@ interface RouteLayer {
};
}
/** Runs a route's full middleware chain (e.g. requireAdmin, then the handler). */
function handlersFor(
router: express.Router,
pathName: string,
method: string,
): RequestHandler[] {
const layer = (router as unknown as { stack: RouteLayer[] }).stack.find(
(candidate) =>
candidate.route?.path === pathName && candidate.route.methods[method],
);
if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`);
return layer.route!.stack.map((entry) => entry.handle);
function routesOf(router: express.Router) {
return (router as unknown as { stack: RouteLayer[] }).stack
.filter((layer) => layer.route)
.map((layer) => ({
path: layer.route!.path,
methods: Object.keys(layer.route!.methods),
handlers: layer.route!.stack.map((entry) => entry.handle),
}));
}
async function invokeChain(
handlers: RequestHandler[],
body: unknown = {},
): Promise<{ statusCode: number; body: unknown }> {
const req = { body, headers: {} } as unknown as Request;
async function call(handler: RequestHandler) {
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
@@ -72,67 +54,34 @@ async function invokeChain(
return this;
},
} as unknown as Response;
for (const handler of handlers) {
let proceeded = false;
await handler(req, res, ((err?: unknown) => {
if (err) throw err;
proceeded = true;
}) as NextFunction);
if (!proceeded) return result;
}
await handler(
{ body: {}, headers: {} } as Request,
res,
(() => {}) as NextFunction,
);
return result;
}
/** Mimics the real requireAdmin: 401 with no session, 403 for a non-admin. */
function fakeRequireAdmin(
outcome: "unauthenticated" | "not-admin" | "admin",
): RequestHandler {
return (_req, res, next) => {
if (outcome === "unauthenticated") {
res.status(401).json({ error: "Missing authentication token" });
return;
}
if (outcome === "not-admin") {
res.status(403).json({ error: "Not authorized" });
return;
}
// Must return (not just call) next() so invokeChain's await actually
// waits for the downstream async handler to finish before returning.
return next();
};
}
function buildRouter(requireAdmin: RequestHandler) {
const router = express.Router();
registerBrandingRoutes(router, requireAdmin);
return router;
}
let pngDataUrl: string;
beforeAll(async () => {
const buffer = await sharp({
create: { width: 4, height: 4, channels: 3, background: "#112233" },
})
.png()
.toBuffer();
pngDataUrl = `data:image/png;base64,${buffer.toString("base64")}`;
});
beforeEach(() => {
state.settings = {};
});
describe("GET /users/branding", () => {
it("succeeds with no auth at all, since the route registers no auth handler", async () => {
// requireAdmin is only ever wired onto PATCH below; GET's own handler chain
// has exactly one entry, proving the public route never runs an admin gate.
const router = buildRouter(fakeRequireAdmin("unauthenticated"));
const handlers = handlersFor(router, "/branding", "get");
expect(handlers).toHaveLength(1);
it("is the only branding route and has no auth handler", () => {
const router = express.Router();
registerBrandingRoutes(router);
const routes = routesOf(router);
const response = await invokeChain(handlers);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/branding");
expect(routes[0].methods).toEqual(["get"]);
expect(routes[0].handlers).toHaveLength(1);
});
it("answers defaults when nothing is set", async () => {
const router = express.Router();
registerBrandingRoutes(router);
const response = await call(routesOf(router)[0].handlers[0]);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
@@ -142,126 +91,19 @@ describe("GET /users/branding", () => {
});
});
it("reflects persisted branding values", async () => {
state.settings.branding_app_name = "Acme Ops";
state.settings.branding_tagline = "Ship it";
state.settings.branding_logo = pngDataUrl;
const router = buildRouter(fakeRequireAdmin("admin"));
it("keeps serving the stored branding of an older install", async () => {
state.settings = {
branding_app_name: "Acme",
branding_tagline: "Ops",
};
const router = express.Router();
registerBrandingRoutes(router);
const response = await call(routesOf(router)[0].handlers[0]);
const response = await invokeChain(handlersFor(router, "/branding", "get"));
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
appName: "Acme Ops",
tagline: "Ship it",
logo: pngDataUrl,
appName: "Acme",
tagline: "Ops",
logo: null,
});
});
});
describe("PATCH /users/branding", () => {
it("rejects an unauthenticated request and persists nothing", async () => {
const router = buildRouter(fakeRequireAdmin("unauthenticated"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Hijacked" },
);
expect(response.statusCode).toBe(401);
expect(state.settings).toEqual({});
});
it("rejects a non-admin request and persists nothing", async () => {
const router = buildRouter(fakeRequireAdmin("not-admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Hijacked" },
);
expect(response.statusCode).toBe(403);
expect(state.settings).toEqual({});
});
it("persists only the allowed fields for an admin request", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Acme Ops", tagline: "Ship it", logo: pngDataUrl },
);
expect(response.statusCode).toBe(200);
expect(state.settings.branding_app_name).toBe("Acme Ops");
expect(state.settings.branding_tagline).toBe("Ship it");
expect(state.settings.branding_logo).toMatch(/^data:image\/png;base64,/);
expect(response.body).toMatchObject({
appName: "Acme Ops",
tagline: "Ship it",
});
});
it("rejects unknown fields without writing anything", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ theme: "dark" },
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_UNKNOWN_FIELD",
});
expect(state.settings).toEqual({});
});
it("rejects an app name over the length limit", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "x".repeat(61) },
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_INVALID",
field: "appName",
});
expect(state.settings).toEqual({});
});
it("rejects malformed/non-raster logo data without persisting it", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{
logo: `data:image/png;base64,${Buffer.from("not an image").toString("base64")}`,
},
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_INVALID",
field: "logo",
});
expect(state.settings).toEqual({});
});
it("clears the logo when patched with null", async () => {
state.settings.branding_logo = pngDataUrl;
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ logo: null },
);
expect(response.statusCode).toBe(200);
expect(state.settings.branding_logo).toBeUndefined();
expect(response.body).toMatchObject({ logo: null });
});
});
@@ -1,149 +1,17 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const {
BRANDING_DEFAULT_APP_NAME,
BRANDING_LOGO_MAX_BYTES,
parseBrandingAppName,
parseBrandingLogoDataUrl,
parseBrandingTagline,
resolveBrandingSettings,
} = await import("../../../database/routes/branding-settings.js");
const { BRANDING_DEFAULT_APP_NAME, resolveBrandingSettings } =
await import("../../../database/routes/branding-settings.js");
function settingSource(values: Record<string, string>) {
return { get: async (key: string) => values[key] ?? null };
}
async function toDataUrl(
buffer: Buffer,
mime: "image/png" | "image/jpeg" | "image/webp",
): Promise<string> {
return `data:${mime};base64,${buffer.toString("base64")}`;
}
let pngBuffer: Buffer;
let jpegBuffer: Buffer;
let webpBuffer: Buffer;
let oversizedPngBuffer: Buffer;
beforeAll(async () => {
const image = sharp({
create: { width: 4, height: 4, channels: 3, background: "#336699" },
});
pngBuffer = await image.clone().png().toBuffer();
jpegBuffer = await image.clone().jpeg().toBuffer();
webpBuffer = await image.clone().webp().toBuffer();
oversizedPngBuffer = await sharp({
create: { width: 1200, height: 1200, channels: 4, background: "#abcdef" },
})
.png({ compressionLevel: 0 })
.toBuffer();
});
describe("parseBrandingAppName", () => {
it("trims and accepts a normal name", () => {
expect(parseBrandingAppName(" My Company ")).toBe("My Company");
});
it("falls back to the default when cleared", () => {
expect(parseBrandingAppName("")).toBe(BRANDING_DEFAULT_APP_NAME);
expect(parseBrandingAppName(" ")).toBe(BRANDING_DEFAULT_APP_NAME);
});
it("rejects names over 60 characters", () => {
expect(parseBrandingAppName("x".repeat(61))).toBeNull();
});
it("rejects non-string values", () => {
expect(parseBrandingAppName(42)).toBeNull();
expect(parseBrandingAppName(null)).toBeNull();
});
});
describe("parseBrandingTagline", () => {
it("trims and accepts a normal tagline", () => {
expect(parseBrandingTagline(" Ship faster ")).toBe("Ship faster");
});
it("accepts an empty tagline (means: use the default)", () => {
expect(parseBrandingTagline("")).toBe("");
expect(parseBrandingTagline(" ")).toBe("");
});
it("rejects taglines over 160 characters", () => {
expect(parseBrandingTagline("x".repeat(161))).toBeNull();
});
it("rejects non-string values", () => {
expect(parseBrandingTagline(42)).toBeNull();
});
});
describe("parseBrandingLogoDataUrl", () => {
it("accepts a PNG data URL and re-encodes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(pngBuffer, "image/png"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
const decoded = Buffer.from(result!.split(",")[1]!, "base64");
expect(decoded.subarray(1, 4).toString()).toBe("PNG");
});
it("accepts a JPEG data URL and normalizes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(jpegBuffer, "image/jpeg"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
});
it("accepts a WEBP data URL and normalizes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(webpBuffer, "image/webp"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
});
it("rejects a non-image/SVG mime prefix outright", async () => {
const svg = Buffer.from('<svg onload="alert(1)"></svg>', "utf8").toString(
"base64",
);
const result = await parseBrandingLogoDataUrl(
`data:image/svg+xml;base64,${svg}`,
);
expect(result).toBeNull();
});
it("rejects a value that isn't a data URL at all", async () => {
expect(await parseBrandingLogoDataUrl("not-a-data-url")).toBeNull();
expect(await parseBrandingLogoDataUrl(undefined)).toBeNull();
});
it("rejects a spoofed data URL whose bytes aren't a real image", async () => {
const fake = Buffer.from("definitely not an image").toString("base64");
const result = await parseBrandingLogoDataUrl(
`data:image/png;base64,${fake}`,
);
expect(result).toBeNull();
});
it("rejects an image that normalizes over the size cap", async () => {
// Sanity check the fixture is actually large enough to exceed the cap.
expect(oversizedPngBuffer.length).toBeGreaterThan(BRANDING_LOGO_MAX_BYTES);
const result = await parseBrandingLogoDataUrl(
await toDataUrl(oversizedPngBuffer, "image/png"),
);
expect(result).toBeNull();
});
});
describe("resolveBrandingSettings", () => {
it("returns built-in defaults when nothing is persisted", async () => {
const settings = await resolveBrandingSettings(settingSource({}));
@@ -169,3 +37,34 @@ describe("resolveBrandingSettings", () => {
});
});
});
describe("branding provider", () => {
it("uses a plugin's branding provider and falls back when it throws", async () => {
const registry = await import("../../../plugins/registry.js");
const provider = async () => ({
appName: " Acme ",
tagline: "Hello",
logo: "data:image/png;base64,AAAA",
});
registry.provide("demo.brandingProvider", provider);
try {
await expect(resolveBrandingSettings(settingSource({}))).resolves.toEqual(
{
appName: "Acme",
tagline: "Hello",
logo: "data:image/png;base64,AAAA",
},
);
registry.revoke("demo.brandingProvider");
registry.provide("demo.brandingProvider", async () => {
throw new Error("broken");
});
await expect(
resolveBrandingSettings(settingSource({ branding_app_name: "Stored" })),
).resolves.toMatchObject({ appName: "Stored" });
} finally {
registry.clearRegistry();
}
});
});
@@ -52,12 +52,6 @@ vi.mock("../../../utils/logger.js", () => {
};
return { authLogger: log, sshLogger: log, databaseLogger: log };
});
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
registerCredentialKeyRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
registerCredentialDeployRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
registerCredentialBulkRoutes: () => {},
}));
@@ -34,15 +34,12 @@ vi.mock("../../../utils/data-crypto.js", () => ({
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRecentActivityRepository: () => ({
listByUserId: async () => state.activity,
vi.mock("../../../plugins/activity-provider.js", () => ({
recentActivity: {
list: async () => state.activity,
deleteByUserId: async () => {},
}),
}));
vi.mock("../../../services/recent-activity.js", () => ({
recordRecentActivity: async () => state.recordResult,
record: async () => state.recordResult,
},
}));
const { default: dashboardRoutes } =
@@ -3,7 +3,7 @@ import {
importedHostUsername,
notifyImportedHosts,
parseSSHConfig,
} from "../../../database/routes/host-bulk-routes.js";
} from "../../../hosts/import/host-import.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
describe("parseSSHConfig", () => {
@@ -1,5 +1,5 @@
import { beforeEach, expect, it, vi } from "vitest";
import type { Router, RequestHandler, Request, Response } from "express";
import type { RequestHandler, Request, Response } from "express";
const mocks = vi.hoisted(() => ({
create: vi.fn(),
@@ -35,23 +35,35 @@ vi.mock("../../../database/repositories/factory.js", () => ({
findHostsByUserId: mocks.list,
}),
}));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import {
ImportError,
importResponse,
runBulkImport,
runSshConfigImport,
} from "../../../hosts/import/host-import.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = {
post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
},
put: () => {},
patch: () => {},
delete: () => {},
} as unknown as Router;
const pass: RequestHandler = (_req, _res, next) => next();
registerHostBulkRoutes(router, pass, pass, pass, pass);
// The same wrapping the plugin's routes give the shared import code.
const wrap =
(run: typeof runBulkImport): RequestHandler =>
async (req, res) => {
try {
const body = (req as unknown as { body: never }).body;
res.json(
importResponse(
await run((req as unknown as { userId: string }).userId, body),
),
);
} catch (error) {
if (error instanceof ImportError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
};
const handler = wrap(runBulkImport);
const sshConfigHandler = wrap(runSshConfigImport as typeof runBulkImport);
const host = {
ip: "192.0.2.1",
port: 22,
@@ -76,12 +88,7 @@ it.each([false, true])(
"carries plugin host settings through export selection and import (overwrite=%s)",
async (overwrite) => {
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const payload = buildExportPayload(
{ hosts: [host] },
null,
new Set(["featureFlags", "advanced"]),
false,
);
const payload = { hosts: [host] };
expect(payload.hosts[0]).toMatchObject(host);
const json = vi.fn();
await handler(
@@ -130,12 +137,7 @@ it.each([20, "20"])(
{ ...host, ip: "192.0.2.2", exportId: 20 },
],
};
const payload = buildExportPayload(
raw,
null,
new Set(["jumpHosts"]),
false,
);
const payload = raw;
const res = await importHosts(payload.hosts);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: 2, failed: 0 }),
@@ -15,7 +15,8 @@ const service = vi.hoisted(() => ({
get: vi.fn(),
check: vi.fn(),
refresh: vi.fn(),
retimeAll: vi.fn(),
getIntervalSeconds: vi.fn(),
setIntervalSeconds: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => ({
@@ -35,18 +36,8 @@ vi.mock("../../../utils/permission-manager.js", () => ({
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
}),
}));
vi.mock("../../../hosts/status/host-status-service.js", () => ({
DEFAULT_STATUS_INTERVAL: 60,
GLOBAL_STATUS_INTERVAL_KEY: "global_status_check_interval",
hostStatusService: service,
vi.mock("../../../plugins/host-status-provider.js", () => ({
hostStatus: service,
}));
const { registerHostStatusRoutes, parseStatusHostIds } =
@@ -83,6 +74,8 @@ async function call(method: string, path: string, body?: unknown) {
beforeEach(() => {
state.settings = {};
service.getIntervalSeconds.mockResolvedValue(60);
service.setIntervalSeconds.mockResolvedValue(undefined);
state.locked = false;
state.accessible = new Set([1, 2]);
state.statuses = new Map([
@@ -149,14 +142,13 @@ describe("status settings", () => {
});
});
it("saves a new interval and re-times the checks", async () => {
it("saves a new interval through the provider", async () => {
await start();
const { status } = await call("PUT", "/host/status/settings", {
statusCheckInterval: 90,
});
expect(status).toBe(200);
expect(state.settings.global_status_check_interval).toBe("90");
expect(service.retimeAll).toHaveBeenCalledOnce();
expect(service.setIntervalSeconds).toHaveBeenCalledWith(90);
});
it("rejects an interval out of range", async () => {
@@ -24,14 +24,12 @@ describe("RBAC coverage for split host routers", () => {
it("gates bulk mutations by action and data-access permissions", () => {
const router = express.Router();
const authenticate = middleware();
const requireCreate = middleware();
const requireEdit = middleware();
const requireDataAccess = middleware();
registerHostBulkRoutes(
router,
authenticate,
requireCreate,
requireEdit,
requireDataAccess,
);
@@ -46,14 +44,6 @@ describe("RBAC coverage for split host routers", () => {
requireEdit,
requireDataAccess,
]);
for (const path of ["/bulk-import", "/ssh-config-import"]) {
expect(handlers(router, "post", path).slice(0, 4)).toEqual([
authenticate,
requireCreate,
requireEdit,
requireDataAccess,
]);
}
});
it("gates folder reads and mutations with their matching permissions", () => {
@@ -28,11 +28,18 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
vi.mock("../../../plugins/activity-provider.js", () => ({
recentActivity: {
deleteByUserId: async (userId: string) => {
calls.deletedFor.push(`activity:${userId}`);
},
},
}));
vi.mock("../../../plugins/events.js", () => ({
pluginEvents: {
emit: (_topic: string, payload: { userId: string }) => {
@@ -1,86 +0,0 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const { createC2SAuthBridge } = require("../../../../electron/c2s-auth.cjs");
function setup() {
const sender = Object.assign(new EventEmitter(), {
send: vi.fn(),
isDestroyed: () => false,
});
const ws = Object.assign(new EventEmitter(), {
readyState: 1,
send: vi.fn(),
close: vi.fn(() => {
ws.readyState = 3;
ws.emit("close");
}),
});
let answer!: (
event: { sender: unknown },
id: string,
value: unknown,
) => boolean;
const attach = createC2SAuthBridge(
{
handle: (_name: string, fn: typeof answer) => {
answer = fn;
},
},
() => ({ isDestroyed: () => false, webContents: sender }),
);
attach(ws, "dynamic");
const prompt = () =>
ws.emit(
"message",
Buffer.from(
JSON.stringify({
type: "auth-prompt",
requestId: "server-challenge",
request: { kind: "totp", prompt: "Code:", retry: false },
}),
),
false,
);
return { sender, ws, answer, prompt };
}
afterEach(() => vi.useRealTimers());
describe("desktop tunnel prompt bridge", () => {
it("binds answers to the renderer and challenge, and rejects replay", () => {
const { sender, ws, answer, prompt } = setup();
prompt();
const id = sender.send.mock.calls[0][1].id;
expect(answer({ sender: {} }, id, "123456")).toBe(false);
expect(answer({ sender }, "unknown", "123456")).toBe(false);
expect(answer({ sender }, id, { secret: "bad" })).toBe(false);
expect(ws.send).not.toHaveBeenCalled();
expect(answer({ sender }, id, "123456")).toBe(true);
expect(JSON.parse(ws.send.mock.calls[0][0])).toEqual({
type: "auth-response",
requestId: "server-challenge",
answer: "123456",
});
expect(answer({ sender }, id, "again")).toBe(false);
expect(sender.listenerCount("destroyed")).toBe(0);
ws.close();
});
it.each(["close", "reload", "timeout"])(
"clears a pending dialog on %s",
(reason) => {
vi.useFakeTimers();
const { sender, ws, answer, prompt } = setup();
prompt();
const id = sender.send.mock.calls[0][1].id;
if (reason === "close") ws.close();
else if (reason === "reload") sender.emit("did-start-loading");
else vi.advanceTimersByTime(60_000);
expect(sender.send).toHaveBeenLastCalledWith("c2s-auth-prompt", {
id,
closed: true,
});
expect(answer({ sender }, id, "late")).toBe(false);
expect(ws.close).toHaveBeenCalled();
},
);
});
@@ -1,238 +0,0 @@
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const main = readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const section = (from: string, to: string) =>
main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from)));
function setup() {
const sockets: Socket[] = [];
class Socket extends EventEmitter {
readyState = 1;
send = vi.fn();
close = vi.fn(() => {
if (this.readyState === 3) return;
this.readyState = 3;
this.emit("close");
});
constructor() {
super();
sockets.push(this);
queueMicrotask(() => this.emit("open"));
}
ready() {
this.emit(
"message",
Buffer.from(JSON.stringify({ type: "ready" })),
false,
);
}
}
const runtimes = new Map();
let incoming!: (socket: EventEmitter) => void;
const server = Object.assign(new EventEmitter(), {
listen: (_options: unknown, ready: () => void) => ready(),
close: vi.fn((done?: () => void) => done?.()),
});
const headers = vi.fn(async () => ({}));
const dynamic = vi.fn();
const context = vm.createContext({
setTimeout,
clearTimeout,
WebSocket: Socket,
crypto: { randomUUID: () => "runtime-token" },
net: {
createServer: (callback: typeof incoming) => {
incoming = callback;
return server;
},
},
c2sTunnelRuntimes: runtimes,
normalizeC2STunnelAddresses: (t: unknown) => t,
resolveC2SRemoteSourceHost: async (t: unknown) => t,
getC2STunnelName: (t: { name: string }) => t.name,
getC2SLocalAddress: () => "127.0.0.1",
getC2SRemoteAddress: () => "127.0.0.1",
getC2SRelayUrl: () => "wss://server/relay",
getC2SRelayHeaders: headers,
getWebSocketOptions: (_url: string, options: unknown) => options,
attachC2SAuth: vi.fn(),
keepC2SSocketAlive: vi.fn(),
logToFile: vi.fn(),
emitC2STunnelStatuses: vi.fn(),
checkLocalPortAvailable: async () => ({ available: true }),
handleC2SDynamicConnection: dynamic,
setC2STunnelStatus: (name: string, status: unknown) => {
runtimes.get(name).status = status;
},
setC2STunnelError: (name: string, error: string) => {
runtimes.get(name).status = { connected: false, reason: error };
},
createC2SFailure: (error: string) => ({ success: false, error }),
normalizeC2SErrorMessage: (message: string) => message,
C2S_REMOTE_SESSION_EXPIRED_ERROR: "expired",
});
vm.runInContext(
section("async function openC2SRelay(", "async function testC2SRelay(") +
section("async function testC2SRelay(", "async function testC2STunnel(") +
section("async function startC2STunnel(", "function stopAllC2STunnels("),
context,
);
return {
context,
runtimes,
sockets,
server,
headers,
dynamic,
incoming: () => incoming,
};
}
describe("client tunnel runtime authentication", () => {
it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => {
const { context, runtimes, sockets, server, headers, dynamic, incoming } =
setup();
const tunnel = {
name: "socks",
mode: "dynamic",
sourceHostId: 7,
sourcePort: 1080,
};
await context.startC2STunnel(tunnel, 0, "local-auth");
await vi.waitFor(() => expect(sockets).toHaveLength(1));
const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() });
incoming()(premature);
expect(premature.destroy).toHaveBeenCalledOnce();
expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth");
expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({
keepAlive: true,
tunnelConfig: { sessionId: "runtime-token" },
});
sockets[0].ready();
await vi.waitFor(() =>
expect(runtimes.get("socks").status.connected).toBe(true),
);
expect(sockets[0].close).not.toHaveBeenCalled();
const client = Object.assign(new EventEmitter(), { destroy: vi.fn() });
incoming()(client);
expect(dynamic).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: "runtime-token" }),
client,
"local-auth",
);
await context.stopC2STunnel("socks");
expect(sockets[0].close).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
expect(server.close).toHaveBeenCalledOnce();
expect(runtimes.size).toBe(0);
});
it("does not retain a socket for a standalone test or a stopped runtime", async () => {
const { context, sockets } = setup();
const test = context.testC2SRelay({ name: "test" });
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].ready();
expect(await test).toEqual({ success: true });
expect(sockets[0].close).toHaveBeenCalledOnce();
expect(
await context.testC2SRelay({ name: "stopped" }, null, null, null, {}),
).toEqual({ success: false, error: "Tunnel stopped" });
expect(sockets).toHaveLength(1);
});
it("keeps the relay's reason when it closes after an error", async () => {
const { context, runtimes, sockets } = setup();
const runtime = { status: { connected: true } };
runtimes.set("web", runtime);
const test = context.testC2SRelay(
{ name: "web" },
"127.0.0.1",
8443,
null,
runtime,
);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].emit(
"message",
Buffer.from(
JSON.stringify({
type: "error",
error: "Target 127.0.0.1:8443 refused",
}),
),
false,
);
sockets[0].close();
expect(await test).toMatchObject({ success: false });
expect(runtimes.get("web").status.reason).toBe(
"Target 127.0.0.1:8443 refused",
);
});
it("keeps the tunnel connected when one of its connections fails", async () => {
const { context, runtimes, sockets } = setup();
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
const local = () =>
Object.assign(new EventEmitter(), {
destroy: vi.fn(),
write: vi.fn(),
});
const first = local();
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, first);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
first.emit("error", new Error("ECONNRESET"));
sockets[0].emit(
"message",
Buffer.from(JSON.stringify({ type: "error", error: "refused" })),
false,
);
expect(runtimes.get("web").status.connected).toBe(true);
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, local());
await vi.waitFor(() => expect(sockets).toHaveLength(2));
sockets[1].emit(
"message",
Buffer.from(JSON.stringify({ type: "error", error: "expired" })),
false,
);
expect(runtimes.get("web").status).toMatchObject({
connected: false,
reason: "expired",
});
});
});
describe("client tunnel socket keepalive", () => {
it("pings an open socket until it closes", () => {
vi.useFakeTimers();
try {
const context = vm.createContext({
setInterval,
clearInterval,
WebSocket: { OPEN: 1 },
});
vm.runInContext(
section("function keepC2SSocketAlive(", "// C2S (client-to-server)"),
context,
);
const ws = Object.assign(new EventEmitter(), {
readyState: 1,
ping: vi.fn(),
});
context.keepC2SSocketAlive(ws);
vi.advanceTimersByTime(50_000);
expect(ws.ping).toHaveBeenCalledTimes(2);
ws.emit("close");
vi.advanceTimersByTime(50_000);
expect(ws.ping).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
@@ -14,7 +14,7 @@ const source = fs.readFileSync(
);
const tls = source.slice(
source.indexOf("const INSECURE_MODE_VALUES"),
source.indexOf("function httpFetch("),
source.indexOf('if (process.platform === "linux") {'),
);
const ORIGIN = "https://termix.example";
@@ -30,10 +30,7 @@ function load(link: Record<string, unknown> | null) {
vm.runInContext(tls, context);
return context as unknown as {
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
getWebSocketOptions: (
url: string,
options?: Record<string, unknown>,
) => Record<string, unknown>;
getTlsVerificationOptions: (url: string) => Record<string, unknown>;
};
}
@@ -41,8 +38,7 @@ describe("linked server certificate exception", () => {
it("covers WebSockets to the linked server", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
headers: {},
expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({
rejectUnauthorized: false,
});
});
@@ -58,7 +54,7 @@ describe("linked server certificate exception", () => {
it("keeps verifying when the link does not allow it", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({
rejectUnauthorized: true,
});
});
@@ -1,54 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { describe, expect, it } from "vitest";
const source = fs.readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const collector = source.slice(
source.indexOf("function collectLocalFilesFromPath("),
source.indexOf('ipcMain.handle("get-local-home-directory"'),
);
const handler = source.slice(
source.indexOf('ipcMain.handle("collect-local-files"'),
source.indexOf('ipcMain.handle("create-local-folder"'),
);
describe("local collection limit", () => {
it.each([9999, 10000, 10001])(
"reports truncation only when %s files exceed the limit",
(count) => {
let collect: (
_event: unknown,
paths: string[],
) => { success: boolean; files: unknown[]; truncated: boolean };
vm.runInNewContext(collector + handler, {
fs: {
lstatSync: (p: string) => ({
isSymbolicLink: () => false,
isFile: () => p !== "/root",
isDirectory: () => p === "/root",
size: 1,
birthtime: new Date(0),
mtime: new Date(0),
}),
readdirSync: () =>
Array.from({ length: count }, (_, i) => `file${i}`),
},
path: path.posix,
normalizeRelativeLocalPath: (p: string) => p,
ipcMain: {
handle: (_name: string, callback: typeof collect) => {
collect = callback;
},
},
});
const result = collect!(null, ["/root"]);
expect(result.success).toBe(true);
expect(result.files).toHaveLength(Math.min(10000, count));
expect(result.truncated).toBe(count > 10000);
},
);
});
File diff suppressed because it is too large. Load diff
@@ -1,36 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { buildRdpFile, validateNativeRdpOptions } =
require("../../../../electron/native-rdp.cjs") as {
buildRdpFile: (options: Record<string, unknown>) => string;
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
};
describe("native Windows RDP", () => {
it("creates a credential-prompting RDP file without a password", () => {
const result = buildRdpFile({
host: "server.example.com",
port: 3390,
username: "alice",
domain: "EXAMPLE",
password: "must-not-be-written",
});
expect(result).toContain("full address:s:server.example.com:3390");
expect(result).toContain("username:s:EXAMPLE\\alice");
expect(result).toContain("prompt for credentials:i:1");
expect(result).not.toContain("must-not-be-written");
});
it("rejects unsafe host and port values", () => {
expect(() =>
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
).not.toThrow();
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
expect(() =>
validateNativeRdpOptions({ host: "server", port: 70000 }),
).toThrow();
});
});
@@ -1,41 +0,0 @@
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { renameLocalPath } = createRequire(import.meta.url)(
"../../../../electron/rename-local-path.cjs",
);
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-rename-"));
});
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
describe("local rename", () => {
it("preserves both files on a name collision", () => {
const a = path.join(root, "a"),
b = path.join(root, "b");
fs.writeFileSync(a, "source");
fs.writeFileSync(b, "destination");
expect(() => renameLocalPath(a, b)).toThrow(/already exists/);
expect(fs.readFileSync(a, "utf8")).toBe("source");
expect(fs.readFileSync(b, "utf8")).toBe("destination");
});
it("refuses to replace even an empty destination directory", () => {
const a = path.join(root, "a"),
b = path.join(root, "b");
fs.mkdirSync(a);
fs.mkdirSync(b);
expect(() => renameLocalPath(a, b)).toThrow(/already exists/);
expect(fs.readdirSync(root).sort()).toEqual(["a", "b"]);
});
it("renames a file and handles an unchanged name", () => {
const a = path.join(root, "a"),
b = path.join(root, "b");
fs.writeFileSync(a, "source");
renameLocalPath(a, a);
renameLocalPath(a, b);
expect(fs.existsSync(a)).toBe(false);
expect(fs.readFileSync(b, "utf8")).toBe("source");
});
});
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
hosts: [] as Record<string, unknown>[],
credentials: [] as Record<string, unknown>[],
resolve: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: async () => state.hosts,
findHostByIdForUser: async (id: number) =>
state.hosts.find((host) => host.id === id) ?? null,
}),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => state.credentials,
}),
}));
vi.mock("../../../database/routes/host-normalizers.js", () => ({
hostTerminalExport: () => ({ terminalConfig: null, sshOptions: null }),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
loadHostPluginSettings: async () => new Map(),
}));
vi.mock("../../../hosts/protocol-auth/protocol-auth.js", () => ({
listProtocolLogins: async () => [],
toPortableLogins: () => ({
rdp: {
authType: "credential",
credentialId: 4,
username: "bob",
password: "rdp-secret",
fields: { domain: "corp", token: "t" },
},
}),
}));
vi.mock("../../../hosts/protocol-auth/registry.js", () => ({
findHostProtocol: () => ({
credentialFields: [{ key: "token", secret: true }, { key: "domain" }],
}),
}));
vi.mock("../../../hosts/resolve-host-credentials.js", () => ({
resolveHostCredentials: state.resolve,
}));
import { exportAllHosts } from "../../../hosts/import/host-export.js";
const sshHost = {
id: 1,
connectionType: "ssh",
name: "web",
ip: "192.0.2.1",
port: 22,
username: "alice",
password: "pw",
key: "KEY",
keyPassword: "kp",
sudoPassword: "sp",
socks5Password: "s5",
authType: "credential",
credentialId: 7,
folder: "Prod",
tags: "a,b",
jumpHosts: JSON.stringify([{ hostId: 2 }]),
forceKeyboardInteractive: "true",
};
beforeEach(() => {
state.hosts = [{ ...sshHost }];
state.credentials = [
{ id: 7, name: "deploy", authType: "key", username: "alice", tags: "x" },
];
state.resolve.mockReset().mockImplementation(async (host) => ({
...host,
password: "resolved-pw",
key: "RESOLVED-KEY",
}));
});
describe("exportAllHosts", () => {
it("includes decrypted secrets without share mode", async () => {
const result = await exportAllHosts("u1", false);
expect(result.credentials).toBeUndefined();
expect(result.hosts[0]).toMatchObject({
exportId: 1,
password: "resolved-pw",
key: "RESOLVED-KEY",
sudoPassword: "sp",
credentialId: 7,
tags: ["a", "b"],
jumpHosts: [{ hostId: 2 }],
forceKeyboardInteractive: true,
});
expect(state.resolve).toHaveBeenCalled();
});
it("leaves out every secret and credential id in share mode", async () => {
const result = await exportAllHosts("u1", true);
const [host] = result.hosts;
expect(state.resolve).not.toHaveBeenCalled();
expect(host).toMatchObject({
password: null,
key: null,
keyPassword: null,
sudoPassword: null,
socks5Password: null,
credentialAlias: "deploy",
});
expect(host).not.toHaveProperty("credentialId");
expect(result.credentials).toEqual([
expect.objectContaining({ alias: "deploy", authType: "key" }),
]);
expect(JSON.stringify(result)).not.toMatch(/rdp-secret|"t"/);
expect(host.protocolAuth).toMatchObject({
rdp: {
authType: "direct",
credentialId: null,
password: null,
fields: { domain: "corp" },
},
});
expect(result.version).toBe("1");
});
it("exports a remote desktop host without SSH fields", async () => {
state.hosts = [{ ...sshHost, connectionType: "rdp" }];
const result = await exportAllHosts("u1", false);
expect(result.hosts[0]).not.toHaveProperty("authType");
expect(result.hosts[0]).toHaveProperty("protocolAuth");
});
});
describe("exportAllHosts with hostIds", () => {
it("exports only the asked hosts the user owns", async () => {
state.hosts = [
{ ...sshHost, id: 1 },
{ ...sshHost, id: 2 },
];
const result = await exportAllHosts("u1", false, [2, 99]);
expect(result.hosts.map((host) => host.exportId)).toEqual([2]);
});
it("returns nothing for an id the user does not own", async () => {
const result = await exportAllHosts("u1", false, [99]);
expect(result.hosts).toEqual([]);
});
});
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
list: vi.fn(),
applyDefaults: vi.fn(async () => {}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: vi.fn(),
setHostPluginEnabled: vi.fn(),
}));
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
applyDefaultsAfterHostWrites: mocks.applyDefaults,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({ listHosts: async () => [] }),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => [],
}),
createCurrentHostRepository: () => ({
createEncryptedForUser: mocks.create,
updateEncryptedForUser: mocks.update,
deleteForUser: mocks.remove,
}),
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: mocks.list,
}),
}));
import {
ImportError,
runBulkImport,
runSshConfigImport,
} from "../../../hosts/import/host-import.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
const good = {
ip: "192.0.2.1",
port: 22,
username: "alice",
authType: "none",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.list.mockResolvedValue([]);
mocks.remove.mockResolvedValue({ syncId: null });
});
describe("runBulkImport", () => {
it("creates hosts, returns their ids and tells listeners", async () => {
mocks.create.mockResolvedValueOnce({ id: 11 });
const created: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (p) => created.push(p));
try {
const result = await runBulkImport("u1", { hosts: [good] });
expect(result).toMatchObject({ success: 1, failed: 0 });
expect(result.createdHostIds).toEqual([11]);
expect(mocks.applyDefaults).toHaveBeenCalledWith([11]);
} finally {
off();
}
expect(created).toEqual([{ hostId: 11, userId: "u1" }]);
});
it("refuses an empty list and more than 100 hosts", async () => {
await expect(runBulkImport("u1", { hosts: [] })).rejects.toBeInstanceOf(
ImportError,
);
await expect(
runBulkImport("u1", {
hosts: Array.from({ length: 101 }, () => ({ ...good })),
}),
).rejects.toThrow(/Maximum 100/);
});
it("keeps the rows that worked when atomic is off", async () => {
mocks.create.mockResolvedValueOnce({ id: 11 });
const result = await runBulkImport("u1", {
hosts: [good, { ...good, ip: "" }],
});
expect(result).toMatchObject({ success: 1, failed: 1 });
expect(mocks.remove).not.toHaveBeenCalled();
});
it("deletes what it created and stays quiet when atomic and a row fails", async () => {
mocks.create
.mockResolvedValueOnce({ id: 11 })
.mockResolvedValueOnce({ id: 12 });
const events: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (p) => events.push(p));
try {
const result = await runBulkImport("u1", {
hosts: [good, { ...good, ip: "192.0.2.2" }, { ...good, ip: "" }],
atomic: true,
});
expect(result.rolledBack).toBe(true);
expect(result.success).toBe(0);
expect(result.createdHostIds).toEqual([]);
expect(result.failed).toBe(1);
} finally {
off();
}
expect(mocks.remove.mock.calls.map((call) => call[1])).toEqual([12, 11]);
expect(mocks.applyDefaults).not.toHaveBeenCalled();
expect(events).toEqual([]);
});
it("refuses overwrite together with atomic", async () => {
await expect(
runBulkImport("u1", { hosts: [good], overwrite: true, atomic: true }),
).rejects.toThrow(/cannot overwrite/);
});
});
describe("runSshConfigImport", () => {
const config = "Host web\n HostName 192.0.2.9\n User bob\n";
it("imports Host blocks", async () => {
mocks.create.mockResolvedValueOnce({ id: 31 });
const result = await runSshConfigImport("u1", { content: config });
expect(result).toMatchObject({ success: 1, createdHostIds: [31] });
});
it("rejects empty content", async () => {
await expect(
runSshConfigImport("u1", { content: " " }),
).rejects.toBeInstanceOf(ImportError);
});
});
@@ -1,312 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: vi.fn(),
getCurrentSettingValue: vi.fn(() => null),
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { HostStatusService, toStatusTarget } =
await import("../../../hosts/status/host-status-service.js");
const { hostSessionStatus } =
await import("../../../hosts/host-session-status.js");
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
type Target = ReturnType<typeof toStatusTarget>;
function target(id: number, extra: Partial<Target> = {}): Target {
return {
id,
userId: "owner",
ip: `10.0.0.${id}`,
port: 22,
connectionType: "ssh",
jumpHosts: [],
statusCheckEnabled: true,
statusCheckInterval: null,
...extra,
};
}
function setup(
targets: Target[],
reachable = true,
shared: Record<string, number[]> = {},
) {
const emitted: unknown[] = [];
const ping = vi.fn(async () => reachable);
const pingThroughJumpHosts = vi.fn(async () => reachable);
const loadTargets = vi.fn(
async (filter: { userId?: string; hostIds?: number[] }) =>
targets.filter(
(t) =>
(!filter.userId || t.userId === filter.userId) &&
(!filter.hostIds || filter.hostIds.includes(t.id)),
),
);
const service = new HostStatusService({
loadTargets,
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
retryDelayMs: 0,
});
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
let active: InstanceType<typeof HostStatusService> | null = null;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
});
afterEach(() => {
active?.stop();
active = null;
pluginEvents.clear();
vi.useRealTimers();
});
describe("HostStatusService", () => {
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
"skips probes while a session is open, including jump hosts: %j",
async ({ jumpHosts }) => {
const { service, ping, pingThroughJumpHosts } = setup([
target(7, { jumpHosts }),
]);
active = service;
const close = hostSessionStatus.register(7);
try {
await service.statusesFor("owner", null);
await flush();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(service.get(7)?.status).toBe("online");
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
close();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(
jumpHosts.length ? pingThroughJumpHosts : ping,
).toHaveBeenCalledOnce();
} finally {
close();
}
},
);
it("starts a user's own hosts and reports them online", async () => {
const { service, emitted, ping } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 22);
expect(service.get(1)?.status).toBe("online");
expect(emitted).toContainEqual({
hostId: 1,
ownerUserId: "owner",
status: "online",
previous: null,
online: true,
});
});
it("checks a host shared with the user before its owner ever asks", async () => {
const { service, ping } = setup([target(5)], true, { recipient: [5] });
active = service;
await service.statusesFor("recipient", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.5", 22);
expect(service.get(5)?.status).toBe("online");
});
it("only starts the shared hosts a desktop request names", async () => {
const { service, ping } = setup([target(5), target(6)], true, {
recipient: [5, 6],
});
active = service;
await service.statusesFor("recipient", new Set([6]));
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.6", 22);
expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22);
});
it("reports offline for a host that does not answer", async () => {
const { service } = setup([target(1)], false);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(service.get(1)?.status).toBe("offline");
});
it("never checks a host with status checks off", async () => {
const { service, ping } = setup([target(1, { statusCheckEnabled: false })]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).not.toHaveBeenCalled();
expect(service.get(1)).toBeNull();
});
it("goes through the jump hosts when a host has them", async () => {
const { service, ping, pingThroughJumpHosts } = setup([
target(1, { jumpHosts: [{ hostId: 9 }] }),
]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).toHaveBeenCalledOnce();
});
it("asks a registered plugin for a protocol's port", async () => {
const { service, ping } = setup([target(1, { connectionType: "rdp" })]);
active = service;
service.registerPort("rdp", async () => 3390);
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390);
});
it("falls back to the host's port when no plugin knows the protocol", async () => {
const { service, ping } = setup([
target(1, { connectionType: "rdp", port: 3389 }),
]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
});
it("ignores a failed login and counts a working one as online", async () => {
const { service, emitted } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
const before = emitted.length;
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("online");
expect(emitted).toHaveLength(before);
});
it("marks a host online when a terminal session opens", async () => {
const { service } = setup([target(1)], false);
active = service;
service.start();
await service.statusesFor("owner", null);
expect(service.get(1)?.status).toBe("offline");
const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online");
release();
});
it("tries a failed ping again before reporting offline", async () => {
const { service, ping } = setup([target(1)]);
active = service;
ping.mockResolvedValueOnce(false);
await service.statusesFor("owner", null);
expect(ping).toHaveBeenCalledTimes(2);
expect(service.get(1)?.status).toBe("online");
});
it("answers the first request with the first checks done", async () => {
const { service } = setup([target(1), target(2)]);
active = service;
const statuses = await service.statusesFor("owner", null);
expect(statuses.get(1)?.status).toBe("online");
expect(statuses.get(2)?.status).toBe("online");
});
it("only checks the requested hosts on the desktop app", async () => {
const { service, ping } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", new Set([2]));
await flush();
expect(ping).toHaveBeenCalledTimes(1);
expect(ping).toHaveBeenCalledWith("10.0.0.2", 22);
});
it("checks again on its interval", async () => {
const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(15_000);
await flush();
expect(ping).toHaveBeenCalledTimes(2);
});
it("checks a host on demand unless the last result is fresh", async () => {
const { service, ping } = setup([target(1)]);
active = service;
expect(await service.check(1)).toMatchObject({ status: "online" });
expect(await service.check(1)).toMatchObject({ status: "online" });
expect(ping).toHaveBeenCalledTimes(1);
});
it("forgets a deleted host", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 });
expect(service.get(1)).toBeNull();
});
it("never logs in to check a host", async () => {
// The only transports are the two ping functions: nothing here can
// authenticate, so a RADIUS or Duo backed host gets no 2FA push.
const { service, ping, pingThroughJumpHosts } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]);
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
});
});
describe("toStatusTarget", () => {
it("parses the jump hosts and defaults the connection type", () => {
expect(
toStatusTarget({
id: 1,
userId: "u",
ip: "h",
port: 22,
connectionType: "",
jumpHosts: '[{"hostId":3},{"bad":1}]',
statusCheckEnabled: false,
statusCheckInterval: 30,
}),
).toEqual({
id: 1,
userId: "u",
ip: "h",
port: 22,
connectionType: "ssh",
jumpHosts: [{ hostId: 3 }],
statusCheckEnabled: false,
statusCheckInterval: 30,
});
});
});
@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
"Host denied (verification failed)",
"Host key changed - please connect via Terminal to verify the new key",
])("classifies %s", (message) => {
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
});
it("does not classify ordinary authentication failures", () => {
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
false,
);
});
});
@@ -1,162 +0,0 @@
import { describe, expect, it } from "vitest";
import { ConcurrentLimiter } from "../../../hosts/status/limiter.js";
describe("ConcurrentLimiter", () => {
it("never exceeds max concurrent runners", async () => {
const limiter = new ConcurrentLimiter(2);
let peak = 0;
let current = 0;
const job = async () => {
current += 1;
peak = Math.max(peak, current);
await new Promise((r) => setTimeout(r, 30));
current -= 1;
};
await Promise.all([
limiter.run(job),
limiter.run(job),
limiter.run(job),
limiter.run(job),
]);
expect(peak).toBeLessThanOrEqual(2);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("runs waiters in FIFO order after a slot frees", async () => {
const limiter = new ConcurrentLimiter(1);
const order: number[] = [];
const first = limiter.run(async () => {
order.push(1);
await new Promise((r) => setTimeout(r, 20));
});
const second = limiter.run(async () => {
order.push(2);
});
const third = limiter.run(async () => {
order.push(3);
});
await Promise.all([first, second, third]);
expect(order).toEqual([1, 2, 3]);
});
it("rejects invalid maxConcurrent", () => {
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
});
describe("setLimit", () => {
it("releases queued waiters as soon as the ceiling is raised", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(1);
expect(limiter.pendingCount).toBe(3);
// Widening must drain the backlog without waiting for the running job.
limiter.setLimit(4);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
expect(limiter.pendingCount).toBe(0);
release.forEach((fn) => fn());
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
});
it("does not over-release beyond the new ceiling", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
// Later waves of woken jobs enqueue their own resolvers, so draining has
// to keep going until nothing is left rather than flushing a snapshot.
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
limiter.setLimit(3);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(3);
expect(limiter.pendingCount).toBe(2);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("lets running work finish when the ceiling shrinks", async () => {
const limiter = new ConcurrentLimiter(4);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
// Shrinking never kills in-flight work; it applies to later releases.
limiter.setLimit(2);
expect(limiter.activeCount).toBe(4);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
// The two queued jobs ran only after the shrink, so they never pushed
// occupancy back up to the old width.
expect(peak).toBe(4);
});
it("rejects an invalid new limit", () => {
const limiter = new ConcurrentLimiter(2);
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
expect(limiter.limit).toBe(2);
});
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
const access = vi.hoisted(() => ({ owned: true, shared: [] as number[] }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
isHostOwnedByUser: async () => access.owned,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentRbacAccessRepository: () => ({
listVisibleHostAccessEntries: async () =>
access.shared.map((hostId) => ({ hostId })),
}),
}));
import {
provideActivity,
recentActivity,
type ActivityProvider,
} from "../../plugins/activity-provider.js";
function fake(): ActivityProvider {
return {
record: vi.fn(async () => ({ status: "logged" as const, id: 1 })),
list: vi.fn(async () => []),
deleteByUserId: vi.fn(async () => {}),
deleteByHostIds: vi.fn(async () => {}),
};
}
const entry = { type: "ssh", hostId: 1, hostName: "h" };
describe("recent activity provider", () => {
it("is unavailable with no provider", async () => {
expect(await recentActivity.record("u", entry)).toEqual({
status: "unavailable",
});
expect(await recentActivity.list("u", 5)).toEqual([]);
await expect(recentActivity.deleteByUserId("u")).resolves.toBeUndefined();
await expect(recentActivity.deleteByHostId(7)).resolves.toBeUndefined();
});
it("routes to the plugin and back to nothing when it goes", async () => {
const plugin = fake();
const remove = provideActivity("homepage", plugin);
await recentActivity.record("u", entry);
await recentActivity.deleteByHostId(7);
expect(plugin.record).toHaveBeenCalledOnce();
expect(plugin.deleteByHostIds).toHaveBeenCalledWith([7]);
remove();
expect(await recentActivity.list("u", 5)).toEqual([]);
expect(plugin.list).not.toHaveBeenCalled();
});
it("checks host access before the provider records", async () => {
const plugin = fake();
const remove = provideActivity("homepage", plugin);
access.owned = false;
access.shared = [];
expect(await recentActivity.record("u", entry)).toEqual({
status: "denied",
});
expect(plugin.record).not.toHaveBeenCalled();
access.shared = [1];
expect((await recentActivity.record("u", entry)).status).toBe("logged");
remove();
access.owned = true;
});
it("refuses a second provider", () => {
const remove = provideActivity("one", fake());
expect(() => provideActivity("two", fake())).toThrow(
/already provided by plugin "one"/,
);
remove();
});
});
+118 -71
View File
@@ -1,112 +1,159 @@
/**
* ctx.audit.record: a plugin writes an audit line under its own action name,
* and the user it names is always the runtime's actor, never one the plugin
* picked.
* ctx.audit.subscribe, list and exportBatches: audit:read gates reading, and
* subscribers see lines after they are accepted without delaying the writer.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const auditEntries: Array<Record<string, unknown>> = [];
const grants = new Map<string, string[]>();
const rows = vi.hoisted(() => ({ list: [] as Array<Record<string, unknown>> }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
createCurrentAuditLogRepository: () => ({
listPage: async ({ limit }: { limit: number }) => ({
logs: rows.list.slice(0, limit),
total: rows.list.length,
}),
listForExport: async ({
limit,
offset,
}: {
limit: number;
offset: number;
}) => rows.list.slice(offset, offset + limit),
create: async () => {},
}),
createCurrentUserRepository: () => ({ findById: async () => null }),
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { runAsActor } from "../../plugins/actor.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { emitAuditEntry } from "../../utils/audit-hooks.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string) {
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: "Audit Fixture",
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
return { ctx: createPluginContext(manifest, handle), handle };
}
const line = (id: number) => ({
id,
timestamp: new Date(1_700_000_000_000 + id),
userId: null,
username: "root",
action: "login",
resourceType: "user",
resourceId: null,
resourceName: null,
details: null,
ipAddress: null,
userAgent: null,
success: true,
errorMessage: null,
});
beforeEach(() => {
auditEntries.length = 0;
grants.clear();
invalidatePluginPermissionCache();
rows.list = [line(1), line(2), line(3)];
});
describe("ctx.audit.record", () => {
it("writes the plugin's action for the acting user", async () => {
const ctx = contextFor("audit-fixture");
await runAsActor("user-3", "request", () =>
ctx.audit.record({
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
success: true,
}),
describe("ctx.audit reading", () => {
it("refuses list and export without audit:read", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.audit.list()).rejects.toThrow(/audit:read/);
await expect(ctx.audit.exportBatches().next()).rejects.toThrow(
/audit:read/,
);
expect(auditEntries).toEqual([
expect.objectContaining({
userId: "user-3",
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
details: "via plugin audit-fixture",
success: true,
}),
]);
expect(() => ctx.audit.subscribe(() => {})).toThrow(/audit:read/);
});
it("falls back to the plugin as the resource and system as the user", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({ action: "cleanup", success: false });
expect(auditEntries[0]).toMatchObject({
it("lists stored lines", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const page = await ctx.audit.list({ limit: 2 });
expect(page.total).toBe(3);
expect(page.entries.map((entry) => entry.id)).toEqual([1, 2]);
expect(page.entries[0].timestamp).toMatch(/^20\d\d-/);
});
it("exports in batches", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const sizes: number[] = [];
for await (const batch of ctx.audit.exportBatches({}, 2)) {
sizes.push(batch.length);
}
expect(sizes).toEqual([2, 1]);
});
it("delivers new lines to a subscriber until it unsubscribes", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const seen: string[] = [];
const stop = ctx.audit.subscribe((entry) => seen.push(entry.action));
await new Promise((resolve) => setTimeout(resolve, 20));
emitAuditEntry({
userId: null,
username: "system",
resourceType: "plugin",
resourceId: "audit-fixture",
resourceName: "Audit Fixture",
success: false,
});
});
});
describe("ctx.audit.record with a request", () => {
it("records the request's IP address and user agent", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_room_end",
username: "root",
action: "one",
resourceType: "x",
success: true,
request: {
ip: "203.0.113.4",
headers: { "user-agent": "TestAgent/1.0" },
},
});
expect(auditEntries[0]).toMatchObject({
ipAddress: "203.0.113.4",
userAgent: "TestAgent/1.0",
await new Promise((resolve) => setTimeout(resolve, 5));
stop();
emitAuditEntry({
userId: null,
username: "root",
action: "two",
resourceType: "x",
success: true,
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(seen).toEqual(["one"]);
});
it("falls back to the socket address on an upgrade request", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_join",
it("stops delivering when the plugin deactivates", async () => {
grants.set("demo", ["audit:read"]);
const { ctx, handle } = contextFor("demo", ["audit:read"]);
const seen: string[] = [];
ctx.audit.subscribe((entry) => seen.push(entry.action));
await new Promise((resolve) => setTimeout(resolve, 20));
await handle.bag.disposeAll();
emitAuditEntry({
userId: null,
username: "root",
action: "late",
resourceType: "x",
success: true,
request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } },
});
expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(seen).toEqual([]);
});
});
@@ -0,0 +1,179 @@
/**
* ctx.credentials.publicKey and resolveKeyRefs, and the capability gate on
* ctx.ssh.exec.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import ssh2 from "ssh2";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
permissions: new Set<string>(["credentials.view"]),
credential: null as Record<string, unknown> | null,
resolve: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentHostResolutionRepository: () => ({
findCredentialByIdForUser: async () => state.credential,
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_user: string, name: string) =>
state.permissions.has(name),
}),
},
}));
vi.mock("../../hosts/stored-ssh-key.js", () => ({
resolveKeyReferences: state.resolve,
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
return createPluginContext(
manifest,
createPluginHandle("demo", { activate: () => {} }),
);
}
const asUser = <T>(fn: () => Promise<T>) => runAsActor("user-1", "request", fn);
const pair = ssh2.utils.generateKeyPairSync("ed25519");
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.permissions = new Set(["credentials.view"]);
state.credential = null;
state.resolve.mockReset();
});
describe("ctx.credentials.publicKey", () => {
it("refuses without credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow(
/credentials:use/,
);
});
it("refuses when the user lacks credentials.view", async () => {
grants.set("demo", ["credentials:use"]);
state.permissions = new Set();
const ctx = contextFor(["credentials:use"]);
await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow(
/credentials.view/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("returns null for a missing credential or one that is not a key", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull();
state.credential = { id: 1, authType: "password" };
expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull();
});
it("returns the stored public key", async () => {
grants.set("demo", ["credentials:use"]);
state.credential = {
id: 1,
authType: "key",
publicKey: " ssh-ed25519 AAA ",
};
const ctx = contextFor(["credentials:use"]);
expect(await asUser(() => ctx.credentials.publicKey(1))).toBe(
"ssh-ed25519 AAA",
);
});
it("derives it from the private key behind a reference", async () => {
grants.set("demo", ["credentials:use"]);
state.credential = { id: 1, authType: "key", key: "op://vault/key" };
state.resolve.mockResolvedValue({ key: pair.private });
const ctx = contextFor(["credentials:use"]);
const result = await asUser(() => ctx.credentials.publicKey(1));
expect(result).toMatch(/^ssh-ed25519 /);
expect(state.resolve).toHaveBeenCalledWith(
"user-1",
"op://vault/key",
null,
);
});
it("rejects a bad id", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(asUser(() => ctx.credentials.publicKey(0))).rejects.toThrow(
/credential id/,
);
});
});
describe("ctx.credentials.resolveKeyRefs", () => {
it("refuses with only credentials:use", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(
asUser(() => ctx.credentials.resolveKeyRefs("op://a")),
).rejects.toThrow(/credentials:read/);
expect(state.resolve).not.toHaveBeenCalled();
});
it("resolves for the acting user and audits", async () => {
grants.set("demo", ["credentials:read"]);
state.resolve.mockResolvedValue({ key: "KEY", passphrase: "PW" });
const ctx = contextFor(["credentials:read"]);
const result = await asUser(() =>
ctx.credentials.resolveKeyRefs("op://a", "op://b"),
);
expect(result).toEqual({ key: "KEY", passphrase: "PW" });
expect(state.resolve).toHaveBeenCalledWith("user-1", "op://a", "op://b");
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_resolve_key",
success: true,
});
});
});
describe("ctx.ssh.exec", () => {
it("refuses without ssh:connect and credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(asUser(() => ctx.ssh.exec(1, "id"))).rejects.toThrow(
/ssh:connect/,
);
});
});
@@ -0,0 +1,248 @@
/**
* ctx.desktop.local: desktop:local is checked and audited, and nothing
* touches the machine unless the backend runs embedded in the desktop app.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const bridge = vi.hoisted(() => ({
requestFromElectronMain: vi.fn(async () => ({ success: true })),
}));
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
isElectronIpcAvailable: () => process.env.ELECTRON_EMBEDDED === "true",
requestFromElectronMain: bridge.requestFromElectronMain,
}));
vi.mock("../../sync/client/link-store.js", () => ({
getLink: async () => ({
serverUrl: "https://termix.example.test",
sessionToken: "token",
}),
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
}
let dir: string;
const previous = process.env.ELECTRON_EMBEDDED;
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
bridge.requestFromElectronMain.mockClear();
process.env.ELECTRON_EMBEDDED = "true";
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-local-"));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
if (previous === undefined) delete process.env.ELECTRON_EMBEDDED;
else process.env.ELECTRON_EMBEDDED = previous;
});
describe("ctx.desktop.local", () => {
it("refuses without desktop:local and audits the refusal", async () => {
grants.set("demo", []);
const ctx = contextFor("demo", []);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
/desktop:local/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("refuses when granted but not declared", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", []);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
/desktop:local/,
);
});
it("refuses outside the desktop app even with the capability", async () => {
grants.set("demo", ["desktop:local"]);
process.env.ELECTRON_EMBEDDED = "false";
const ctx = contextFor("demo", ["desktop:local"]);
expect(ctx.desktop.local.available()).toBe(false);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(/desktop app/);
});
it("lists and audits once per action", async () => {
grants.set("demo", ["desktop:local"]);
fs.writeFileSync(path.join(dir, "a.txt"), "hi");
const ctx = contextFor("demo", ["desktop:local"]);
const first = await ctx.desktop.local.fs.list(dir);
await ctx.desktop.local.fs.list(dir);
expect(first.entries.map((entry) => entry.name)).toEqual(["a.txt"]);
expect(
auditEntries.filter(
(entry) => entry.action === "plugin_desktop_local_fs",
),
).toHaveLength(1);
});
it("rejects relative paths and unsafe names", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
await expect(ctx.desktop.local.fs.list("relative/path")).rejects.toThrow(
/absolute/,
);
await expect(ctx.desktop.local.fs.mkdir(dir, "../evil")).rejects.toThrow(
/Invalid/,
);
});
it("never renames onto an existing name", async () => {
grants.set("demo", ["desktop:local"]);
fs.writeFileSync(path.join(dir, "a"), "1");
fs.writeFileSync(path.join(dir, "b"), "2");
const ctx = contextFor("demo", ["desktop:local"]);
await expect(
ctx.desktop.local.fs.rename(path.join(dir, "a"), "b"),
).rejects.toThrow(/already exists/);
expect(fs.readFileSync(path.join(dir, "b"), "utf8")).toBe("2");
});
it("keeps downloads inside the chosen folder and publishes on success", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
await expect(
ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "..", "escape.txt"),
transferId: "t1",
}),
).rejects.toThrow(/outside/);
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "ok.txt"),
transferId: "t2",
});
download.stream.write("data");
expect(await download.publish()).toBe(path.join(dir, "ok.txt"));
expect(fs.readFileSync(path.join(dir, "ok.txt"), "utf8")).toBe("data");
expect(fs.readdirSync(dir)).toEqual(["ok.txt"]);
});
it("removes the partial file when a download is aborted", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "gone.txt"),
transferId: "t3",
});
download.stream.write("partial");
await download.abort();
expect(fs.readdirSync(dir)).toEqual([]);
});
it("sends trash and open through Electron main and audits each call", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
const target = path.join(dir, "x.txt");
fs.writeFileSync(target, "x");
await expect(ctx.desktop.local.fs.trash([target])).resolves.toEqual({
trashed: 1,
failed: [],
});
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
"shell-trash-item",
{ path: target },
);
await ctx.desktop.local.fs.open(target);
await ctx.desktop.local.fs.open(target);
expect(
auditEntries.filter(
(entry) => entry.action === "plugin_desktop_local_open",
),
).toHaveLength(2);
});
it("only calls the plugin's own routes", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
for (const bad of [
"relative",
"//evil.example/x",
"/../other",
"/a/../../b",
String.raw`/a\b`,
]) {
await expect(ctx.desktop.local.pluginFetch("local", bad)).rejects.toThrow(
/Invalid plugin route path/,
);
}
await expect(
ctx.desktop.local.pluginFetch("elsewhere" as never, "/ok"),
).rejects.toThrow(/Unknown origin|No acting user/);
});
it("reports the link and the embedded backend port", async () => {
grants.set("demo", ["desktop:local"]);
process.env.EMBEDDED_BACKEND_PORT = "31234";
const ctx = contextFor("demo", ["desktop:local"]);
const info = await ctx.desktop.local.info();
expect(info.backendPort).toBe(31234);
expect(info.link).toMatchObject({
serverUrl: "https://termix.example.test",
token: "token",
allowInvalidCertificate: false,
});
expect(info.link?.headers.Cookie).toBe("jwt=token");
delete process.env.EMBEDDED_BACKEND_PORT;
});
});
@@ -0,0 +1,264 @@
/**
* ctx.hosts.importMany, importSshConfig, exportMany, registerImportNormalizer:
* capability checks, core permission checks, audit lines and the registry key.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
permissions: new Set<string>(["hosts.create", "hosts.edit"]),
}));
const importer = vi.hoisted(() => ({
runBulkImport: vi.fn(),
runSshConfigImport: vi.fn(),
exportAllHosts: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_user: string, name: string) =>
state.permissions.has(name),
}),
},
}));
vi.mock("../../hosts/import/host-import.js", () => ({
runBulkImport: importer.runBulkImport,
runSshConfigImport: importer.runSshConfigImport,
}));
vi.mock("../../hosts/import/host-export.js", () => ({
exportAllHosts: importer.exportAllHosts,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { batched: (fn: () => unknown) => fn },
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import * as registry from "../../plugins/registry.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
const asUser = <T>(fn: () => Promise<T>) => runAsActor("user-1", "request", fn);
const outcome = {
success: 1,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [5],
};
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.permissions = new Set(["hosts.create", "hosts.edit"]);
importer.runBulkImport.mockReset().mockResolvedValue(outcome);
importer.runSshConfigImport.mockReset().mockResolvedValue(outcome);
importer.exportAllHosts.mockReset().mockResolvedValue({ hosts: [{ id: 1 }] });
});
describe("ctx.hosts.importMany", () => {
it("refuses without hosts:write", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).rejects.toThrow(/hosts:write/);
expect(importer.runBulkImport).not.toHaveBeenCalled();
});
it("needs credentials:write only when credentials are passed", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).resolves.toMatchObject({ success: 1 });
await expect(
asUser(() =>
ctx.hosts.importMany([{ ip: "a" }], { credentials: [{ alias: "k" }] }),
),
).rejects.toThrow(/credentials:write/);
});
it("refuses when the acting user lacks the core permissions", async () => {
grants.set("demo", ["hosts:write"]);
state.permissions = new Set(["hosts.create"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).rejects.toThrow(/hosts.edit/);
expect(importer.runBulkImport).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_hosts_import",
success: false,
});
});
it("needs an acting user", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(ctx.hosts.importMany([{ ip: "a" }])).rejects.toThrow(
/acting user/,
);
});
it("passes the options through and audits the counts", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
const result = await asUser(() =>
ctx.hosts.importMany([{ ip: "a" }], { atomic: true }),
);
expect(result.createdHostIds).toEqual([5]);
expect(importer.runBulkImport).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ atomic: true, hosts: [{ ip: "a" }] }),
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_hosts_import",
success: true,
});
});
it("surfaces the importer's request errors and audits the failure", async () => {
grants.set("demo", ["hosts:write"]);
importer.runBulkImport.mockRejectedValue(new Error("Maximum 100 hosts"));
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(asUser(() => ctx.hosts.importMany([]))).rejects.toThrow(
/Maximum 100/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
});
describe("ctx.hosts.importSshConfig", () => {
it("refuses without hosts:write and runs with it", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
asUser(() => ctx.hosts.importSshConfig("Host a")),
).rejects.toThrow(/hosts:write/);
grants.set("demo", ["hosts:write"]);
invalidatePluginPermissionCache();
const { ctx: allowed } = contextFor("demo", ["hosts:write"]);
await asUser(() => allowed.hosts.importSshConfig("Host a"));
expect(importer.runSshConfigImport).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ content: "Host a" }),
);
});
});
describe("ctx.hosts.exportMany", () => {
it("refuses with only hosts:write and audits the refusal", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(asUser(() => ctx.hosts.exportMany())).rejects.toThrow(
/hosts:export/,
);
expect(importer.exportAllHosts).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("exports with secrets by default and audits it", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await expect(asUser(() => ctx.hosts.exportMany())).resolves.toEqual({
hosts: [{ id: 1 }],
});
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
false,
undefined,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_export_hosts",
success: true,
});
expect(String(auditEntries.at(-1)?.details)).toMatch(/with secrets/);
});
it("passes share mode through", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await asUser(() => ctx.hosts.exportMany({ share: true }));
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
true,
undefined,
);
expect(String(auditEntries.at(-1)?.details)).toMatch(/without secrets/);
});
it("passes the host ids filter through", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await asUser(() => ctx.hosts.exportMany({ hostIds: [4, 5] }));
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
false,
[4, 5],
);
});
});
describe("ctx.hosts.registerImportNormalizer", () => {
it("needs hosts:write", () => {
const { ctx } = contextFor("demo", []);
expect(() => ctx.hosts.registerImportNormalizer(() => null)).toThrow(
/hosts:write/,
);
});
it("registers under the key the import path reads and removes it on deactivate", async () => {
const { ctx, handle } = contextFor("demo", ["hosts:write"]);
const normalizer = () => ({ a: 1 });
ctx.hosts.registerImportNormalizer(normalizer);
expect(registry.consume("demo.hostImportNormalizer")).toBe(normalizer);
await handle.bag.disposeAll();
expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined();
});
it("returns a remover", () => {
const { ctx } = contextFor("demo", ["hosts:write"]);
const remove = ctx.hosts.registerImportNormalizer(() => null);
remove();
expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined();
});
});
+10 -9
View File
@@ -110,14 +110,14 @@ vi.mock("../../database/repositories/factory.js", () => ({
listDecryptedByUserId: async () => h.ownedHosts,
}),
}));
vi.mock("../../services/recent-activity.js", () => ({
recordRecentActivity: async (
userId: string,
entry: Record<string, unknown>,
) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
vi.mock("../../plugins/activity-provider.js", () => ({
recentActivity: {
record: async (userId: string, entry: Record<string, unknown>) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
},
},
provideActivity: vi.fn(() => () => {}),
}));
vi.mock("../../hosts/host-session-status.js", () => ({
hostSessionStatus: {
@@ -127,8 +127,9 @@ vi.mock("../../hosts/host-session-status.js", () => ({
},
},
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
vi.mock("../../plugins/host-status-provider.js", () => ({
provideHostStatus: vi.fn(() => () => {}),
hostStatus: {
get: (hostId: number) =>
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
check: async (hostId: number) => ({
+182
View File
@@ -0,0 +1,182 @@
/**
* ctx.network.tcpProbe, ctx.ssh.probe and ctx.hosts.probeTargets: gated by
* capability, and only ever offered hosts the actor may connect to.
*/
import net from "node:net";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const state = vi.hoisted(() => ({
owned: [] as Array<Record<string, unknown>>,
shared: [] as Array<Record<string, unknown>>,
sharedIds: [] as number[],
allowed: new Set<number>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentHostResolutionRepository: () => ({
listStatusTargets: async (filter: {
userId?: string;
hostIds?: number[];
}) => {
const rows = filter.userId ? state.owned : state.shared;
return rows.filter(
(row) => !filter.hostIds || filter.hostIds.includes(row.id as number),
);
},
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentRbacAccessRepository: () => ({
listVisibleHostAccessEntries: async () =>
state.sharedIds.map((hostId) => ({ hostId })),
}),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
filterAccessibleHostIds: async (_user: string, ids: number[]) =>
new Set(ids.filter((id) => state.allowed.has(id))),
canAccessHost: async (_user: string, id: number) => ({
hasAccess: state.allowed.has(id),
}),
}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} }));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
return createPluginContext(
manifest,
createPluginHandle("demo", { activate: () => {} }),
);
}
const row = (id: number, userId: string) => ({
id,
userId,
ip: "127.0.0.1",
port: 22,
connectionType: "ssh",
jumpHosts: null,
statusCheckEnabled: true,
statusCheckInterval: null,
});
let server: net.Server;
let port = 0;
beforeEach(async () => {
grants.clear();
invalidatePluginPermissionCache();
state.owned = [row(1, "alice")];
state.shared = [row(2, "bob"), row(3, "bob")];
state.sharedIds = [2, 3];
state.allowed = new Set([2]);
server = net.createServer((socket) => socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
port = (server.address() as AddressInfo).port;
});
afterEach(() => {
server.close();
});
describe("ctx.network.tcpProbe", () => {
it("refuses without network:probe", async () => {
grants.set("demo", []);
await expect(
contextFor([]).network.tcpProbe("127.0.0.1", port),
).rejects.toThrow(/network:probe/);
});
it("reports an open port and a closed one", async () => {
grants.set("demo", ["network:probe"]);
const ctx = contextFor(["network:probe"]);
expect(await ctx.network.tcpProbe("127.0.0.1", port)).toBe(true);
await new Promise<void>((resolve) => server.close(() => resolve()));
expect(
await ctx.network.tcpProbe("127.0.0.1", port, { timeoutMs: 500 }),
).toBe(false);
});
it("rejects a bad port", async () => {
grants.set("demo", ["network:probe"]);
await expect(
contextFor(["network:probe"]).network.tcpProbe("127.0.0.1", 0),
).rejects.toThrow(/port/);
});
});
describe("ctx.hosts.probeTargets", () => {
it("refuses without hosts:read", async () => {
grants.set("demo", []);
await expect(contextFor([]).hosts.probeTargets()).rejects.toThrow(
/hosts:read/,
);
});
it("needs an acting user", async () => {
grants.set("demo", ["hosts:read"]);
await expect(
contextFor(["hosts:read"]).hosts.probeTargets(),
).rejects.toThrow(/acting user/);
});
it("returns own hosts and only shared hosts the actor may connect to", async () => {
grants.set("demo", ["hosts:read"]);
const ctx = contextFor(["hosts:read"]);
const targets = await runAsActor("alice", "request", () =>
ctx.hosts.probeTargets(),
);
expect(targets.map((target) => target.id)).toEqual([1, 2]);
expect(targets[1].ownerUserId).toBe("bob");
});
});
describe("ctx.ssh.probe", () => {
it("refuses without ssh:connect", async () => {
grants.set("demo", []);
await expect(contextFor([]).ssh.probe(1)).rejects.toThrow(/ssh:connect/);
});
it("answers false for a host the actor cannot connect to", async () => {
grants.set("demo", ["ssh:connect"]);
const ctx = contextFor(["ssh:connect"]);
expect(await runAsActor("alice", "request", () => ctx.ssh.probe(3))).toBe(
false,
);
});
it("probes a reachable host directly", async () => {
grants.set("demo", ["ssh:connect"]);
const ctx = contextFor(["ssh:connect"]);
expect(
await runAsActor("alice", "request", () => ctx.ssh.probe(2, { port })),
).toBe(true);
});
});
@@ -104,8 +104,8 @@ vi.mock("../../hosts/host-resolver.js", () => ({
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
vi.mock("../../plugins/host-status-provider.js", () => ({
hostStatus: {
reportLogin: (hostId: number, outcome: unknown) =>
h.logins.push({ hostId, outcome }),
},
@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runExec, type ExecClient } from "../../plugins/ctx-ssh-exec.js";
function fakeClient(
script: (stream: FakeStream) => void,
execError?: Error,
): ExecClient & { commands: string[] } {
const commands: string[] = [];
return {
commands,
exec: (command, callback) => {
commands.push(command);
if (execError) return callback(execError, undefined as never);
const stream = new FakeStream();
callback(undefined, stream as never);
script(stream);
},
};
}
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
written: string[] = [];
closed = false;
write(data: string) {
this.written.push(data);
}
end(data?: string) {
if (data !== undefined) this.written.push(data);
}
close() {
this.closed = true;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("runExec", () => {
it("collects output and the exit code", async () => {
const client = fakeClient((stream) => {
stream.emit("data", Buffer.from("out"));
stream.stderr.emit("data", Buffer.from("err"));
stream.emit("close", 3);
});
await expect(runExec(client, "echo hi")).resolves.toEqual({
stdout: "out",
stderr: "err",
code: 3,
truncated: false,
timedOut: false,
});
expect(client.commands).toEqual(["echo hi"]);
});
it("keeps only maxOutputBytes and says so", async () => {
const client = fakeClient((stream) => {
stream.emit("data", Buffer.alloc(3000, "a"));
stream.emit("close", 0);
});
const result = await runExec(client, "yes", { maxOutputBytes: 1024 });
expect(result.stdout).toHaveLength(1024);
expect(result.truncated).toBe(true);
});
it("writes stdin and closes it", async () => {
let seen: FakeStream | undefined;
const client = fakeClient((stream) => {
seen = stream;
stream.emit("close", 0);
});
await runExec(client, "cat >> f", { stdin: "key\n" });
expect(seen?.written).toEqual(["key\n"]);
});
it("stops a command that runs past the time limit", async () => {
vi.useFakeTimers();
let seen: FakeStream | undefined;
const client = fakeClient((stream) => {
seen = stream;
});
const pending = runExec(client, "sleep 999", { timeoutMs: 2000 });
await vi.advanceTimersByTimeAsync(2100);
const result = await pending;
expect(result).toMatchObject({ code: null, timedOut: true });
expect(seen?.closed).toBe(true);
});
it("rejects when the exec itself fails", async () => {
const client = fakeClient(() => {}, new Error("channel open failure"));
await expect(runExec(client, "ls")).rejects.toThrow(/channel open/);
});
it("rejects an empty or oversized command before running it", async () => {
const client = fakeClient(() => {});
await expect(runExec(client, " ")).rejects.toThrow(/required/);
await expect(runExec(client, "x".repeat(70_000))).rejects.toThrow(
/too long/,
);
expect(client.commands).toEqual([]);
});
});
@@ -0,0 +1,483 @@
/**
* The local file helpers behind ctx.desktop.local.fs. Ported from the old
* Electron local-files tests: path containment, link refusal, publish and
* replace semantics, and the exclusive rename.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertWithinRoot,
createEmptyFile,
defaultPublishFs,
ensureDirectory,
existingPaths,
listDirectory,
makeDirectory,
openDownload,
prepareDownloadPath,
publishDownload,
renameEntry,
renameLocalPath,
walkPaths,
type PublishFs,
} from "../../plugins/desktop-local/local-fs.js";
let root: string;
beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-desktop-local-"));
});
afterEach(() => fsp.rm(root, { recursive: true, force: true }));
function windowsLikeFs(
overrides: Partial<PublishFs> = {},
): PublishFs & { renames: Array<[string, string]> } {
const renames: Array<[string, string]> = [];
return {
...defaultPublishFs,
renames,
rename: async (from, to) => {
renames.push([from, to]);
if (fs.existsSync(to)) {
throw Object.assign(new Error("EEXIST: file already exists, rename"), {
code: "EEXIST",
});
}
await defaultPublishFs.rename(from, to);
},
...overrides,
};
}
describe("download destination containment", () => {
const win = path.win32;
const winRoot = "C:\\Downloads\\selected";
it("accepts ordinary nested destinations on Windows", () => {
expect(
assertWithinRoot(winRoot, "C:\\Downloads\\selected\\a.txt", win),
).toBe("C:\\Downloads\\selected\\a.txt");
expect(
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\docs\\2026\\report.pdf",
win,
),
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
expect(
assertWithinRoot(winRoot, "c:\\downloads\\SELECTED\\b.txt", win),
).toBe("c:\\downloads\\SELECTED\\b.txt");
});
it("rejects backslash traversal that normalises out of the root on Windows", () => {
expect(() =>
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\..\\outside.txt",
win,
),
).toThrow(/outside the selected folder/);
expect(() =>
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt",
win,
),
).toThrow(/outside the selected folder/);
expect(() => assertWithinRoot(winRoot, winRoot, win)).toThrow(
/outside the selected folder/,
);
});
it("rejects absolute, drive-qualified and UNC destinations on Windows", () => {
for (const dest of [
"C:\\Windows\\evil.dll",
"D:\\Downloads\\selected\\x.txt",
"\\\\server\\share\\x.txt",
"C:\\Downloads\\selected-other\\x.txt",
]) {
expect(() => assertWithinRoot(winRoot, dest, win)).toThrow(
/outside the selected folder/,
);
}
});
it("requires a root and rejects POSIX traversal too", () => {
expect(() => assertWithinRoot("", "/tmp/x", path.posix)).toThrow(
/download folder is required/,
);
expect(() =>
assertWithinRoot("/home/max/dl", "/home/max/dl/../x", path.posix),
).toThrow(/outside the selected folder/);
expect(
assertWithinRoot("/home/max/dl", "/home/max/dl/..\\x", path.posix),
).toBe("/home/max/dl/..\\x");
});
it("refuses an escaping download before any disk write", async () => {
const selected = path.join(root, "selected");
await fsp.mkdir(selected);
await expect(
openDownload({
rootPath: selected,
destPath: path.join(selected, "..", "outside.txt"),
transferId: "e1",
}),
).rejects.toMatchObject({ code: "EINVAL" });
await expect(
ensureDirectory(path.join(selected, "..", "escaped-dir"), selected),
).rejects.toMatchObject({ code: "EINVAL" });
await expect(
openDownload({
rootPath: "",
destPath: path.join(selected, "ok.bin"),
transferId: "e2",
}),
).rejects.toMatchObject({ code: "EINVAL" });
expect(await fsp.readdir(root)).toEqual(["selected"]);
expect(await fsp.readdir(selected)).toEqual([]);
});
});
describe("links inside a download tree", () => {
it("rejects existing linked parents for downloads and directory creation", async () => {
const selected = path.join(root, "selected");
const outside = path.join(root, "outside");
await fsp.mkdir(selected);
await fsp.mkdir(outside);
await fsp.symlink(outside, path.join(selected, "linked"), "junction");
await expect(
openDownload({
rootPath: selected,
destPath: path.join(selected, "linked", "file.bin"),
transferId: "l1",
}),
).rejects.toThrow(/cannot follow links/);
await expect(
ensureDirectory(path.join(selected, "linked", "new"), selected),
).rejects.toThrow(/cannot follow links/);
expect(await fsp.readdir(outside)).toEqual([]);
});
it("allows an explicitly selected linked root", async () => {
const actual = path.join(root, "actual");
const selected = path.join(root, "selected");
await fsp.mkdir(actual);
await fsp.symlink(actual, selected, "junction");
const download = await openDownload({
rootPath: selected,
destPath: path.join(selected, "nested", "file.bin"),
transferId: "l2",
});
download.stream.write("data");
await download.publish();
expect(
await fsp.readFile(path.join(actual, "nested", "file.bin"), "utf8"),
).toBe("data");
});
it("rejects a destination parent replaced by a link before publish", async () => {
const selected = path.join(root, "selected");
const nested = path.join(selected, "nested");
const outside = path.join(root, "outside");
await fsp.mkdir(nested, { recursive: true });
await fsp.mkdir(outside);
const download = await openDownload({
rootPath: selected,
destPath: path.join(nested, "file.bin"),
transferId: "l3",
});
download.stream.write("data");
await fsp.rename(nested, path.join(selected, "original"));
await fsp.symlink(outside, nested, "junction");
await expect(download.publish()).rejects.toThrow(/cannot follow links/);
expect(await fsp.readdir(outside)).toEqual([]);
});
it("prepareDownloadPath refuses a root that is not a directory", async () => {
const file = path.join(root, "file");
await fsp.writeFile(file, "x");
await expect(
prepareDownloadPath(file, path.join(file, "child")),
).rejects.toBeTruthy();
});
});
describe("downloads", () => {
it("refuses to replace an existing file by default", async () => {
const dest = path.join(root, "keep.bin");
await fsp.writeFile(dest, "original");
await expect(
openDownload({ rootPath: root, destPath: dest, transferId: "d1" }),
).rejects.toMatchObject({ code: "EEXIST" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect(await fsp.readdir(root)).toEqual(["keep.bin"]);
});
it("replaces an existing file only when overwrite is requested", async () => {
const dest = path.join(root, "replace.bin");
await fsp.writeFile(dest, "original");
const download = await openDownload({
rootPath: root,
destPath: dest,
transferId: "d2",
overwrite: true,
});
download.stream.write("fresh");
await download.publish();
expect(await fsp.readFile(dest, "utf8")).toBe("fresh");
expect(await fsp.readdir(root)).toEqual(["replace.bin"]);
});
it("refuses to publish over a file that appeared while it ran", async () => {
const dest = path.join(root, "race.bin");
const download = await openDownload({
rootPath: root,
destPath: dest,
transferId: "d3",
});
download.stream.write("mine");
await fsp.writeFile(dest, "someone else wrote this");
await expect(download.publish()).rejects.toMatchObject({ code: "EEXIST" });
expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this");
expect(await fsp.readdir(root)).toEqual(["race.bin"]);
});
it("uses a transfer-unique partial and rejects a concurrent download to the same destination", async () => {
const dest = path.join(root, "same.bin");
const first = await openDownload({
rootPath: root,
destPath: dest,
transferId: "first",
});
await expect(
openDownload({ rootPath: root, destPath: dest, transferId: "second" }),
).rejects.toMatchObject({ code: "EBUSY" });
expect(
(await fsp.readdir(root)).filter((n) => n.endsWith(".termix-part")),
).toEqual(["same.bin.first.termix-part"]);
first.stream.write("done");
await first.publish();
expect(await fsp.readdir(root)).toEqual(["same.bin"]);
// The destination is free again once the first one finished.
const again = await openDownload({
rootPath: root,
destPath: path.join(root, "same2.bin"),
transferId: "third",
});
await again.abort();
});
it("allows two downloads of different files side by side and aborts one cleanly", async () => {
const a = await openDownload({
rootPath: root,
destPath: path.join(root, "a.bin"),
transferId: "a",
});
const b = await openDownload({
rootPath: root,
destPath: path.join(root, "b.bin"),
transferId: "b",
});
a.stream.write("A");
b.stream.write("B");
await a.abort();
await b.publish();
expect(await fsp.readdir(root)).toEqual(["b.bin"]);
// Abort after publish is harmless.
await b.abort();
expect(await fsp.readdir(root)).toEqual(["b.bin"]);
});
});
describe("replace primitive (Windows-safe overwrite)", () => {
const seed = async (name: string, existing: string, fresh: Buffer) => {
const dest = path.join(root, name);
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(dest, existing);
await fsp.writeFile(partial, fresh);
return { dest, partial };
};
it("replaces an existing file without renaming onto an occupied name", async () => {
const fresh = crypto.randomBytes(4096);
const { dest, partial } = await seed("swap.bin", "original", fresh);
const io = windowsLikeFs();
await publishDownload(partial, dest, true, "t1", io);
expect((await fsp.readFile(dest)).equals(fresh)).toBe(true);
expect(await fsp.readdir(root)).toEqual(["swap.bin"]);
expect(io.renames.length).toBeGreaterThan(0);
for (const [, to] of io.renames) {
expect(to === dest || to.endsWith(".termix-replaced")).toBe(true);
}
});
it("restores the original byte for byte when publishing fails", async () => {
const fresh = crypto.randomBytes(1024);
const { dest, partial } = await seed("restore.bin", "original", fresh);
const denied = () =>
Promise.reject(
Object.assign(new Error("EACCES: permission denied"), {
code: "EACCES",
}),
);
const io = windowsLikeFs({ link: denied, copyFileExcl: denied });
await expect(
publishDownload(partial, dest, true, "t1", io),
).rejects.toMatchObject({ code: "EACCES" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect((await fsp.readdir(root)).sort()).toEqual(
["restore.bin", "restore.bin.t1.termix-part"].sort(),
);
});
it("reports EBUSY and touches nothing when the file cannot be moved aside", async () => {
const fresh = crypto.randomBytes(1024);
const { dest, partial } = await seed("locked.bin", "original", fresh);
const io = windowsLikeFs({
rename: () =>
Promise.reject(
Object.assign(new Error("EPERM: operation not permitted"), {
code: "EPERM",
}),
),
});
await expect(
publishDownload(partial, dest, true, "t1", io),
).rejects.toMatchObject({ code: "EBUSY" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect((await fsp.readFile(partial)).equals(fresh)).toBe(true);
});
it("refuses to replace a folder with a file", async () => {
const dest = path.join(root, "folder");
await fsp.mkdir(dest);
await fsp.writeFile(path.join(dest, "inner.txt"), "keep");
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(partial, "new");
await expect(
publishDownload(partial, dest, true, "t1", windowsLikeFs()),
).rejects.toMatchObject({ code: "EISDIR" });
expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe(
"keep",
);
});
it("falls back to an exclusive publish when the file to replace is gone", async () => {
const dest = path.join(root, "gone.bin");
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(partial, "new");
await publishDownload(partial, dest, true, "t1", windowsLikeFs());
expect(await fsp.readFile(dest, "utf8")).toBe("new");
expect(await fsp.readdir(root)).toEqual(["gone.bin"]);
});
});
describe("local rename", () => {
it("preserves both files on a name collision", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.writeFile(a, "source");
await fsp.writeFile(b, "destination");
await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/);
expect(await fsp.readFile(a, "utf8")).toBe("source");
expect(await fsp.readFile(b, "utf8")).toBe("destination");
});
it("refuses to replace even an empty destination directory", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.mkdir(a);
await fsp.mkdir(b);
await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/);
expect((await fsp.readdir(root)).sort()).toEqual(["a", "b"]);
});
it("renames a file and handles an unchanged name", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.writeFile(a, "source");
await renameLocalPath(a, a);
await renameLocalPath(a, b);
expect(fs.existsSync(a)).toBe(false);
expect(await fsp.readFile(b, "utf8")).toBe("source");
});
it("renameEntry only takes a plain name in the same folder", async () => {
const a = path.join(root, "a");
await fsp.writeFile(a, "x");
await expect(renameEntry(a, "../escape")).rejects.toThrow(/Invalid/);
await expect(renameEntry(a, "")).rejects.toThrow(/Invalid/);
expect(await renameEntry(a, "c")).toBe(path.join(root, "c"));
});
});
describe("listing and creating", () => {
it("lists entries with type, size and hidden flag", async () => {
await fsp.mkdir(path.join(root, "dir"));
await fsp.writeFile(path.join(root, "f.txt"), "abc");
await fsp.writeFile(path.join(root, ".hidden"), "");
const listing = await listDirectory(root);
const byName = Object.fromEntries(listing.entries.map((e) => [e.name, e]));
expect(byName["dir"].type).toBe("directory");
expect(byName["f.txt"]).toMatchObject({ type: "file", size: 3 });
expect(byName[".hidden"].hidden).toBe(true);
expect(listing.parent).toBe(path.dirname(root));
});
it("rejects relative paths", async () => {
await expect(listDirectory("relative")).rejects.toThrow(/absolute/);
});
it("creates folders and files without replacing anything", async () => {
expect(await makeDirectory(root, "new")).toBe(path.join(root, "new"));
await expect(makeDirectory(root, "new")).rejects.toBeTruthy();
expect(await createEmptyFile(root, "a.txt")).toBe(path.join(root, "a.txt"));
await fsp.writeFile(path.join(root, "a.txt"), "keep");
await expect(createEmptyFile(root, "a.txt")).rejects.toBeTruthy();
expect(await fsp.readFile(path.join(root, "a.txt"), "utf8")).toBe("keep");
});
it("reports which paths exist", async () => {
await fsp.writeFile(path.join(root, "yes"), "");
expect(
await existingPaths([path.join(root, "yes"), path.join(root, "no")]),
).toEqual([path.join(root, "yes")]);
});
it("walks dropped paths into a flat list with empty folders", async () => {
await fsp.mkdir(path.join(root, "tree", "empty"), { recursive: true });
await fsp.mkdir(path.join(root, "tree", "sub"));
await fsp.writeFile(path.join(root, "tree", "sub", "a.txt"), "12345");
await fsp.writeFile(path.join(root, "single.txt"), "xy");
const result = await walkPaths([
path.join(root, "tree"),
path.join(root, "single.txt"),
]);
expect(result.files.map((f) => f.relativePath).sort()).toEqual([
"single.txt",
"tree/sub/a.txt",
]);
expect(result.emptyDirs).toEqual(["tree/empty"]);
expect(result.totalBytes).toBe(7);
});
});
@@ -1,23 +1,9 @@
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { resolveLocalShell, repairSpawnHelpers } =
require("../../../../electron/local-shell.cjs") as {
resolveLocalShell: (
platform: NodeJS.Platform,
requestedShell?: string,
env?: NodeJS.ProcessEnv,
exists?: (file: string) => boolean,
) => { file: string; args: string[] };
repairSpawnHelpers: (
ptyDir: string,
fsImpl: {
statSync: (file: string) => { mode: number };
chmodSync: (file: string, mode: number) => void;
},
) => number;
};
import {
repairSpawnHelpers,
resolveLocalShell,
} from "../../plugins/desktop-local/local-pty.js";
describe("resolveLocalShell", () => {
it("starts the default WSL distribution without PowerShell arguments", () => {
@@ -74,9 +60,9 @@ describe("repairSpawnHelpers", () => {
"/Applications/Termix.app/Contents/Resources/app.asar/node_modules/node-pty";
const unpacked = dir.replace("app.asar", "app.asar.unpacked");
const release = `${unpacked}/build/Release/spawn-helper`;
modes.set(release.split("/").join(require("node:path").sep), 0o644);
modes.set(release.split("/").join(path.sep), 0o644);
modes.set(release, 0o644);
expect(repairSpawnHelpers(dir, fsImpl)).toBe(1);
expect(repairSpawnHelpers(dir, fsImpl as never)).toBe(1);
expect(chmods[0][1] & 0o111).not.toBe(0);
expect(chmods[0][0]).toContain("app.asar.unpacked");
});
@@ -88,11 +74,11 @@ describe("repairSpawnHelpers", () => {
const fsImpl = {
statSync: () => ({ mode: 0o644 }),
chmodSync: (file: string) => {
chmods.push(file.split(require("node:path").sep).join("/"));
chmods.push(file.split(path.sep).join("/"));
},
};
const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`;
repairSpawnHelpers(dir, fsImpl);
repairSpawnHelpers(dir, fsImpl as never);
expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`);
},
);
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
hostStatus,
provideHostStatus,
registerBuiltinHostStatusProvider,
type HostStatusProvider,
} from "../../plugins/host-status-provider.js";
function fakeProvider(label: string): HostStatusProvider & {
ports: string[];
} {
const ports: string[] = [];
return {
ports,
start: vi.fn(),
stop: vi.fn(),
get: (hostId) => ({ status: "online", lastChecked: `${label}:${hostId}` }),
statusesFor: async () => new Map(),
refresh: vi.fn(async () => {}),
check: async (hostId) => ({
status: "offline",
lastChecked: `${label}:${hostId}`,
}),
reportLogin: vi.fn(),
registerPort: (type) => {
ports.push(`+${type}`);
return () => ports.push(`-${type}`);
},
getIntervalSeconds: async () => 45,
setIntervalSeconds: vi.fn(async () => {}),
};
}
beforeEach(() => {
hostStatus.stop();
registerBuiltinHostStatusProvider(null);
});
describe("host status provider", () => {
it("answers unknown when nothing provides status", async () => {
expect(hostStatus.get(1)).toBeNull();
expect((await hostStatus.statusesFor("u", null)).size).toBe(0);
expect(await hostStatus.check(1)).toBeNull();
expect(await hostStatus.getIntervalSeconds()).toBe(30);
});
it("uses the built-in until a plugin provides", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1");
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(hostStatus.get(1)?.lastChecked).toBe("plugin:1");
remove();
expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1");
});
it("refuses a second provider", () => {
const remove = provideHostStatus("one", fakeProvider("one"));
expect(() => provideHostStatus("two", fakeProvider("two"))).toThrow(
/already provided by plugin "one"/,
);
remove();
});
it("hands lifecycle over when the provider changes", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
hostStatus.start();
expect(builtin.start).toHaveBeenCalledTimes(1);
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(builtin.stop).toHaveBeenCalledTimes(1);
expect(plugin.start).toHaveBeenCalledTimes(1);
remove();
expect(plugin.stop).toHaveBeenCalledTimes(1);
expect(builtin.start).toHaveBeenCalledTimes(2);
});
it("keeps registered ports across a provider change", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
const drop = hostStatus.registerPort("rdp", () => 3389);
expect(builtin.ports).toEqual(["+rdp"]);
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(builtin.ports).toEqual(["+rdp", "-rdp"]);
expect(plugin.ports).toEqual(["+rdp"]);
drop();
expect(plugin.ports).toEqual(["+rdp", "-rdp"]);
remove();
});
});
@@ -1078,3 +1078,25 @@ describe("layouts and themes", () => {
expect(has('duplicates "dusk"')).toBe(true);
});
});
describe("manifest runtime", () => {
it("accepts a desktop runtime plugin", () => {
expect(
validateManifest(
base({ runtime: "desktop", capabilities: ["desktop:local"] }),
),
).toEqual([]);
});
it("rejects an unknown runtime", () => {
expect(validateManifest(base({ runtime: "cloud" })).join()).toMatch(
/runtime/,
);
});
it("rejects a desktop runtime that is mirrored or server only", () => {
expect(
validateManifest(base({ runtime: "desktop", desktop: "mirror" })).join(),
).toMatch(/local/);
});
});
@@ -200,6 +200,10 @@ describe("assertOwnedTables", () => {
const owners = Object.entries(LEGACY_TABLE_OWNERS);
expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length);
});
it("lets the homepage plugin adopt recent_activity", () => {
expect(LEGACY_TABLE_OWNERS.recent_activity).toBe("homepage");
});
});
describe("applyPluginMigrations", () => {
@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
import { JumpProbePool } from "../../../plugins/probe/jump-probe-pool.js";
function fakeClient(reachable = true) {
const client = Object.assign(new EventEmitter(), {
@@ -1,6 +1,6 @@
import type { Client } from "ssh2";
import { describe, expect, it, vi } from "vitest";
import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js";
import { tcpPingThroughJumpHost } from "../../../plugins/probe/tcp-ping.js";
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
detectSshKeyType,
generateSshKeyPair,
publicKeyFromPrivate,
validateSshKeyPair,
} from "../../../../packages/plugin-sdk/src/ssh-keys.js";
describe("plugin SDK ssh-keys", () => {
it("generates a usable ed25519 pair", () => {
const pair = generateSshKeyPair("ssh-ed25519", { comment: "test" });
expect(pair.publicKey).toMatch(/^ssh-ed25519 /);
expect(detectSshKeyType(pair.privateKey)).toBe("ssh-ed25519");
expect(detectSshKeyType(pair.publicKey)).toBe("ssh-ed25519");
expect(validateSshKeyPair(pair.privateKey, pair.publicKey)).toEqual({
isValid: true,
});
});
it("derives the public key and ignores the comment when matching", () => {
const pair = generateSshKeyPair("ecdsa-sha2-nistp256");
const derived = publicKeyFromPrivate(pair.privateKey);
expect(derived).toMatch(/^ecdsa-sha2-nistp256 /);
expect(
validateSshKeyPair(pair.privateKey, `${derived} someone@host`).isValid,
).toBe(true);
});
it("generates RSA with a minimum size", () => {
expect(() => generateSshKeyPair("ssh-rsa", { bits: 512 })).toThrow(/2048/);
const pair = generateSshKeyPair("ssh-rsa", { bits: 2048 });
expect(detectSshKeyType(pair.privateKey)).toBe("ssh-rsa");
});
it("works with a passphrase and fails without it", () => {
const pair = generateSshKeyPair("ssh-ed25519", { passphrase: "secret" });
expect(publicKeyFromPrivate(pair.privateKey)).toBeNull();
expect(publicKeyFromPrivate(pair.privateKey, "secret")).toBe(
pair.publicKey.split(" ").slice(0, 2).join(" "),
);
});
it("flags a pair that does not belong together", () => {
const a = generateSshKeyPair("ssh-ed25519");
const b = generateSshKeyPair("ssh-ed25519");
const result = validateSshKeyPair(a.privateKey, b.publicKey);
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/match/);
});
it("reports garbage as unreadable", () => {
expect(detectSshKeyType("not a key")).toBeNull();
expect(publicKeyFromPrivate("not a key")).toBeNull();
expect(validateSshKeyPair("x", "y").isValid).toBe(false);
});
it("rejects an unknown key type", () => {
expect(() => generateSshKeyPair("ssh-dss" as never)).toThrow(/Unsupported/);
});
});
@@ -79,6 +79,7 @@ const UNGATED: Record<string, string> = {
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
currentActor: "reads the actor, cannot set it",
"desktop.available": "a yes/no about the environment",
"desktop.local.available": "a yes/no about the environment",
"http.baseUrl": "reads the request's public origin",
"ssh.poolKey": "a string built from a host the plugin already holds",
"ssh.dropPooled": "closes only this plugin's own pooled connections",
@@ -162,7 +163,15 @@ describe("every privileged ctx member is guarded", () => {
let caught: unknown;
await runAsActor(ACTOR, "request", async () => {
try {
await member.fn(...PLACEHOLDER_ARGS);
const result = await member.fn(...PLACEHOLDER_ARGS);
// An async generator only runs its guard on the first next().
if (
result &&
typeof (result as AsyncGenerator).next === "function" &&
Symbol.asyncIterator in (result as object)
) {
await (result as AsyncGenerator).next();
}
} catch (error) {
caught = error;
}
@@ -0,0 +1,144 @@
/**
* The audit forwarding settings migration.
*
* An upgrade must keep forwarding working: the collector URL and token have to
* reach the audit-tools plugin's settings (token encrypted) and be gone from
* the core settings table. Running it twice must not clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
core: new Map<string, string>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "audit-tools" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows = state.pluginRows.filter(
(row) => !(row.scope === scope && row.key === key),
);
state.pluginRows.push({ scope, scopeId, key, value, encrypted });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.core.get(key) ?? null,
delete: async (key: string) => {
state.core.delete(key);
},
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `enc(${value})`,
}));
import { runAuditForwardingMigration } from "../../upgrade/audit-forwarding-migration.js";
const row = (key: string) =>
state.pluginRows.find((entry) => entry.key === key);
beforeEach(() => {
state.pluginRows = [];
state.core = new Map();
state.pluginInstalled = true;
});
describe("audit forwarding migration", () => {
it("does nothing until the plugin exists", async () => {
state.pluginInstalled = false;
state.core.set("audit_log_forward_url", "https://siem.test/in");
expect(await runAuditForwardingMigration()).toEqual({
url: false,
token: false,
});
expect(state.core.get("audit_log_forward_url")).toBe(
"https://siem.test/in",
);
});
it("copies the url and encrypts the token, then clears core", async () => {
state.core.set("audit_log_forward_url", " https://siem.test/in ");
state.core.set("audit_log_forward_token", "secret");
expect(await runAuditForwardingMigration()).toEqual({
url: true,
token: true,
});
expect(JSON.parse(row("url")!.value!)).toBe("https://siem.test/in");
expect(row("url")!.encrypted).toBe(false);
expect(JSON.parse(row("token")!.value!)).toBe("enc(secret)");
expect(row("token")!.encrypted).toBe(true);
expect(state.core.size).toBe(0);
});
it("is safe to run twice", async () => {
state.core.set("audit_log_forward_url", "https://siem.test/in");
state.core.set("audit_log_forward_token", "secret");
await runAuditForwardingMigration();
const again = await runAuditForwardingMigration();
expect(again).toEqual({ url: false, token: false });
expect(state.pluginRows).toHaveLength(2);
});
it("keeps what the plugin already has", async () => {
state.pluginRows.push({
scope: "admin",
scopeId: null,
key: "url",
value: JSON.stringify("https://new.test/in"),
encrypted: false,
});
state.core.set("audit_log_forward_url", "https://old.test/in");
const result = await runAuditForwardingMigration();
expect(result.url).toBe(false);
expect(JSON.parse(row("url")!.value!)).toBe("https://new.test/in");
expect(state.core.has("audit_log_forward_url")).toBe(false);
});
it("drops a stray token when there is no url", async () => {
state.core.set("audit_log_forward_token", "orphan");
await runAuditForwardingMigration();
expect(state.core.has("audit_log_forward_token")).toBe(false);
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,86 @@
/**
* The status check interval moves into the host-status plugin once. A value
* the plugin already holds is kept, and running it twice changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
core: new Map<string, string>(),
plugin: new Map<string, string>(),
installed: true,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (h.installed ? { id } : null),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => h.core.get(key) ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, _id: string | null, key: string) =>
h.plugin.has(key) ? { value: h.plugin.get(key) } : null,
set: async (
_p: string,
_s: string,
_id: string | null,
key: string,
value: string,
) => {
h.plugin.set(key, value);
},
}),
}));
const { runHostStatusSettingsMigration } =
await import("../../upgrade/host-status-settings-migration.js");
beforeEach(() => {
h.core.clear();
h.plugin.clear();
h.installed = true;
});
describe("host status settings migration", () => {
it("copies the interval into the plugin", async () => {
h.core.set("global_status_check_interval", "90");
expect(await runHostStatusSettingsMigration()).toBe(90);
expect(h.plugin.get("intervalSeconds")).toBe("90");
});
it("keeps a value the plugin already has", async () => {
h.core.set("global_status_check_interval", "90");
h.plugin.set("intervalSeconds", "45");
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.get("intervalSeconds")).toBe("45");
});
it("does nothing twice", async () => {
h.core.set("global_status_check_interval", "120");
await runHostStatusSettingsMigration();
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.get("intervalSeconds")).toBe("120");
});
it.each(["abc", "4", "7200", "1.5", ""])(
"ignores the invalid value %j",
async (value) => {
h.core.set("global_status_check_interval", value);
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.has("intervalSeconds")).toBe(false);
},
);
it("does nothing without a stored interval or the plugin", async () => {
expect(await runHostStatusSettingsMigration()).toBeNull();
h.core.set("global_status_check_interval", "90");
h.installed = false;
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.size).toBe(0);
});
});
@@ -1,125 +0,0 @@
import { describe, expect, it } from "vitest";
import {
escapeCsvField,
exportFilename,
toCsv,
toNdjson,
} from "../../utils/audit-export.js";
import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js";
function entry(overrides: Partial<AuditLogRecord> = {}): AuditLogRecord {
return {
id: 1,
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
resourceName: "prod-db",
details: null,
ipAddress: "203.0.113.9",
userAgent: "Mozilla/5.0",
success: true,
errorMessage: null,
timestamp: "2026-07-28 10:00:00",
...overrides,
} as AuditLogRecord;
}
describe("escapeCsvField", () => {
it("leaves plain values alone", () => {
expect(escapeCsvField("prod-db")).toBe("prod-db");
expect(escapeCsvField(42)).toBe("42");
expect(escapeCsvField(true)).toBe("true");
});
it("renders null and undefined as empty", () => {
expect(escapeCsvField(null)).toBe("");
expect(escapeCsvField(undefined)).toBe("");
});
it("quotes and doubles embedded quotes", () => {
expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""');
});
it("quotes values containing commas or newlines", () => {
expect(escapeCsvField("a,b")).toBe('"a,b"');
expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"');
});
it("neutralises spreadsheet formulas", () => {
// An audit entry can carry an attacker-chosen resource name; without this
// the exported file executes it when opened.
expect(escapeCsvField("=1+1")).toBe("'=1+1");
expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)");
expect(escapeCsvField("-2+3")).toBe("'-2+3");
expect(escapeCsvField("@import")).toBe("'@import");
});
it("still quotes a formula that also contains a comma", () => {
expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`);
});
});
describe("toCsv", () => {
it("writes a header even with no rows", () => {
expect(toCsv([])).toBe(
"id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n",
);
});
it("writes one line per entry in column order", () => {
const lines = toCsv([entry(), entry({ id: 2, username: "bob" })])
.trim()
.split("\n");
expect(lines).toHaveLength(3);
expect(
lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"),
).toBe(true);
expect(lines[2].startsWith("2,")).toBe(true);
});
it("keeps a detached entry readable", () => {
const line = toCsv([entry({ userId: null })])
.trim()
.split("\n")[1];
// username survives so the row still names who acted.
expect(line).toContain("alice");
expect(line.split(",")[3]).toBe("");
});
});
describe("toNdjson", () => {
it("emits one parseable object per line", () => {
const out = toNdjson([entry(), entry({ id: 2 })]);
const parsed = out
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(parsed).toHaveLength(2);
expect(parsed[0].action).toBe("delete_host");
expect(parsed[1].id).toBe(2);
});
it("returns nothing for an empty set", () => {
expect(toNdjson([])).toBe("");
});
});
describe("exportFilename", () => {
it("is filesystem-safe and carries the timestamp", () => {
const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z"));
expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv");
expect(name).not.toMatch(/[:\s]/);
});
it("uses the ndjson extension for the streaming format", () => {
expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe(
"termix-audit-2026-07-28-10-11-12.ndjson",
);
});
});
@@ -1,169 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const safeFetch = vi.hoisted(() => vi.fn());
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
vi.mock("../../database/repositories/factory.js", () => ({
getCurrentSettingValue: () => null,
}));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: safeFetch,
}));
vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs }));
const {
auditForwardTarget,
forwardAuditEntry,
forwardPayload,
resetAuditForwarderState,
AUDIT_FORWARD_URL_ENV,
AUDIT_FORWARD_TOKEN_ENV,
} = await import("../../utils/audit-forwarder.js");
const ENTRY = {
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
success: true,
ipAddress: "203.0.113.9",
};
const NOW = new Date("2026-07-28T10:00:00.000Z");
beforeEach(() => {
safeFetch.mockReset();
logs.info.mockReset();
logs.warn.mockReset();
resetAuditForwarderState();
});
describe("auditForwardTarget", () => {
it("is off unless a URL is configured", () => {
expect(auditForwardTarget({})).toBeNull();
expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull();
});
it("carries an optional bearer token", () => {
expect(
auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }),
).toEqual({ url: "https://siem/ingest" });
expect(
auditForwardTarget({
[AUDIT_FORWARD_URL_ENV]: "https://siem/ingest",
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
}),
).toEqual({ url: "https://siem/ingest", token: "secret" });
});
});
describe("forwardPayload", () => {
it("matches the export shape, with absent fields as null", () => {
expect(forwardPayload(ENTRY, NOW)).toEqual({
timestamp: "2026-07-28T10:00:00.000Z",
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
resourceName: null,
success: true,
ipAddress: "203.0.113.9",
userAgent: null,
errorMessage: null,
details: null,
});
});
});
describe("forwardAuditEntry", () => {
const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" };
it("does nothing when forwarding is not configured", async () => {
await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false);
expect(safeFetch).not.toHaveBeenCalled();
});
it("posts one NDJSON line through the SSRF-checked fetch", async () => {
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true);
const [url, init] = safeFetch.mock.calls[0];
expect(url).toBe("https://siem.example/ingest");
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/x-ndjson");
expect(init.headers.Authorization).toBeUndefined();
expect(JSON.parse(init.body.trim()).action).toBe("delete_host");
expect(init.body.endsWith("\n")).toBe(true);
});
it("sends the bearer token when one is set", async () => {
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await forwardAuditEntry(ENTRY, NOW, {
...env,
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
});
expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe(
"Bearer secret",
);
});
it("reports a rejected delivery without throwing", async () => {
safeFetch.mockResolvedValueOnce({ ok: false, status: 503 });
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
expect(logs.warn).toHaveBeenCalledWith(
"Failed to forward audit entry",
expect.objectContaining({ reason: "collector returned 503" }),
);
});
it("swallows transport errors, a dead SIEM must not break auditing", async () => {
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
expect(logs.warn).toHaveBeenCalledWith(
"Failed to forward audit entry",
expect.objectContaining({ reason: "ECONNREFUSED" }),
);
});
it("stops repeating itself once the collector is persistently down", async () => {
safeFetch.mockResolvedValue({ ok: false, status: 500 });
for (let i = 0; i < 8; i++) {
await forwardAuditEntry(ENTRY, NOW, env);
}
// 5 per-entry warnings, then one suppression notice, not 8.
const perEntry = logs.warn.mock.calls.filter(
(call) => call[0] === "Failed to forward audit entry",
);
expect(perEntry).toHaveLength(5);
expect(
logs.warn.mock.calls.some((call) =>
String(call[0]).includes("suppressing further messages"),
),
).toBe(true);
// It keeps trying regardless.
expect(safeFetch).toHaveBeenCalledTimes(8);
});
it("announces recovery after a suppressed outage", async () => {
safeFetch.mockResolvedValue({ ok: false, status: 500 });
for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env);
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await forwardAuditEntry(ENTRY, NOW, env);
expect(logs.info).toHaveBeenCalledWith(
"Audit forwarding recovered",
expect.objectContaining({ operation: "audit_forward_recovered" }),
);
});
});
@@ -0,0 +1,87 @@
/**
* Moves the audit forwarding collector URL and token out of the core settings
* table into the audit-tools plugin's admin settings. The token is stored
* encrypted with the installation key, like any plugin secret setting, and the
* core copies are removed once the plugin has its own.
*
* Idempotent: a value the plugin already has is kept, and a core value is only
* removed after the plugin holds a copy. Does nothing until the plugin row
* exists (plugin_settings has a foreign key to plugins).
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "audit-tools";
const URL_SETTING = "audit_log_forward_url";
const TOKEN_SETTING = "audit_log_forward_token";
export interface AuditForwardingMigrationResult {
url: boolean;
token: boolean;
}
export async function runAuditForwardingMigration(): Promise<AuditForwardingMigrationResult> {
const result: AuditForwardingMigrationResult = { url: false, token: false };
try {
if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) {
return result;
}
} catch {
return result;
}
const core = createCurrentSettingsRepository();
const plugin = createCurrentPluginSettingsRepository();
const legacyUrl = (await core.get(URL_SETTING))?.trim();
const legacyToken = (await core.get(TOKEN_SETTING))?.trim();
const has = async (key: string) => {
const row = await plugin.get(PLUGIN_ID, "admin", null, key);
return !!row && row.value !== null && row.value !== '""';
};
if (legacyUrl) {
if (!(await has("url"))) {
await plugin.set(
PLUGIN_ID,
"admin",
null,
"url",
JSON.stringify(legacyUrl),
);
result.url = true;
}
if (legacyToken && !(await has("token"))) {
await plugin.set(
PLUGIN_ID,
"admin",
null,
"token",
JSON.stringify(await encryptSystemSecret(legacyToken)),
true,
);
result.token = true;
}
}
// Safe to drop now: the plugin holds the values, or never had a collector.
if (legacyUrl ? await has("url") : true) {
if (legacyUrl) await core.delete(URL_SETTING);
if (legacyToken && (await has("token"))) await core.delete(TOKEN_SETTING);
else if (!legacyUrl && legacyToken) await core.delete(TOKEN_SETTING);
}
if (result.url || result.token) {
databaseLogger.info("Moved audit forwarding settings into audit-tools", {
operation: "audit_forwarding_migration",
});
}
return result;
}
@@ -0,0 +1,64 @@
/**
* Moves the global status check interval into the host-status plugin, before
* it activates, so its first checks use the interval an admin chose instead of
* the plugin's 30 second default.
*
* Idempotent: a value the plugin already has is kept.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "host-status";
const LEGACY_KEY = "global_status_check_interval";
function seconds(value: unknown): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 5 && parsed <= 3600
? parsed
: null;
}
export async function runHostStatusSettingsMigration(): Promise<number | null> {
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return null;
const settings = createCurrentPluginSettingsRepository();
const existing = await settings.get(
PLUGIN_ID,
"admin",
null,
"intervalSeconds",
);
if (existing && existing.value !== null) return null;
const legacy = seconds(
await createCurrentSettingsRepository().get(LEGACY_KEY),
);
if (legacy === null) return null;
await settings.set(
PLUGIN_ID,
"admin",
null,
"intervalSeconds",
JSON.stringify(legacy),
);
databaseLogger.info("Moved the status check interval into the plugin", {
operation: "host_status_settings_migration",
intervalSeconds: legacy,
});
return legacy;
} catch (error) {
databaseLogger.warn("Host status settings migration failed", {
operation: "host_status_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more