mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -1160,13 +1160,6 @@ function formatSSHHostOutput(
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHostRepository } from "../repositories/factory.js";
|
||||
|
||||
type HostAutostartRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
requireViewPermission: RequestHandler;
|
||||
requireEditPermission: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
};
|
||||
|
||||
export function registerHostAutostartRoutes(
|
||||
router: Router,
|
||||
{
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
}: HostAutostartRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/enable:
|
||||
* post:
|
||||
* summary: Enable autostart for SSH configuration
|
||||
* description: Enables autostart for a specific SSH configuration.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sshConfigId:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AutoStart enabled successfully.
|
||||
* 400:
|
||||
* description: Valid sshConfigId is required.
|
||||
* 404:
|
||||
* description: SSH configuration not found.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.post(
|
||||
"/autostart/enable",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart enable request",
|
||||
{
|
||||
operation: "autostart_enable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
sshLogger.warn(
|
||||
"User attempted to enable autostart without unlocked data",
|
||||
{
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "data_locked",
|
||||
},
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: "Failed to enable autostart. Ensure user data is unlocked.",
|
||||
});
|
||||
}
|
||||
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const config = await hostRepository.findByIdForUser(
|
||||
userId,
|
||||
sshConfigId,
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
sshLogger.warn("SSH config not found for autostart enable", {
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "config_not_found",
|
||||
});
|
||||
return res.status(404).json({
|
||||
error: "SSH configuration not found",
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedConfig = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
config,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
let updatedTunnelConnections = config.tunnelConnections;
|
||||
if (config.tunnelConnections) {
|
||||
try {
|
||||
const tunnelConnections = JSON.parse(config.tunnelConnections);
|
||||
const endpointHosts = await hostRepository.listByUserId(userId);
|
||||
|
||||
const resolvedConnections = await Promise.all(
|
||||
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
|
||||
if (
|
||||
tunnel.autoStart &&
|
||||
tunnel.endpointHost &&
|
||||
!tunnel.endpointPassword &&
|
||||
!tunnel.endpointKey
|
||||
) {
|
||||
const endpointHost = endpointHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
const decryptedEndpoint = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
endpointHost,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return {
|
||||
...tunnel,
|
||||
endpointPassword: decryptedEndpoint.password || null,
|
||||
endpointKey: decryptedEndpoint.key || null,
|
||||
endpointKeyPassword:
|
||||
decryptedEndpoint.keyPassword || null,
|
||||
endpointAuthType: endpointHost.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
return tunnel;
|
||||
}),
|
||||
);
|
||||
|
||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to update tunnel connections", {
|
||||
operation: "tunnel_connections_update_failed",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await hostRepository.updateForUser(userId, sshConfigId, {
|
||||
autostartPassword: decryptedConfig.password || null,
|
||||
autostartKey: decryptedConfig.key || null,
|
||||
autostartKeyPassword: decryptedConfig.keyPassword || null,
|
||||
tunnelConnections: updatedTunnelConnections,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "AutoStart enabled successfully",
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error enabling autostart", error, {
|
||||
operation: "autostart_enable_error",
|
||||
userId,
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/disable:
|
||||
* delete:
|
||||
* summary: Disable autostart for SSH configuration
|
||||
* description: Disables autostart for a specific SSH configuration.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sshConfigId:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AutoStart disabled successfully.
|
||||
* 400:
|
||||
* description: Valid sshConfigId is required.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.delete(
|
||||
"/autostart/disable",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart disable request",
|
||||
{
|
||||
operation: "autostart_disable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentHostRepository().updateForUser(userId, sshConfigId, {
|
||||
autostartPassword: null,
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "AutoStart disabled successfully",
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error disabling autostart", error, {
|
||||
operation: "autostart_disable_error",
|
||||
userId,
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/status:
|
||||
* get:
|
||||
* summary: Get autostart status
|
||||
* description: Retrieves the autostart status for the user's SSH configurations.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of autostart configurations.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.get(
|
||||
"/autostart/status",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const autostartConfigs = (
|
||||
await createCurrentHostRepository().listByUserId(userId)
|
||||
).filter((config) => config.autostartPassword || config.autostartKey);
|
||||
|
||||
const statusList = autostartConfigs.map((config) => ({
|
||||
sshConfigId: config.id,
|
||||
host: config.ip,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
authType: config.authType,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
autostart_configs: statusList,
|
||||
total_count: statusList.length,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error getting autostart status", error, {
|
||||
operation: "autostart_status_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,16 @@ import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { applyPluginHostImportSettings } from "./host-plugin-settings.js";
|
||||
import {
|
||||
listSshAuthProviders,
|
||||
listSshAuthTypeOwners,
|
||||
} from "../../hosts/connect/auth-provider-registry.js";
|
||||
import {
|
||||
applyPluginHostImportSettings,
|
||||
setHostPluginEnabled,
|
||||
} from "./host-plugin-settings.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
@@ -182,6 +188,15 @@ export function registerHostBulkRoutes(
|
||||
* parentHostId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* statusCheckEnabled:
|
||||
* type: boolean
|
||||
* pin:
|
||||
* type: boolean
|
||||
* pluginEnable:
|
||||
* type: object
|
||||
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
|
||||
* additionalProperties:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Bulk update completed.
|
||||
@@ -278,16 +293,6 @@ export function registerHostBulkRoutes(
|
||||
simpleUpdates.folder = null;
|
||||
}
|
||||
}
|
||||
if (typeof updates.enableTerminal === "boolean")
|
||||
simpleUpdates.enableTerminal = updates.enableTerminal;
|
||||
if (typeof updates.enableTunnel === "boolean")
|
||||
simpleUpdates.enableTunnel = updates.enableTunnel;
|
||||
if (typeof updates.enableFileManager === "boolean")
|
||||
simpleUpdates.enableFileManager = updates.enableFileManager;
|
||||
if (typeof updates.enableTmuxMonitor === "boolean")
|
||||
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
|
||||
if (typeof updates.enableTerminalToolbar === "boolean")
|
||||
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
|
||||
if (typeof updates.statusCheckEnabled === "boolean")
|
||||
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
|
||||
|
||||
@@ -299,91 +304,15 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
// already stored on the host, so discovery works without picking one
|
||||
// by hand. Existing proxmoxConfig values are preserved.
|
||||
if (typeof updates.enableProxmox === "boolean") {
|
||||
const pluginSettingsRepository =
|
||||
createCurrentPluginSettingsRepository();
|
||||
for (const host of ownedHosts) {
|
||||
try {
|
||||
const scopeId = String(host.id);
|
||||
await pluginSettingsRepository.set(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableProxmox",
|
||||
JSON.stringify(updates.enableProxmox),
|
||||
);
|
||||
|
||||
if (updates.enableProxmox) {
|
||||
const existingRow = await pluginSettingsRepository.get(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"proxmoxConfig",
|
||||
);
|
||||
const existing = existingRow?.value
|
||||
? JSON.parse(existingRow.value)
|
||||
: {};
|
||||
const merged = {
|
||||
defaultCredentialId:
|
||||
existing.defaultCredentialId ?? host.credentialId ?? null,
|
||||
windowsPatterns: existing.windowsPatterns ?? "win, windows",
|
||||
dockerPatterns: existing.dockerPatterns ?? "docker",
|
||||
preferredPrefixes:
|
||||
existing.preferredPrefixes ?? "10., 192.168.",
|
||||
autoSyncEnabled: existing.autoSyncEnabled ?? false,
|
||||
syncIntervalMinutes: existing.syncIntervalMinutes ?? 15,
|
||||
markMissingGuests: existing.markMissingGuests ?? true,
|
||||
};
|
||||
await pluginSettingsRepository.set(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"proxmoxConfig",
|
||||
JSON.stringify(merged),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push(
|
||||
`Failed to ${updates.enableProxmox ? "enable" : "disable"} Proxmox for host ${host.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Web endpoint enable/disable lives in the plugin's own host-scope
|
||||
// settings. Disabling clears webUiConfig, matching the old column
|
||||
// write it replaces.
|
||||
if (typeof updates.enableWebUi === "boolean") {
|
||||
const pluginSettingsRepository =
|
||||
createCurrentPluginSettingsRepository();
|
||||
for (const host of ownedHosts) {
|
||||
try {
|
||||
const scopeId = String(host.id);
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableWebUi",
|
||||
JSON.stringify(updates.enableWebUi),
|
||||
);
|
||||
if (!updates.enableWebUi) {
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"webUiConfig",
|
||||
JSON.stringify(null),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push(
|
||||
`Failed to ${updates.enableWebUi ? "enable" : "disable"} web endpoints for host ${host.id}`,
|
||||
);
|
||||
// Each plugin's own host switch, by plugin id: the field its manifest
|
||||
// names in contributes.settings.host.enableKey.
|
||||
if (updates.pluginEnable && typeof updates.pluginEnable === "object") {
|
||||
for (const [pluginId, enabled] of Object.entries(
|
||||
updates.pluginEnable as Record<string, unknown>,
|
||||
)) {
|
||||
if (typeof enabled !== "boolean") continue;
|
||||
if (!(await setHostPluginEnabled(pluginId, ownedIds, enabled))) {
|
||||
errors.push(`Plugin ${pluginId} has no host switch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -605,6 +534,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const knownAuthTypes = listKnownAuthTypes();
|
||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
@@ -644,20 +574,11 @@ export function registerHostBulkRoutes(
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType &&
|
||||
![
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"none",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"vault",
|
||||
].includes(hostData.authType)
|
||||
!knownAuthTypes.has(hostData.authType)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
|
||||
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -744,22 +665,7 @@ export function registerHostBulkRoutes(
|
||||
port: hostData.port,
|
||||
username,
|
||||
pin: hostData.pin || false,
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
|
||||
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
|
||||
enableCommandHistory: hostData.enableCommandHistory !== false,
|
||||
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
|
||||
showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
|
||||
showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
sudoPassword: hostData.sudoPassword || null,
|
||||
tunnelConnections: hostData.tunnelConnections
|
||||
? JSON.stringify(hostData.tunnelConnections)
|
||||
: "[]",
|
||||
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: hostData.quickActions
|
||||
? JSON.stringify(hostData.quickActions)
|
||||
@@ -1004,19 +910,7 @@ export function registerHostBulkRoutes(
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: true,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
showTerminalInSidebar: 0,
|
||||
showFileManagerInSidebar: 0,
|
||||
showTunnelInSidebar: 0,
|
||||
showDockerInSidebar: 0,
|
||||
showServerStatsInSidebar: 0,
|
||||
defaultPath: "/",
|
||||
sudoPassword: null,
|
||||
tunnelConnections: "[]",
|
||||
jumpHosts: hostData.jumpHosts
|
||||
? JSON.stringify(hostData.jumpHosts)
|
||||
: null,
|
||||
@@ -1098,3 +992,20 @@ function importedStatusCheck(raw: Record<string, unknown>): {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const BUILTIN_SSH_AUTH_TYPES = [
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"agent",
|
||||
"none",
|
||||
];
|
||||
|
||||
/** Core's own types plus every type a plugin registers or declares. */
|
||||
function listKnownAuthTypes(): Set<string> {
|
||||
return new Set([
|
||||
...BUILTIN_SSH_AUTH_TYPES,
|
||||
...listSshAuthProviders().map((provider) => provider.type),
|
||||
...listSshAuthTypeOwners().map((owner) => owner.type),
|
||||
]);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export function applyHostEnrollmentDefaults(
|
||||
connectionType: "ssh",
|
||||
port: 22,
|
||||
authType: "none",
|
||||
enableTerminal: true,
|
||||
enableSsh: true,
|
||||
...hostData,
|
||||
};
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
|
||||
export function registerHostInternalRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/internal:
|
||||
* get:
|
||||
* summary: Get internal SSH host data
|
||||
* description: Returns internal SSH host data for autostart tunnels. Requires internal auth token.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of autostart hosts.
|
||||
* 403:
|
||||
* description: Forbidden.
|
||||
* 500:
|
||||
* description: Failed to fetch autostart SSH data.
|
||||
*/
|
||||
router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
sshLogger.warn(
|
||||
"Unauthorized attempt to access internal SSH host endpoint",
|
||||
{
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
providedToken: internalToken ? "present" : "missing",
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to validate internal auth token", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
||||
try {
|
||||
const autostartHosts =
|
||||
await createCurrentHostResolutionRepository().listHostsWithTunnelConnections();
|
||||
|
||||
const result = autostartHosts
|
||||
.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
const hasAutoStartTunnels = tunnelConnections.some(
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
);
|
||||
|
||||
if (!hasAutoStartTunnels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `autostart-${host.id}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
keyType: host.keyType,
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: true,
|
||||
tunnelConnections: tunnelConnections.filter(
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
),
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
tags: ["autostart"],
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch autostart SSH data", err);
|
||||
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/internal/all:
|
||||
* get:
|
||||
* summary: Get all internal SSH host data
|
||||
* description: Returns all internal SSH host data. Requires internal auth token.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of all hosts.
|
||||
* 401:
|
||||
* description: Invalid or missing internal authentication token.
|
||||
* 500:
|
||||
* description: Failed to fetch all hosts.
|
||||
*/
|
||||
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
if (!internalToken) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Internal authentication token required" });
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Invalid internal authentication token" });
|
||||
}
|
||||
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().listAllHosts();
|
||||
|
||||
const result = allHosts.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `${host.username}@${host.ip}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
keyType: host.keyType,
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: tunnelConnections,
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch all hosts for internal use", err);
|
||||
res.status(500).json({ error: "Failed to fetch all hosts" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -170,30 +170,11 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
credentialId?: number;
|
||||
credentialAlias?: string;
|
||||
pin?: unknown;
|
||||
enableTerminal?: unknown;
|
||||
enableTunnel?: unknown;
|
||||
enableFileManager?: unknown;
|
||||
enableWebUi?: unknown;
|
||||
enableProxmox?: unknown;
|
||||
enableTmuxMonitor?: unknown;
|
||||
enableTerminalToolbar?: unknown;
|
||||
enableCommandHistory?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
showTunnelInSidebar?: unknown;
|
||||
showDockerInSidebar?: unknown;
|
||||
showServerStatsInSidebar?: unknown;
|
||||
defaultPath?: unknown;
|
||||
sudoPassword?: unknown;
|
||||
tunnelConnections?: unknown;
|
||||
jumpHosts?: unknown;
|
||||
quickActions?: unknown;
|
||||
statusCheckEnabled?: unknown;
|
||||
statusCheckInterval?: unknown;
|
||||
webUiConfig?: unknown;
|
||||
proxmoxConfig?: unknown;
|
||||
enableProxmoxStats?: unknown;
|
||||
proxmoxStatsConfig?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
forceKeyboardInteractive?: unknown;
|
||||
notes?: unknown;
|
||||
@@ -333,24 +314,11 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"shareSshAuth",
|
||||
"authOverrides",
|
||||
"connectionType",
|
||||
"enableTerminal",
|
||||
"enableTunnel",
|
||||
"enableFileManager",
|
||||
"enableTmuxMonitor",
|
||||
"enableTerminalToolbar",
|
||||
"showTerminalInSidebar",
|
||||
"showFileManagerInSidebar",
|
||||
"showTunnelInSidebar",
|
||||
"showDockerInSidebar",
|
||||
"showServerStatsInSidebar",
|
||||
"statusCheckEnabled",
|
||||
"statusCheckInterval",
|
||||
"enableSsh",
|
||||
"sshPort",
|
||||
"rdpAuthType",
|
||||
"defaultPath",
|
||||
"scpLegacy",
|
||||
"tunnelConnections",
|
||||
"jumpHosts",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -428,25 +396,12 @@ export function transformHostResponse(
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
shareSshAuth: !!host.shareSshAuth,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||
enableTerminalToolbar: host.enableTerminalToolbar !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
enableSsh: !!host.enableSsh,
|
||||
sshPort: host.sshPort ?? host.port ?? 22,
|
||||
rdpUser: host.rdpUser || undefined,
|
||||
rdpDomain: host.rdpDomain || undefined,
|
||||
vncUser: host.vncUser || undefined,
|
||||
telnetUser: host.telnetUser || undefined,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
|
||||
quickActions: host.quickActions
|
||||
? JSON.parse(host.quickActions as string)
|
||||
|
||||
@@ -13,13 +13,34 @@
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
|
||||
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
|
||||
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
|
||||
import {
|
||||
declaredFields,
|
||||
resolveFieldValue,
|
||||
setSetting,
|
||||
} from "../../plugins/settings.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { consume } from "../../plugins/registry.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Marks a host changed after one of its plugin settings did, so remote sync,
|
||||
* which pulls by updated_at, carries the new value across.
|
||||
*/
|
||||
export async function touchHost(hostId: number): Promise<void> {
|
||||
try {
|
||||
const { sql } = await import("drizzle-orm");
|
||||
const { runStatement } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
await runStatement(
|
||||
sql`UPDATE ssh_data SET updated_at = ${new Date().toISOString()} WHERE id = ${hostId}`,
|
||||
);
|
||||
} catch {
|
||||
// Only a sync hint; the setting itself is already saved.
|
||||
}
|
||||
}
|
||||
|
||||
/** Enabled plugins that declare host-scope settings, with their manifests. */
|
||||
export function hostSettingsPlugins(): PluginManifest[] {
|
||||
try {
|
||||
@@ -105,6 +126,71 @@ export async function loadHostPluginSettings(
|
||||
return result;
|
||||
}
|
||||
|
||||
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||
|
||||
/**
|
||||
* Drops the fields a shared host's recipient may not read: a field declares
|
||||
* the lowest share level that sees it with shareRead. The owner sees all.
|
||||
*/
|
||||
export function visibleHostPluginSettings(
|
||||
values: HostPluginSettings,
|
||||
host: Record<string, unknown>,
|
||||
): HostPluginSettings {
|
||||
if (!host.isShared) return values;
|
||||
const level = SHARE_LEVELS.indexOf(
|
||||
typeof host.permissionLevel === "string" ? host.permissionLevel : "connect",
|
||||
);
|
||||
const result: HostPluginSettings = {};
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const own = values[manifest.id];
|
||||
if (!own) continue;
|
||||
const copy = { ...own };
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (
|
||||
field.shareRead &&
|
||||
SHARE_LEVELS.indexOf(field.shareRead) > Math.max(level, 0)
|
||||
) {
|
||||
delete copy[field.key];
|
||||
}
|
||||
}
|
||||
result[manifest.id] = copy;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields a plugin still puts on the host payload in their pre-2.9 shape, for
|
||||
* clients outside Termix (Termix-Mobile) that have not moved to
|
||||
* pluginSettings yet. Registered as ctx.registry.provide(
|
||||
* "<id>.hostPayloadLegacy", fn); the function gets the plugin's own host
|
||||
* values and the host, and never overwrites a field core already set.
|
||||
*/
|
||||
export type PluginHostPayloadLegacy = (
|
||||
values: Record<string, unknown>,
|
||||
host: Record<string, unknown>,
|
||||
) => Record<string, unknown> | null;
|
||||
|
||||
function applyLegacyFields(
|
||||
host: Record<string, unknown>,
|
||||
values: HostPluginSettings,
|
||||
): void {
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const own = values[manifest.id];
|
||||
if (!own) continue;
|
||||
const legacy = consume<PluginHostPayloadLegacy>(
|
||||
`${manifest.id}.hostPayloadLegacy`,
|
||||
);
|
||||
if (!legacy) continue;
|
||||
try {
|
||||
for (const [key, value] of Object.entries(legacy(own, host) ?? {})) {
|
||||
if (!(key in host)) host[key] = value;
|
||||
}
|
||||
} catch {
|
||||
// A plugin's compat shape must never break a host read.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attaches the map to already-transformed host objects, in place. */
|
||||
export function attachHostPluginSettings(
|
||||
hosts: Record<string, unknown>[],
|
||||
@@ -114,7 +200,9 @@ export function attachHostPluginSettings(
|
||||
for (const host of hosts) {
|
||||
const hostId = Number(host.id);
|
||||
const values = settings.get(hostId);
|
||||
if (values) host.pluginSettings = values;
|
||||
if (!values) continue;
|
||||
host.pluginSettings = visibleHostPluginSettings(values, host);
|
||||
applyLegacyFields(host, host.pluginSettings as HostPluginSettings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +215,13 @@ export async function withHostPluginSettings(
|
||||
|
||||
const settings = await loadHostPluginSettings([hostId]);
|
||||
const values = settings.get(hostId);
|
||||
return values ? { ...host, pluginSettings: values } : host;
|
||||
if (!values) return host;
|
||||
const result: Record<string, unknown> = {
|
||||
...host,
|
||||
pluginSettings: visibleHostPluginSettings(values, host),
|
||||
};
|
||||
applyLegacyFields(result, result.pluginSettings as HostPluginSettings);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,13 +269,33 @@ export async function applyPluginHostImportSettings(
|
||||
hostId: number,
|
||||
raw: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const exported =
|
||||
raw.pluginSettings && typeof raw.pluginSettings === "object"
|
||||
? (raw.pluginSettings as HostPluginSettings)
|
||||
: {};
|
||||
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const normalizer = consume<PluginHostImportNormalizer>(
|
||||
`${manifest.id}.hostImportNormalizer`,
|
||||
);
|
||||
if (!normalizer) continue;
|
||||
try {
|
||||
const values = normalizer(raw);
|
||||
// What an export carried for this plugin, validated like any other
|
||||
// write. Secrets never leave the server, so there are none to restore.
|
||||
const carried = exported[manifest.id];
|
||||
if (carried && typeof carried === "object") {
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (field.type === "secret" || !(field.key in carried)) continue;
|
||||
await setSetting(
|
||||
manifest,
|
||||
"host",
|
||||
hostId,
|
||||
field.key,
|
||||
carried[field.key],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizer = consume<PluginHostImportNormalizer>(
|
||||
`${manifest.id}.hostImportNormalizer`,
|
||||
);
|
||||
const values = normalizer?.(raw);
|
||||
if (values) await writeHostPluginSettings(manifest.id, hostId, values);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Plugin host import normalizer failed", {
|
||||
@@ -193,3 +307,22 @@ export async function applyPluginHostImportSettings(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one plugin's host switch on or off for a set of hosts. Returns false
|
||||
* when the plugin is not running or declares no enableKey.
|
||||
*/
|
||||
export async function setHostPluginEnabled(
|
||||
pluginId: string,
|
||||
hostIds: number[],
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
const manifest = hostSettingsPlugins().find((m) => m.id === pluginId);
|
||||
const enableKey = manifest?.contributes?.settings?.host?.enableKey;
|
||||
if (!manifest || !enableKey) return false;
|
||||
for (const hostId of hostIds) {
|
||||
await setSetting(manifest, "host", hostId, enableKey, enabled);
|
||||
await touchHost(hostId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -41,14 +41,11 @@ import {
|
||||
import {
|
||||
attachHostPluginSettings,
|
||||
loadHostPluginSettings,
|
||||
writeHostPluginSettings,
|
||||
withHostPluginSettings,
|
||||
} from "./host-plugin-settings.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
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";
|
||||
@@ -104,8 +101,6 @@ const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
registerHostInternalRoutes(router);
|
||||
|
||||
registerHostStatusRoutes(router, {
|
||||
authenticateJWT,
|
||||
requireAdmin: authManager.createAdminMiddleware(),
|
||||
@@ -188,28 +183,10 @@ router.post(
|
||||
keyType,
|
||||
sudoPassword,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableCommandHistory,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
scpLegacy,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
showDockerInSidebar,
|
||||
showServerStatsInSidebar,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statusCheckEnabled,
|
||||
statusCheckInterval,
|
||||
proxmoxConfig,
|
||||
enableProxmoxStats,
|
||||
proxmoxStatsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -306,26 +283,10 @@ router.post(
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableCommandHistory: enableCommandHistory ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
tunnelConnections: Array.isArray(tunnelConnections)
|
||||
? JSON.stringify(tunnelConnections)
|
||||
: null,
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: Array.isArray(quickActions)
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
scpLegacy: scpLegacy ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
|
||||
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
||||
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
||||
terminalConfig: terminalConfig
|
||||
@@ -451,36 +412,6 @@ router.post(
|
||||
|
||||
const createdHost = result;
|
||||
|
||||
if (
|
||||
enableProxmox !== undefined ||
|
||||
proxmoxConfig !== undefined ||
|
||||
enableProxmoxStats !== undefined ||
|
||||
proxmoxStatsConfig !== undefined
|
||||
) {
|
||||
try {
|
||||
await writeHostPluginSettings("proxmox", createdHost.id as number, {
|
||||
enableProxmox: !!enableProxmox,
|
||||
proxmoxConfig: proxmoxConfig
|
||||
? typeof proxmoxConfig === "string"
|
||||
? safeParseJson(proxmoxConfig)
|
||||
: proxmoxConfig
|
||||
: null,
|
||||
enableProxmoxStats: !!enableProxmoxStats,
|
||||
proxmoxStatsConfig: proxmoxStatsConfig
|
||||
? typeof proxmoxStatsConfig === "string"
|
||||
? safeParseJson(proxmoxStatsConfig)
|
||||
: proxmoxStatsConfig
|
||||
: null,
|
||||
});
|
||||
} catch (proxmoxSettingsError) {
|
||||
sshLogger.warn("Failed to save Proxmox host settings", {
|
||||
operation: "host_create_proxmox_settings",
|
||||
hostId: createdHost.id,
|
||||
error: getErrorMessage(proxmoxSettingsError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Standing folder shares apply to the newcomer.
|
||||
try {
|
||||
await applyFolderAccessRules(
|
||||
@@ -582,12 +513,6 @@ router.post(
|
||||
* - type: array
|
||||
* items:
|
||||
* type: string
|
||||
* enableTerminal:
|
||||
* type: boolean
|
||||
* enableFileManager:
|
||||
* type: boolean
|
||||
* enableTunnel:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Host enrolled successfully.
|
||||
@@ -735,19 +660,6 @@ router.post(
|
||||
key: resolvedKey,
|
||||
keyPassword: resolvedKeyPassword,
|
||||
keyType: resolvedKeyType,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
enableWebUi: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
showDockerInSidebar: false,
|
||||
showServerStatsInSidebar: false,
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
jumpHosts: [],
|
||||
quickActions: [],
|
||||
statusCheckEnabled: true,
|
||||
@@ -860,28 +772,10 @@ router.put(
|
||||
keyType,
|
||||
sudoPassword,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableCommandHistory,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
scpLegacy,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
showDockerInSidebar,
|
||||
showServerStatsInSidebar,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statusCheckEnabled,
|
||||
statusCheckInterval,
|
||||
proxmoxConfig,
|
||||
enableProxmoxStats,
|
||||
proxmoxStatsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -979,26 +873,10 @@ router.put(
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableCommandHistory: enableCommandHistory ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
tunnelConnections: Array.isArray(tunnelConnections)
|
||||
? JSON.stringify(tunnelConnections)
|
||||
: null,
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: Array.isArray(quickActions)
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
scpLegacy: scpLegacy ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
|
||||
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
||||
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
||||
terminalConfig: terminalConfig
|
||||
@@ -1300,36 +1178,6 @@ router.put(
|
||||
sshDataObj,
|
||||
);
|
||||
|
||||
if (
|
||||
enableProxmox !== undefined ||
|
||||
proxmoxConfig !== undefined ||
|
||||
enableProxmoxStats !== undefined ||
|
||||
proxmoxStatsConfig !== undefined
|
||||
) {
|
||||
try {
|
||||
await writeHostPluginSettings("proxmox", Number(hostId), {
|
||||
enableProxmox: !!enableProxmox,
|
||||
proxmoxConfig: proxmoxConfig
|
||||
? typeof proxmoxConfig === "string"
|
||||
? safeParseJson(proxmoxConfig)
|
||||
: proxmoxConfig
|
||||
: null,
|
||||
enableProxmoxStats: !!enableProxmoxStats,
|
||||
proxmoxStatsConfig: proxmoxStatsConfig
|
||||
? typeof proxmoxStatsConfig === "string"
|
||||
? safeParseJson(proxmoxStatsConfig)
|
||||
: proxmoxStatsConfig
|
||||
: null,
|
||||
});
|
||||
} catch (proxmoxSettingsError) {
|
||||
sshLogger.warn("Failed to save Proxmox host settings", {
|
||||
operation: "host_update_proxmox_settings",
|
||||
hostId: parseInt(hostId),
|
||||
error: getErrorMessage(proxmoxSettingsError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A host that moved into a folder inherits that folder's standing shares.
|
||||
try {
|
||||
await applyFolderAccessRules(
|
||||
@@ -1987,16 +1835,6 @@ router.get(
|
||||
const hostPluginSettings = (
|
||||
await loadHostPluginSettings([Number(hostId)])
|
||||
).get(Number(hostId));
|
||||
const proxmoxSettings = hostPluginSettings?.proxmox as
|
||||
| {
|
||||
enableProxmox?: boolean;
|
||||
proxmoxConfig?: unknown;
|
||||
enableProxmoxStats?: boolean;
|
||||
proxmoxStatsConfig?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
|
||||
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
|
||||
|
||||
const exportedConnectionType =
|
||||
(resolvedHost.connectionType as string) || "ssh";
|
||||
@@ -2047,35 +1885,13 @@ router.get(
|
||||
credentialId: resolvedHost.credentialId || null,
|
||||
overrideCredentialUsername:
|
||||
!!resolvedHost.overrideCredentialUsername,
|
||||
enableTerminal: !!resolvedHost.enableTerminal,
|
||||
enableCommandHistory: resolvedHost.enableCommandHistory !== false,
|
||||
enableTunnel: !!resolvedHost.enableTunnel,
|
||||
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||
scpLegacy: !!resolvedHost.scpLegacy,
|
||||
enableWebUi: !!webEndpointSettings?.enableWebUi,
|
||||
enableProxmox: !!proxmoxSettings?.enableProxmox,
|
||||
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
|
||||
defaultPath: resolvedHost.defaultPath,
|
||||
sudoPassword: resolvedHost.sudoPassword || null,
|
||||
tunnelConnections: resolvedHost.tunnelConnections
|
||||
? JSON.parse(resolvedHost.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: resolvedHost.jumpHosts
|
||||
? JSON.parse(resolvedHost.jumpHosts as string)
|
||||
: null,
|
||||
quickActions: resolvedHost.quickActions
|
||||
? JSON.parse(resolvedHost.quickActions as string)
|
||||
: null,
|
||||
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
|
||||
proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null,
|
||||
proxmoxStatsConfig: proxmoxSettings?.proxmoxStatsConfig ?? null,
|
||||
terminalConfig: resolvedHost.terminalConfig
|
||||
? JSON.parse(resolvedHost.terminalConfig as string)
|
||||
: null,
|
||||
@@ -2162,10 +1978,6 @@ router.get(
|
||||
? host
|
||||
: (await resolveHostCredentials(host, userId)) || host;
|
||||
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
|
||||
const proxmoxSettings = hostPluginSettings?.proxmox as
|
||||
{ enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined;
|
||||
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
|
||||
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
|
||||
|
||||
const exportedConnectionType =
|
||||
(resolvedHost.connectionType as string) || "ssh";
|
||||
@@ -2204,37 +2016,15 @@ router.get(
|
||||
credentialId: resolvedHost.credentialId || null,
|
||||
overrideCredentialUsername:
|
||||
!!resolvedHost.overrideCredentialUsername,
|
||||
enableTerminal: !!resolvedHost.enableTerminal,
|
||||
enableCommandHistory: resolvedHost.enableCommandHistory !== false,
|
||||
enableTunnel: !!resolvedHost.enableTunnel,
|
||||
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||
enableWebUi: !!webEndpointSettings?.enableWebUi,
|
||||
enableProxmox: !!proxmoxSettings?.enableProxmox,
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
enableTerminalToolbar:
|
||||
resolvedHost.enableTerminalToolbar !== false,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
|
||||
defaultPath: resolvedHost.defaultPath,
|
||||
sudoPassword: shareMode
|
||||
? null
|
||||
: resolvedHost.sudoPassword || null,
|
||||
tunnelConnections: resolvedHost.tunnelConnections
|
||||
? JSON.parse(resolvedHost.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: resolvedHost.jumpHosts
|
||||
? JSON.parse(resolvedHost.jumpHosts as string)
|
||||
: null,
|
||||
quickActions: resolvedHost.quickActions
|
||||
? JSON.parse(resolvedHost.quickActions as string)
|
||||
: null,
|
||||
webUiConfig: webEndpointSettings?.webUiConfig ?? {
|
||||
endpoints: [],
|
||||
},
|
||||
proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null,
|
||||
terminalConfig: resolvedHost.terminalConfig
|
||||
? JSON.parse(resolvedHost.terminalConfig as string)
|
||||
: null,
|
||||
@@ -2646,13 +2436,6 @@ registerHostBulkRoutes(
|
||||
requireDataAccess,
|
||||
);
|
||||
|
||||
registerHostAutostartRoutes(router, {
|
||||
authenticateJWT,
|
||||
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
||||
requireDataAccess,
|
||||
});
|
||||
|
||||
registerHostAuthCompatRoutes(router);
|
||||
|
||||
registerHostNetworkRoutes(router, {
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
|
||||
import { sendDiscord } from "../../utils/discord-sender.js";
|
||||
import { deliverNotification } from "../../utils/notification-sender.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -224,43 +223,15 @@ router.post(
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
const testPayload = {
|
||||
hostName: "Test Host",
|
||||
hostId: 0,
|
||||
triggerType: "test",
|
||||
message: "This is a test notification from Termix",
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: 0,
|
||||
ruleName: "Test",
|
||||
};
|
||||
|
||||
try {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(row.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Invalid channel config" });
|
||||
}
|
||||
|
||||
if (row.type === "webhook") {
|
||||
await sendWebhook(
|
||||
config as unknown as Parameters<typeof sendWebhook>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "ntfy") {
|
||||
await sendNtfy(
|
||||
config as unknown as Parameters<typeof sendNtfy>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "discord") {
|
||||
await sendDiscord(
|
||||
config as unknown as Parameters<typeof sendDiscord>[0],
|
||||
testPayload,
|
||||
);
|
||||
}
|
||||
// The same path an automation's notify step takes, so a test proves
|
||||
// the channel works for real, private-network opt-in included.
|
||||
await deliverNotification(row, {
|
||||
title: "Termix",
|
||||
body: "This is a test notification from Termix",
|
||||
severity: "info",
|
||||
context: { sourceName: "Test", triggerType: "test" },
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({
|
||||
|
||||
@@ -78,4 +78,32 @@ export function mountPluginApi(app: express.Express): void {
|
||||
app.use("/plugin-api", router);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the old URLs plugins declare in contributes.http.legacyPaths, each
|
||||
* under "/<plugin id>/", through that plugin's router with the prefix
|
||||
* removed. Mounted after every core route, so core wins a clash.
|
||||
*/
|
||||
export function mountPluginLegacyPaths(
|
||||
app: express.Express,
|
||||
listActive: () => Array<{ id: string; legacyPaths: string[] }>,
|
||||
): void {
|
||||
app.use((req: Request, res: Response, next) => {
|
||||
for (const plugin of listActive()) {
|
||||
const matched = plugin.legacyPaths.some(
|
||||
(path) => req.path === path || req.path.startsWith(`${path}/`),
|
||||
);
|
||||
if (!matched) continue;
|
||||
const pluginRouter = getPluginRouter(plugin.id);
|
||||
if (!pluginRouter) {
|
||||
res.status(404).json({ error: "Plugin not installed or not running" });
|
||||
return;
|
||||
}
|
||||
req.url = req.url.slice(plugin.id.length + 1) || "/";
|
||||
pluginRouter(req, res, next);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { getSetting } from "../../plugins/settings.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import {
|
||||
findField,
|
||||
getAllSettings,
|
||||
setSetting,
|
||||
validateSettingsSave,
|
||||
type PluginSettingsScope,
|
||||
} from "../../plugins/settings.js";
|
||||
import {
|
||||
@@ -231,57 +233,59 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
);
|
||||
}
|
||||
|
||||
const plugins = records.map((record) => {
|
||||
const loaded = live.get(record.id);
|
||||
const plugins = await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const loaded = live.get(record.id);
|
||||
|
||||
let contributes: unknown = null;
|
||||
let capabilities: string[] = [];
|
||||
let icon: string | undefined;
|
||||
let dependencies: Record<string, string> = {};
|
||||
let optionalDependencies: Record<string, string> = {};
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
icon?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
let contributes: unknown = null;
|
||||
let capabilities: string[] = [];
|
||||
let icon: string | undefined;
|
||||
let dependencies: Record<string, string> = {};
|
||||
let optionalDependencies: Record<string, string> = {};
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
icon?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
contributes = await withHostFieldDefaults(manifest as PluginManifest);
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||
dependencies = manifest?.dependencies ?? {};
|
||||
optionalDependencies = manifest?.optionalDependencies ?? {};
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||
dependencies = manifest?.dependencies ?? {};
|
||||
optionalDependencies = manifest?.optionalDependencies ?? {};
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
if (!canManage) return summary;
|
||||
|
||||
if (!canManage) return summary;
|
||||
|
||||
return {
|
||||
...summary,
|
||||
tier: record.tier,
|
||||
source: record.source,
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...summary,
|
||||
tier: record.tier,
|
||||
source: record.source,
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
res.json(plugins);
|
||||
} catch (error) {
|
||||
@@ -762,10 +766,11 @@ async function applySettings(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
body: Record<string, unknown>,
|
||||
options: { isHostOwner?: boolean } = {},
|
||||
): Promise<Record<string, string>> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
for (const [key] of Object.entries(body)) {
|
||||
const field = findField(manifest, scope, key);
|
||||
if (!field) {
|
||||
errors[key] = "Not a settings field this plugin declares";
|
||||
@@ -775,7 +780,22 @@ async function applySettings(
|
||||
errors[key] = "You do not have permission to change this setting";
|
||||
continue;
|
||||
}
|
||||
if (scope === "host" && field.ownerOnly && !options.isHostOwner) {
|
||||
errors[key] = "Only the host's owner can change this setting";
|
||||
}
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return errors;
|
||||
|
||||
// The plugin's own checks see the whole save before anything is written.
|
||||
const rejected = await validateSettingsSave(
|
||||
manifest.id,
|
||||
scope,
|
||||
scopeId,
|
||||
body,
|
||||
);
|
||||
if (Object.keys(rejected).length > 0) return rejected;
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const error = await setSetting(manifest, scope, scopeId, key, value);
|
||||
if (error) errors[key] = error;
|
||||
}
|
||||
@@ -1208,11 +1228,14 @@ router.put(
|
||||
"host",
|
||||
String(hostId),
|
||||
req.body,
|
||||
{ isHostOwner: access.isOwner === true },
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||
return;
|
||||
}
|
||||
const { touchHost } = await import("./host-plugin-settings.js");
|
||||
await touchHost(hostId);
|
||||
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
@@ -1230,3 +1253,37 @@ router.put(
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
/**
|
||||
* A host field with defaultFrom starts new hosts at an admin setting's value,
|
||||
* so the host editor gets that value as the field's default.
|
||||
*/
|
||||
async function withHostFieldDefaults(
|
||||
manifest: PluginManifest,
|
||||
): Promise<unknown> {
|
||||
const contributes = manifest?.contributes;
|
||||
const host = contributes?.settings?.host;
|
||||
if (!host?.fields.some((field) => field.defaultFrom)) {
|
||||
return contributes ?? null;
|
||||
}
|
||||
const fields: PluginSettingsField[] = await Promise.all(
|
||||
host.fields.map(async (field) => {
|
||||
if (!field.defaultFrom) return field;
|
||||
try {
|
||||
const value = await getSetting(
|
||||
manifest,
|
||||
"admin",
|
||||
null,
|
||||
field.defaultFrom,
|
||||
);
|
||||
return value === undefined ? field : { ...field, default: value };
|
||||
} catch {
|
||||
return field;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...contributes,
|
||||
settings: { ...contributes.settings, host: { ...host, fields } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Host plugin settings over remote sync. A host row travels with a
|
||||
* pluginSettings map of its non-secret values; a plugin whose value refers to
|
||||
* one of its own rows by local id translates it with a registered
|
||||
* ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue }).
|
||||
*/
|
||||
|
||||
import { consume } from "../../plugins/registry.js";
|
||||
import {
|
||||
declaredFields,
|
||||
getAllSettings,
|
||||
setSetting,
|
||||
} from "../../plugins/settings.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
hostSettingsPlugins,
|
||||
type HostPluginSettings,
|
||||
} from "./host-plugin-settings.js";
|
||||
|
||||
export interface PluginHostSettingsSync {
|
||||
exportValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
|
||||
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What a host carries to the other side of a sync pair. */
|
||||
export async function exportHostPluginSettings(
|
||||
hostId: number,
|
||||
): Promise<HostPluginSettings> {
|
||||
const result: HostPluginSettings = {};
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const fields = declaredFields(manifest, "host").filter(
|
||||
(field) => field.type !== "secret",
|
||||
);
|
||||
if (fields.length === 0) continue;
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
const own: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const value = values[field.key];
|
||||
own[field.key] = hook?.exportValue
|
||||
? await hook.exportValue(field.key, value)
|
||||
: value;
|
||||
}
|
||||
result[manifest.id] = own;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Writes what a synced host carried, translated back to local ids. */
|
||||
export async function importHostPluginSettings(
|
||||
hostId: number,
|
||||
carried: unknown,
|
||||
): Promise<void> {
|
||||
if (!carried || typeof carried !== "object") return;
|
||||
const byPlugin = carried as HostPluginSettings;
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const own = byPlugin[manifest.id];
|
||||
if (!own || typeof own !== "object") continue;
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (field.type === "secret" || !(field.key in own)) continue;
|
||||
try {
|
||||
const value = hook?.importValue
|
||||
? await hook.importValue(field.key, own[field.key])
|
||||
: own[field.key];
|
||||
await setSetting(manifest, "host", hostId, field.key, value);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not apply a synced host plugin setting", {
|
||||
operation: "sync_host_plugin_settings",
|
||||
pluginId: manifest.id,
|
||||
hostId,
|
||||
key: field.key,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
updateReturning,
|
||||
} from "../repositories/returning.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import {
|
||||
exportHostPluginSettings,
|
||||
importHostPluginSettings,
|
||||
} from "./sync-host-plugin-settings.js";
|
||||
|
||||
// Primed at import so every consumer of this module - the routes below, the
|
||||
// reference resolvers, the Electron entity-types endpoint - sees the same
|
||||
@@ -302,6 +306,11 @@ router.get(
|
||||
(referenceType, id) =>
|
||||
findReferenceSyncId(context, referenceType, id, userId),
|
||||
);
|
||||
if (entityType === "hosts") {
|
||||
result.pluginSettings = await exportHostPluginSettings(
|
||||
row.id as number,
|
||||
);
|
||||
}
|
||||
return singleton
|
||||
? { ...result, syncId: `${entityType}:singleton` }
|
||||
: result;
|
||||
@@ -461,6 +470,8 @@ router.post(
|
||||
);
|
||||
if (parentError) return res.status(400).json({ error: parentError });
|
||||
}
|
||||
const carriedPluginSettings = resolvedPayload.pluginSettings;
|
||||
delete resolvedPayload.pluginSettings;
|
||||
const writePayload = stripWritePayload(entityType, resolvedPayload);
|
||||
const encryptedPayload = encryptIfNeeded(
|
||||
entityType,
|
||||
@@ -495,6 +506,10 @@ router.post(
|
||||
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (entityType === "hosts" && typeof resultRow?.id === "number") {
|
||||
await importHostPluginSettings(resultRow.id, carriedPluginSettings);
|
||||
}
|
||||
|
||||
await DatabaseSaveTrigger.forceSave("sync_upsert");
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -35,8 +35,6 @@ export type HostDefaults = {
|
||||
theme?: string;
|
||||
cursorStyle?: string;
|
||||
cursorBlink?: boolean;
|
||||
enableSessionLogging?: boolean;
|
||||
enableCommandHistory?: boolean;
|
||||
autoTmux?: boolean;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user