fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+72
View File
@@ -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),
});
}
}
}
+29
View File
@@ -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;
}
-3
View File
@@ -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,
+45
View File
@@ -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,
};
}
-12
View File
@@ -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")
: [];
+13 -1
View File
@@ -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> {
+14
View File
@@ -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}`,
+97 -2
View File
@@ -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;
+13
View File
@@ -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,