feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
1 parent 14eb558b85
commit 0a3f05d99d
308 files changed
+53073 -8985

No files matched your search

+46 -5
View File
@@ -806,7 +806,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
default_path TEXT,
stats_config TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
docker_config TEXT,
web_ui_config TEXT,
terminal_config TEXT,
@@ -928,8 +929,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, status_check_enabled, status_check_interval, docker_config, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -969,7 +970,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.showDockerInSidebar ? 1 : 0,
decrypted.showServerStatsInSidebar ? 1 : 0,
decrypted.defaultPath || null,
decrypted.statsConfig || null,
decrypted.statusCheckEnabled === false ? 0 : 1,
decrypted.statusCheckInterval ?? null,
decrypted.dockerConfig || null,
decrypted.webUiConfig || null,
decrypted.terminalConfig || null,
@@ -1378,7 +1380,7 @@ app.post(
host.show_server_stats_in_sidebar,
),
defaultPath: host.default_path,
statsConfig: host.stats_config,
...legacyStatusCheck(host),
terminalConfig: host.terminal_config,
quickActions: host.quick_actions,
notes: host.notes,
@@ -2156,3 +2158,42 @@ if (
);
}
}
/**
* Status check columns from an export row. Exports from before 2.9.0 only
* have stats_config, which carried them.
*/
function legacyStatusCheck(row: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
if (row.status_check_enabled !== undefined) {
return {
statusCheckEnabled: Boolean(row.status_check_enabled),
statusCheckInterval:
typeof row.status_check_interval === "number"
? row.status_check_interval
: null,
};
}
let legacy: Record<string, unknown> = {};
try {
legacy =
typeof row.stats_config === "string" && row.stats_config
? JSON.parse(row.stats_config)
: {};
} catch {
legacy = {};
}
const seconds = Number(legacy.statusCheckInterval);
return {
statusCheckEnabled:
legacy.statusCheckEnabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
legacy.useGlobalStatusInterval === false &&
Number.isInteger(seconds) &&
seconds >= 5
? seconds
: null,
};
}
+8 -88
View File
@@ -334,7 +334,8 @@ async function initializeCompleteDatabase(): Promise<void> {
autostart_key TEXT,
autostart_key_password TEXT,
force_keyboard_interactive TEXT,
stats_config TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
docker_config TEXT,
web_ui_config TEXT,
terminal_config TEXT,
@@ -687,20 +688,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_metrics_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
layout TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at 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 UNIQUE INDEX IF NOT EXISTS idx_host_metrics_prefs_user_host
ON host_metrics_preferences (user_id, host_id);
CREATE TABLE IF NOT EXISTS host_sidebar_preferences (
user_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
@@ -722,37 +709,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_health_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
checks TEXT NOT NULL,
interval_seconds INTEGER NOT NULL DEFAULT 300,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at 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 UNIQUE INDEX IF NOT EXISTS idx_host_health_checks_user_host
ON host_health_checks (user_id, host_id);
CREATE TABLE IF NOT EXISTS host_health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
check_id TEXT NOT NULL,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ok INTEGER NOT NULL,
latency_ms INTEGER,
detail TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_host_health_history_lookup
ON host_health_history (user_id, host_id, check_id, ts);
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -1111,7 +1067,12 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists(
"ssh_data",
"status_check_enabled",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists(
@@ -2034,34 +1995,6 @@ const migrateSchema = () => {
});
}
// --- metrics-history begin ---
try {
sqlite.prepare("SELECT id FROM host_metrics_history LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS host_metrics_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
cpu_percent REAL,
mem_percent REAL,
disk_percent REAL,
net_rx_bytes INTEGER,
net_tx_bytes INTEGER
);
CREATE INDEX IF NOT EXISTS idx_host_metrics_history_host_ts
ON host_metrics_history (host_id, ts DESC);
`);
} catch (createError) {
databaseLogger.warn("Failed to create host_metrics_history table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- metrics-history end ---
// --- alerts begin ---
// alert_rules, alert_rule_channels and alert_firings were only ever created
// here, never declared in schema.ts, so they never existed on Postgres or
@@ -2258,19 +2191,6 @@ const migrateSchema = () => {
}
// --- automations end ---
// Seed default metrics history retention setting
try {
ensureRawSettingDefault("metrics_history_retention_days", "7");
} catch (e) {
databaseLogger.warn(
"Could not initialize metrics_history_retention_days setting",
{
operation: "schema_migration",
error: e,
},
);
}
// --- homepage begin ---
try {
sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
+5 -88
View File
@@ -295,7 +295,10 @@ export const hosts = mysqlTable(
.notNull()
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
statusCheckEnabled: boolean("status_check_enabled")
.notNull()
.default(true),
statusCheckInterval: int("status_check_interval"),
dockerConfig: text("docker_config"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
@@ -887,40 +890,12 @@ export const userPreferences = mysqlTable("user_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const hostMetricsPreferences = mysqlTable(
"host_metrics_preferences",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// One layout per user per host. Enforced in production since the inline DDL
// creates it, but it was never declared here, so the generated Postgres and
// MySQL schemas lacked it — and the upsert has nothing to conflict on.
(table) => [
uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId),
],
);
export const hostSidebarPreferences = mysqlTable("host_sidebar_preferences", {
userId: varchar("user_id", { length: 255 })
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON like hostMetricsPreferences.layout.
// plain JSON.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
@@ -955,47 +930,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const hostHealthChecks = mysqlTable(
"host_health_checks",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: int("interval_seconds").notNull().default(300),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Same as above: one set of checks per user per host.
(table) => [
uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthHistory = mysqlTable("host_health_history", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`(CURRENT_TIMESTAMP)`),
ok: boolean("ok").notNull(),
latencyMs: int("latency_ms"),
detail: text("detail"),
});
export const dashboardServiceLinks = mysqlTable("dashboard_service_links", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -1086,23 +1020,6 @@ export const termixIdentityCa = mysqlTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- metrics-history begin ---
export const hostMetricsHistory = mysqlTable("host_metrics_history", {
id: int("id").autoincrement().primaryKey(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
ts: text("ts")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
cpuPercent: double("cpu_percent"),
memPercent: double("mem_percent"),
diskPercent: double("disk_percent"),
netRxBytes: int("net_rx_bytes"),
netTxBytes: int("net_tx_bytes"),
});
// --- metrics-history end ---
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
id: int("id").autoincrement().primaryKey(),
+5 -88
View File
@@ -296,7 +296,10 @@ export const hosts = pgTable(
.notNull()
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
statusCheckEnabled: boolean("status_check_enabled")
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
dockerConfig: text("docker_config"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
@@ -888,40 +891,12 @@ export const userPreferences = pgTable("user_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostMetricsPreferences = pgTable(
"host_metrics_preferences",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// One layout per user per host. Enforced in production since the inline DDL
// creates it, but it was never declared here, so the generated Postgres and
// MySQL schemas lacked it — and the upsert has nothing to conflict on.
(table) => [
uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId),
],
);
export const hostSidebarPreferences = pgTable("host_sidebar_preferences", {
userId: varchar("user_id", { length: 255 })
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON like hostMetricsPreferences.layout.
// plain JSON.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
@@ -956,47 +931,6 @@ export const uiPreferences = pgTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthChecks = pgTable(
"host_health_checks",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: integer("interval_seconds").notNull().default(300),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same as above: one set of checks per user per host.
(table) => [
uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthHistory = pgTable("host_health_history", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`),
ok: boolean("ok").notNull(),
latencyMs: integer("latency_ms"),
detail: text("detail"),
});
export const dashboardServiceLinks = pgTable("dashboard_service_links", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -1087,23 +1021,6 @@ export const termixIdentityCa = pgTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- metrics-history begin ---
export const hostMetricsHistory = pgTable("host_metrics_history", {
id: serial("id").primaryKey(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
ts: text("ts")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
cpuPercent: doublePrecision("cpu_percent"),
memPercent: doublePrecision("mem_percent"),
diskPercent: doublePrecision("disk_percent"),
netRxBytes: integer("net_rx_bytes"),
netTxBytes: integer("net_tx_bytes"),
});
// --- metrics-history end ---
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
id: serial("id").primaryKey(),
+5 -88
View File
@@ -288,7 +288,10 @@ export const hosts = sqliteTable(
.notNull()
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
statusCheckEnabled: integer("status_check_enabled", { mode: "boolean" })
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
dockerConfig: text("docker_config"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
@@ -884,40 +887,12 @@ export const userPreferences = sqliteTable("user_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostMetricsPreferences = sqliteTable(
"host_metrics_preferences",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// One layout per user per host. Enforced in production since the inline DDL
// creates it, but it was never declared here, so the generated Postgres and
// MySQL schemas lacked it — and the upsert has nothing to conflict on.
(table) => [
uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId),
],
);
export const hostSidebarPreferences = sqliteTable("host_sidebar_preferences", {
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON like hostMetricsPreferences.layout.
// plain JSON.
data: text("data").notNull(),
updatedAt: text("updated_at")
.notNull()
@@ -952,47 +927,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthChecks = sqliteTable(
"host_health_checks",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: integer("interval_seconds").notNull().default(300),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same as above: one set of checks per user per host.
(table) => [
uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthHistory = sqliteTable("host_health_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`),
ok: integer("ok", { mode: "boolean" }).notNull(),
latencyMs: integer("latency_ms"),
detail: text("detail"),
});
export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -1083,23 +1017,6 @@ export const termixIdentityCa = sqliteTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- metrics-history begin ---
export const hostMetricsHistory = sqliteTable("host_metrics_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
ts: text("ts")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
cpuPercent: real("cpu_percent"),
memPercent: real("mem_percent"),
diskPercent: real("disk_percent"),
netRxBytes: integer("net_rx_bytes"),
netTxBytes: integer("net_tx_bytes"),
});
// --- metrics-history end ---
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -15,9 +15,6 @@ import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { HomepageItemRepository } from "./homepage-item-repository.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
import { HostHealthRepository } from "./host-health-repository.js";
import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
import { HostRepository } from "./host-repository.js";
import { HostResolutionRepository } from "./host-resolution-repository.js";
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
@@ -230,31 +227,6 @@ export function createCurrentHostFolderRepository(): HostFolderRepository {
);
}
export function createCurrentHostHealthRepository(): HostHealthRepository {
return new HostHealthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_health_repository_write"),
);
}
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
return new HostMetricsHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryLazyWriteHook(
"host_metrics_history_repository_write",
),
);
}
export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
return new HostMetricsPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"host_metrics_preference_repository_write",
),
);
}
export function createCurrentHostRepository(): HostRepository {
return new HostRepository(
createCurrentRepositoryContext(),
@@ -1,178 +0,0 @@
import { and, desc, eq, notInArray } from "drizzle-orm";
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
export interface HostHealthResultInput {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
export class HostHealthRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findChecksByUserAndHost(
userId: string,
hostId: number,
): Promise<HostHealthCheckRecord | null> {
const rows = await this.context.drizzle
.select()
.from(hostHealthChecks)
.where(
and(
eq(hostHealthChecks.userId, userId),
eq(hostHealthChecks.hostId, hostId),
),
)
.limit(1);
return rows[0] ?? null;
}
async upsertChecks(
userId: string,
hostId: number,
checks: string,
intervalSeconds: number,
now = new Date().toISOString(),
): Promise<HostHealthCheckRecord> {
const existing = await this.findChecksByUserAndHost(userId, hostId);
if (existing) {
const [updated] = await updateReturning(
this.context,
hostHealthChecks,
{ checks, intervalSeconds, updatedAt: now },
eq(hostHealthChecks.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await insertReturning(this.context, hostHealthChecks, {
userId,
hostId,
checks,
intervalSeconds,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
}
async recordHistory(
userId: string,
hostId: number,
results: HostHealthResultInput[],
keep: number,
now = new Date().toISOString(),
): Promise<number> {
if (results.length === 0) {
return 0;
}
await this.context.drizzle.insert(hostHealthHistory).values(
results.map((result) => ({
userId,
hostId,
checkId: result.checkId,
ts: now,
ok: result.ok,
latencyMs: result.latencyMs,
detail: result.detail,
})),
);
await this.pruneHistory(userId, hostId, keep);
await this.afterWrite();
return results.length;
}
async listHistory(
userId: string,
hostId: number,
limit: number,
): Promise<HostHealthHistoryRecord[]> {
return this.context.drizzle
.select()
.from(hostHealthHistory)
.where(
and(
eq(hostHealthHistory.userId, userId),
eq(hostHealthHistory.hostId, hostId),
),
)
.orderBy(desc(hostHealthHistory.ts))
.limit(limit);
}
async deleteByUserId(userId: string): Promise<{
checksDeleted: number;
historyDeleted: number;
}> {
const historyResult = await this.context.drizzle
.delete(hostHealthHistory)
.where(eq(hostHealthHistory.userId, userId));
const result = await this.context.drizzle
.delete(hostHealthChecks)
.where(eq(hostHealthChecks.userId, userId));
if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
checksDeleted: rowsAffected(result),
historyDeleted: rowsAffected(historyResult),
};
}
/** Keeps the newest `keep` rows for the host and drops the rest. */
private async pruneHistory(
userId: string,
hostId: number,
keep: number,
): Promise<void> {
const scope = and(
eq(hostHealthHistory.userId, userId),
eq(hostHealthHistory.hostId, hostId),
);
const retained = await this.context.drizzle
.select({ id: hostHealthHistory.id })
.from(hostHealthHistory)
.where(scope)
.orderBy(desc(hostHealthHistory.ts))
.limit(keep);
// Nothing retained means nothing to keep back, so the scope alone is the
// delete condition.
await this.context.drizzle.delete(hostHealthHistory).where(
retained.length
? and(
scope,
notInArray(
hostHealthHistory.id,
retained.map((row) => row.id),
),
)
: scope,
);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,68 +0,0 @@
import { and, asc, eq, gte, lt, lte } from "drizzle-orm";
import { hostMetricsHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect;
export interface HostMetricsHistoryCreateInput {
hostId: number;
cpuPercent?: number | null;
memPercent?: number | null;
diskPercent?: number | null;
netRxBytes?: number | null;
netTxBytes?: number | null;
}
export class HostMetricsHistoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(input: HostMetricsHistoryCreateInput): Promise<void> {
await this.context.drizzle.insert(hostMetricsHistory).values({
hostId: input.hostId,
cpuPercent: input.cpuPercent,
memPercent: input.memPercent,
diskPercent: input.diskPercent,
netRxBytes: input.netRxBytes,
netTxBytes: input.netTxBytes,
});
await this.afterWrite();
}
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
await this.context.drizzle
.delete(hostMetricsHistory)
.where(
and(
eq(hostMetricsHistory.hostId, hostId),
lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)),
),
);
}
async listRange(
hostId: number,
fromTs: string,
toTs: string,
): Promise<HostMetricsHistoryRecord[]> {
return this.context.drizzle
.select()
.from(hostMetricsHistory)
.where(
and(
eq(hostMetricsHistory.hostId, hostId),
gte(hostMetricsHistory.ts, fromTs),
lte(hostMetricsHistory.ts, toTs),
),
)
.orderBy(asc(hostMetricsHistory.ts));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,99 +0,0 @@
import { and, eq } from "drizzle-orm";
import { hostMetricsPreferences, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostMetricsPreferenceRecord =
typeof hostMetricsPreferences.$inferSelect;
export class HostMetricsPreferenceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserAndHost(
userId: string,
hostId: number,
): Promise<HostMetricsPreferenceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(hostMetricsPreferences)
.where(
and(
eq(hostMetricsPreferences.userId, userId),
eq(hostMetricsPreferences.hostId, hostId),
),
)
.limit(1);
return rows[0] ?? null;
}
async upsertLayout(
userId: string,
hostId: number,
layout: string,
now = new Date().toISOString(),
): Promise<HostMetricsPreferenceRecord> {
const existing = await this.findByUserAndHost(userId, hostId);
if (existing) {
const [updated] = await updateReturning(
this.context,
hostMetricsPreferences,
{ layout, updatedAt: now },
eq(hostMetricsPreferences.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await insertReturning(
this.context,
hostMetricsPreferences,
{
userId,
hostId,
layout,
createdAt: now,
updatedAt: now,
},
);
await this.afterWrite();
return created;
}
async updateHostStatsConfig(
userId: string,
hostId: number,
statsConfig: string,
): Promise<boolean> {
const result = await this.context.drizzle
.update(hosts)
.set({ statsConfig })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(hostMetricsPreferences)
.where(eq(hostMetricsPreferences.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -15,7 +15,6 @@ export type NewHostRecord = typeof hosts.$inferInsert;
export type HostUpdate = Partial<Omit<NewHostRecord, "id" | "userId">>;
export interface HostBulkUpdateState {
id: number;
statsConfig: string | null;
credentialId: number | null;
}
@@ -232,7 +231,6 @@ export class HostRepository {
return this.context.drizzle
.select({
id: hosts.id,
statsConfig: hosts.statsConfig,
credentialId: hosts.credentialId,
})
.from(hosts)
@@ -57,6 +57,17 @@ export type HostListRow = HostResolutionHostRecord & {
expiresAt?: string | null;
};
export interface HostStatusTargetRow {
id: number;
userId: string;
ip: string;
port: number;
connectionType: string;
jumpHosts: string | null;
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
}
export class HostResolutionRepository {
constructor(
private readonly context: DatabaseContext,
@@ -262,6 +273,34 @@ export class HostResolutionRepository {
return rows.length > 0;
}
/**
* What the status checker needs, without decrypting anything: none of these
* columns are encrypted. Filtered by owner, by id, or both.
*/
async listStatusTargets(filter: {
userId?: string;
hostIds?: number[];
}): Promise<HostStatusTargetRow[]> {
if (filter.hostIds && filter.hostIds.length === 0) return [];
const conditions = [
filter.userId ? eq(hosts.userId, filter.userId) : undefined,
filter.hostIds ? inArray(hosts.id, filter.hostIds) : undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select({
id: hosts.id,
userId: hosts.userId,
ip: hosts.ip,
port: hosts.port,
connectionType: hosts.connectionType,
jumpHosts: hosts.jumpHosts,
statusCheckEnabled: hosts.statusCheckEnabled,
statusCheckInterval: hosts.statusCheckInterval,
})
.from(hosts)
.where(conditions.length > 0 ? and(...conditions) : undefined);
}
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
const rows = await this.context.drizzle.select().from(hosts);
@@ -9,9 +9,7 @@ import {
createCurrentDismissedAlertRepository,
createCurrentHomepageItemRepository,
createCurrentHomepageLayoutRepository,
createCurrentHostHealthRepository,
createCurrentHostFolderRepository,
createCurrentHostMetricsPreferenceRepository,
createCurrentHostRepository,
createCurrentHostSidebarPreferenceRepository,
createCurrentCredentialSidebarPreferenceRepository,
@@ -93,8 +91,6 @@ export async function deleteUserAndRelatedData(
await createCurrentHostFolderRepository().deleteByUserId(userId);
await createCurrentHostHealthRepository().deleteByUserId(userId);
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
await createCurrentCredentialSidebarPreferenceRepository().deleteByUserId(
userId,
+31 -20
View File
@@ -292,6 +292,8 @@ export function registerHostBulkRoutes(
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
if (typeof updates.enableAiAssistant === "boolean")
simpleUpdates.enableAiAssistant = updates.enableAiAssistant;
if (typeof updates.statusCheckEnabled === "boolean")
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
if (Object.keys(simpleUpdates).length > 0) {
await hostRepository.updateManyForUser(
@@ -301,22 +303,6 @@ export function registerHostBulkRoutes(
);
}
if (updates.statsConfig && typeof updates.statsConfig === "object") {
for (const host of ownedHosts) {
try {
const existing = host.statsConfig
? JSON.parse(host.statsConfig as string)
: {};
const merged = { ...existing, ...updates.statsConfig };
await hostRepository.updateForUser(userId, host.id, {
statsConfig: JSON.stringify(merged),
});
} catch {
errors.push(`Failed to update statsConfig for host ${host.id}`);
}
}
}
// Proxmox enable/disable now lives in the proxmox plugin's own
// host-scope settings, not a hosts column. Disabling is a plain flag
// flip; enabling also defaults the Proxmox credential to the one
@@ -784,9 +770,7 @@ export function registerHostBulkRoutes(
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
: null,
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
...importedStatusCheck(hostData as Record<string, unknown>),
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
@@ -1048,7 +1032,8 @@ export function registerHostBulkRoutes(
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: null,
statsConfig: null,
statusCheckEnabled: true,
statusCheckInterval: null,
dockerConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
@@ -1099,3 +1084,29 @@ export function registerHostBulkRoutes(
},
);
}
/**
* 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,
};
}
@@ -191,7 +191,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
tunnelConnections?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statsConfig?: unknown;
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
dockerConfig?: unknown;
webUiConfig?: unknown;
proxmoxConfig?: unknown;
@@ -348,6 +349,8 @@ const CONNECT_LEVEL_FIELDS = new Set([
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"statusCheckEnabled",
"statusCheckInterval",
"enableSsh",
"sshPort",
"rdpAuthType",
@@ -456,9 +459,12 @@ export function transformHostResponse(
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
: [],
statsConfig: host.statsConfig
? JSON.parse(host.statsConfig as string)
: undefined,
statusCheckEnabled:
host.statusCheckEnabled !== false && host.statusCheckEnabled !== 0,
statusCheckInterval:
typeof host.statusCheckInterval === "number"
? host.statusCheckInterval
: null,
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig as string)
: undefined,
@@ -0,0 +1,259 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
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,
type HostStatusEntry,
} from "../../hosts/status/host-status-service.js";
interface HostStatusRoutesDeps {
authenticateJWT: RequestHandler;
requireAdmin: RequestHandler;
}
/** "1,2,3" to a set of ids; undefined means the caller asked for all. */
export function parseStatusHostIds(value: unknown): Set<number> | null {
if (value === undefined) return null;
if (typeof value !== "string") return new Set();
return new Set(
value
.split(",")
.map(Number)
.filter((id) => Number.isSafeInteger(id) && id > 0),
);
}
function sessionExpired(res: Response): Response {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
export function registerHostStatusRoutes(
router: Router,
{ authenticateJWT, requireAdmin }: HostStatusRoutesDeps,
): void {
const permissionManager = PermissionManager.getInstance();
/**
* @openapi
* /host/status:
* get:
* summary: Get host statuses
* description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostIds
* required: false
* schema:
* type: string
* description: Comma separated ids. Only these of the user's own hosts are checked.
* responses:
* 200:
* description: A map of host ids to status entries.
* 401:
* description: Session expired - please log in again.
*/
router.get(
"/status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null)
return sessionExpired(res);
const requested = parseStatusHostIds(req.query.hostIds);
const statuses = await hostStatusService.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.
const entries = [...statuses.entries()];
const allowed = await permissionManager.filterAccessibleHostIds(
userId,
entries.map(([id]) => id),
);
const result: Record<number, HostStatusEntry> = {};
for (const [id, entry] of entries) {
if (allowed.has(id) && (requested === null || requested.has(id))) {
result[id] = entry;
}
}
res.json(result);
},
);
/**
* @openapi
* /host/status/settings:
* get:
* summary: Get status check settings
* description: Returns the default number of seconds between status checks.
* tags:
* - SSH
* responses:
* 200:
* description: The status check settings.
* 403:
* description: Admin access required.
*/
router.get(
"/status/settings",
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,
});
},
);
/**
* @openapi
* /host/status/settings:
* put:
* summary: Update status check settings
* description: Sets the default number of seconds between status checks and re-times every running check.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* statusCheckInterval:
* type: integer
* minimum: 5
* maximum: 3600
* responses:
* 200:
* description: Settings saved.
* 400:
* description: Invalid interval.
* 403:
* description: Admin access required.
*/
router.put(
"/status/settings",
authenticateJWT,
requireAdmin,
async (req: Request, res: Response) => {
const seconds = Number(req.body?.statusCheckInterval);
if (!Number.isInteger(seconds) || seconds < 5 || seconds > 3600) {
return res
.status(400)
.json({ error: "Status check interval must be 5 to 3600 seconds" });
}
try {
await createCurrentSettingsRepository().set(
GLOBAL_STATUS_INTERVAL_KEY,
String(seconds),
);
hostStatusService.retimeAll();
res.json({ statusCheckInterval: seconds });
} catch (error) {
sshLogger.error("Failed to save status check settings", error, {
operation: "host_status_settings",
});
res.status(500).json({ error: "Failed to save settings" });
}
},
);
/**
* @openapi
* /host/status/refresh:
* post:
* summary: Restart status checks
* description: Re-reads the user's own hosts and restarts their status checks.
* tags:
* - SSH
* responses:
* 200:
* description: Status checks restarted.
* 401:
* description: Session expired - please log in again.
*/
router.post(
"/status/refresh",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
await hostStatusService.refresh(userId);
res.json({ message: "Status checks restarted" });
},
);
/**
* @openapi
* /host/status/{id}:
* get:
* summary: Get one host's status
* description: Returns the status of one host the user can connect to.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The host's status entry.
* 400:
* description: Invalid host id.
* 404:
* description: Status not available.
*/
router.get(
"/status/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
const access = await permissionManager.canAccessHost(
userId,
id,
"connect",
);
if (!access.hasAccess) {
return res.status(404).json({ error: "Status not available" });
}
await hostStatusService.statusesFor(userId, null);
const entry =
hostStatusService.get(id) ?? (await hostStatusService.check(id));
if (!entry) {
return res.status(404).json({ error: "Status not available" });
}
res.json(entry);
},
);
}
+25 -19
View File
@@ -55,6 +55,7 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
import {
applyHostEnrollmentDefaults,
requireHostEnrollmentAccessForPath,
@@ -109,6 +110,11 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
registerHostInternalRoutes(router);
registerHostStatusRoutes(router, {
authenticateJWT,
requireAdmin: authManager.createAdminMiddleware(),
});
/**
* @openapi
* /host/db/host:
@@ -207,7 +213,8 @@ router.post(
tunnelConnections,
jumpHosts,
quickActions,
statsConfig,
statusCheckEnabled,
statusCheckInterval,
dockerConfig,
proxmoxConfig,
enableProxmoxStats,
@@ -335,11 +342,8 @@ router.post(
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig
? typeof statsConfig === "string"
? statsConfig
: JSON.stringify(statsConfig)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
dockerConfig: dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
@@ -774,7 +778,8 @@ router.post(
tunnelConnections: [],
jumpHosts: [],
quickActions: [],
statsConfig: {},
statusCheckEnabled: true,
statusCheckInterval: null,
notes: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -904,7 +909,8 @@ router.put(
tunnelConnections,
jumpHosts,
quickActions,
statsConfig,
statusCheckEnabled,
statusCheckInterval,
dockerConfig,
proxmoxConfig,
enableProxmoxStats,
@@ -1033,11 +1039,8 @@ router.put(
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig
? typeof statsConfig === "string"
? statsConfig
: JSON.stringify(statsConfig)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
dockerConfig: dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
@@ -2119,9 +2122,6 @@ router.get(
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
@@ -2285,9 +2285,6 @@ router.get(
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? {
endpoints: [],
},
@@ -2929,3 +2926,12 @@ function safeParseJson(raw: string): unknown {
return null;
}
}
/** Seconds between status checks, or null to follow the global setting. */
function normalizeStatusInterval(value: unknown): number | null {
const seconds = Number(value);
if (value === null || value === undefined || value === "") return null;
return Number.isInteger(seconds) && seconds >= 5 && seconds <= 86400
? seconds
: null;
}
@@ -32,7 +32,6 @@ export type HostDefaults = {
socks5Username?: string;
socks5Password?: string;
credentialId?: number | null;
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
fontSize?: number;
fontFamily?: string;
+2 -2
View File
@@ -1,4 +1,4 @@
import { authFailureTracker } from "./metrics-shared/state.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import type { WebSocket } from "ws";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
import { sshLogger } from "../utils/logger.js";
@@ -328,7 +328,7 @@ export class SSHHostKeyVerifier {
algorithm,
currentChangeCount,
);
authFailureTracker.resetHostKeyFailure(hostId);
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId });
}
private static async promptUserForNewKey(
-7
View File
@@ -117,13 +117,6 @@ export async function resolveHostById(
host.tunnelConnections = [];
}
}
if (typeof host.statsConfig === "string" && host.statsConfig) {
try {
host.statsConfig = JSON.parse(host.statsConfig as string);
} catch {
host.statsConfig = undefined;
}
}
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
try {
host.terminalConfig = JSON.parse(host.terminalConfig as string);
@@ -1,145 +0,0 @@
import type { Client, ClientChannel } from "ssh2";
export function execCommand(
client: Client,
command: string,
timeoutMs = 30000,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
let settled = false;
let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
if (stream) {
try {
stream.removeAllListeners();
if (stream.stderr) {
stream.stderr.removeAllListeners();
}
stream.destroy();
} catch {
// expected - cleanup errors ignored
}
}
};
client.exec(command, { pty: false }, (err, _stream) => {
if (err) {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
return;
}
stream = _stream;
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
if (!settled) {
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.on("error", (streamErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(streamErr);
}
});
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
})
.on("error", (stderrErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(stderrErr);
}
});
}
});
});
}
export type HostPlatform = "darwin" | "linux" | "windows" | "other";
// Windows OpenSSH's default shell is usually cmd.exe (occasionally
// PowerShell), so scripts are sent base64-encoded via -EncodedCommand -
// that avoids every cmd.exe quoting/escaping pitfall entirely, since the
// argument ends up being plain alphanumeric text with no shell metacharacters.
export function encodePowerShellCommand(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}
export function execPowerShell(
client: Client,
script: string,
timeoutMs = 30000,
): Promise<{ stdout: string; stderr: string; code: number | null }> {
const encoded = encodePowerShellCommand(script);
return execCommand(
client,
`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
timeoutMs,
);
}
export async function detectPlatform(client: Client): Promise<HostPlatform> {
try {
const { stdout, code } = await execCommand(client, "uname -s", 10000);
const kernel = stdout.trim().toLowerCase();
if (kernel === "darwin") return "darwin";
if (kernel === "linux") return "linux";
if (code === 0 && kernel) return "other";
} catch {
// expected on hosts with no POSIX shell (e.g. Windows via cmd.exe)
}
try {
const { stdout } = await execPowerShell(client, "'WIN_OK'", 10000);
if (stdout.includes("WIN_OK")) return "windows";
} catch {
// expected on hosts with no PowerShell available
}
return "other";
}
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
@@ -1,169 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
export class ElevationError extends Error {
code: ElevationErrorCode;
constructor(code: ElevationErrorCode, message: string) {
super(message);
this.name = "ElevationError";
this.code = code;
}
}
export interface ElevatedResult {
stdout: string;
stderr: string;
code: number | null;
usedSudo: boolean;
}
const PERMISSION_DENIED = [
"permission denied",
"operation not permitted",
"must be run as root",
"must be superuser",
"you need to be root",
"are you root",
"access denied",
];
/**
* Phrases sudo itself prints to STDERR when authentication or authorization
* fails. We only match these against sudo's stderr (never command output), so a
* command that happens to print "permission denied" on stdout is not mistaken
* for a sudo failure.
*/
const SUDO_AUTH_FAILED = [
"incorrect password",
"a password is required",
"a terminal is required",
"no tty present",
"sorry, try again",
"no password was provided",
"1 incorrect password attempt",
];
const SUDO_NOT_SUDOER = [
"is not in the sudoers file",
"not allowed to run sudo",
"not allowed to execute",
];
/**
* Marker printed only after sudo has successfully authenticated and started the
* inner shell. Its presence on stdout is the authoritative "elevation worked"
* signal; its absence (together with sudo stderr) means auth failed.
*/
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
/** Escape a value for single-quoted shell context. */
export function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
/**
* Build the elevated command string:
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
*
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
* command's own output stays clean and sudo's auth errors stay on stderr. The
* marker is echoed by the inner shell once sudo authenticates, letting us tell a
* genuine auth failure from command output that merely contains scary words.
*/
export function buildSudoCommand(
command: string,
sudoPassword: string,
): string {
const pw = shellSingleQuote(sudoPassword);
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
}
function includesAny(text: string, needles: string[]): boolean {
const lower = text.toLowerCase();
return needles.some((n) => lower.includes(n));
}
function looksLikePermissionDenied(text: string): boolean {
return includesAny(text, PERMISSION_DENIED);
}
/** Strip the success marker line from the front of stdout. */
function stripMarker(stdout: string): string {
return stdout.replace(SUDO_OK_LINE_RE, "");
}
/**
* Run a command on a pooled client, elevating with the host's stored sudo
* password only when needed (or when forced). Throws a typed `ElevationError`
* when elevation is required but unavailable/incorrect.
*/
export async function execElevated(
client: Client,
command: string,
sudoPassword: string | undefined,
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
): Promise<ElevatedResult> {
const timeoutMs = opts.timeoutMs ?? 30000;
if (!opts.forceSudo) {
const direct = await execCommand(client, command, timeoutMs);
if (direct.code === 0) {
return { ...direct, usedSudo: false };
}
// Only escalate when the failure looks like a privilege problem. A
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
if (!looksLikePermissionDenied(direct.stderr)) {
return { ...direct, usedSudo: false };
}
if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
} else if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
const result = await execCommand(client, sudoCmd, timeoutMs);
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
if (!authenticated) {
// Elevation never started: diagnose from sudo's stderr only.
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
if (notSudoer) {
throw new ElevationError(
"NOT_SUDOER",
"The connected user is not permitted to use sudo on this host.",
);
}
if (authFailed) {
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
// No marker and no recognizable sudo error: treat as a generic failure but
// keep the original output so the caller can surface it.
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
return {
stdout: stripMarker(result.stdout),
stderr: result.stderr,
code: result.code,
usedSudo: true,
};
}
@@ -1,84 +0,0 @@
import type { PackageManager } from "./platform.js";
export interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
switch (pkg) {
case "apt":
return "apt list --upgradable 2>/dev/null | tail -n +2";
case "dnf":
return "dnf -q check-update 2>/dev/null || true";
case "yum":
return "yum -q check-update 2>/dev/null || true";
case "pacman":
return "pacman -Qu 2>/dev/null || true";
default:
return null;
}
}
export function parseUpgradable(
pkg: PackageManager,
output: string,
): UpgradablePackage[] {
const out: UpgradablePackage[] = [];
const lines = output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (pkg === "apt") {
for (const line of lines) {
// name/suite newver arch [upgradable from: oldver]
const m = line.match(
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
);
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
}
} else if (pkg === "dnf" || pkg === "yum") {
for (const line of lines) {
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
}
} else if (pkg === "pacman") {
for (const line of lines) {
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
}
}
return out;
}
export type PackageAction = "upgrade-all" | "install" | "upgrade";
export function buildPackageActionCommand(
pkg: PackageManager,
action: PackageAction,
name?: string,
): string | null {
const target = name ? ` ${name}` : "";
switch (pkg) {
case "apt":
if (action === "upgrade-all")
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
case "dnf":
return action === "upgrade-all"
? "dnf -y upgrade"
: `dnf -y install${target}`;
case "yum":
return action === "upgrade-all"
? "yum -y update"
: `yum -y install${target}`;
case "pacman":
return action === "upgrade-all"
? "pacman -Syu --noconfirm"
: `pacman -S --noconfirm${target}`;
default:
return null;
}
}
@@ -1,75 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
export interface PlatformInfo {
hasSystemd: boolean;
pkg: PackageManager;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` +
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
/**
* Single probe that reports which tooling is available. Each line is
* "key=value" so the parser is trivial and order-independent.
*/
export const PLATFORM_PROBE_COMMAND = [
`PATH="${EXTRA_PATH_DIRS}:$PATH"`,
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
`echo docker=$(${DOCKER_PROBE} && echo 1 || echo 0)`,
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
].join("; ");
export function parsePlatformProbe(output: string): PlatformInfo {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
const idx = line.indexOf("=");
if (idx === -1) continue;
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
}
const on = (k: string) => map.get(k) === "1";
// Prefer dnf over yum when both exist (dnf is the modern front-end).
let pkg: PackageManager = null;
if (on("apt")) pkg = "apt";
else if (on("dnf")) pkg = "dnf";
else if (on("yum")) pkg = "yum";
else if (on("pacman")) pkg = "pacman";
const os = map.get("os");
return {
hasSystemd: on("systemd"),
pkg,
hasCertbot: on("certbot"),
hasAcmeSh: on("acmesh"),
hasDocker: on("docker"),
osPrettyName: os && os.length > 0 ? os : null,
};
}
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
return parsePlatformProbe(stdout);
}
-434
View File
@@ -1,434 +0,0 @@
class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>();
private requestTimeout = 60000;
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const wrappedRequest = async () => {
try {
const result = await Promise.race<T>([
request(),
new Promise<never>((_, rej) =>
setTimeout(
() =>
rej(
new Error(
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
),
),
this.requestTimeout,
),
),
]);
resolve(result);
} catch (error) {
reject(error);
}
};
const queue = this.queues.get(hostId) || [];
queue.push(wrappedRequest);
this.queues.set(hostId, queue);
this.processQueue(hostId);
});
}
private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return;
this.processing.add(hostId);
const queue = this.queues.get(hostId) || [];
while (queue.length > 0) {
const request = queue.shift();
if (request) {
try {
await request();
} catch {
// expected
}
}
}
this.processing.delete(hostId);
const currentQueue = this.queues.get(hostId);
if (currentQueue && currentQueue.length > 0) {
this.processQueue(hostId);
}
}
}
interface CachedMetrics {
data: unknown;
timestamp: number;
hostId: number;
}
class MetricsCache {
private cache = new Map<number, CachedMetrics>();
private ttl = 30000;
get(hostId: number): unknown | null {
const cached = this.cache.get(hostId);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
return null;
}
set(hostId: number, data: unknown): void {
this.cache.set(hostId, {
data,
timestamp: Date.now(),
hostId,
});
}
clear(hostId?: number): void {
if (hostId) {
this.cache.delete(hostId);
} else {
this.cache.clear();
}
}
}
interface AuthFailureRecord {
count: number;
lastFailure: number;
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
permanent: boolean;
}
class AuthFailureTracker {
private failures = new Map<number, AuthFailureRecord>();
private maxRetries = 3;
private backoffBase = 5000;
recordFailure(
hostId: number,
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
permanent = false,
): void {
const existing = this.failures.get(hostId);
if (existing) {
existing.count++;
existing.lastFailure = Date.now();
existing.reason = reason;
if (permanent) existing.permanent = true;
} else {
this.failures.set(hostId, {
count: 1,
lastFailure: Date.now(),
reason,
permanent,
});
}
}
shouldSkip(hostId: number): boolean {
const record = this.failures.get(hostId);
if (!record) return false;
if (record.reason === "TOTP" || record.permanent) {
return true;
}
if (record.count >= this.maxRetries) {
return true;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
return timeSinceFailure < backoffTime;
}
getSkipReason(hostId: number): string | null {
const record = this.failures.get(hostId);
if (!record) return null;
if (record.reason === "TOTP") {
return "TOTP authentication required (metrics unavailable)";
}
if (record.reason === "HOST_KEY") {
return "SSH host key changed (verification required)";
}
if (record.permanent) {
return "Authentication permanently failed";
}
if (record.count >= this.maxRetries) {
return `Too many authentication failures (${record.count} attempts)`;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
if (timeSinceFailure < backoffTime) {
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
}
return null;
}
resetHostKeyFailure(hostId: number): void {
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, record] of this.failures.entries()) {
if (!record.permanent && now - record.lastFailure > maxAge) {
this.failures.delete(hostId);
}
}
}
}
class PollingBackoff {
private failures = new Map<number, { count: number; nextRetry: number }>();
private baseDelay = 30000;
private maxDelay = 600000;
private maxRetries = 5;
recordFailure(hostId: number): void {
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
const delay = Math.min(
this.baseDelay * Math.pow(2, existing.count),
this.maxDelay,
);
this.failures.set(hostId, {
count: existing.count + 1,
nextRetry: Date.now() + delay,
});
}
shouldSkip(hostId: number): boolean {
const backoff = this.failures.get(hostId);
if (!backoff) return false;
if (backoff.count >= this.maxRetries) {
return true;
}
return Date.now() < backoff.nextRetry;
}
getBackoffInfo(hostId: number): string | null {
const backoff = this.failures.get(hostId);
if (!backoff) return null;
if (backoff.count >= this.maxRetries) {
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
}
const remainingMs = backoff.nextRetry - Date.now();
if (remainingMs > 0) {
const remainingSec = Math.ceil(remainingMs / 1000);
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, backoff] of this.failures.entries()) {
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
this.failures.delete(hostId);
}
}
}
}
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop host-metrics status/metrics polls from stampeding under load.
*/
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();
}
}
}
/**
* How many metrics polls may run at once for a given number of polled hosts.
*
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
* interval, so polling fell permanently behind and a host could wait over a
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
* its interval; the cap keeps file descriptors and CPU bounded.
*/
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
const MIN_METRICS_CONCURRENCY = 5;
const MAX_METRICS_CONCURRENCY = 50;
/** Aim to spend about a twentieth of the interval per sweep wave. */
const HOSTS_PER_WORKER = 20;
export function metricsConcurrencyFor(
hostCount: number,
env: NodeJS.ProcessEnv = process.env,
): number {
const override = Number(env[METRICS_CONCURRENCY_ENV]);
if (Number.isFinite(override) && override >= 1) {
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
}
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
return Math.min(
Math.max(scaled, MIN_METRICS_CONCURRENCY),
MAX_METRICS_CONCURRENCY,
);
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/** TCP status checks are cheap relative to SSH metrics collection. */
export const statusPollLimiter = new ConcurrentLimiter(20);
/** SSH metrics execs are expensive; keep concurrency tight. */
export const metricsPollLimiter = new ConcurrentLimiter(5);
/** Viewer registration is bursty; admit only two first samples at a time. */
export const initialMetricsPollLimiter = new ConcurrentLimiter(2);
export function canStartInitialMetrics(
status: HostStatus | undefined,
hasViewers: boolean,
statusCheckEnabled = true,
): boolean {
return (
hasViewers &&
(!statusCheckEnabled || status === "reachable" || status === "online")
);
}
export const hostPollCache = new HostPollCache(30_000);
export const requestQueue = new RequestQueue();
export const metricsCache = new MetricsCache();
export const authFailureTracker = new AuthFailureTracker();
export const pollingBackoff = new PollingBackoff();
import type { HostStatus } from "./host-status.js";
@@ -1,127 +0,0 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
@@ -0,0 +1,458 @@
/**
* The host list's status dot.
*
* Every host with status checks on gets a TCP connection to its port on a
* timer. That alone says "reachable". A host becomes "online" when a login
* worked: an open terminal session, or a plugin reporting one through
* ctx.hosts.status.reportLogin. Probing never logs in: on RADIUS or Duo backed
* devices a login fires a real 2FA push, every interval.
*
* 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.
*/
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 {
statusAfterAuthentication,
statusAfterReachabilityCheck,
type HostStatus,
} from "./host-status.js";
import { ConcurrentLimiter } from "./limiter.js";
import { tcpPing, tcpPingThroughJumpHost } from "./tcp-ping.js";
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
export const DEFAULT_STATUS_INTERVAL = 60;
/** A status younger than this is fresh enough for check(). */
const FRESH_MS = 30_000;
export interface HostStatusEntry {
status: HostStatus;
lastChecked: string;
reason?: "host_key_changed";
}
export interface HostStatusPayload {
hostId: number;
ownerUserId: string;
status: HostStatus;
previous: HostStatus | null;
/** Anything but offline, the shape automations' trigger already reads. */
online: boolean;
reason?: "host_key_changed";
}
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[]>;
ping: (host: string, port: number) => Promise<boolean>;
pingThroughJumpHosts: (
target: StatusTarget,
port: number,
) => Promise<boolean>;
globalInterval: () => number;
emit: (payload: HostStatusPayload) => void;
}
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 defaultDeps: HostStatusDeps = {
loadTargets: async (filter) =>
(
await createCurrentHostResolutionRepository().listStatusTargets(filter)
).map(toStatusTarget),
ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: async (target, port) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
const client = await createJumpHostChain(
target.jumpHosts,
target.userId,
).catch(() => null);
return client
? tcpPingThroughJumpHost(client, target.ip, port, 5000)
: false;
},
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 sessionOnline = new Set<number>();
private readonly loggedIn = new Set<number>();
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) =>
this.setSessionOnline(hostId, online),
),
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);
}),
pluginEvents.on(TOPICS.hostKeyUpdated, (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) this.clearHostKeyReason(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>> {
if (requestedHostIds !== null) {
await this.reconcile(userId, requestedHostIds);
} else if (!this.startedUsers.has(userId)) {
await this.startUser(userId);
}
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;
}
reportLogin(
hostId: number,
outcome: { ok: boolean; hostKeyChanged?: boolean },
): void {
if (outcome.ok) {
this.loggedIn.add(hostId);
this.set(hostId, { status: statusAfterAuthentication(true) });
return;
}
this.loggedIn.delete(hostId);
this.set(hostId, {
status: this.sessionOnline.has(hostId)
? "online"
: statusAfterAuthentication(false, this.store.get(hostId)?.status),
...(outcome.hostKeyChanged ? { reason: "host_key_changed" } : {}),
});
}
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 startUser(userId: string): 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.startPolling(target);
}
}
private async reconcile(userId: string, allowed: Set<number>): 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.startPolling(target);
}
}
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);
this.loggedIn.delete(hostId);
}
private clearHostKeyReason(hostId: number): void {
const current = this.store.get(hostId);
if (current?.reason !== "host_key_changed") return;
this.store.set(hostId, {
status: current.status,
lastChecked: current.lastChecked,
});
}
private setSessionOnline(hostId: number, online: boolean): void {
if (online) {
this.sessionOnline.add(hostId);
if (this.polled.has(hostId)) this.set(hostId, { status: "online" });
return;
}
this.sessionOnline.delete(hostId);
if (
!this.loggedIn.has(hostId) &&
this.store.get(hostId)?.status === "online"
) {
this.set(hostId, { status: "reachable" });
}
}
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)))
);
}
private startPolling(target: StatusTarget, probeNow = true): void {
this.stopPolling(target.id);
this.owners.set(target.id, target.userId);
if (!target.statusCheckEnabled) {
this.store.delete(target.id);
return;
}
const polled: Polled = { target };
this.polled.set(target.id, polled);
if (probeNow) void this.probe(target);
polled.timer = setInterval(() => {
const latest = this.polled.get(target.id);
if (latest) void this.probe(latest.target);
}, this.intervalMs(target));
polled.timer.unref?.();
}
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 probeNow(target: StatusTarget): Promise<void> {
this.owners.set(target.id, target.userId);
let reachable = false;
try {
const port = await this.portFor(target);
reachable =
target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
} catch {
reachable = false;
}
const current = this.store.get(target.id);
this.set(target.id, {
status:
reachable && this.sessionOnline.has(target.id)
? "online"
: statusAfterReachabilityCheck(reachable, current?.status),
...(reachable && current?.reason === "host_key_changed"
? { reason: "host_key_changed" as const }
: {}),
});
}
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 !== "offline",
...(entry.reason ? { reason: entry.reason } : {}),
});
}
}
export const hostStatusService = new HostStatusService();
+81
View File
@@ -0,0 +1,81 @@
/**
* 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();
}
}
}
+92
View File
@@ -0,0 +1,92 @@
import net from "net";
import type { Client } from "ssh2";
/**
* Opens a TCP connection and closes it again. An SSH server gets a polite
* banner back so it does not log a failed handshake.
*/
export function tcpPing(
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
resolve(result);
};
const cleanup = () => {
try {
socket.destroy();
} catch {
// expected
}
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => {
const dataTimeout = setTimeout(() => {
cleanup();
finish(true);
}, 2000);
socket.once("data", (data) => {
clearTimeout(dataTimeout);
if (data.toString("utf8").startsWith("SSH-")) {
try {
socket.end("SSH-2.0-TermixHealthCheck\r\n");
} catch {
// expected
}
setTimeout(cleanup, 200);
} else {
cleanup();
}
finish(true);
});
});
socket.once("timeout", () => {
cleanup();
finish(false);
});
socket.once("error", () => {
cleanup();
finish(false);
});
socket.connect(port, host);
});
}
/** The same check from the far end of a jump host chain. Ends the chain. */
export function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream);
});
});
}
+39 -1
View File
@@ -25,6 +25,8 @@ import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { assertCapability } 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 type { DisposableBag } from "./disposables.js";
type AuditFn = (
action: string,
@@ -34,6 +36,8 @@ type AuditFn = (
interface Deps {
manifest: PluginManifest;
/** Where registerPort entries go, so deactivate removes them. */
bag?: DisposableBag;
audit: AuditFn;
}
@@ -98,11 +102,16 @@ function actingUser(): string {
return actor;
}
export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
const requireReadSync = () => {
if (!declared.includes("hosts:read")) {
throw new PluginCapabilityError(pluginId, "hosts:read");
}
};
const requireWrite = () =>
assertCapability(pluginId, "hosts:write", declared);
@@ -418,5 +427,34 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
},
// Synchronous and not audited, like trackSession: pollers call these on
// every sample.
status: {
get: (hostId) => {
requireReadSync();
return hostStatusService.get(hostId);
},
check: async (hostId) => {
await requireRead();
return hostStatusService.check(hostId);
},
reportLogin: (hostId, outcome) => {
requireReadSync();
hostStatusService.reportLogin(hostId, outcome);
},
registerPort: (connectionType, resolve) => {
requireReadSync();
const unregister = hostStatusService.registerPort(
connectionType,
resolve,
);
const drop = bag?.add(unregister, `status port for ${connectionType}`);
return () => {
drop?.();
unregister();
};
},
},
};
}
+20 -3
View File
@@ -90,7 +90,9 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const open = new Set<() => void>();
const poolKeys = new Set<string>();
// Pool key -> host id, so dropPooled can find a host's keys after its
// address changed.
const poolKeys = new Map<string, number>();
bag.add(async () => {
for (const dispose of [...open]) dispose();
@@ -98,7 +100,9 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
if (poolKeys.size > 0) {
const { connectionPool } =
await import("../hosts/ssh-connection-pool.js");
for (const key of poolKeys) connectionPool.clearKeyConnections(key);
for (const key of poolKeys.keys()) {
connectionPool.clearKeyConnections(key);
}
poolKeys.clear();
}
}, "SSH connections");
@@ -183,7 +187,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
actingUser(host),
);
const key = poolKey(options.pool, resolved as PluginSshHost);
poolKeys.add(key);
poolKeys.set(key, resolved.id);
const { withConnection } =
await import("../hosts/ssh-connection-pool.js");
return withConnection(
@@ -228,6 +232,19 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
poolKey,
dropPooled: (pool, hostId) => {
const keys = [...poolKeys.entries()]
.filter(([key, id]) => id === hostId && key.startsWith(`${pool}:`))
.map(([key]) => key);
if (keys.length === 0) return;
for (const key of keys) poolKeys.delete(key);
void import("../hosts/ssh-connection-pool.js").then(
({ connectionPool }) => {
for (const key of keys) connectionPool.clearKeyConnections(key);
},
);
},
resolveHost: async (hostId, options) => {
await checkSsh(true);
const userId = actingUser(hostId);
+15 -2
View File
@@ -53,6 +53,7 @@ import {
} from "../database/db/dialect.js";
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -425,11 +426,15 @@ export function createPluginContext(
refs: () => dbRefs() as never,
// Gated but not audited: it follows every write, and the write itself
// went through a client() call that already left an audit line.
persist: async () => {
persist: async (options) => {
await assertCapability(pluginId, "db:own", manifest.capabilities);
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
const { DatabaseSaveTrigger } =
await import("../utils/database-save-trigger.js");
if (options?.lazy) {
DatabaseSaveTrigger.triggerSave(`plugin_${pluginId}_write`);
return;
}
await DatabaseSaveTrigger.forceSave(`plugin_${pluginId}_write`);
},
get dialect() {
@@ -715,7 +720,15 @@ export function createPluginContext(
},
},
hosts: createPluginHosts({ manifest, audit: auditCall }),
hosts: createPluginHosts({ manifest, bag: handle.bag, audit: auditCall }),
schedule: createPluginSchedule(handle.bag, (message, error) =>
pluginLogger.error(
message,
error instanceof Error ? error : new Error(String(error)),
logContext,
),
),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
+4 -7
View File
@@ -1,9 +1,8 @@
/**
* The process-wide event bus behind ctx.events.
*
* This formalises three ad-hoc publish paths that already existed:
* This formalises the ad-hoc publish paths that already existed:
* - hosts/automation-events.ts (generic named internal events)
* - hosts/metrics/automation-bridge.ts (metrics-shaped siblings)
* - hosts/host-session-status.ts (the "host.session.status" topic)
*
* Each of those lazily imported the automations engine and swallowed errors,
@@ -28,12 +27,10 @@ export const TOPICS = {
hostSessionStatus: "host.session.status",
/** A generic named internal event (host_deleted, user_login, ...). */
internalEvent: "internal.event",
/** A metrics snapshot for one host. */
hostMetrics: "host.metrics",
/** A host went online or offline as seen by the metrics poller. */
/** A host's status dot changed (hosts/status/host-status-service.ts). */
hostStatus: "host.status",
/** A health check result. */
hostHealthCheck: "host.health_check",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
/** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */
+81
View File
@@ -0,0 +1,81 @@
/**
* ctx.schedule: timers a plugin owns. They live in the plugin's disposable
* bag, so deactivate clears every one of them.
*/
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { DisposableBag } from "./disposables.js";
type Log = (message: string, error: unknown) => void;
export function createPluginSchedule(
bag: DisposableBag,
logError: Log,
): PluginSchedule {
const run = async (fn: () => void | Promise<void>) => {
try {
await fn();
} catch (error) {
logError("Scheduled job failed", error);
}
};
return {
every: (intervalMs, fn, options) => {
let running = false;
let stopped = false;
let interval: NodeJS.Timeout | undefined;
const tick = () => {
if (running || stopped) return;
running = true;
void run(fn).finally(() => {
running = false;
});
};
const jitter = Math.max(0, Math.floor(options?.jitterMs ?? 0));
const start = setTimeout(
() => {
if (stopped) return;
interval = setInterval(tick, Math.max(1, intervalMs));
if (jitter > 0) tick();
},
jitter > 0 ? Math.floor(Math.random() * jitter) : 0,
);
if (options?.runNow) tick();
let drop = () => {};
const stop = () => {
if (stopped) return;
stopped = true;
clearTimeout(start);
if (interval) clearInterval(interval);
drop();
};
drop = bag.add(stop, "scheduled job");
return stop;
},
after: (delayMs, fn) => {
let done = false;
let drop = () => {};
const timer = setTimeout(
() => {
done = true;
drop();
void run(fn);
},
Math.max(0, delayMs),
);
const cancel = () => {
if (done) return;
done = true;
clearTimeout(timer);
drop();
};
drop = bag.add(cancel, "scheduled job");
return cancel;
},
};
}
+1 -5
View File
@@ -38,11 +38,7 @@ export type { PluginSettingsScope };
* to it means deciding that every plugin holding settings:read-core may see
* that value, so it is a review decision rather than a convenience.
*/
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"global_metrics_interval",
"global_status_check_interval",
];
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = ["app_name"];
type ChangeListener = (value: unknown) => void;
+12
View File
@@ -269,6 +269,14 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/external-identity-migration.js");
await runExternalIdentityMigration();
const { runHostStatusConfigMigration } =
await import("./utils/crypto-migration/host-status-config-migration.js");
await runHostStatusConfigMigration();
const { hostStatusService } =
await import("./hosts/status/host-status-service.js");
hostStatusService.start();
if (process.env.ELECTRON_EMBEDDED === "true") {
await provisionLocalDesktopUserIfNeeded();
}
@@ -381,6 +389,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runRemoteDesktopSettingsMigration } =
await import("./utils/crypto-migration/remote-desktop-settings-migration.js");
await runRemoteDesktopSettingsMigration();
const { runHostMetricsSettingsMigration } =
await import("./utils/crypto-migration/host-metrics-settings-migration.js");
await runHostMetricsSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -483,7 +483,6 @@ describe("HostRepository and CredentialRepository", () => {
port: 22,
username: "root",
authType: "password",
statsConfig: JSON.stringify({ cpu: true }),
});
const second = await repo.hosts.create({
userId: "user-1",
@@ -1,155 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostHealthRepository } from "../../../database/repositories/host-health-repository.js";
describe("HostHealthRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HostHealthRepository> {
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-2', 'two', '10.0.0.1', 22, 'root', 'password');
INSERT INTO host_health_checks (
user_id, host_id, checks, interval_seconds, created_at, updated_at
)
VALUES (
'user-1',
1,
'[{"id":"tcp","name":"TCP","type":"tcp","target":"localhost","port":22}]',
300,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z'
);
`);
return new HostHealthRepository(context, onWrite);
}
it("finds and upserts check configuration", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const existing = await repo.findChecksByUserAndHost("user-1", 1);
expect(existing?.intervalSeconds).toBe(300);
const updated = await repo.upsertChecks(
"user-1",
1,
'[{"id":"http"}]',
60,
"2026-02-01T00:00:00.000Z",
);
expect(updated).toMatchObject({
id: existing?.id,
checks: '[{"id":"http"}]',
intervalSeconds: 60,
updatedAt: "2026-02-01T00:00:00.000Z",
});
const created = await repo.upsertChecks(
"user-2",
2,
'[{"id":"tcp"}]',
120,
"2026-03-01T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-2",
hostId: 2,
checks: '[{"id":"tcp"}]',
intervalSeconds: 120,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
expect(writeCount).toBe(2);
});
it("records and prunes history", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.recordHistory(
"user-1",
1,
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
1,
"2026-01-01T00:00:00.000Z",
),
).toBe(1);
expect(
await repo.recordHistory(
"user-1",
1,
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
1,
"2026-01-02T00:00:00.000Z",
),
).toBe(1);
const history = await repo.listHistory("user-1", 1, 10);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
userId: "user-1",
hostId: 1,
checkId: "two",
ok: false,
latencyMs: null,
detail: "closed",
});
expect(writeCount).toBe(2);
});
it("deletes all health checks and history for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertChecks("user-2", 2, '[{"id":"tcp"}]', 120);
await repo.recordHistory(
"user-1",
1,
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
10,
);
await repo.recordHistory(
"user-2",
2,
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
10,
);
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
checksDeleted: 1,
historyDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
checksDeleted: 0,
historyDeleted: 0,
});
expect(await repo.findChecksByUserAndHost("user-1", 1)).toBeNull();
expect(await repo.listHistory("user-1", 1, 10)).toEqual([]);
expect((await repo.findChecksByUserAndHost("user-2", 2))?.hostId).toBe(2);
expect(await repo.listHistory("user-2", 2, 10)).toHaveLength(1);
expect(writeCount).toBe(4);
});
});
@@ -148,7 +148,6 @@ describe("HostRepository Proxmox sync inserts", () => {
tunnelConnections: "[]",
jumpHosts: null,
quickActions: null,
statsConfig: null,
dockerConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
@@ -0,0 +1,197 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express, { type RequestHandler } from "express";
const state = vi.hoisted(() => ({
settings: {} as Record<string, string>,
locked: false,
accessible: new Set<number>([1, 2]),
statuses: new Map<number, { status: string; lastChecked: string }>(),
}));
const service = vi.hoisted(() => ({
statusesFor: vi.fn(),
get: vi.fn(),
check: vi.fn(),
refresh: vi.fn(),
retimeAll: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => (state.locked ? null : "key") },
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
filterAccessibleHostIds: async (_user: string, ids: number[]) =>
new Set(ids.filter((id) => state.accessible.has(id))),
canAccessHost: async (_user: string, id: number) => ({
hasAccess: state.accessible.has(id),
}),
}),
},
}));
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,
}));
const { registerHostStatusRoutes, parseStatusHostIds } =
await import("../../../database/routes/host-status-routes.js");
let server: http.Server | null = null;
let base = "";
async function start(isAdmin = true) {
const router = express.Router();
const authenticateJWT: RequestHandler = (req, _res, next) => {
(req as unknown as { userId: string }).userId = "user-1";
next();
};
const requireAdmin: RequestHandler = (_req, res, next) =>
isAdmin ? next() : void res.status(403).json({ error: "Admin only" });
registerHostStatusRoutes(router, { authenticateJWT, requireAdmin });
const app = express();
app.use(express.json());
app.use("/host", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server!.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
async function call(method: string, path: string, body?: unknown) {
const response = await fetch(`${base}${path}`, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, body: await response.json() };
}
beforeEach(() => {
state.settings = {};
state.locked = false;
state.accessible = new Set([1, 2]);
state.statuses = new Map([
[1, { status: "online", lastChecked: "t" }],
[2, { status: "offline", lastChecked: "t" }],
[3, { status: "reachable", lastChecked: "t" }],
]);
service.statusesFor.mockImplementation(async () => state.statuses);
service.get.mockImplementation(
(id: number) => state.statuses.get(id) ?? null,
);
service.check.mockResolvedValue(null);
});
afterEach(async () => {
if (server)
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
vi.clearAllMocks();
});
describe("GET /host/status", () => {
it("only returns hosts the user can reach", async () => {
await start();
const { status, body } = await call("GET", "/host/status");
expect(status).toBe(200);
expect(Object.keys(body)).toEqual(["1", "2"]);
expect(service.statusesFor).toHaveBeenCalledWith("user-1", null);
});
it("narrows to the requested hosts", async () => {
await start();
const { body } = await call("GET", "/host/status?hostIds=2");
expect(Object.keys(body)).toEqual(["2"]);
expect(service.statusesFor).toHaveBeenCalledWith("user-1", new Set([2]));
});
it("answers 401 while the user's data is locked", async () => {
await start();
state.locked = true;
expect((await call("GET", "/host/status")).status).toBe(401);
});
});
describe("GET /host/status/:id", () => {
it("returns one host's status", async () => {
await start();
const { status, body } = await call("GET", "/host/status/1");
expect(status).toBe(200);
expect(body.status).toBe("online");
});
it("hides a host the user cannot reach", async () => {
await start();
expect((await call("GET", "/host/status/3")).status).toBe(404);
});
});
describe("status settings", () => {
it("reads the default interval", async () => {
await start();
expect((await call("GET", "/host/status/settings")).body).toEqual({
statusCheckInterval: 60,
});
});
it("saves a new interval and re-times the checks", 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();
});
it("rejects an interval out of range", async () => {
await start();
expect(
(await call("PUT", "/host/status/settings", { statusCheckInterval: 1 }))
.status,
).toBe(400);
});
it("is admin only", async () => {
await start(false);
expect((await call("GET", "/host/status/settings")).status).toBe(403);
expect(
(await call("PUT", "/host/status/settings", { statusCheckInterval: 90 }))
.status,
).toBe(403);
});
});
describe("POST /host/status/refresh", () => {
it("restarts the user's checks", async () => {
await start();
expect((await call("POST", "/host/status/refresh")).status).toBe(200);
expect(service.refresh).toHaveBeenCalledWith("user-1");
});
});
describe("parseStatusHostIds", () => {
it("tells an unrestricted request from an empty host set", () => {
expect(parseStatusHostIds(undefined)).toBeNull();
expect(parseStatusHostIds("")).toEqual(new Set());
});
it("keeps only valid positive host ids", () => {
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
});
});
@@ -1,8 +1,8 @@
import { EventEmitter } from "node:events";
import type { WebSocket } from "ws";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
import { authFailureTracker } from "../../hosts/metrics-shared/state.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
const { updateHostKey } = vi.hoisted(() => ({ updateHostKey: vi.fn() }));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -12,10 +12,15 @@ vi.mock("../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const hostId = 991267;
const accepted: unknown[] = [];
const unsubscribe = pluginEvents.on(TOPICS.hostKeyUpdated, (payload) =>
accepted.push(payload),
);
afterEach(() => {
authFailureTracker.reset(hostId);
accepted.length = 0;
vi.resetAllMocks();
});
afterAll(() => unsubscribe());
async function verifyChangedKey(action: "accept" | "reject") {
const socket = new EventEmitter() as EventEmitter & {
@@ -52,35 +57,22 @@ async function verifyChangedKey(action: "accept" | "reject") {
);
}
describe("metrics recovery after host key verification", () => {
it("allows polling after accepting and saving the replacement key", async () => {
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
describe("host key recovery", () => {
it("announces an accepted and saved replacement key", async () => {
expect(await verifyChangedKey("accept")).toBe(true);
expect(updateHostKey).toHaveBeenCalledOnce();
expect(authFailureTracker.shouldSkip(hostId)).toBe(false);
expect(accepted).toEqual([{ hostId }]);
});
it("keeps polling blocked when the new key is rejected", async () => {
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
it("announces nothing when the new key is rejected", async () => {
expect(await verifyChangedKey("reject")).toBe(false);
expect(updateHostKey).not.toHaveBeenCalled();
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
expect(accepted).toEqual([]);
});
it("keeps polling blocked if persisting the accepted key fails", async () => {
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
it("announces nothing if persisting the accepted key fails", async () => {
updateHostKey.mockRejectedValueOnce(new Error("database unavailable"));
expect(await verifyChangedKey("accept")).toBe(false);
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
expect(accepted).toEqual([]);
});
it.each(["AUTH", "TOTP"] as const)(
"preserves unrelated %s failures",
async (reason) => {
authFailureTracker.recordFailure(hostId, reason, true);
expect(await verifyChangedKey("accept")).toBe(true);
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
},
);
});
@@ -122,7 +122,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
autostartKeyPassword: null,
jumpHosts: null,
tunnelConnections: null,
statsConfig: null,
terminalConfig: null,
socks5ProxyChain: null,
quickActions: null,
@@ -0,0 +1,259 @@
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) {
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,
ping,
pingThroughJumpHosts,
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
});
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("starts a user's own hosts and reports them reachable", 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("reachable");
expect(emitted).toContainEqual({
hostId: 1,
ownerUserId: "owner",
status: "reachable",
previous: null,
online: true,
});
});
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("turns reachable into online when a plugin reports a login", async () => {
const { service, emitted } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: true });
await flush();
expect(service.get(1)?.status).toBe("online");
expect(emitted.at(-1)).toMatchObject({
status: "online",
previous: "reachable",
});
service.reportLogin(1, { ok: false, hostKeyChanged: true });
expect(service.get(1)).toMatchObject({
status: "reachable",
reason: "host_key_changed",
});
});
it("clears the host key reason once the new key is accepted", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false, hostKeyChanged: true });
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
expect(service.get(1)?.reason).toBeUndefined();
});
it("shows a host with an open terminal session as online", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online");
release();
expect(service.get(1)?.status).toBe("reachable");
});
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: "reachable" });
expect(await service.check(1)).toMatchObject({ status: "reachable" });
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,
});
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} 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,
);
});
});
describe("host availability status", () => {
it("does not call a TCP-reachable host online before authentication", () => {
expect(statusAfterReachabilityCheck(true)).toBe("reachable");
});
it("keeps a verified host online across later reachability checks", () => {
expect(statusAfterReachabilityCheck(true, "online")).toBe("online");
});
it("marks successful authentication online", () => {
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
});
it("downgrades failed authentication without hiding reachability", () => {
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
});
});
@@ -0,0 +1,162 @@
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,57 @@
import type { Client } from "ssh2";
import { describe, expect, it, vi } from "vitest";
import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js";
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
@@ -22,6 +22,9 @@ const h = vi.hoisted(() => ({
sessions: [] as number[],
released: [] as number[],
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
statusReports: [] as Array<Record<string, unknown>>,
statusPorts: [] as string[],
statusPortsRemoved: [] as string[],
roles: [] as Array<{
id: number;
name: string;
@@ -111,6 +114,22 @@ vi.mock("../../hosts/host-session-status.js", () => ({
},
},
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
get: (hostId: number) =>
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
check: async (hostId: number) => ({
status: "offline",
lastChecked: String(hostId),
}),
reportLogin: (hostId: number, outcome: Record<string, unknown>) =>
h.statusReports.push({ hostId, ...outcome }),
registerPort: (connectionType: string) => {
h.statusPorts.push(connectionType);
return () => h.statusPortsRemoved.push(connectionType);
},
},
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: async (
hostId: number,
@@ -419,3 +438,69 @@ describe("ctx.hosts session tracking and activity", () => {
]);
});
});
describe("ctx.hosts.status", () => {
it("reads and checks core's status with hosts:read", async () => {
h.granted = new Set(["hosts:read"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
expect(hosts.status.get(4)).toEqual({
status: "reachable",
lastChecked: "t",
});
expect(hosts.status.get(5)).toBeNull();
await expect(hosts.status.check(5)).resolves.toEqual({
status: "offline",
lastChecked: "5",
});
});
it("refuses every status member without hosts:read", async () => {
const hosts = createPluginHosts({
manifest: manifest([]),
audit: vi.fn(async () => {}),
});
expect(() => hosts.status.get(4)).toThrow(PluginCapabilityError);
expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow(
PluginCapabilityError,
);
expect(() => hosts.status.registerPort("rdp", () => 3389)).toThrow(
PluginCapabilityError,
);
await expect(hosts.status.check(4)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
it("passes login reports on to core", () => {
h.statusReports = [];
h.granted = new Set(["hosts:read"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
expect(h.statusReports).toEqual([
{ hostId: 4, ok: false, hostKeyChanged: true },
]);
});
it("removes a registered port when the plugin is disposed", async () => {
h.statusPorts = [];
h.statusPortsRemoved = [];
h.granted = new Set(["hosts:read"]);
const { DisposableBag } = await import("../../plugins/disposables.js");
const bag = new DisposableBag("fixture");
const hosts = createPluginHosts({
manifest: manifest(["hosts:read"]),
bag,
audit: vi.fn(async () => {}),
});
hosts.status.registerPort("rdp", () => 3389);
expect(h.statusPorts).toEqual(["rdp"]);
await bag.disposeAll();
expect(h.statusPortsRemoved).toEqual(["rdp"]);
});
});
@@ -6,6 +6,9 @@ const h = vi.hoisted(() => ({
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
actor: undefined as string | undefined,
cleared: [] as string[],
clearedAll: 0,
pooled: [] as string[],
}));
vi.mock("../../utils/logger.js", () => {
@@ -27,7 +30,33 @@ vi.mock("../../plugins/permissions.js", async () => {
};
});
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
key: string,
factory: () => Promise<unknown>,
fn: (client: unknown) => Promise<unknown>,
) => {
h.pooled.push(key);
return fn(await factory());
},
connectionPool: {
clearKeyConnections: (key: string) => h.cleared.push(key),
clearAllConnections: () => {
h.clearedAll += 1;
},
},
}));
vi.mock("../../hosts/connect/connect-host.js", () => ({
resolveConnectHost: async (target: unknown) =>
typeof target === "number"
? {
id: target,
userId: "user-1",
ip: `10.0.0.${target}`,
port: 22,
username: "root",
}
: target,
connectHost: async (target: unknown, options: Record<string, unknown>) => {
h.connects.push({ target, options });
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
@@ -71,6 +100,9 @@ beforeEach(() => {
h.connects = [];
h.factors = [];
h.actor = "user-1";
h.cleared = [];
h.clearedAll = 0;
h.pooled = [];
});
describe("ctx.ssh", () => {
@@ -170,6 +202,62 @@ describe("ctx.ssh", () => {
});
});
describe("ctx.ssh pooled connections", () => {
const both = ["ssh:connect", "credentials:use"];
function plugin(id: string) {
const bag = new DisposableBag(id);
const ssh = createPluginSsh({
manifest: { ...(manifest(both) as object), id } as never,
bag,
audit: vi.fn(async () => {}),
});
return { bag, ssh };
}
it("disabling one plugin leaves ctx.ssh and the pool working for others", async () => {
h.granted = new Set(both);
const metrics = plugin("host-metrics");
const other = plugin("fleets");
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => "a");
await other.ssh.withConnection(8, { pool: "fleet" }, async () => "b");
await metrics.bag.disposeAll();
expect(h.clearedAll).toBe(0);
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
await expect(
other.ssh.withConnection(8, { pool: "fleet" }, async () => "still"),
).resolves.toBe("still");
});
it("dropPooled clears only this plugin's keys for that host", async () => {
h.granted = new Set(both);
const metrics = plugin("host-metrics");
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
await metrics.ssh.withConnection(8, { pool: "stats" }, async () => 1);
metrics.ssh.dropPooled("stats", 7);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
// Dropped keys are forgotten, so deactivate only clears what is left.
h.cleared = [];
await metrics.bag.disposeAll();
expect(h.cleared).toEqual(["stats:user-1:10.0.0.8:22:root"]);
});
it("dropPooled ignores another pool and an unknown host", async () => {
h.granted = new Set(both);
const metrics = plugin("host-metrics");
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
metrics.ssh.dropPooled("other", 7);
metrics.ssh.dropPooled("stats", 99);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.cleared).toEqual([]);
});
});
describe("ctx.auth", () => {
it("needs auth:provide and a contributes.auth entry to register anything", () => {
const auth = createPluginAuth({
+1 -26
View File
@@ -96,9 +96,8 @@ describe("plugin event bus", () => {
it("exposes the internal topic names the server publishes", () => {
expect(TOPICS.hostSessionStatus).toBe("host.session.status");
expect(TOPICS.internalEvent).toBe("internal.event");
expect(TOPICS.hostMetrics).toBe("host.metrics");
expect(TOPICS.hostStatus).toBe("host.status");
expect(TOPICS.hostHealthCheck).toBe("host.health_check");
expect(TOPICS.hostKeyUpdated).toBe("host.key.updated");
});
});
@@ -135,30 +134,6 @@ describe("hosts publishers route through the bus", () => {
expect(listener).not.toHaveBeenCalled();
});
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
const { notifyAutomationStatus, notifyAutomationMetrics } =
await import("../../../../plugins/host-metrics/src/backend/automation-bridge.js");
const status = vi.fn();
const metrics = vi.fn();
pluginEvents.on(TOPICS.hostStatus, status);
pluginEvents.on(TOPICS.hostMetrics, metrics);
notifyAutomationStatus(42, "user-1", true);
notifyAutomationMetrics(42, "user-1", { cpu: 10 } as never);
expect(status).toHaveBeenCalledWith({
hostId: 42,
ownerUserId: "user-1",
online: true,
});
expect(metrics).toHaveBeenCalledWith({
hostId: 42,
ownerUserId: "user-1",
metrics: { cpu: 10 },
});
});
it("hostSessionStatus publishes host.session.status on the refcount edges", async () => {
const { HostSessionStatus } =
await import("../../hosts/host-session-status.js");
+100
View File
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { pluginLogger: log };
});
const { createPluginSchedule } = await import("../../plugins/schedule.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("ctx.schedule", () => {
it("repeats a job and stops it", async () => {
const bag = new DisposableBag("fixture");
const schedule = createPluginSchedule(bag, vi.fn());
const job = vi.fn();
const stop = schedule.every(1000, job);
await vi.advanceTimersByTimeAsync(3000);
expect(job).toHaveBeenCalledTimes(3);
stop();
await vi.advanceTimersByTimeAsync(3000);
expect(job).toHaveBeenCalledTimes(3);
expect(bag.size).toBe(0);
});
it("runs straight away when asked", async () => {
const schedule = createPluginSchedule(
new DisposableBag("fixture"),
vi.fn(),
);
const job = vi.fn();
schedule.every(1000, job, { runNow: true });
expect(job).toHaveBeenCalledTimes(1);
});
it("never overlaps a slow run with the next tick", async () => {
const schedule = createPluginSchedule(
new DisposableBag("fixture"),
vi.fn(),
);
let running = 0;
let peak = 0;
schedule.every(100, async () => {
running++;
peak = Math.max(peak, running);
await new Promise((resolve) => setTimeout(resolve, 350));
running--;
});
await vi.advanceTimersByTimeAsync(1000);
expect(peak).toBe(1);
});
it("logs a failing job instead of throwing", async () => {
const log = vi.fn();
const schedule = createPluginSchedule(new DisposableBag("fixture"), log);
schedule.every(100, () => {
throw new Error("boom");
});
await vi.advanceTimersByTimeAsync(100);
expect(log).toHaveBeenCalledWith("Scheduled job failed", expect.any(Error));
});
it("clears every timer when the plugin is disposed", async () => {
const bag = new DisposableBag("fixture");
const schedule = createPluginSchedule(bag, vi.fn());
const repeating = vi.fn();
const once = vi.fn();
schedule.every(100, repeating, { jitterMs: 50 });
schedule.after(500, once);
await bag.disposeAll();
await vi.advanceTimersByTimeAsync(2000);
expect(repeating).not.toHaveBeenCalled();
expect(once).not.toHaveBeenCalled();
});
it("runs a one-off job once and can cancel it", async () => {
const bag = new DisposableBag("fixture");
const schedule = createPluginSchedule(bag, vi.fn());
const once = vi.fn();
schedule.after(100, once);
const cancelled = vi.fn();
const cancel = schedule.after(100, cancelled);
cancel();
await vi.advanceTimersByTimeAsync(500);
expect(once).toHaveBeenCalledOnce();
expect(cancelled).not.toHaveBeenCalled();
expect(bag.size).toBe(0);
});
});
@@ -0,0 +1,200 @@
/**
* Host Metrics' settings leaving core: the two admin keys, the new-host
* default inside host_defaults, and the metrics half of every host's
* stats_config. Nothing may be lost, and a second run must change nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
coreSettings: new Map<string, string>(),
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
pluginInstalled: true,
db: null as unknown,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.pluginRows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "host-metrics" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) existing.value = value;
else state.pluginRows.push({ scope, scopeId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
let sqlite: Database.Database;
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.coreSettings.clear();
state.pluginRows = [];
state.pluginInstalled = true;
sqlite = new Database(":memory:");
sqlite.exec(
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
);
state.db = drizzle(sqlite);
});
describe("hostSettingsFromStatsConfig", () => {
it("keeps only what differs from the defaults", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify({
statusCheckEnabled: false,
metricsEnabled: false,
metricsInterval: 45,
useGlobalMetricsInterval: false,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
}),
),
).toEqual({
metricsEnabled: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }],
});
});
it("ignores the host's interval while it followed the global one", () => {
expect(
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
).toEqual({});
});
it("reads a value that was stringified twice", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
),
).toEqual({ metricsEnabled: false });
});
});
describe("runHostMetricsSettingsMigration", () => {
it("moves the admin keys, the new-host default and every host", async () => {
state.coreSettings.set("global_metrics_interval", "20");
state.coreSettings.set("metrics_history_retention_days", "30");
state.coreSettings.set(
"host_defaults",
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
);
const insert = sqlite.prepare(
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
);
insert.run(1, JSON.stringify({ metricsEnabled: false }));
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
insert.run(4, null);
const result = await runHostMetricsSettingsMigration();
expect(result.moved.sort()).toEqual([
"global_metrics_interval",
"host_defaults.metricsEnabled",
"metrics_history_retention_days",
]);
expect(value("admin", null, "metricsInterval")).toBe(20);
expect(value("admin", null, "historyRetentionDays")).toBe(30);
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
expect(value("host", "1", "metricsEnabled")).toBe(false);
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
// Only the status half, which is core's: nothing for the plugin.
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
expect(result.hostsMoved).toBe(2);
});
it("changes nothing the second time", async () => {
state.coreSettings.set("global_metrics_interval", "20");
sqlite
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
.run(1, JSON.stringify({ metricsEnabled: false }));
await runHostMetricsSettingsMigration();
// Changed through the plugin after the upgrade.
const interval = find("admin", null, "metricsInterval")!;
interval.value = "90";
const enabled = find("host", "1", "metricsEnabled")!;
enabled.value = "true";
const second = await runHostMetricsSettingsMigration();
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
expect(value("admin", null, "metricsInterval")).toBe(90);
expect(value("host", "1", "metricsEnabled")).toBe(true);
});
it("waits until the plugin row exists", async () => {
state.pluginInstalled = false;
state.coreSettings.set("global_metrics_interval", "20");
expect(await runHostMetricsSettingsMigration()).toEqual({
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
it("skips values out of range", async () => {
state.coreSettings.set("global_metrics_interval", "1");
state.coreSettings.set("metrics_history_retention_days", "900");
await runHostMetricsSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,143 @@
/**
* The status half of ssh_data.stats_config moving into the core
* status_check_enabled and status_check_interval columns. Run against a real
* SQLite database through the same raw SQL helpers the boot path uses.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
settings: new Map<string, string>(),
db: null as unknown,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const {
runHostStatusConfigMigration,
statusConfigFromStatsConfig,
HOST_STATUS_CONFIG_MIGRATED,
} =
await import("../../utils/crypto-migration/host-status-config-migration.js");
let sqlite: Database.Database;
function createHosts(withStatsConfig = true) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY,
${withStatsConfig ? "stats_config TEXT," : ""}
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER
);
`);
state.db = drizzle(sqlite);
}
const hosts = () =>
sqlite
.prepare(
"SELECT id, status_check_enabled AS enabled, status_check_interval AS interval FROM ssh_data ORDER BY id",
)
.all();
beforeEach(() => {
state.settings.clear();
createHosts();
});
describe("statusConfigFromStatsConfig", () => {
it.each([
[null, true, null],
["{}", true, null],
['{"statusCheckEnabled":false}', false, null],
['{"disableTcpPing":true}', false, null],
['{"useGlobalStatusInterval":false,"statusCheckInterval":45}', true, 45],
['{"useGlobalStatusInterval":true,"statusCheckInterval":45}', true, null],
['{"useGlobalStatusInterval":false,"statusCheckInterval":2}', true, null],
[
JSON.stringify(JSON.stringify({ statusCheckEnabled: false })),
false,
null,
],
["not json", true, null],
])("%s", (raw, enabled, interval) => {
expect(statusConfigFromStatsConfig(raw)).toEqual({
statusCheckEnabled: enabled,
statusCheckInterval: interval,
});
});
});
describe("runHostStatusConfigMigration", () => {
it("copies each host's status options into the new columns", async () => {
const insert = sqlite.prepare(
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
);
insert.run(1, JSON.stringify({ statusCheckEnabled: false }));
insert.run(
2,
JSON.stringify({
useGlobalStatusInterval: false,
statusCheckInterval: 45,
}),
);
insert.run(
3,
JSON.stringify({ disableTcpPing: true, metricsEnabled: false }),
);
insert.run(4, JSON.stringify({ metricsInterval: 10 }));
insert.run(5, null);
const result = await runHostStatusConfigMigration();
expect(result).toEqual({ skipped: false, hostsMoved: 3 });
expect(hosts()).toEqual([
{ id: 1, enabled: 0, interval: null },
{ id: 2, enabled: 1, interval: 45 },
{ id: 3, enabled: 0, interval: null },
{ id: 4, enabled: 1, interval: null },
{ id: 5, enabled: 1, interval: null },
]);
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
});
it("changes nothing the second time", async () => {
sqlite
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
.run(1, JSON.stringify({ statusCheckEnabled: false }));
await runHostStatusConfigMigration();
// The user turns checks back on after the upgrade.
sqlite.exec("UPDATE ssh_data SET status_check_enabled = 1 WHERE id = 1");
expect(await runHostStatusConfigMigration()).toEqual({
skipped: true,
hostsMoved: 0,
});
expect(hosts()).toEqual([{ id: 1, enabled: 1, interval: null }]);
});
it("finishes on a fresh install that never had stats_config", async () => {
createHosts(false);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
expect(await runHostStatusConfigMigration()).toEqual({
skipped: false,
hostsMoved: 0,
});
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
});
});
@@ -0,0 +1,182 @@
/**
* Moves Host Metrics' settings out of core and into the host-metrics
* plugin's own settings.
*
* Admin keys from `settings`: global_metrics_interval,
* metrics_history_retention_days and metricsEnabled inside host_defaults (the
* default for new hosts). Per host, the metrics half of
* ssh_data.stats_config: metricsEnabled, the host's own interval, the enabled
* widgets and the mount lists. The status half is core and moved by
* host-status-config-migration.ts.
*
* The old settings rows and the column stay in place, unused.
*
* Idempotent: an admin key already set, or a host that already has any
* host-metrics row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { selectRows } from "./raw-rows.js";
const PLUGIN_ID = "host-metrics";
/** A JSON object stored as text, or {} when it is anything else. */
function parseStatsConfig(raw: string | null): Record<string, unknown> {
try {
let parsed: unknown = raw ? JSON.parse(raw) : null;
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function seconds(value: unknown, min: number, max: number): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= min && parsed <= max
? parsed
: null;
}
/** The plugin's host settings for one stats_config value, defaults left out. */
export function hostSettingsFromStatsConfig(
raw: string | null,
): Record<string, unknown> {
const config = parseStatsConfig(raw);
const values: Record<string, unknown> = {};
if (config.metricsEnabled === false) values.metricsEnabled = false;
if (config.useGlobalMetricsInterval === false) {
const interval = seconds(config.metricsInterval, 5, 3600);
if (interval !== null) values.metricsInterval = interval;
}
if (Array.isArray(config.enabledWidgets)) {
values.enabledWidgets = config.enabledWidgets.filter(
(widget): widget is string => typeof widget === "string",
);
}
if (Array.isArray(config.excludedMounts) && config.excludedMounts.length) {
values.excludedMounts = config.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
);
}
if (Array.isArray(config.monitoredMounts) && config.monitoredMounts.length) {
values.monitoredMounts = config.monitoredMounts.filter(
(mount) =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
);
}
return values;
}
export interface HostMetricsSettingsMigrationResult {
moved: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runHostMetricsSettingsMigration(): Promise<HostMetricsSettingsMigrationResult> {
const result: HostMetricsSettingsMigrationResult = {
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, number, number][] = [
["global_metrics_interval", "metricsInterval", 5, 3600],
["metrics_history_retention_days", "historyRetentionDays", 1, 90],
];
for (const [legacyKey, field, min, max] of moves) {
const value = seconds(await settings.get(legacyKey), min, max);
if (value === null) continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, value);
result.moved.push(legacyKey);
}
const defaults = parseStatsConfig(await settings.get("host_defaults"));
if (defaults.metricsEnabled === false) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"enabledForNewHosts",
);
if (!existing || existing.value === null) {
await write("admin", null, "enabledForNewHosts", false);
result.moved.push("host_defaults.metricsEnabled");
}
}
} catch (error) {
databaseLogger.warn("Host Metrics admin settings migration failed", {
operation: "host_metrics_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
// Raw SQL: schema.ts no longer declares stats_config.
const rows = await selectRows<{ id: number; stats_config: string | null }>(
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
);
for (const row of rows) {
const values = hostSettingsFromStatsConfig(row.stats_config);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
} catch {
// A fresh install never had the column.
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Host Metrics setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "host_metrics_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,110 @@
/**
* Copies the status check options out of ssh_data.stats_config into the
* status_check_enabled and status_check_interval columns. Status checks are
* core; the rest of stats_config belongs to the host-metrics plugin and is
* moved by host-metrics-settings-migration.ts.
*
* The stats_config column stays in the database, unused: core's drizzle
* migrations run before this does, so dropping it there would lose the data.
*
* Runs once, marked by a settings row, because the new columns have defaults
* and there is no other way to tell a copied row from an untouched one.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
import { runStatement, selectRows } from "./raw-rows.js";
export const HOST_STATUS_CONFIG_MIGRATED = "host_status_config_migrated";
export interface LegacyStatusConfig {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
}
/** The status part of an old stats_config value. */
export function statusConfigFromStatsConfig(
raw: string | null,
): LegacyStatusConfig {
let parsed: unknown = null;
try {
parsed = raw ? JSON.parse(raw) : null;
// Some rows were stringified twice.
if (typeof parsed === "string") parsed = JSON.parse(parsed);
} catch {
parsed = null;
}
const config =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
const seconds = Number(config.statusCheckInterval);
return {
statusCheckEnabled:
config.statusCheckEnabled !== false && config.disableTcpPing !== true,
statusCheckInterval:
config.useGlobalStatusInterval === false &&
Number.isInteger(seconds) &&
seconds >= 5
? seconds
: null,
};
}
export interface HostStatusConfigMigrationResult {
skipped: boolean;
hostsMoved: number;
}
export async function runHostStatusConfigMigration(): Promise<HostStatusConfigMigrationResult> {
const settings = createCurrentSettingsRepository();
try {
if ((await settings.get(HOST_STATUS_CONFIG_MIGRATED)) === "true") {
return { skipped: true, hostsMoved: 0 };
}
} catch {
return { skipped: true, hostsMoved: 0 };
}
let hostsMoved = 0;
try {
// Raw SQL: schema.ts no longer declares stats_config.
const rows = await selectRows<{ id: number; stats_config: string | null }>(
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
);
for (const row of rows) {
const config = statusConfigFromStatsConfig(row.stats_config);
if (config.statusCheckEnabled && config.statusCheckInterval === null) {
continue;
}
// TRUE and FALSE as literals: SQLite cannot bind a JS boolean.
await runStatement(sql`
UPDATE ssh_data
SET status_check_enabled = ${sql.raw(config.statusCheckEnabled ? "TRUE" : "FALSE")},
status_check_interval = ${config.statusCheckInterval}
WHERE id = ${row.id}
`);
hostsMoved++;
}
} catch {
// A fresh install never had the column.
}
try {
await settings.set(HOST_STATUS_CONFIG_MIGRATED, "true");
} catch (error) {
databaseLogger.warn("Could not mark the host status migration done", {
operation: "host_status_config_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (hostsMoved > 0) {
databaseLogger.info(
`Moved status check options for ${hostsMoved} host(s) out of stats_config`,
{ operation: "host_status_config_migration" },
);
}
return { skipped: false, hostsMoved };
}