feat: ship host metrics as a first-party in-process plugin

This commit is contained in:
LukeGus committed 2026-09-21 11:30:46 -05:00
1 parent a2479c19b0
commit 19b70430a1
145 files changed
+675 -349

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ coverage
# imports for type-checking (src/types, src/backend/utils, repositories...);
# scripts/copy-bundled-plugins.cjs removes those after every compile, this
# is a safety net for running tsc -p tsconfig.plugins.json directly.
/plugins/*/backend/*.js
/plugins/*/backend/**/*.js
/src/**/*.js
.vscode/*
+2 -3
View File
@@ -45,9 +45,8 @@ export async function register({
});
if (registerHostEditorTab) {
const { HostDockerTab } = await import(
"../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"
);
const { HostDockerTab } =
await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
registerHostEditorTab({
id: hostEditorTabId,
labelKey: "hosts.tabDocker",
@@ -1,11 +1,11 @@
import { createCurrentAlertRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js";
import { createCurrentAlertRepository } from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import {
sendNotification,
type AlertPayload,
type NotificationChannel,
} from "../../utils/notification-sender.js";
import { sendDiscord } from "../../utils/discord-sender.js";
} from "../../../src/backend/utils/notification-sender.js";
import { sendDiscord } from "../../../src/backend/utils/discord-sender.js";
type AlertTriggerType =
| "host_offline"
@@ -11,8 +11,8 @@
* `hosts/automation-events.ts` -- that one is shared across features.
*/
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import type { MetricsSnapshot } from "../../automations/conditions.js";
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
import type { MetricsSnapshot } from "../../../src/backend/automations/conditions.js";
export interface HostMetricsPayload {
hostId: number;
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
export type StatsCapableHost = {
connectionType?: string;
@@ -1,8 +1,8 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js";
import type { HostAction } from "../../utils/permission-manager.js";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import type { HostAction } from "../../../src/backend/utils/permission-manager.js";
type HistoryRoutesDeps = {
validateHostId: RequestHandler;
+66
View File
@@ -0,0 +1,66 @@
/**
* Host Metrics - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ssh-terminal and docker do (see src/backend/plugins/first-party.ts):
* its polling manager holds long-lived ssh2.Client connections across a
* whole fleet of hosts, reused between polling ticks, and its connect flow
* implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault
* keyboard-interactive handshakes the terminal does. None of that can cross
* the structured-clone postMessage boundary a worker plugin talks over, and
* the worker ctx.ssh primitive (connect + single exec-and-collect) has no
* hook for a session that outlives one request.
*
* routes.ts is what used to be src/backend/hosts/metrics/index.ts, moved
* here and rewritten from a module-scope SIGINT/SIGTERM + listenOnServicePort
* call into startHostMetricsService()/shutdown(), called from
* activate()/deactivate() below.
*
* Proxmox-related code (metrics/proxmox/*, proxmox-stats-*) stays in core at
* src/backend/hosts/metrics/ rather than moving here -- it is planned as its
* own future plugin, so this move does not touch it.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled
* .js for the moved host-metrics files (routes.js, ...) sits right next to
* this .mjs, and core modules resolve via ../../../backend/backend/... from
* there (dist/plugins/host-metrics/backend -> dist/backend/backend). Under
* vitest/dev nothing imports this plugin directly (the loader loads
* in-process plugins with a plain dynamic import, no tsx loader involved),
* so the only layout that needs to resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let routesModule = null;
let httpServer = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
httpServer = routesModule.startHostMetricsService();
ctx.log.info(`Host Metrics listening on ${routesModule.PORT ?? 30005}`);
}
export async function deactivate() {
if (routesModule) {
routesModule.shutdown();
routesModule = null;
}
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise((resolve) => server.close(() => resolve()));
}
}
@@ -1,7 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
@@ -1,6 +1,6 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "./exec-elevated.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPort,
@@ -8,8 +8,8 @@ import {
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "./validation.js";
import { detectPlatform } from "./platform.js";
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
@@ -1,11 +1,11 @@
import type { Express } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../types/index.js";
import { execCommand } from "../widgets/common-utils.js";
import { createCurrentHostHealthRepository } from "../../../database/repositories/factory.js";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { createCurrentHostHealthRepository } from "../../../../src/backend/database/repositories/factory.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import { isValidPort } from "./validation.js";
import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isValidPort } from "../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { AlertEngine } from "../alert-engine.js";
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { detectPlatform } from "./platform.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
@@ -1,8 +1,14 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated, shellSingleQuote } from "./exec-elevated.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import {
isAllowedPath,
isValidSystemdUnit,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
@@ -0,0 +1,71 @@
import type { Express } from "express";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
import {
buildListUpgradableCommand,
buildPackageActionCommand,
parseUpgradable,
type PackageAction,
} from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
import type { ManagerRoutesDeps } from "./types.js";
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
const { stdout } = await execCommand(client, cmd, 60000);
return {
pkg: platform.pkg,
upgradable: parseUpgradable(platform.pkg, stdout),
};
}),
);
app.post(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
"connect",
"packages_action",
async (client, host, req) => {
const { action, pkg: name } = req.body as {
action?: PackageAction;
pkg?: string;
};
if (
action !== "upgrade-all" &&
action !== "install" &&
action !== "upgrade"
) {
throw new ManagerInputError("Invalid action");
}
if (action !== "upgrade-all" && !isValidPackageName(name)) {
throw new ManagerInputError("Invalid package name");
}
const platform = await detectPlatform(client);
const cmd = buildPackageActionCommand(platform.pkg, action, name);
if (!cmd) throw new ManagerInputError("No supported package manager");
// Package operations can be slow; allow up to 10 minutes.
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 600000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
}
@@ -1,8 +1,12 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import {
isValidPid,
isValidSignal,
type Signal,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
@@ -1,11 +1,11 @@
import { getErrorMessage } from "../../../utils/error-message.js";
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../types/index.js";
import { statsLogger } from "../../../utils/logger.js";
import { ElevationError } from "./exec-elevated.js";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { ElevationError } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../utils/permission-manager.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
@@ -1,12 +1,12 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "./validation.js";
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
@@ -1,13 +1,16 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated, shellSingleQuote } from "./exec-elevated.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "./validation.js";
import { detectPlatform } from "./platform.js";
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
@@ -1,9 +1,9 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { isValidTailscaleAction } from "./validation.js";
import { isValidTailscaleAction } from "../../../../src/backend/hosts/metrics-shared/validation.js";
export interface TailscalePeer {
hostname: string;
@@ -1,6 +1,6 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { HostAction } from "../../../utils/permission-manager.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
@@ -1,8 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidUsername, isValidGroupName } from "./validation.js";
import {
isValidUsername,
isValidGroupName,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
@@ -1,11 +1,11 @@
import type { Express } from "express";
import { execElevated } from "./exec-elevated.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "./validation.js";
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
export interface WireGuardPeer {
publicKey: string;
@@ -1,13 +1,13 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentHostMetricsPreferenceRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import { createCurrentHostMetricsPreferenceRepository } from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../../types/host-metrics.js";
import type { HostAction } from "../../utils/permission-manager.js";
} from "../../../src/types/host-metrics.js";
import type { HostAction } from "../../../src/backend/utils/permission-manager.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
@@ -1,30 +1,33 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import net from "net";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import { createCompressionMiddleware } from "../../utils/compression-config.js";
import { createCorsMiddleware } from "../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../src/backend/utils/compression-config.js";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "../credential-username.js";
} from "../../../src/backend/hosts/credential-username.js";
import {
getCurrentSettingValue,
createCurrentHostMetricsHistoryRepository,
createCurrentHostResolutionRepository,
} from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js";
} from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
import type {
AuthenticatedRequest,
ProxyNode,
} from "../../../src/types/index.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../types/connection-log.js";
} from "../../../src/types/connection-log.js";
import {
collectCpuMetrics,
clearCpuSampleCache,
@@ -41,7 +44,7 @@ import {
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { detectPlatform } from "./widgets/common-utils.js";
import { detectPlatform } from "../../../src/backend/hosts/metrics-shared/common-utils.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
@@ -50,31 +53,34 @@ import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../utils/socks5-helper.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { connectionPool, withConnection } from "../ssh-connection-pool.js";
} from "../../../src/backend/utils/socks5-helper.js";
import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
import {
connectionPool,
withConnection,
} from "../../../src/backend/hosts/ssh-connection-pool.js";
import { registerHostMetricsSettingsRoutes } from "./settings-routes.js";
import { registerHostMetricsViewerRoutes } from "./viewer-routes.js";
import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js";
import { registerHostMetricsHistoryRoutes } from "./history-routes.js";
import { registerProxmoxStatsRoutes } from "./proxmox-stats-routes.js";
import { registerProxmoxStatsHistoryRoutes } from "./proxmox-stats-history-routes.js";
import { ProxmoxPollingManager } from "./proxmox-stats-polling.js";
import type { HostSessionStatusPayload } from "../host-session-status.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { registerProxmoxStatsRoutes } from "../../../src/backend/hosts/metrics/proxmox-stats-routes.js";
import { registerProxmoxStatsHistoryRoutes } from "../../../src/backend/hosts/metrics/proxmox-stats-history-routes.js";
import { ProxmoxPollingManager } from "../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
import type { HostSessionStatusPayload } from "../../../src/backend/hosts/host-session-status.js";
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
import { AlertEngine } from "./alert-engine.js";
import {
notifyAutomationMetrics,
notifyAutomationStatus,
} from "./automation-bridge.js";
import { notifyAutomationInternalEvent } from "../automation-events.js";
import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js";
import { registerManagerRoutes } from "./managers/index.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
import { AccessDeniedError } from "./managers/route-helpers.js";
import type { ManagerHost } from "./managers/types.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { resolveHostById } from "../host-resolver.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import {
isTcpPingEnabled,
parseStatusHostIds,
@@ -86,8 +92,8 @@ import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "./host-status.js";
import { createConnectionLog } from "../connection-log.js";
} from "../../../src/backend/hosts/metrics-shared/host-status.js";
import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js";
import {
cleanupMetricsSession,
getSessionKey,
@@ -107,8 +113,8 @@ import {
pollingBackoff,
requestQueue,
statusPollLimiter,
} from "./state.js";
import { listenOnServicePort } from "../../utils/service-listen.js";
} from "../../../src/backend/hosts/metrics-shared/state.js";
import { listenOnServicePort } from "../../../src/backend/utils/service-listen.js";
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
@@ -1076,7 +1082,7 @@ function validateHostId(
next();
}
const app = express();
export const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
@@ -1100,8 +1106,9 @@ app.post("/internal/login-alert", async (req, res) => {
) {
return res.status(403).json({ error: "Forbidden" });
}
const systemCrypto = (await import("../../utils/system-crypto.js"))
.SystemCrypto;
const systemCrypto = (
await import("../../../src/backend/utils/system-crypto.js")
).SystemCrypto;
const expectedToken = await systemCrypto.getInstance().getInternalAuthToken();
const token = req.headers["x-internal-auth"];
if (!token || token !== expectedToken) {
@@ -1248,7 +1255,7 @@ async function resolveHostCredentials(
if (isSharedHost) {
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await import("../../../src/backend/utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number,
@@ -1515,18 +1522,20 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
// Set up OPKSSH cert auth if needed (requires client instance)
if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const { getOPKSSHToken } =
await import("../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
throw new Error(
"OPKSSH authentication required. Please open a Terminal connection first.",
);
}
const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js");
const { setupOPKSSHCertAuth } =
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("../vault-ssh-connect.js");
await import("../../../src/backend/hosts/vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
@@ -2446,7 +2455,8 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
const client = new Client();
if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const { getOPKSSHToken } =
await import("../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
connectionLogs.push(
@@ -2462,11 +2472,12 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
connectionLogs,
});
}
const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js");
const { setupOPKSSHCertAuth } =
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("../vault-ssh-connect.js");
await import("../../../src/backend/hosts/vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
@@ -3035,7 +3046,7 @@ app.post("/metrics/connect-totp", async (req, res) => {
// Lets automations keep metrics flowing for hosts they watch, through the same
// viewer refcount the UI uses rather than around it.
import("../../automations/headless-viewer.js")
import("../../../src/backend/automations/headless-viewer.js")
.then(({ setViewerRegistry }) => {
setViewerRegistry({
registerViewer: (hostId, sessionId, userId) =>
@@ -3146,41 +3157,56 @@ registerManagerRoutes(app, {
},
});
process.on("SIGINT", () => {
export const PORT = 30005;
let cleanupInterval: NodeJS.Timeout | undefined;
/**
* Starts the Host Metrics HTTP service. Called from the plugin's activate()
* instead of running at module scope, so enabling/disabling the plugin
* actually starts and stops the listener.
*/
export function startHostMetricsService(): ReturnType<
typeof listenOnServicePort
> {
return listenOnServicePort({
app,
port: PORT,
logger: statsLogger,
serviceName: "metrics",
onListening: async () => {
try {
await authManager.initialize();
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
cleanupInterval = setInterval(
() => {
authFailureTracker.cleanup();
pollingBackoff.cleanup();
},
10 * 60 * 1000,
);
},
});
}
/**
* Releases everything the service holds: polling timers, pooled SSH
* connections and the periodic auth-failure cleanup. Called from the
* plugin's deactivate(), replacing the old SIGINT/SIGTERM handlers -- the
* process itself no longer owns this service's shutdown, the plugin
* lifecycle does.
*/
export function shutdown(): void {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = undefined;
}
pollingManager.destroy();
proxmoxPollingManager.destroy();
connectionPool.destroy();
process.exit(0);
});
process.on("SIGTERM", () => {
pollingManager.destroy();
proxmoxPollingManager.destroy();
connectionPool.destroy();
process.exit(0);
});
const PORT = 30005;
listenOnServicePort({
app,
port: PORT,
logger: statsLogger,
serviceName: "metrics",
onListening: async () => {
try {
await authManager.initialize();
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
setInterval(
() => {
authFailureTracker.cleanup();
pollingBackoff.cleanup();
},
10 * 60 * 1000,
);
},
});
}
@@ -1,5 +1,5 @@
import type { Client, ConnectConfig } from "ssh2";
import { statsLogger } from "../../utils/logger.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
export interface MetricsSession {
client: Client;
@@ -1,6 +1,6 @@
import type { Express, RequestHandler } from "express";
import { statsLogger } from "../../utils/logger.js";
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js";
type HostMetricsSettingsConfig = {
statusCheckInterval: number;
@@ -1,8 +1,8 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { statsLogger } from "../../utils/logger.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
type ViewerStatsConfig = {
metricsEnabled: boolean;
@@ -4,7 +4,7 @@ import {
execPowerShell,
toFixedNum,
type HostPlatform,
} from "./common-utils.js";
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
/**
* CPU percent is derived from the delta against the previous poll's
@@ -4,7 +4,7 @@ import {
execPowerShell,
toFixedNum,
type HostPlatform,
} from "./common-utils.js";
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../../types/stats-widgets.js";
} from "../../../../src/types/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
@@ -1,5 +1,8 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import {
execCommand,
toFixedNum,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface GpuDevice {
index: number;
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface LoginRecord {
user: string;
@@ -5,7 +5,7 @@ import {
toFixedNum,
kibToGiB,
type HostPlatform,
} from "./common-utils.js";
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
async function collectDarwinMemoryMetrics(client: Client): Promise<{
percent: number | null;
@@ -3,7 +3,7 @@ import {
execCommand,
execPowerShell,
type HostPlatform,
} from "./common-utils.js";
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface NetworkCounters {
rx: string;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
PortsMetrics,
ListeningPort,
} from "../../../../types/stats-widgets.js";
} from "../../../../src/types/stats-widgets.js";
function parseSsOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
@@ -3,7 +3,7 @@ import {
execCommand,
execPowerShell,
type HostPlatform,
} from "./common-utils.js";
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
async function collectWindowsSystemMetrics(client: Client): Promise<{
hostname: string | null;
@@ -1,5 +1,8 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import {
execCommand,
toFixedNum,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface TemperatureSensor {
label: string;
@@ -3,7 +3,7 @@ import {
execCommand,
execPowerShell,
type HostPlatform,
} from "./common-utils.js";
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
function formatUptime(uptimeSeconds: number): string {
const days = Math.floor(uptimeSeconds / 86400);
@@ -1,6 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { HostMetricsTab } from "@/features/host-metrics/HostMetricsTab.tsx";
import { HostMetricsTab } from "./HostMetricsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
@@ -3,7 +3,7 @@ import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import {
getMetricsHistory,
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { FilesystemPicker } from "./FilesystemPicker";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import {
getMetricsHistory,
@@ -1,7 +1,7 @@
import { ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
export function FirewallCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import type { GpuDevice, GpuProcess } from "@/types/stats-widgets";
import { MiniStat, RadialGauge, Sparkline, StatRow } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
import type { GpuHistories, GpuHistory } from "./gpu-history";
const NA = "N/A";
@@ -1,7 +1,7 @@
import { UserCheck, UserX } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
export function LoginStatsCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -3,7 +3,7 @@ import { MemoryStick } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import {
getMetricsHistory,
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import {
getMetricsHistory,
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { Unplug, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics, ListeningPort } from "@/main-axios";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
import { Select2 } from "@/components/select2";
function formatAddress(addr: string) {
@@ -2,7 +2,7 @@ import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
export function ProcessesCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -2,7 +2,7 @@ import { Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
export function SystemCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -2,7 +2,7 @@ import { Thermometer } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
import { useEffect, useState } from "react";
import {
selectTemperatureSensor,
@@ -1,7 +1,7 @@
import { Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "./MetricCard";
import { MetricCard } from "@/components/metric-card";
export function UptimeCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { RefreshCw, AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { MetricCard } from "../MetricCard";
import { MetricCard } from "@/components/metric-card";
import type { ManagerError } from "./useManagerData";
export function ManagerCardShell({
+49
View File
@@ -0,0 +1,49 @@
/**
* Frontend half of the host-metrics plugin.
*
* Registers the Host Metrics tab through the existing extension seams
* (registerRailItem / registerTabComponent), the same way ssh-terminal's and
* docker's frontend do.
*
* HostEditorStatsTab itself stays in src/ui/sidebar/HostEditorStatsTab.tsx
* rather than moving into this plugin: it takes the same {form, setField}
* pair every other host editor tab does, and those types (HostEditorForm,
* the setField signature) are core types HostEditor.tsx owns -- the same
* reason docker leaves HostDockerTab in core.
*
* register() is called when the plugin is enabled, unregister() when it is
* disabled. As of this change neither is actually invoked anywhere yet, the
* same as ssh-terminal's and docker's frontend entries -- there is no
* frontend plugin loader yet. This file follows the same shape so it is
* ready the moment that loader exists.
*/
export const id = "host-metrics";
export const tabId = "host-metrics";
export async function register({
registerRailItem,
registerTabComponent,
icons,
}) {
registerTabComponent(tabId, () =>
import("./HostMetricsTab.tsx").then((m) => ({
default: m.HostMetricsTab,
})),
);
registerRailItem({
id: tabId,
icon: icons.Activity,
labelKey: "nav.hostMetrics",
kind: "tab",
});
}
export async function unregister({
unregisterRailItem,
unregisterTabComponent,
}) {
unregisterRailItem(tabId);
unregisterTabComponent(tabId);
}
+30
View File
@@ -0,0 +1,30 @@
{
"id": "host-metrics",
"name": "Host Metrics",
"version": "1.0.0",
"description": "Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Monitoring",
"icon": "Activity",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"contributes": {
"tabs": [
{
"id": "host-metrics",
"titleKey": "nav.hostMetrics",
"icon": "Activity",
"openFrom": ["host-context-menu", "palette"]
}
]
},
"sidecars": []
}
+38 -14
View File
@@ -6,19 +6,21 @@
* tsc only emits .ts, so without a copy step the plugins directory simply
* would not exist in dist and the loader would find nothing.
*
* The docker plugin is the exception: its backend is real TypeScript,
* physically relocated from src/backend/hosts/docker/ rather than kept as
* hand-written JS, because it is ~4000 lines of typed SSH/session logic that
* is not worth hand-transpiling. tsconfig.plugins.json compiles the plugin
* backend TypeScript to a sibling .js next to its source. Its imports
* into core (e.g. "../../../src/backend/utils/logger.js") are written
* relative to the TypeScript SOURCE tree so tsc can type-check them, since
* plugins/ is not under tsconfig.node.json's rootDir and cannot be added to
* it without changing every existing dist/backend/backend/... path in the
* codebase. That means the emitted .js still points at src/backend/, which
* does not exist in a built server -- only its compiled counterpart at
* docker and host-metrics are the exception: their backends are real
* TypeScript, physically relocated from src/backend/hosts/docker/ and
* src/backend/hosts/metrics/ rather than kept as hand-written JS, because
* each is thousands of lines of typed SSH/session logic that is not worth
* hand-transpiling. tsconfig.plugins.json compiles each plugin backend's
* TypeScript to a sibling .js next to its source. Their imports into core
* (e.g. "../../../src/backend/utils/logger.js") are written relative to the
* TypeScript SOURCE tree so tsc can type-check them, since plugins/ is not
* under tsconfig.node.json's rootDir and cannot be added to it without
* changing every existing dist/backend/backend/... path in the codebase.
* That means the emitted .js still points at src/backend/, which does not
* exist in a built server -- only its compiled counterpart at
* dist/backend/backend/ does. rewriteCoreImports() below corrects that one
* prefix after compilation, before the directory is copied into dist/.
* prefix after compilation, for every plugins/*\/backend directory tsc
* compiled, before the directory is copied into dist/.
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
@@ -64,7 +66,9 @@ function rewriteCoreImports(dir) {
const original = fs.readFileSync(entryPath, "utf8");
if (!original.includes(SRC_BACKEND_PREFIX)) continue;
const rewritten = original.split(SRC_BACKEND_PREFIX).join(COMPILED_BACKEND_PREFIX);
const rewritten = original
.split(SRC_BACKEND_PREFIX)
.join(COMPILED_BACKEND_PREFIX);
fs.writeFileSync(entryPath, rewritten);
}
}
@@ -111,8 +115,28 @@ function removeStrayEmits(dir) {
}
}
/**
* Every plugin directory with a backend/ that tsc actually compiled (i.e. it
* has a .ts file in it, not just a hand-written index.mjs). Scanned rather
* than hardcoded so a future plugin only needs a tsconfig.plugins.json entry,
* not a second call site here.
*/
function compiledPluginBackendDirs() {
return fs
.readdirSync(source, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(source, entry.name, "backend"))
.filter(
(dir) =>
fs.existsSync(dir) &&
fs.readdirSync(dir).some((name) => name.endsWith(".ts")),
);
}
compilePluginBackends();
rewriteCoreImports(path.join(source, "docker", "backend"));
for (const dir of compiledPluginBackendDirs()) {
rewriteCoreImports(dir);
}
removeStrayEmits(path.join(root, "src"));
fs.rmSync(destination, { recursive: true, force: true });
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from "../../database/repositories/factory.js";
import { validateDefinition } from "../../database/routes/automations.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { execCommand } from "../../hosts/metrics/widgets/common-utils.js";
import { execCommand } from "../../hosts/metrics-shared/common-utils.js";
import {
createFleetSshFactory,
getFleetPoolKey,
+2 -2
View File
@@ -2,11 +2,11 @@ import {
DEFAULT_STEP_TIMEOUT_MS,
type Step,
} from "../../../types/automations.js";
import { execCommand } from "../../hosts/metrics/widgets/common-utils.js";
import { execCommand } from "../../hosts/metrics-shared/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../hosts/metrics/managers/exec-elevated.js";
} from "../../hosts/metrics-shared/exec-elevated.js";
import {
createFleetSshFactory,
getFleetPoolKey,
+1 -1
View File
@@ -2,7 +2,7 @@ import type { AutomationDefinition } from "../../types/automations.js";
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
import { resolveHostById } from "../hosts/host-resolver.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { execCommand } from "../hosts/metrics/widgets/common-utils.js";
import { execCommand } from "../hosts/metrics-shared/common-utils.js";
import {
createFleetSshFactory,
getFleetPoolKey,
+5 -5
View File
@@ -28,14 +28,14 @@ import {
createFleetSshFactory,
} from "../../hosts/ssh-client-factory.js";
import { withConnection } from "../../hosts/ssh-connection-pool.js";
import { execCommand } from "../../hosts/metrics/widgets/common-utils.js";
import { detectPlatform } from "../../hosts/metrics/managers/platform.js";
import { execCommand } from "../../hosts/metrics-shared/common-utils.js";
import { detectPlatform } from "../../hosts/metrics-shared/platform.js";
import {
execElevated,
ElevationError,
} from "../../hosts/metrics/managers/exec-elevated.js";
import { buildPackageActionCommand } from "../../hosts/metrics/managers/packages.js";
import { isValidPackageName } from "../../hosts/metrics/managers/validation.js";
} from "../../hosts/metrics-shared/exec-elevated.js";
import { buildPackageActionCommand } from "../../hosts/metrics-shared/package-commands.js";
import { isValidPackageName } from "../../hosts/metrics-shared/validation.js";
import { resolveSnippetCommand } from "./snippets-execution.js";
const router = express.Router();
+1 -1
View File
@@ -18,7 +18,7 @@ import {
serializeProxmoxJumpHosts,
} from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "../../hosts/proxmox-shared.js";
import { execElevated } from "../../hosts/metrics/managers/exec-elevated.js";
import { execElevated } from "../../hosts/metrics-shared/exec-elevated.js";
const router = express.Router();
const proxmoxLogger = logger;
+1 -1
View File
@@ -1,4 +1,4 @@
import { authFailureTracker } from "./metrics/state.js";
import { authFailureTracker } from "./metrics-shared/state.js";
import type { WebSocket } from "ws";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
import { sshLogger } from "../utils/logger.js";
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
import { execCommand } from "./common-utils.js";
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
@@ -1,10 +1,4 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "./validation.js";
import { detectPlatform, type PackageManager } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
import type { PackageManager } from "./platform.js";
export interface UpgradablePackage {
name: string;
@@ -88,61 +82,3 @@ export function buildPackageActionCommand(
return null;
}
}
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
const { stdout } = await execCommand(client, cmd, 60000);
return {
pkg: platform.pkg,
upgradable: parseUpgradable(platform.pkg, stdout),
};
}),
);
app.post(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
"connect",
"packages_action",
async (client, host, req) => {
const { action, pkg: name } = req.body as {
action?: PackageAction;
pkg?: string;
};
if (
action !== "upgrade-all" &&
action !== "install" &&
action !== "upgrade"
) {
throw new ManagerInputError("Invalid action");
}
if (action !== "upgrade-all" && !isValidPackageName(name)) {
throw new ManagerInputError("Invalid package name");
}
const platform = await detectPlatform(client);
const cmd = buildPackageActionCommand(platform.pkg, action, name);
if (!cmd) throw new ManagerInputError("No supported package manager");
// Package operations can be slow; allow up to 10 minutes.
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 600000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
}
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
import { execCommand } from "./common-utils.js";
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
File renamed without changes.
@@ -3,7 +3,7 @@ import {
createCurrentProxmoxNodeHistoryRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { ConcurrentLimiter } from "./state.js";
import { ConcurrentLimiter } from "../metrics-shared/state.js";
import {
collectProxmoxStats,
type ProxmoxStatsSnapshot,
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
import { execCommand } from "../../metrics-shared/common-utils.js";
export interface ProxmoxClusterNodeEntry {
name: string;
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
import { execCommand } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
import {
collectProxmoxNodeStatus,
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../widgets/common-utils.js";
import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxGuestSummaryEntry {
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
import { execCommand } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxNodeNetworkInterface {
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../widgets/common-utils.js";
import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxNodeStatusResult {
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../widgets/common-utils.js";
import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxStoragePoolEntry {
+2 -1
View File
@@ -14,7 +14,8 @@ import { resolveSshConnectConfigHost } from "./ssh-dns.js";
/**
* Non-interactive SSH connection helpers shared by fleet execution.
*
* Mirrors buildSshConfig/createSshFactory/getPoolKey in hosts/metrics/index.ts,
* Mirrors buildSshConfig/createSshFactory/getPoolKey in
* plugins/host-metrics/backend/routes.ts,
* minus the keyboard-interactive TOTP prompt handling (metrics/terminal-only)
* and OPKSSH/Vault cert setup (not supported as fleet auth for v1 - those flows
* need an interactive browser step that has no place in a batch fleet run).
+8
View File
@@ -38,10 +38,18 @@
* session manager reuses a live ssh2.Client across many REST calls, and its
* SSH connect flow implements TOTP/Warpgate as a stateful multi-request
* handshake the worker ctx.ssh API has no hook for.
*
* host-metrics is here for the same reason: its polling manager holds
* long-lived ssh2 clients across a whole fleet of hosts, reused between
* polling ticks rather than opened per request, and its connect flow
* implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault
* keyboard-interactive handshakes the terminal does -- none of which the
* worker ctx.ssh API (connect + single exec-and-collect) has a hook for.
*/
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ssh-terminal",
"docker",
"host-metrics",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more