mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -29,6 +29,7 @@ import { runAcmeSslSettingsMigration } from "./acme-ssl-settings-migration.js";
|
||||
import { runVaultSettingsMigration } from "./vault-settings-migration.js";
|
||||
import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration.js";
|
||||
import { runTotpMigration } from "./totp-migration.js";
|
||||
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
@@ -55,6 +56,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runVaultSettingsMigration", runVaultSettingsMigration],
|
||||
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
|
||||
["runTotpMigration", runTotpMigration],
|
||||
["runNotificationChannelMigration", runNotificationChannelMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Moves 2.8 notification channels into the alerts plugin.
|
||||
*
|
||||
* 2.8 kept them in core's notification_channels, each config encrypted under
|
||||
* its owner's data key. This copies every row into p_alerts_channels with the
|
||||
* same id, because automations' notify steps and links name it, then empties
|
||||
* the old config column so no webhook token or ntfy key stays behind.
|
||||
*
|
||||
* The plugin reads configs sealed with the installation key, so it can send
|
||||
* an alert with no user signed in. A config is resealed as soon as its
|
||||
* owner's data key opens: at boot for most users, at their next password
|
||||
* login for a 2.8 key only the password unwraps. Until then the channel shows
|
||||
* as needing to be saved again.
|
||||
*
|
||||
* The old table stays until 3.0.0, like the old columns. Idempotent.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { resolveDatabaseDialect } from "../database/db/dialect.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
|
||||
import {
|
||||
encryptSystemSecret,
|
||||
isSystemEncrypted,
|
||||
} from "../utils/system-secret-crypto.js";
|
||||
import {
|
||||
runStatement,
|
||||
selectRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const TARGET = "p_alerts_channels";
|
||||
const SOURCE = "notification_channels";
|
||||
|
||||
interface LegacyChannel {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string | null;
|
||||
enabled: unknown;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationChannelMigrationResult {
|
||||
/** Channels copied into the plugin's table. */
|
||||
moved: number;
|
||||
/** Configs sealed with the installation key. */
|
||||
sealed: number;
|
||||
}
|
||||
|
||||
async function tableExists(name: string): Promise<boolean> {
|
||||
try {
|
||||
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOn(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "t";
|
||||
}
|
||||
|
||||
/** Explicit ids leave a Postgres sequence behind them. */
|
||||
async function syncSequence(): Promise<void> {
|
||||
if (resolveDatabaseDialect() !== "postgres") return;
|
||||
await runStatement(
|
||||
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The config in plain JSON, or null while the owner's key is closed. */
|
||||
function openLegacyConfig(row: {
|
||||
id: number;
|
||||
user_id: string;
|
||||
config: string;
|
||||
}): string | null {
|
||||
const dek = DataCrypto.getUserDataKey(row.user_id);
|
||||
if (!dek) return null;
|
||||
const plain = LazyFieldEncryption.safeGetFieldValue(
|
||||
row.config,
|
||||
dek,
|
||||
String(row.id),
|
||||
"config",
|
||||
);
|
||||
if (!plain) return null;
|
||||
try {
|
||||
JSON.parse(plain);
|
||||
return plain;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyChannels(
|
||||
result: NotificationChannelMigrationResult,
|
||||
): Promise<void> {
|
||||
if (!(await tableExists(SOURCE))) return;
|
||||
const rows = await selectRows<LegacyChannel>(
|
||||
sql`SELECT id, user_id, name, type, config, enabled, created_at FROM ${sql.identifier(SOURCE)} ORDER BY id`,
|
||||
);
|
||||
const sqlite = resolveDatabaseDialect() === "sqlite";
|
||||
let copied = false;
|
||||
for (const row of rows) {
|
||||
// Emptied once copied.
|
||||
if (!row.config) continue;
|
||||
const taken = await selectRows(
|
||||
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
|
||||
);
|
||||
if (taken.length > 0) continue;
|
||||
const enabled = isOn(row.enabled);
|
||||
const createdAt = row.created_at ?? new Date().toISOString();
|
||||
await runStatement(
|
||||
sql`INSERT INTO ${sql.identifier(TARGET)} (id, user_id, name, type, config, enabled, created_at, updated_at) VALUES (${row.id}, ${row.user_id}, ${row.name}, ${row.type}, ${row.config}, ${sqlite ? (enabled ? 1 : 0) : enabled}, ${createdAt}, ${createdAt})`,
|
||||
);
|
||||
await runStatement(
|
||||
sql`UPDATE ${sql.identifier(SOURCE)} SET config = '' WHERE id = ${row.id}`,
|
||||
);
|
||||
copied = true;
|
||||
result.moved++;
|
||||
}
|
||||
if (copied) await syncSequence();
|
||||
}
|
||||
|
||||
async function sealConfigs(
|
||||
result: NotificationChannelMigrationResult,
|
||||
onlyUserId?: string,
|
||||
): Promise<void> {
|
||||
const rows = await selectRows<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
config: string | null;
|
||||
}>(
|
||||
onlyUserId
|
||||
? sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)} WHERE user_id = ${onlyUserId}`
|
||||
: sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)}`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (!row.config || isSystemEncrypted(row.config)) continue;
|
||||
try {
|
||||
const plain = openLegacyConfig({
|
||||
id: Number(row.id),
|
||||
user_id: row.user_id,
|
||||
config: row.config,
|
||||
});
|
||||
if (!plain) continue;
|
||||
await runStatement(
|
||||
sql`UPDATE ${sql.identifier(TARGET)} SET config = ${await encryptSystemSecret(plain)} WHERE id = ${row.id}`,
|
||||
);
|
||||
result.sealed++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not seal a notification channel's config", {
|
||||
operation: "notification_channel_migration",
|
||||
channelId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass a user id to reseal only that user's channels, as a password login
|
||||
* does once it has opened a data key the boot run could not.
|
||||
*/
|
||||
export async function runNotificationChannelMigration(
|
||||
onlyUserId?: string,
|
||||
): Promise<NotificationChannelMigrationResult> {
|
||||
const result: NotificationChannelMigrationResult = { moved: 0, sealed: 0 };
|
||||
try {
|
||||
if (!(await tableExists(TARGET))) return result;
|
||||
if (!onlyUserId) await copyChannels(result);
|
||||
await sealConfigs(result, onlyUserId);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Notification channel migration failed", error, {
|
||||
operation: "notification_channel_migration",
|
||||
});
|
||||
}
|
||||
if (result.moved > 0 || result.sealed > 0) {
|
||||
databaseLogger.info("Moved notification channels into the alerts plugin", {
|
||||
operation: "notification_channel_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
|
||||
import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js";
|
||||
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
|
||||
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
|
||||
|
||||
export async function runPluginDataMoves(): Promise<void> {
|
||||
await runLdapProviderMigration();
|
||||
@@ -18,4 +19,6 @@ export async function runPluginDataMoves(): Promise<void> {
|
||||
// Both set how long history is kept, which the plugin prunes by on activate.
|
||||
await runSessionRecordingDataMigration();
|
||||
await runHostMetricsSettingsMigration();
|
||||
// Automations name channels by id, so they are in place before it starts.
|
||||
await runNotificationChannelMigration();
|
||||
}
|
||||
Reference in new issue
Block a user