mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Boot copies of 2.8 data into plugin settings and plugin tables.
|
||||
*
|
||||
* They run after the plugins are seeded and activated: plugin_settings has a
|
||||
* foreign key to plugins, and some copies write into tables a plugin adopts
|
||||
* in its own migrations. Each one is idempotent, so they also run again when
|
||||
* an admin enables a plugin without a restart.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { runTailscaleSettingsMigration } from "../utils/crypto-migration/tailscale-settings-migration.js";
|
||||
import { runProxmoxSettingsMigration } from "../utils/crypto-migration/proxmox-settings-migration.js";
|
||||
import { runFileManagerSettingsMigration } from "../utils/crypto-migration/file-manager-settings-migration.js";
|
||||
import { runTunnelsSettingsMigration } from "../utils/crypto-migration/tunnels-settings-migration.js";
|
||||
import { runWebEndpointSettingsMigration } from "../utils/crypto-migration/web-endpoint-settings-migration.js";
|
||||
import { runSshTerminalSettingsMigration } from "../utils/crypto-migration/ssh-terminal-settings-migration.js";
|
||||
import { runTmuxMonitorSettingsMigration } from "../utils/crypto-migration/tmux-monitor-settings-migration.js";
|
||||
import { runSessionSharingSettingsMigration } from "../utils/crypto-migration/session-sharing-settings-migration.js";
|
||||
import { runSessionRecordingSettingsMigration } from "../utils/crypto-migration/session-recording-settings-migration.js";
|
||||
import { runRemoteDesktopSettingsMigration } from "../utils/crypto-migration/remote-desktop-settings-migration.js";
|
||||
import { runHostMetricsSettingsMigration } from "../utils/crypto-migration/host-metrics-settings-migration.js";
|
||||
import { runDockerSettingsMigration } from "../utils/crypto-migration/docker-settings-migration.js";
|
||||
import { runAiSettingsMigration } from "../utils/crypto-migration/ai-settings-migration.js";
|
||||
import { runWakeOnLanSettingsMigration } from "../utils/crypto-migration/wake-on-lan-settings-migration.js";
|
||||
import { runWarpgateSettingsMigration } from "../utils/crypto-migration/warpgate-settings-migration.js";
|
||||
import { runStepCaSettingsMigration } from "../utils/crypto-migration/step-ca-settings-migration.js";
|
||||
import { runAcmeSslSettingsMigration } from "../utils/crypto-migration/acme-ssl-settings-migration.js";
|
||||
import { runVaultSettingsMigration } from "../utils/crypto-migration/vault-settings-migration.js";
|
||||
import { runSecretSourcesTokenMigration } from "../utils/crypto-migration/secret-sources-token-migration.js";
|
||||
import { runTotpMigration } from "../utils/crypto-migration/totp-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "../utils/crypto-migration/termix-identity-ca-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
|
||||
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
|
||||
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
|
||||
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
|
||||
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
|
||||
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
|
||||
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
|
||||
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
|
||||
[
|
||||
"runSessionRecordingSettingsMigration",
|
||||
runSessionRecordingSettingsMigration,
|
||||
],
|
||||
["runRemoteDesktopSettingsMigration", runRemoteDesktopSettingsMigration],
|
||||
["runHostMetricsSettingsMigration", runHostMetricsSettingsMigration],
|
||||
["runDockerSettingsMigration", runDockerSettingsMigration],
|
||||
["runAiSettingsMigration", runAiSettingsMigration],
|
||||
["runWakeOnLanSettingsMigration", runWakeOnLanSettingsMigration],
|
||||
["runWarpgateSettingsMigration", runWarpgateSettingsMigration],
|
||||
["runStepCaSettingsMigration", runStepCaSettingsMigration],
|
||||
["runAcmeSslSettingsMigration", runAcmeSslSettingsMigration],
|
||||
["runVaultSettingsMigration", runVaultSettingsMigration],
|
||||
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
|
||||
["runTotpMigration", runTotpMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
];
|
||||
|
||||
export async function runPluginDataMigrations(): Promise<void> {
|
||||
for (const [name, run] of MIGRATIONS) {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
databaseLogger.warn(`Plugin data migration ${name} failed`, {
|
||||
operation: "plugin_data_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Registrations that clashed with another plugin's: a service provider name,
|
||||
* a registry key or a permission namespace two plugins both claimed. Each is
|
||||
* refused or logged where it happens; this keeps the list so an admin view or
|
||||
* a test can see them all.
|
||||
*/
|
||||
|
||||
export interface RegistrationConflict {
|
||||
kind: "service" | "registry" | "permission" | "ws";
|
||||
pluginId: string;
|
||||
/** Who already held it, when known. */
|
||||
heldBy?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const conflicts: RegistrationConflict[] = [];
|
||||
|
||||
export function recordConflict(conflict: RegistrationConflict): void {
|
||||
conflicts.push(conflict);
|
||||
}
|
||||
|
||||
export function listRegistrationConflicts(): RegistrationConflict[] {
|
||||
return [...conflicts];
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearRegistrationConflicts(): void {
|
||||
conflicts.length = 0;
|
||||
}
|
||||
@@ -82,9 +82,6 @@ function toRecord(host: Record<string, unknown>): PluginHostRecord {
|
||||
folder: (host.folder as string | null) ?? null,
|
||||
jumpHosts: host.jumpHosts,
|
||||
enableSsh: (host.enableSsh as boolean | null) ?? null,
|
||||
enableTerminal: (host.enableTerminal as boolean | null) ?? null,
|
||||
enableFileManager: (host.enableFileManager as boolean | null) ?? null,
|
||||
enableTunnel: (host.enableTunnel as boolean | null) ?? null,
|
||||
createdAt: (host.createdAt as string | null) ?? null,
|
||||
updatedAt: (host.updatedAt as string | null) ?? null,
|
||||
...host,
|
||||
|
||||
@@ -434,7 +434,15 @@ export function createPluginContext(
|
||||
pluginEvents.emit(topic, payload);
|
||||
},
|
||||
|
||||
// Core topics (host.*, user.*) carry other users' data, so listening
|
||||
// needs events:core too. Any plugin may listen to plugin.* topics.
|
||||
on: (topic, listener) => {
|
||||
if (!topic.startsWith("plugin.") && !declared.includes("events:core")) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
|
||||
`Declare the events:core capability to listen to core topics.`,
|
||||
);
|
||||
}
|
||||
const unsubscribe = pluginEvents.on(topic, listener);
|
||||
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
|
||||
return unsubscribe;
|
||||
@@ -766,6 +774,11 @@ export function createPluginContext(
|
||||
if (error) throw new Error(error);
|
||||
},
|
||||
|
||||
listHostValues: async (key) => {
|
||||
await assertCapability(pluginId, "hosts:read", manifest.capabilities);
|
||||
return pluginSettings.listHostValues(manifest, key) as never;
|
||||
},
|
||||
|
||||
getAll: (scope, scopeId) =>
|
||||
pluginSettings.getAllSettings(manifest, scope, scopeId ?? null),
|
||||
|
||||
@@ -779,6 +792,16 @@ export function createPluginContext(
|
||||
return unsubscribe;
|
||||
},
|
||||
|
||||
onValidate: (scope, validator) => {
|
||||
const unsubscribe = pluginSettings.onSettingsValidate(
|
||||
pluginId,
|
||||
scope,
|
||||
validator,
|
||||
);
|
||||
handle.bag.add(unsubscribe, `settings validator for ${scope}`);
|
||||
return unsubscribe;
|
||||
},
|
||||
|
||||
readCore: (key) => settingsReadCore(key),
|
||||
},
|
||||
|
||||
@@ -849,7 +872,9 @@ export function createPluginContext(
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
const actor = getActor() ?? "system";
|
||||
const meta = requestMeta(entry.request);
|
||||
await logAudit({
|
||||
...meta,
|
||||
userId: actor,
|
||||
username: actor,
|
||||
action: entry.action,
|
||||
@@ -1012,3 +1037,23 @@ export async function disposePluginHandle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** IP address and user agent off an express request or upgrade request. */
|
||||
function requestMeta(request: unknown): {
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
} {
|
||||
const req = request as
|
||||
| {
|
||||
ip?: string;
|
||||
headers?: Record<string, unknown>;
|
||||
socket?: { remoteAddress?: string };
|
||||
}
|
||||
| undefined;
|
||||
if (!req || typeof req !== "object" || !req.headers) return {};
|
||||
const userAgent = req.headers["user-agent"];
|
||||
return {
|
||||
ipAddress: req.ip || req.socket?.remoteAddress || undefined,
|
||||
userAgent: typeof userAgent === "string" ? userAgent : undefined,
|
||||
};
|
||||
}
|
||||
@@ -21,9 +21,6 @@ export interface PluginHostView {
|
||||
folder: string | null;
|
||||
tags: string[];
|
||||
authType: string | null;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -38,11 +35,6 @@ const STRING_FIELDS = [
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const;
|
||||
const BOOLEAN_FIELDS = [
|
||||
"enableTerminal",
|
||||
"enableTunnel",
|
||||
"enableFileManager",
|
||||
] as const;
|
||||
|
||||
export function toPluginHostView(
|
||||
host: Record<string, unknown>,
|
||||
@@ -56,10 +48,6 @@ export function toPluginHostView(
|
||||
const value = host[field];
|
||||
view[field] = typeof value === "string" ? value : null;
|
||||
}
|
||||
for (const field of BOOLEAN_FIELDS) {
|
||||
view[field] = Boolean(host[field]);
|
||||
}
|
||||
|
||||
view.tags = Array.isArray(host.tags)
|
||||
? host.tags.filter((tag): tag is string => typeof tag === "string")
|
||||
: [];
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { PluginPermissionContribution } from "./manifest.js";
|
||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
|
||||
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
|
||||
let loader: PluginLoader | null = null;
|
||||
|
||||
@@ -258,8 +259,15 @@ async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
|
||||
const accepted = declared.filter((permission) => {
|
||||
const head = permission.name.split(".")[0];
|
||||
if (coreGroups.has(head) || otherPluginIds.has(head)) {
|
||||
pluginLogger.warn(
|
||||
recordConflict({
|
||||
kind: "permission",
|
||||
pluginId: plugin.id,
|
||||
heldBy: coreGroups.has(head) ? "core" : head,
|
||||
name: permission.name,
|
||||
});
|
||||
pluginLogger.error(
|
||||
`Ignoring ${plugin.id} permission "${permission.name}": it starts with "${head}", which belongs to someone else`,
|
||||
undefined,
|
||||
{ operation: "plugin_permissions" },
|
||||
);
|
||||
return false;
|
||||
@@ -421,6 +429,10 @@ export async function activatePlugin(pluginId: string): Promise<void> {
|
||||
if (plugin) await registerPluginPermissions(plugin);
|
||||
|
||||
await pluginLoader.activate(pluginId);
|
||||
|
||||
// A plugin enabled without a restart still needs its 2.8 data copied.
|
||||
const { runPluginDataMigrations } = await import("./boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
}
|
||||
|
||||
export async function deactivatePlugin(pluginId: string): Promise<void> {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
|
||||
|
||||
@@ -135,6 +136,19 @@ export function provideService(
|
||||
): ServiceRegistration {
|
||||
const name = registration.name ?? "";
|
||||
const existing = lookup(registration.service, name);
|
||||
// Two plugins cannot answer to the same service and provider name. The
|
||||
// same plugin replacing its own registration (a restart) is fine.
|
||||
if (existing && existing.pluginId !== registration.pluginId) {
|
||||
recordConflict({
|
||||
kind: "service",
|
||||
pluginId: registration.pluginId,
|
||||
heldBy: existing.pluginId,
|
||||
name: `${registration.service}${name ? `:${name}` : ""}`,
|
||||
});
|
||||
throw new Error(
|
||||
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
pluginLogger.warn(
|
||||
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
isRedactedSecret,
|
||||
validateSettingValue,
|
||||
} from "@termix/plugin-sdk/settings";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { PluginSettingsScope } from "../database/repositories/plugin-settings-repository.js";
|
||||
import {
|
||||
decryptSystemSecret,
|
||||
@@ -87,9 +90,67 @@ function notify(pluginId: string, key: string, value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
export type SettingsValidator = (
|
||||
values: Record<string, unknown>,
|
||||
context: { hostId?: number },
|
||||
) =>
|
||||
| Record<string, string>
|
||||
| void
|
||||
| undefined
|
||||
| Promise<Record<string, string> | void | undefined>;
|
||||
|
||||
const validators = new Map<
|
||||
string,
|
||||
Set<{ scope: PluginSettingsScope; run: SettingsValidator }>
|
||||
>();
|
||||
|
||||
/** A plugin's own check on a settings save, before anything is written. */
|
||||
export function onSettingsValidate(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
run: SettingsValidator,
|
||||
): () => void {
|
||||
let set = validators.get(pluginId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
validators.set(pluginId, set);
|
||||
}
|
||||
const entry = { scope, run };
|
||||
set.add(entry);
|
||||
return () => {
|
||||
set!.delete(entry);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the plugin's validators over a save. Returns field key to message;
|
||||
* a validator that throws rejects the whole save under "_".
|
||||
*/
|
||||
export async function validateSettingsSave(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<Record<string, string>> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of validators.get(pluginId) ?? []) {
|
||||
if (entry.scope !== scope) continue;
|
||||
try {
|
||||
const result = await entry.run(values, {
|
||||
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
|
||||
});
|
||||
Object.assign(errors, result ?? {});
|
||||
} catch (error) {
|
||||
errors._ = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Drops every listener a plugin registered. Called when it deactivates. */
|
||||
export function clearSettingsListeners(pluginId: string): void {
|
||||
listeners.delete(pluginId);
|
||||
validators.delete(pluginId);
|
||||
}
|
||||
|
||||
/** The fields a manifest declares for one scope. */
|
||||
@@ -114,7 +175,7 @@ export function declaredFields(
|
||||
key: host.enableKey,
|
||||
type: "boolean",
|
||||
labelKey: host.enableLabelKey,
|
||||
default: false,
|
||||
default: host.enableDefault ?? false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -241,6 +302,40 @@ export async function setSetting(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every stored value of one host field, with each host's owner. */
|
||||
export async function listHostValues(
|
||||
manifest: PluginManifest,
|
||||
key: string,
|
||||
): Promise<Array<{ hostId: number; userId: string; value: unknown }>> {
|
||||
const field = findField(manifest, "host", key);
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
`"${key}" is not a host settings field this plugin declares`,
|
||||
);
|
||||
}
|
||||
if (field.type === "secret") {
|
||||
throw new Error(`"${key}" is a secret and cannot be listed`);
|
||||
}
|
||||
|
||||
const rows = await createCurrentPluginSettingsRepository().listByKey(
|
||||
manifest.id,
|
||||
"host",
|
||||
key,
|
||||
);
|
||||
const hosts = createCurrentHostRepository();
|
||||
const result: Array<{ hostId: number; userId: string; value: unknown }> = [];
|
||||
for (const row of rows) {
|
||||
const hostId = Number(row.scopeId);
|
||||
if (!Number.isInteger(hostId) || row.value === null) continue;
|
||||
const host = await hosts.findById(hostId);
|
||||
if (!host?.userId) continue;
|
||||
const value = decode(row.value);
|
||||
if (value === undefined) continue;
|
||||
result.push({ hostId, userId: host.userId as string, value });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface GetAllOptions {
|
||||
/** Replaces every secret with { set } so the value never leaves the server. */
|
||||
redactSecrets?: boolean;
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
PluginWebSocketOptions,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { extractWebSocketToken } from "../utils/ws-auth.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
|
||||
@@ -245,6 +246,12 @@ export function registerPluginWsRoute(
|
||||
): () => void {
|
||||
const routeKey = key(pluginId, path);
|
||||
const normalized = normalizePath(path);
|
||||
if (routes.has(routeKey)) {
|
||||
recordConflict({ kind: "ws", pluginId, name: normalized });
|
||||
throw new Error(
|
||||
`/plugin-ws/${pluginId}${normalized} is already registered`,
|
||||
);
|
||||
}
|
||||
|
||||
routes.set(routeKey, {
|
||||
pluginId,
|
||||
@@ -271,6 +278,12 @@ export function registerPluginWsUpgrade(
|
||||
): () => void {
|
||||
const routeKey = key(pluginId, path);
|
||||
const normalized = normalizePath(path);
|
||||
if (routes.has(routeKey)) {
|
||||
recordConflict({ kind: "ws", pluginId, name: normalized });
|
||||
throw new Error(
|
||||
`/plugin-ws/${pluginId}${normalized} is already registered`,
|
||||
);
|
||||
}
|
||||
|
||||
routes.set(routeKey, {
|
||||
pluginId,
|
||||
|
||||
Reference in new issue
Block a user