mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move desktop, status, import, audit and branding features into plugins
This commit is contained in:
1 parent
9d93fb0893
commit
1cdbfc674c
166 files changed
+21298
-12041
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1543,7 +1543,7 @@ registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
|
||||
registerTlsRoutes(router, authenticateJWT);
|
||||
|
||||
registerUserApiKeyRoutes(router, requireAdmin);
|
||||
registerBrandingRoutes(router, requireAdmin);
|
||||
registerBrandingRoutes(router);
|
||||
|
||||
registerAuthRoutes(router);
|
||||
registerAuthCompatRoutes(router);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
+7
@@ -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.
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+9
-23
@@ -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
-1
@@ -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
-1
@@ -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
Reference in new issue
Block a user