mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321) * feat: tighter compact host rows with inline tags (#1325) * feat: bring back the alert feed homepage widget * feat: pick target terminals for snippets again * feat: share button on connection tabs again * feat: recent servers list when linking the desktop app * feat: list the fixed app shortcuts in keyboard shortcuts * fix: translate hardcoded toasts and drop em dashes from ui text * fix: translate leftover hardcoded ui text * fix: toolbar files button opens at the shell directory again * fix: show the remote desktop note under connection origin again * chore: openapi docs for host metrics manager routes * chore: openapi docs for undocumented routes and fix two broken doc blocks * fix: declare terminal.listSessions use for snippet targets * revert: drop the alert feed widget, the notification center replaces it * feat: restore the alert feed homepage widget * fix: quick connect fills the host address from plugin auth editors like tailscale * fix: docker container logs use the base background color * chore: fix dependabot alerts and apply safe npm upgrades * fix: proxmox stats tab reads its switch from plugin host settings * fix: count plugin ssh logins toward host status and stop showing reachable as a warning * fix: hide the built-in source pill in the host editor * fix: host status shows online when the port answers and only warns on a failed login * fix: drop the top border on the split layout menu * fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default * fix: remote desktop auth method labels and notes for vnc and telnet * fix: add description to the web endpoint enable switch * fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release * feat: move telemetry out of core into a usage statistics plugin * fix: recent activity card no longer cuts off host names early * fix: remove duplicate top border on split screen menu * fix: redesign session log player controls, fix tooltip overlap and drop dot separators * fix: file manager and sftp text showing raw i18n keys * fix: onboarding rerunning for existing users after upgrading * fix: fleet detail buttons overflowing the sidebar * fix: session log player scrolling off screen horizontally * fix: network graph lag when adding hosts and on every render * fix: maintenance index name, shared host read only view and badge polling * fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag * chore: update release notes * fix: remote desktop tabs reconnecting on every tab switch * fix: build plugin sdk before linting in release workflow * chore: lint, format, and bump version to 2.9.0 * chore: skip crowdin pre-translate in release for now * chore: switch to calver, bump version to 26.10.0 * fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * feat(ai): add remote coding agent sessions for SSH hosts (#1545) * feat(ai): add host coding agent sessions with configured providers * Wait for agent initialization and shutdown before resuming sessions * Keep Pi transcripts outside its configuration migration directory * Cover Pi session directory separation * Update core permission catalog expectations for remote agents * Install remote agent runtimes from verified official sources * Wait for the identity panel credentials request in its test * Add scoped SSH forwarding setup for remote agents * Verify forwarding setup through a fresh SSH connection * Bound the SSH forwarding verification timeout * Return the forwarding verification callback result * fix(ai): support agent send and interrupt keyboard shortcuts * feat(ai): add agent queues attachments worktrees and session recovery * fix(ai): handle deleted workspace paths and duplicate status events * fix(ai): accept file references in root workspaces * fix(ai): refresh paused queues after agent errors * fix(ai): store agent sessions in plugin tables and lock down OpenCode Agent sessions lived in a single ctx.kv value holding up to 2000 events, which passed the 256 KB kv limit within minutes of a normal run. Every save after that failed, so history, nativeId, queue and archive changes were lost and every event wrote a kv audit row. Sessions, events and queued prompts now have their own tables. A running agent appends events in batches at most once a second with a lazy save; session and queue changes save immediately. Prompts, drafts and stored event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no longer requested. The OpenCode server now requires a per-session password, so other local accounts on the host cannot drive the agent through its loopback port. * test(upgrade): include agent session tables in the reviewed upgrade SQL * test: run the posix-only ai agent tests on linux only * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 * fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi * chore: rework plugin readmes, json changelogs and standalone repo setup * chore: move plugins to their own repos and drop the plugins folder * chore(deps-dev): bump globals (#1560) Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals). Updates `globals` from 17.12.0 to 17.13.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: drop the 2.8 columns, moves and aliases kept for a downgrade * chore: remove the 2.9 finish list * feat: port the demo redesign with Manage and Settings tabs * chore: pin the plugin signing key and use TERMIX_PAT in workflows * chore: rename the plugin sdk to @termix-ssh/plugin-sdk * feat: add a publish workflow for the plugin sdk * chore: cleanup * fix: harden plugin sockets, public routes, prebuilt binaries and login fallback * chore: add a 2.9.1 upgrade check for postgres and mysql * fix: stop full database rewrites on every plugin db call * fix: stop full database rewrites on every plugin db call * fix: restore release video link in 2.9.2 notes * fix: use TERMIX_PAT in workflows * feat: pin the official plugins in the docker image * feat: implement plugin management UI * feat: shared list kit, settings search, rail order and ui consistency fixes * chore: run all workflows on blacksmith runners * chore: split dashboard quick actions into quarters and drop host status row lines * fix: open plain settings from the profile button, navigation only from the rail menu * feat: consent check, developer mode file installs, install counts and issue links for plugins * feat: row detail options for hosts and credentials, open manage from sidebar header * fix: unify connection failure screens and rebuild stale local plugins * feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev * chore: clean up npm scripts, make npm run dev the full runner and drop concurrently * fix: settle quick connect panel loads in tests so they stop flaking * fix: stop the dev runner rebuilding plugins when windows reports file reads as changes * chore: update the plugin sdk ui export snapshot * feat: full screen onboarding with a plugin picker and per step versions * feat: changelog.md release notes, v tags, plugin release notes and videos * chore: add temp changelog * chore: release the plugin sdk as 1.0.1 * feat: show only the latest plugin release notes and cap long ones * chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub * chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN * chore: renew mac app store provisioning profile * chore: drop docs job from release workflow * chore: run sdk publish on github hosted runner * chore: remove dependabot * chore: general file cleanup * chore: rewrite readme and taglines for plugin-based termix * chore: drop optional from ai plugin row in readme * feat: per-repo issue forms and prefilled bug reports instead of the support repo * feat: add report a bug link to the your account settings section * fix: open settings goes to the plugin's page and hides when it has none * feat: show plugin features from the manifest on the plugin page * fix: retry failed or blocked plugins on restart instead of leaving them disabled * fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling * feat: request a feature link for plugins and use the bug form for unverified plugins * feat: beta channels for core and plugins with one stable/beta release workflow * fix(sync): resolve shared hosts for connect-only recipients (#1568) * fix(ssh): accept full passwords in keyboard-interactive prompts (#1569) * fix(ssh): normalize PKCS8 private keys before connecting (#1570) * fix(sidebar): reclaim space from hidden folder actions (#1571) * fix(hosts): display protocol credential usernames for non-SSH hosts (#1572) * fix: ask for a push/TOTP menu choice as text instead of a push confirm * fix: keep client tunnels open when one of their connections fails (Support#1417) * fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420) * fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) * fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) * fix: refresh cached plugin translations when only a translation changes (Support#1411) * fix: import sshpk as a default so the backend starts under ESM * feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2 * fix: drop the ./ from the plugin sdk bin path so npm stops warning * chore: name core and sdk releases Termix vX and Plugin SDK vX * fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) * fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) * fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) * fix: refuse sessionless tokens and keep the secrets file owner only * feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) * chore: standardize repo setup files * chore: remove unused knip config * chore: add LICENSE and use Node 24 from .nvmrc * chore: update translation guidance, only en.json needs editing * feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 * fix: header separators render as borders and center the status divider * fix: lighter confirm prompt overlay to stop lag when it opens * fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 * fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK * fix: keep plugin tailwind variants in core's layer so overrides keep their order * fix: manage tab refetches before giving up on a host or credential it cannot find * fix: tab buttons like share show as soon as the terminal is ready * fix: folder rows no longer nest action buttons inside a button * fix: repin remote-desktop after its 1.0.0 re-release * chore: repin bundled plugins after the 1.0.0 re-release * fix: recent activity rows use the same separator as host status * feat: upload banner * fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24 * fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks * fix: bundled plugins path for source runs and backend cleanup * fix: load plugin strings on guest pages, plugin wording and drop unused keys * fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms * docs: 26.10.0 changelog * fix: current version in the version route example * chore: stop pushing the release- docker tag * feat: remove the donation reminder and its column * docs: note the removed donation reminder * fix: link server wizard always opens inline, onboarding card grows for it * feat: plugin text columns are longtext on mysql so they are not capped at 64KB * fix: linked desktops keep a plugin when its update from the server fails * chore: default mobile download links to v tags * fix: sso only accounts cannot sign in with a passkey or other local method * feat: note in the plugins tab that community plugins are coming * fix: plugin validate accepts beta versions * chore: pin bundled plugins rebuilt on sdk 1.0.5 * fix: skip locale fetches for disabled plugins * fix: show translated names for the built-in admin and user roles * fix: show host keys as SHA256 fingerprints like ssh-keygen * fix: host editor scrolls to a newly enabled protocol section * fix: pass the host to dashboard host row plugins * fix: plugin cards show dividers between author, version and category * fix: refuse plugin archives with paths outside the plugin folder * fix: theme picker shows one dropdown arrow * fix: read activity and audit times as UTC so they show in local time * fix: show the real error for a wrong password or code on plugin sign in * fix: explain that external sign in needs Auto-create external accounts * fix: clear leftover plugin unpack folders at startup * fix: shared session links open for guests again * fix: share panels no longer list yourself as a share target * fix: a host update without an auth type keeps the saved password * fix: guest pages get the tab ids a guest plugin registers * fix: refuse a 2.8 database, which already had a plugins table * fix: dashboard cards fit on a phone * fix: desktop setup no longer asks to secure the local account * fix: the desktop app keeps the desktop layout in a narrow window * fix: the desktop app shows the rail and no phone bar in a narrow window * fix: name every sync type in the Sync panel and pluralize the link prompt * chore: update the sdk ui snapshot for the host key dialog * fix: turning a plugin back on restarts the plugins it turned off * fix: quick connect asks to trust the host key * fix: revoking a manager also removes the shares they handed out * fix: database export works for users with TOTP on (#1426) * fix: rdp and vnc logins use the folder credential when none is picked (#1429) * fix: imported hosts get status checks without a restart * fix: re-pin bundled plugins after 1.0.0 re-release * fix: re-pin bundled plugins after search bar fix * chore: update app icon to smaller version * chore: round the readme logo, keep square source for app icons * feat: grid columns and table view in the plugins tab * fix: match the host key copy button to the fingerprint height * fix: use brand color for toggles in sync settings * fix: re-pin bundled plugins after 1.0.0 re-release * fix: web terminals failing auth behind a proxy that rewrites Host (Support#651) * fix: re-pin snippets and termix-identity after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * chore: remove Crowdin from the release workflow * chore: remove crowdin config --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
3656 files changed
+116210
-460999
No files matched your search
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Moves the 2.8 ACME configuration into the acme-ssl plugin.
|
||||
*
|
||||
* 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each
|
||||
* field becomes a plugin admin setting. "manual" was never ACME (it meant an
|
||||
* uploaded certificate, which core still handles), so it turns renewal off.
|
||||
* The Cloudflare token was plaintext and lands as an encrypted secret; it is
|
||||
* then removed from the old row. The rest of the row stays for one release.
|
||||
*
|
||||
* Idempotent: a field already set in plugin_settings is never overwritten.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
|
||||
|
||||
const PLUGIN_ID = "acme-ssl";
|
||||
const LEGACY_KEY = "acme_ssl_settings";
|
||||
|
||||
export interface AcmeSslSettingsMigrationResult {
|
||||
moved: string[];
|
||||
}
|
||||
|
||||
interface LegacyAcmeSettings {
|
||||
enabled?: unknown;
|
||||
domain?: unknown;
|
||||
email?: unknown;
|
||||
challengeType?: unknown;
|
||||
cloudflareToken?: unknown;
|
||||
}
|
||||
|
||||
const text = (value: unknown) =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
|
||||
export async function runAcmeSslSettingsMigration(): Promise<AcmeSslSettingsMigrationResult> {
|
||||
const result: AcmeSslSettingsMigrationResult = { moved: [] };
|
||||
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const raw = await settings.get(LEGACY_KEY);
|
||||
if (!raw) return result;
|
||||
|
||||
let legacy: LegacyAcmeSettings;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as LegacyAcmeSettings;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
if (!legacy || typeof legacy !== "object") return result;
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const writeIfUnset = async (
|
||||
field: string,
|
||||
value: unknown,
|
||||
secret = false,
|
||||
) => {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) return;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
JSON.stringify(value),
|
||||
secret,
|
||||
);
|
||||
result.moved.push(field);
|
||||
};
|
||||
|
||||
const challenge = legacy.challengeType;
|
||||
const manual = challenge === "manual";
|
||||
|
||||
const domain = text(legacy.domain);
|
||||
if (domain) await writeIfUnset("domain", domain);
|
||||
const email = text(legacy.email);
|
||||
if (email) await writeIfUnset("email", email);
|
||||
if (challenge === "dns-cloudflare") {
|
||||
await writeIfUnset("challengeType", "dns-cloudflare");
|
||||
} else if (challenge === "http-webroot") {
|
||||
await writeIfUnset("challengeType", "http-01");
|
||||
}
|
||||
if (typeof legacy.enabled === "boolean") {
|
||||
await writeIfUnset("autoRenew", legacy.enabled && !manual);
|
||||
}
|
||||
|
||||
const token = text(legacy.cloudflareToken);
|
||||
if (token) {
|
||||
await writeIfUnset(
|
||||
"cloudflareToken",
|
||||
await encryptSystemSecret(token),
|
||||
true,
|
||||
);
|
||||
const { cloudflareToken: _dropped, ...rest } = legacy;
|
||||
await settings.set(LEGACY_KEY, JSON.stringify(rest));
|
||||
}
|
||||
|
||||
if (result.moved.length > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} ACME setting(s) into plugin settings`,
|
||||
{
|
||||
operation: "acme_ssl_settings_migration",
|
||||
moved: result.moved.join(", "),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Must not stop the backend: the plugin then shows "not configured".
|
||||
databaseLogger.warn("ACME settings migration failed", {
|
||||
operation: "acme_ssl_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
/**
|
||||
* Moves the AI assistant's settings and provider keys into the ai plugin.
|
||||
* Moves each AI provider's api_key, encrypted under its owner's data key,
|
||||
* into a ctx.secrets row "provider:<id>" for that owner, and empties the
|
||||
* column so no key material is left in the plugin's table. It needs the
|
||||
* owner's data key, so it runs on every boot until the key opens.
|
||||
*
|
||||
* - settings.ai_globally_enabled and ai_private_endpoint_allowlist become the
|
||||
* admin settings globallyEnabled and privateEndpoints (one host per line).
|
||||
* - user_preferences.ai_assistant_enabled and ai_read_only_commands become
|
||||
* each user's enabled and allowReadOnlyCommands.
|
||||
* - ssh_data.enable_ai_assistant becomes the host setting enableAiAssistant.
|
||||
* - Each provider's api_key, encrypted under its owner's data key, becomes a
|
||||
* ctx.secrets row "provider:<id>" for that owner, and the column is emptied
|
||||
* so no key material is left in the plugin's table.
|
||||
*
|
||||
* The old columns stay in the database, unused: core's drizzle migrations
|
||||
* run before this does, so dropping them there would lose the data first.
|
||||
* The providers table is ai_providers until the plugin adopts it and
|
||||
* p_ai_providers after, so both names are tried.
|
||||
*
|
||||
* Idempotent: nothing is written where the plugin already has a row, and a
|
||||
* key is only cleared once its copy exists. Safe on every boot.
|
||||
* Idempotent: a key is only cleared once its copy exists.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
@@ -24,7 +15,6 @@ import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
|
||||
@@ -37,37 +27,9 @@ import {
|
||||
const PLUGIN_ID = "ai";
|
||||
|
||||
export interface AiSettingsMigrationResult {
|
||||
admin: string[];
|
||||
users: number;
|
||||
hosts: number;
|
||||
keys: number;
|
||||
}
|
||||
|
||||
type Flag = number | boolean | string | null | undefined;
|
||||
|
||||
function isTrue(value: Flag): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "true";
|
||||
}
|
||||
|
||||
function isSet(value: Flag): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** The legacy JSON array as the textarea the plugin now reads. */
|
||||
export function allowlistToText(raw: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
try {
|
||||
return await selectRows<T>(query);
|
||||
@@ -78,12 +40,7 @@ async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
}
|
||||
|
||||
export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResult> {
|
||||
const result: AiSettingsMigrationResult = {
|
||||
admin: [],
|
||||
users: 0,
|
||||
hosts: 0,
|
||||
keys: 0,
|
||||
};
|
||||
const result: AiSettingsMigrationResult = { keys: 0 };
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
@@ -96,91 +53,6 @@ export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResul
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const setIfMissing = async (
|
||||
scope: "admin" | "user" | "host",
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<boolean> => {
|
||||
const existing = await pluginSettings.get(PLUGIN_ID, scope, scopeId, key);
|
||||
if (existing) return false;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const enabled = await settings.get("ai_globally_enabled");
|
||||
if (enabled !== null && enabled !== "") {
|
||||
if (await setIfMissing("admin", null, "globallyEnabled", isTrue(enabled)))
|
||||
result.admin.push("globallyEnabled");
|
||||
}
|
||||
const allowlist = await settings.get("ai_private_endpoint_allowlist");
|
||||
const text = allowlist ? allowlistToText(allowlist) : null;
|
||||
if (text !== null) {
|
||||
if (await setIfMissing("admin", null, "privateEndpoints", text))
|
||||
result.admin.push("privateEndpoints");
|
||||
}
|
||||
} catch (error) {
|
||||
warn("admin settings", error);
|
||||
}
|
||||
|
||||
const users = await tryRows<{
|
||||
user_id: string;
|
||||
ai_assistant_enabled: Flag;
|
||||
ai_read_only_commands: Flag;
|
||||
}>(
|
||||
sql`SELECT user_id, ai_assistant_enabled, ai_read_only_commands FROM user_preferences`,
|
||||
);
|
||||
for (const row of users) {
|
||||
try {
|
||||
let moved = false;
|
||||
if (isSet(row.ai_assistant_enabled)) {
|
||||
moved =
|
||||
(await setIfMissing(
|
||||
"user",
|
||||
row.user_id,
|
||||
"enabled",
|
||||
isTrue(row.ai_assistant_enabled),
|
||||
)) || moved;
|
||||
}
|
||||
if (isSet(row.ai_read_only_commands)) {
|
||||
moved =
|
||||
(await setIfMissing(
|
||||
"user",
|
||||
row.user_id,
|
||||
"allowReadOnlyCommands",
|
||||
isTrue(row.ai_read_only_commands),
|
||||
)) || moved;
|
||||
}
|
||||
if (moved) result.users++;
|
||||
} catch (error) {
|
||||
warn("a user's settings", error);
|
||||
}
|
||||
}
|
||||
|
||||
const hosts = await tryRows<{ id: number; enable_ai_assistant: Flag }>(
|
||||
sql`SELECT id, enable_ai_assistant FROM ssh_data`,
|
||||
);
|
||||
for (const row of hosts) {
|
||||
if (!isTrue(row.enable_ai_assistant)) continue;
|
||||
try {
|
||||
if (
|
||||
await setIfMissing("host", String(row.id), "enableAiAssistant", true)
|
||||
) {
|
||||
result.hosts++;
|
||||
}
|
||||
} catch (error) {
|
||||
warn("a host's setting", error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const table of ["ai_providers", "p_ai_providers"]) {
|
||||
const providers = await tryRows<{
|
||||
id: number;
|
||||
@@ -227,17 +99,9 @@ export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResul
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
result.admin.length > 0 ||
|
||||
result.users > 0 ||
|
||||
result.hosts > 0 ||
|
||||
result.keys > 0
|
||||
) {
|
||||
databaseLogger.info("Moved AI assistant settings into the ai plugin", {
|
||||
if (result.keys > 0) {
|
||||
databaseLogger.info("Moved AI provider keys into the ai plugin", {
|
||||
operation: "ai_settings_migration",
|
||||
admin: result.admin.join(", "),
|
||||
users: result.users,
|
||||
hosts: result.hosts,
|
||||
keys: result.keys,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Boot copies of 2.8 data into plugin settings and plugin tables.
|
||||
* Boot copies of 2.8 data into plugin settings and plugin tables. Only the
|
||||
* ones that need a user's data key are left: they finish once it opens.
|
||||
*
|
||||
* 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
|
||||
@@ -10,70 +11,18 @@
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { runTailscaleSettingsMigration } from "./tailscale-settings-migration.js";
|
||||
import { runProxmoxSettingsMigration } from "./proxmox-settings-migration.js";
|
||||
import { runFileManagerSettingsMigration } from "./file-manager-settings-migration.js";
|
||||
import { runTunnelsSettingsMigration } from "./tunnels-settings-migration.js";
|
||||
import { runWebEndpointSettingsMigration } from "./web-endpoint-settings-migration.js";
|
||||
import { runSshTerminalSettingsMigration } from "./ssh-terminal-settings-migration.js";
|
||||
import { runSshTerminalLookMigration } from "./ssh-terminal-look-migration.js";
|
||||
import { runSshTerminalMacrosMigration } from "./ssh-terminal-macros-migration.js";
|
||||
import { runSnippetsSettingsMigration } from "./snippets-settings-migration.js";
|
||||
import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js";
|
||||
import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js";
|
||||
import { runTelemetrySettingsMigration } from "./telemetry-settings-migration.js";
|
||||
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
|
||||
import { runRemoteDesktopSettingsMigration } from "./remote-desktop-settings-migration.js";
|
||||
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
|
||||
import { runDockerSettingsMigration } from "./docker-settings-migration.js";
|
||||
import { runAiSettingsMigration } from "./ai-settings-migration.js";
|
||||
import { runWakeOnLanSettingsMigration } from "./wake-on-lan-settings-migration.js";
|
||||
import { runWarpgateSettingsMigration } from "./warpgate-settings-migration.js";
|
||||
import { runStepCaSettingsMigration } from "./step-ca-settings-migration.js";
|
||||
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";
|
||||
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
|
||||
import { runSsoLegacyIdentityMigration } from "./sso-legacy-identity-migration.js";
|
||||
import { runHostDefaultsMigration } from "./host-defaults-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
|
||||
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
|
||||
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
|
||||
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
|
||||
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
|
||||
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
|
||||
["runSshTerminalLookMigration", runSshTerminalLookMigration],
|
||||
["runSshTerminalMacrosMigration", runSshTerminalMacrosMigration],
|
||||
["runSnippetsSettingsMigration", runSnippetsSettingsMigration],
|
||||
["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],
|
||||
["runNotificationChannelMigration", runNotificationChannelMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
["runSsoSettingsMigration", runSsoSettingsMigration],
|
||||
["runSsoLegacyIdentityMigration", runSsoLegacyIdentityMigration],
|
||||
["runTelemetrySettingsMigration", runTelemetrySettingsMigration],
|
||||
// Last: it reads what the moves above wrote.
|
||||
["runHostDefaultsMigration", runHostDefaultsMigration],
|
||||
];
|
||||
|
||||
export const runPluginDataMigrations = DatabaseSaveTrigger.batched(
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* Moves each host's Docker options out of ssh_data and into the docker
|
||||
* plugin's host settings: enable_docker becomes enableDocker and the runtime
|
||||
* in docker_config becomes containerRuntime.
|
||||
*
|
||||
* The columns stay in the database, unused: core's drizzle migrations run
|
||||
* before this does, so dropping them there would lose the data first.
|
||||
*
|
||||
* Idempotent: a host that already has any docker row is skipped, so this is
|
||||
* safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "docker";
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
enable_docker: number | boolean | string | null;
|
||||
docker_config: string | null;
|
||||
}
|
||||
|
||||
/** The plugin's host settings for one legacy row, defaults left out. */
|
||||
export function dockerSettingsFromRow(
|
||||
row: LegacyHostRow,
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
const flag = row.enable_docker;
|
||||
if (flag === true || flag === 1 || flag === "1" || flag === "true") {
|
||||
values.enableDocker = true;
|
||||
}
|
||||
if (row.docker_config) {
|
||||
try {
|
||||
const config = JSON.parse(row.docker_config) as { runtime?: unknown };
|
||||
if (config?.runtime === "podman") values.containerRuntime = "podman";
|
||||
} catch {
|
||||
// unreadable config means the default runtime
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface DockerSettingsMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runDockerSettingsMigration(): Promise<DockerSettingsMigrationResult> {
|
||||
const result: DockerSettingsMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
let rows: LegacyHostRow[];
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares these columns.
|
||||
rows = await selectRows<LegacyHostRow>(
|
||||
sql`SELECT id, enable_docker, docker_config FROM ssh_data`,
|
||||
);
|
||||
} catch {
|
||||
// A fresh install never had the columns.
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const values = dockerSettingsFromRow(row);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
try {
|
||||
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
|
||||
if (existing.length > 0) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Docker host settings migration failed for a host", {
|
||||
operation: "docker_settings_migration",
|
||||
hostId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved Docker settings for ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "docker_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Renames the auto-provision setting from its 2.8 name, oidc_auto_provision,
|
||||
* to external_auto_provision: it covers every external login, not only OIDC.
|
||||
*
|
||||
* Idempotent: a value already under the new key wins and the old row is
|
||||
* dropped.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
import { EXTERNAL_AUTO_PROVISION_KEY } from "../auth/provisioning.js";
|
||||
const LEGACY_KEY = "oidc_auto_provision";
|
||||
|
||||
export async function runExternalAutoProvisionMigration(): Promise<boolean> {
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const legacy = await settings.get(LEGACY_KEY);
|
||||
if (legacy === null) return false;
|
||||
if ((await settings.get(EXTERNAL_AUTO_PROVISION_KEY)) === null) {
|
||||
await settings.set(EXTERNAL_AUTO_PROVISION_KEY, legacy);
|
||||
}
|
||||
await settings.delete(LEGACY_KEY);
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Auto-provision setting rename failed", {
|
||||
operation: "external_auto_provision_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Moves sign-in identities into their own table.
|
||||
*
|
||||
* users.oidc_identifier packed provider and subject into one string:
|
||||
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
|
||||
* whose provider was users.sso_provider_id. Each becomes a
|
||||
* user_external_identities row; LDAP ones under provider "ldap:<provider>",
|
||||
* the id the ldap plugin signs in with. TOTP enrolment moves in totp-migration.ts.
|
||||
*
|
||||
* Lossless: the old column is left as it is. Idempotent: rows that already
|
||||
* exist are skipped, so it runs on every boot.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
/** The provider id the sso plugin gives its env-configured provider. */
|
||||
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
export interface ExternalIdentityMigrationResult {
|
||||
identities: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/** Splits a stored identifier into provider and subject. */
|
||||
export function parseLegacyIdentifier(
|
||||
identifier: string,
|
||||
ssoProviderId: number | null | undefined,
|
||||
): { providerId: string; subject: string } | null {
|
||||
if (!identifier) return null;
|
||||
|
||||
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
|
||||
if (prefixed) {
|
||||
const provider = prefixed[2];
|
||||
const missing = provider === "null" || provider === "undefined";
|
||||
// LDAP providers moved to their own table in 2.9 and their identities
|
||||
// carry the "ldap:" prefix so their ids cannot clash with SSO ones.
|
||||
if (prefixed[1] === "ldap") {
|
||||
return missing
|
||||
? null
|
||||
: { providerId: `ldap:${provider}`, subject: prefixed[3] };
|
||||
}
|
||||
return {
|
||||
providerId: missing ? LEGACY_OIDC_PROVIDER_ID : provider,
|
||||
subject: prefixed[3],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
providerId:
|
||||
ssoProviderId !== null && ssoProviderId !== undefined
|
||||
? String(ssoProviderId)
|
||||
: LEGACY_OIDC_PROVIDER_ID,
|
||||
subject: identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runExternalIdentityMigration(): Promise<ExternalIdentityMigrationResult> {
|
||||
const result: ExternalIdentityMigrationResult = {
|
||||
identities: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
const auth = createCurrentUserAuthRepository();
|
||||
|
||||
for (const user of users) {
|
||||
if (user.oidcIdentifier) {
|
||||
const parsed = parseLegacyIdentifier(
|
||||
user.oidcIdentifier,
|
||||
user.ssoProviderId,
|
||||
);
|
||||
if (parsed) {
|
||||
const existing = await auth.findIdentity(
|
||||
parsed.providerId,
|
||||
parsed.subject,
|
||||
);
|
||||
// sso-legacy-identity-migration.ts moves "legacy-oidc" rows to
|
||||
// their provider; recreating one would undo that on every boot.
|
||||
const moved =
|
||||
!existing &&
|
||||
parsed.providerId === LEGACY_OIDC_PROVIDER_ID &&
|
||||
(await auth.listIdentitiesForUser(user.id)).some(
|
||||
(link) => link.subject === parsed.subject,
|
||||
);
|
||||
if (existing || moved) {
|
||||
result.skipped++;
|
||||
} else {
|
||||
await auth.linkIdentity({
|
||||
userId: user.id,
|
||||
providerId: parsed.providerId,
|
||||
subject: parsed.subject,
|
||||
});
|
||||
result.identities++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.identities > 0) {
|
||||
databaseLogger.info("Moved sign-in identities", {
|
||||
operation: "external_identity_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("External identity migration failed", error, {
|
||||
operation: "external_identity_migration",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Moves the three file-manager host columns into the file-manager plugin's
|
||||
* host-scope settings, before those columns are dropped from ssh_data.
|
||||
*
|
||||
* Idempotent: skips a host that already has a file-manager settings row, so
|
||||
* it is safe to run on every boot. Lossless: reads straight off the
|
||||
* still-present ssh_data columns, so it must run and finish before those
|
||||
* columns are ever dropped from schema.ts.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
legacyFlag,
|
||||
selectLegacyRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface FileManagerSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyFileManagerRow {
|
||||
id: number;
|
||||
enable_file_manager: number | boolean | null;
|
||||
default_path: string | null;
|
||||
scp_legacy: number | boolean | null;
|
||||
}
|
||||
|
||||
export async function runFileManagerSettingsMigration(): Promise<FileManagerSettingsMigrationResult> {
|
||||
const result: FileManagerSettingsMigrationResult = { moved: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
// Raw SQL, not the typed schema: this runs once schema.ts has already
|
||||
// dropped these columns, so the query is the only thing left that still
|
||||
// knows they used to exist.
|
||||
const rows = await selectLegacyRows<LegacyFileManagerRow>(sql`
|
||||
SELECT id, enable_file_manager, default_path, scp_legacy
|
||||
FROM ssh_data
|
||||
WHERE enable_file_manager = false OR default_path IS NOT NULL
|
||||
OR scp_legacy = true
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"file-manager",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableFileManager",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"file-manager",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableFileManager",
|
||||
JSON.stringify(legacyFlag(row.enable_file_manager, true)),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"file-manager",
|
||||
"host",
|
||||
scopeId,
|
||||
"defaultPath",
|
||||
JSON.stringify(row.default_path ?? null),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"file-manager",
|
||||
"host",
|
||||
scopeId,
|
||||
"scpLegacy",
|
||||
JSON.stringify(!!row.scp_legacy),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved file manager settings for ${result.moved} host(s) into plugin settings`,
|
||||
{ operation: "file_manager_settings_migration", moved: result.moved },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep
|
||||
// whatever file-manager settings they already had (none, on a fresh run).
|
||||
databaseLogger.warn("File manager settings migration failed", {
|
||||
operation: "file_manager_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
/**
|
||||
* Moves 2.8 and early 2.9 default settings into host defaults, and gets hosts
|
||||
* ready to be classified against them.
|
||||
*
|
||||
* Levels, each moved once:
|
||||
* - core `settings.host_defaults` (SOCKS5 and the status check) becomes the
|
||||
* server level. Its credential and SOCKS5 password are dropped: a
|
||||
* credential belongs to one user and a password is never a default.
|
||||
* - a folder's credential becomes that folder's auth default.
|
||||
* - ssh-terminal's admin "new host" look, tmux and command history become the
|
||||
* server level; each user's terminal defaults, local echo and link click
|
||||
* setting become their user level.
|
||||
* - host-metrics' "enabled for new hosts" becomes the server level.
|
||||
* - remote-desktop's per-user display settings become the user level.
|
||||
*
|
||||
* Hosts: a host saved before host defaults gets the value it really used
|
||||
* written in, where that was not its stored value. A terminal that
|
||||
* followed the user's look used the user's look; an echo mode of "default"
|
||||
* used the user's setting; a credential host with no credential used its
|
||||
* folder's; a remote desktop used the connecting user's display settings.
|
||||
* The classify pass that follows compares those against the new levels, so
|
||||
* nothing a user sees changes.
|
||||
*
|
||||
* The old rows stay in place for a downgrade until 3.0.0.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
createCurrentHostDefaultsRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { HostRow } from "../database/repositories/host-defaults-repository.js";
|
||||
import { hostSettingsPlugins } from "../database/routes/host-plugin-settings.js";
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
defaultValuesEqual,
|
||||
normalizeSocks5Default,
|
||||
parseDefaultOverrides,
|
||||
} from "../../types/host-defaults.js";
|
||||
import { encodeDefaultValue } from "../hosts/defaults/levels.js";
|
||||
import { folderChainPaths } from "../hosts/defaults/resolve.js";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
|
||||
const TERMINAL = "ssh-terminal";
|
||||
const METRICS = "host-metrics";
|
||||
const DESKTOP = "remote-desktop";
|
||||
|
||||
const TERMINAL_APPEARANCE_KEYS = [
|
||||
"theme",
|
||||
"cursorBlink",
|
||||
"cursorStyle",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"bellStyle",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImage",
|
||||
"backgroundImageOpacity",
|
||||
"customThemeColors",
|
||||
];
|
||||
|
||||
/** ssh-terminal admin key -> the host key it seeded. */
|
||||
const TERMINAL_NEW_HOST_KEYS: Record<string, string> = {
|
||||
commandHistoryForNewHosts: "enableCommandHistory",
|
||||
newHostTheme: "theme",
|
||||
newHostFontFamily: "fontFamily",
|
||||
newHostFontSize: "fontSize",
|
||||
newHostCursorStyle: "cursorStyle",
|
||||
newHostCursorBlink: "cursorBlink",
|
||||
newHostAutoTmux: "autoTmux",
|
||||
};
|
||||
|
||||
/** ssh-terminal keys whose "default" meant the user's own setting. */
|
||||
const TERMINAL_USER_MODES: Record<string, string> = {
|
||||
localEcho: "auto",
|
||||
linkClickBehavior: "confirm",
|
||||
};
|
||||
|
||||
const DESKTOP_DISPLAY_KEYS = [
|
||||
"colorDepth",
|
||||
"resizeMethod",
|
||||
"forceLossless",
|
||||
"enableWallpaper",
|
||||
"enableFontSmoothing",
|
||||
"enableDesktopComposition",
|
||||
"disableAudio",
|
||||
"enablePrinting",
|
||||
"enableDrive",
|
||||
"disableCopy",
|
||||
"disablePaste",
|
||||
];
|
||||
|
||||
const MARKER_PREFIX = "host_defaults_moved_";
|
||||
|
||||
function decode(raw: string | null | undefined): unknown {
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
try {
|
||||
let parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "string" && /^[[{]/.test(parsed)) {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function once(name: string, run: () => Promise<void>): Promise<void> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const marker = `${MARKER_PREFIX}${name}`;
|
||||
if (await settings.get(marker)) return;
|
||||
await run();
|
||||
await settings.set(marker, new Date().toISOString());
|
||||
}
|
||||
|
||||
type Level =
|
||||
| { level: "admin" }
|
||||
| { level: "user"; userId: string }
|
||||
| { level: "folder"; userId: string; folderId: number };
|
||||
|
||||
async function setDefault(
|
||||
scope: Level,
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const existing = await repository.listScope(scope);
|
||||
if (existing.some((row) => row.namespace === namespace && row.key === key)) {
|
||||
return;
|
||||
}
|
||||
await repository.apply(
|
||||
scope,
|
||||
[{ namespace, key, value: encodeDefaultValue(value) }],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async function moveCoreLevels(): Promise<void> {
|
||||
const raw = await createCurrentSettingsRepository().get("host_defaults");
|
||||
const stored = asObject(decode(raw)) ?? {};
|
||||
if (stored.useSocks5 === true) {
|
||||
await setDefault(
|
||||
{ level: "admin" },
|
||||
CORE_NAMESPACE,
|
||||
"socks5",
|
||||
normalizeSocks5Default({ ...stored, socks5ProxyChain: null }),
|
||||
);
|
||||
}
|
||||
if (stored.statusCheckEnabled === false) {
|
||||
await setDefault(
|
||||
{ level: "admin" },
|
||||
CORE_NAMESPACE,
|
||||
"statusCheckEnabled",
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (stored.credentialId !== undefined || stored.socks5Password) {
|
||||
databaseLogger.info(
|
||||
"Host defaults no longer hold a credential or proxy password; set a credential per user or folder instead",
|
||||
{ operation: "host_defaults_migration" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFolderCredentials(): Promise<void> {
|
||||
const { createCurrentRepositoryContext } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { sshFolders } = await import("../database/db/schema.js");
|
||||
const { isNotNull } = await import("drizzle-orm");
|
||||
const rows = await createCurrentRepositoryContext()
|
||||
.drizzle.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
credentialId: sshFolders.credentialId,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(isNotNull(sshFolders.credentialId));
|
||||
for (const row of rows) {
|
||||
await setDefault(
|
||||
{ level: "folder", userId: row.userId, folderId: row.id },
|
||||
CORE_NAMESPACE,
|
||||
"auth",
|
||||
{
|
||||
authType: "credential",
|
||||
credentialId: row.credentialId,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: null,
|
||||
agentIdentity: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function storedRows(
|
||||
pluginId: string,
|
||||
scope: "admin" | "user",
|
||||
key: string,
|
||||
) {
|
||||
return (
|
||||
await createCurrentPluginSettingsRepository().listByKey(
|
||||
pluginId,
|
||||
scope,
|
||||
key,
|
||||
)
|
||||
).filter((row) => row.value !== null);
|
||||
}
|
||||
|
||||
async function moveTerminalLevels(): Promise<void> {
|
||||
for (const [adminKey, hostKey] of Object.entries(TERMINAL_NEW_HOST_KEYS)) {
|
||||
for (const row of await storedRows(TERMINAL, "admin", adminKey)) {
|
||||
const value = decode(row.value);
|
||||
if (value === undefined || value === null) continue;
|
||||
await setDefault({ level: "admin" }, TERMINAL, hostKey, value);
|
||||
}
|
||||
}
|
||||
for (const row of await storedRows(TERMINAL, "user", "terminalDefaults")) {
|
||||
const values = asObject(decode(row.value));
|
||||
if (!values || !row.scopeId) continue;
|
||||
for (const key of TERMINAL_APPEARANCE_KEYS) {
|
||||
if (values[key] === undefined) continue;
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
TERMINAL,
|
||||
key,
|
||||
values[key],
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(TERMINAL_USER_MODES)) {
|
||||
for (const row of await storedRows(TERMINAL, "user", key)) {
|
||||
const value = decode(row.value);
|
||||
if (typeof value !== "string" || !row.scopeId) continue;
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
TERMINAL,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMetricsLevels(): Promise<void> {
|
||||
for (const row of await storedRows(METRICS, "admin", "enabledForNewHosts")) {
|
||||
if (decode(row.value) === false) {
|
||||
await setDefault({ level: "admin" }, METRICS, "metricsEnabled", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDesktopLevels(): Promise<void> {
|
||||
for (const key of DESKTOP_DISPLAY_KEYS) {
|
||||
for (const row of await storedRows(DESKTOP, "user", key)) {
|
||||
const value = decode(row.value);
|
||||
if (typeof value !== "string" || value === "inherit" || !row.scopeId) {
|
||||
continue;
|
||||
}
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
DESKTOP,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts saved before host defaults (by an older release, or 2.8 after a
|
||||
* downgrade), which still carry the old meaning. A host saved since has its
|
||||
* overrides set, and a namespace it lacks is classified as it stands.
|
||||
*/
|
||||
function unclassified(rows: HostRow[]): HostRow[] {
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
!row.sharedSource && parseDefaultOverrides(row.defaultOverrides) === null,
|
||||
);
|
||||
}
|
||||
|
||||
async function userValues(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
): Promise<Map<string, unknown>> {
|
||||
const result = new Map<string, unknown>();
|
||||
for (const row of await storedRows(pluginId, "user", key)) {
|
||||
if (row.scopeId) result.set(row.scopeId, decode(row.value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function manifestDefault(manifest: PluginManifest, key: string): unknown {
|
||||
return manifest.contributes?.settings?.host?.fields.find(
|
||||
(field) => field.key === key,
|
||||
)?.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one host value, or removes the row when it is the manifest default
|
||||
* so the host reads that default the usual way.
|
||||
*/
|
||||
async function writeHostValue(
|
||||
manifest: PluginManifest,
|
||||
hostId: number,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
if (defaultValuesEqual(value, manifestDefault(manifest, key))) {
|
||||
await settings.delete(manifest.id, "host", String(hostId), key);
|
||||
} else {
|
||||
await settings.set(
|
||||
manifest.id,
|
||||
"host",
|
||||
String(hostId),
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function bakeTerminalHosts(
|
||||
manifest: PluginManifest,
|
||||
rows: HostRow[],
|
||||
): Promise<void> {
|
||||
const hosts = unclassified(rows);
|
||||
if (hosts.length === 0) return;
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
const lookByUser = await userValues(TERMINAL, "terminalDefaults");
|
||||
const modesByUser = new Map(
|
||||
await Promise.all(
|
||||
Object.keys(TERMINAL_USER_MODES).map(
|
||||
async (key) => [key, await userValues(TERMINAL, key)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const host of hosts) {
|
||||
const stored = new Map(
|
||||
(await settings.getAll(TERMINAL, "host", String(host.id))).map((row) => [
|
||||
row.key,
|
||||
decode(row.value),
|
||||
]),
|
||||
);
|
||||
if (stored.get("inheritAppearance") !== false) {
|
||||
const look = asObject(lookByUser.get(host.userId)) ?? {};
|
||||
for (const key of TERMINAL_APPEARANCE_KEYS) {
|
||||
const value = look[key] ?? manifestDefault(manifest, key);
|
||||
await writeHostValue(manifest, host.id, key, value);
|
||||
}
|
||||
}
|
||||
for (const [key, fallback] of Object.entries(TERMINAL_USER_MODES)) {
|
||||
const own = stored.get(key);
|
||||
if (own !== undefined && own !== "default") continue;
|
||||
const user = modesByUser.get(key)?.get(host.userId);
|
||||
await writeHostValue(
|
||||
manifest,
|
||||
host.id,
|
||||
key,
|
||||
typeof user === "string" && user !== "default" ? user : fallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bakeDesktopHosts(
|
||||
manifest: PluginManifest,
|
||||
rows: HostRow[],
|
||||
): Promise<void> {
|
||||
const hosts = unclassified(rows);
|
||||
if (hosts.length === 0) return;
|
||||
const byKey = new Map(
|
||||
await Promise.all(
|
||||
DESKTOP_DISPLAY_KEYS.map(
|
||||
async (key) => [key, await userValues(DESKTOP, key)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const host of hosts) {
|
||||
for (const key of DESKTOP_DISPLAY_KEYS) {
|
||||
const value = byKey.get(key)?.get(host.userId);
|
||||
if (typeof value !== "string" || value === "inherit") continue;
|
||||
await writeHostValue(manifest, host.id, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A credential host with no credential of its own used its folder's. */
|
||||
async function bakeFolderCredentials(rows: HostRow[]): Promise<void> {
|
||||
const hosts = unclassified(rows).filter(
|
||||
(row) => row.authType === "credential" && !row.credentialId && row.folder,
|
||||
);
|
||||
if (hosts.length === 0) return;
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const resolution = createCurrentHostResolutionRepository();
|
||||
const patches: Array<{ id: number; values: Partial<HostRow> }> = [];
|
||||
for (const host of hosts) {
|
||||
if (folderChainPaths(host.folder).length === 0) continue;
|
||||
const credentialId = await resolution.findFolderCredentialId(
|
||||
host.userId,
|
||||
host.folder!,
|
||||
);
|
||||
if (credentialId) patches.push({ id: host.id, values: { credentialId } });
|
||||
}
|
||||
await repository.updateHosts(patches);
|
||||
}
|
||||
|
||||
export async function runHostDefaultsMigration(): Promise<void> {
|
||||
const manifests = new Map(
|
||||
hostSettingsPlugins().map((manifest) => [manifest.id, manifest]),
|
||||
);
|
||||
try {
|
||||
await once("core", async () => {
|
||||
await moveCoreLevels();
|
||||
await moveFolderCredentials();
|
||||
});
|
||||
if (manifests.has(TERMINAL)) await once(TERMINAL, moveTerminalLevels);
|
||||
if (manifests.has(METRICS)) await once(METRICS, moveMetricsLevels);
|
||||
if (manifests.has(DESKTOP)) await once(DESKTOP, moveDesktopLevels);
|
||||
|
||||
const rows = await createCurrentHostDefaultsRepository().listHosts({
|
||||
all: true,
|
||||
});
|
||||
await bakeFolderCredentials(rows);
|
||||
const terminal = manifests.get(TERMINAL);
|
||||
if (terminal) await bakeTerminalHosts(terminal, rows);
|
||||
const desktop = manifests.get(DESKTOP);
|
||||
if (desktop) await bakeDesktopHosts(desktop, rows);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Host defaults migration failed", {
|
||||
operation: "host_defaults_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* Moves Host Metrics' settings out of core and into the host-metrics
|
||||
* plugin's own settings.
|
||||
*
|
||||
* Admin keys from `settings`: global_metrics_interval,
|
||||
* metrics_history_retention_days and metricsEnabled inside host_defaults (the
|
||||
* default for new hosts). Per host, the metrics half of
|
||||
* ssh_data.stats_config: metricsEnabled, the host's own interval, the enabled
|
||||
* widgets and the mount lists. The status half is core and moved by
|
||||
* host-status-config-migration.ts.
|
||||
*
|
||||
* The old settings rows and the column stay in place, unused.
|
||||
*
|
||||
* Idempotent: an admin key already set, or a host that already has any
|
||||
* host-metrics row, is skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "host-metrics";
|
||||
|
||||
/** A JSON object stored as text, or {} when it is anything else. */
|
||||
function parseStatsConfig(raw: string | null): Record<string, unknown> {
|
||||
try {
|
||||
let parsed: unknown = raw ? JSON.parse(raw) : null;
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function seconds(value: unknown, min: number, max: number): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= min && parsed <= max
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The plugin's host settings for one stats_config value, defaults left out. */
|
||||
export function hostSettingsFromStatsConfig(
|
||||
raw: string | null,
|
||||
): Record<string, unknown> {
|
||||
const config = parseStatsConfig(raw);
|
||||
const values: Record<string, unknown> = {};
|
||||
if (config.metricsEnabled === false) values.metricsEnabled = false;
|
||||
if (config.useGlobalMetricsInterval === false) {
|
||||
const interval = seconds(config.metricsInterval, 5, 3600);
|
||||
if (interval !== null) values.metricsInterval = interval;
|
||||
}
|
||||
if (Array.isArray(config.enabledWidgets)) {
|
||||
values.enabledWidgets = config.enabledWidgets.filter(
|
||||
(widget): widget is string => typeof widget === "string",
|
||||
);
|
||||
}
|
||||
if (Array.isArray(config.excludedMounts) && config.excludedMounts.length) {
|
||||
values.excludedMounts = config.excludedMounts.filter(
|
||||
(mount): mount is string => typeof mount === "string",
|
||||
);
|
||||
}
|
||||
if (Array.isArray(config.monitoredMounts) && config.monitoredMounts.length) {
|
||||
values.monitoredMounts = config.monitoredMounts.filter(
|
||||
(mount) =>
|
||||
!!mount &&
|
||||
typeof mount === "object" &&
|
||||
typeof (mount as { path?: unknown }).path === "string",
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface HostMetricsSettingsMigrationResult {
|
||||
moved: string[];
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runHostMetricsSettingsMigration(): Promise<HostMetricsSettingsMigrationResult> {
|
||||
const result: HostMetricsSettingsMigrationResult = {
|
||||
moved: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const write = (
|
||||
scope: "admin" | "host",
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) =>
|
||||
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const moves: [string, string, number, number][] = [
|
||||
["global_metrics_interval", "metricsInterval", 5, 3600],
|
||||
["metrics_history_retention_days", "historyRetentionDays", 1, 90],
|
||||
];
|
||||
for (const [legacyKey, field, min, max] of moves) {
|
||||
const value = seconds(await settings.get(legacyKey), min, max);
|
||||
if (value === null) continue;
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await write("admin", null, field, value);
|
||||
result.moved.push(legacyKey);
|
||||
}
|
||||
|
||||
const defaults = parseStatsConfig(await settings.get("host_defaults"));
|
||||
if (defaults.metricsEnabled === false) {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"enabledForNewHosts",
|
||||
);
|
||||
if (!existing || existing.value === null) {
|
||||
await write("admin", null, "enabledForNewHosts", false);
|
||||
result.moved.push("host_defaults.metricsEnabled");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Host Metrics admin settings migration failed", {
|
||||
operation: "host_metrics_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares stats_config.
|
||||
const rows = await selectRows<{ id: number; stats_config: string | null }>(
|
||||
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const values = hostSettingsFromStatsConfig(row.stats_config);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
|
||||
if (existing.length > 0) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await write("host", scopeId, key, value);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
}
|
||||
|
||||
if (result.moved.length > 0 || result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} Host Metrics setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "host_metrics_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* 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.
|
||||
* The old table stays until 26.10.0, like the old columns. Idempotent.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
@@ -3,27 +3,20 @@
|
||||
*
|
||||
* The config file moves from DATA_DIR/.opk/config.yml to the plugin's own
|
||||
* folder (DATA_DIR/plugin-data/opkssh/config.yml). The old file is copied, not
|
||||
* moved, so a downgrade still finds it. An install that had a config also
|
||||
* keeps sending the pre-2.9 redirect URI (/host/opkssh-callback), because
|
||||
* that is the one registered with its identity providers: the plugin's
|
||||
* legacyCallback admin setting is turned on.
|
||||
* moved, so a downgrade still finds it.
|
||||
*
|
||||
* Idempotent: an existing target file or setting is never overwritten.
|
||||
* Idempotent: an existing target file is never overwritten.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { createCurrentPluginRepository } from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "opkssh";
|
||||
|
||||
export interface OpksshConfigMigrationResult {
|
||||
copied: boolean;
|
||||
legacyCallback: boolean;
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
@@ -39,10 +32,7 @@ export async function runOpksshConfigMigration(
|
||||
dataDir: string = process.env.DATA_DIR ||
|
||||
path.join(process.cwd(), "db", "data"),
|
||||
): Promise<OpksshConfigMigrationResult> {
|
||||
const result: OpksshConfigMigrationResult = {
|
||||
copied: false,
|
||||
legacyCallback: false,
|
||||
};
|
||||
const result: OpksshConfigMigrationResult = { copied: false };
|
||||
|
||||
const legacyConfig = path.join(dataDir, ".opk", "config.yml");
|
||||
if (!(await exists(legacyConfig))) return result;
|
||||
@@ -61,24 +51,6 @@ export async function runOpksshConfigMigration(
|
||||
await fs.copyFile(legacyConfig, target);
|
||||
result.copied = true;
|
||||
}
|
||||
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
const current = await settings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"legacyCallback",
|
||||
);
|
||||
if (!current) {
|
||||
await settings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"legacyCallback",
|
||||
JSON.stringify(true),
|
||||
);
|
||||
result.legacyCallback = true;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("OPKSSH config migration failed", {
|
||||
operation: "opkssh_config_migration",
|
||||
|
||||
@@ -9,16 +9,14 @@ import { runLdapProviderMigration } from "./ldap-provider-migration.js";
|
||||
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();
|
||||
await runOpksshConfigMigration();
|
||||
await runTermixIdentityCaMigration();
|
||||
// Both set how long history is kept, which the plugin prunes by on activate.
|
||||
// Sets 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();
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* columns into host_protocol_auth, re-encrypted with the owner's data key
|
||||
* under the new table's context.
|
||||
*
|
||||
* The columns stay in the database, unused, until 3.0.0. A user's hosts are
|
||||
* The columns stay in the database, unused, until 26.10.0. A user's hosts are
|
||||
* copied once their data key opens (at boot for a system-wrapped key, at the
|
||||
* next password login for a 2.8 key that only the password opens), and a
|
||||
* per-user marker then stops a later boot from bringing back a login the
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Moves the four Proxmox host columns into the proxmox plugin's host-scope
|
||||
* settings, before those columns are dropped from ssh_data.
|
||||
*
|
||||
* Idempotent: skips a host that already has a proxmox settings row, so it is
|
||||
* safe to run on every boot. Lossless: reads straight off the still-present
|
||||
* ssh_data columns, so it must run and finish before those columns are ever
|
||||
* dropped from schema.ts.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface ProxmoxSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyProxmoxRow {
|
||||
id: number;
|
||||
enable_proxmox: number | boolean | null;
|
||||
proxmox_config: string | null;
|
||||
enable_proxmox_stats: number | boolean | null;
|
||||
proxmox_stats_config: string | null;
|
||||
}
|
||||
|
||||
export async function runProxmoxSettingsMigration(): Promise<ProxmoxSettingsMigrationResult> {
|
||||
const result: ProxmoxSettingsMigrationResult = { moved: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
// Raw SQL, not the typed schema: this runs once schema.ts has already
|
||||
// dropped these columns, so the query is the only thing left that still
|
||||
// knows they used to exist.
|
||||
const rows = await selectLegacyRows<LegacyProxmoxRow>(sql`
|
||||
SELECT id, enable_proxmox, proxmox_config, enable_proxmox_stats, proxmox_stats_config
|
||||
FROM ssh_data
|
||||
WHERE enable_proxmox = true OR proxmox_config IS NOT NULL
|
||||
OR enable_proxmox_stats = true OR proxmox_stats_config IS NOT NULL
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableProxmox",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableProxmox",
|
||||
JSON.stringify(!!row.enable_proxmox),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"proxmoxConfig",
|
||||
JSON.stringify(
|
||||
row.proxmox_config ? safeParse(row.proxmox_config) : null,
|
||||
),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableProxmoxStats",
|
||||
JSON.stringify(!!row.enable_proxmox_stats),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"proxmox",
|
||||
"host",
|
||||
scopeId,
|
||||
"proxmoxStatsConfig",
|
||||
JSON.stringify(
|
||||
row.proxmox_stats_config ? safeParse(row.proxmox_stats_config) : null,
|
||||
),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved Proxmox settings for ${result.moved} host(s) into plugin settings`,
|
||||
{ operation: "proxmox_settings_migration", moved: result.moved },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep
|
||||
// whatever proxmox settings they already had (none, on a fresh run).
|
||||
databaseLogger.warn("Proxmox settings migration failed", {
|
||||
operation: "proxmox_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function safeParse(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* Moves Remote Desktop's settings out of core and into the remote-desktop
|
||||
* plugin's own settings.
|
||||
*
|
||||
* Admin keys from `settings`: guac_enabled and guac_url. Per user, the RDP
|
||||
* defaults JSON in user_preferences.rdp_defaults. Per host, the option
|
||||
* columns in ssh_data: the three protocol switches and ports, rdp_security,
|
||||
* rdp_ignore_cert and guacamole_config, folding in the older security,
|
||||
* ignore_cert and connection_type-only shapes, plus enable_terminal_toolbar,
|
||||
* which also drove the remote desktop toolbar.
|
||||
*
|
||||
* The logins (users, passwords, credential ids, auth types, domain) stay in
|
||||
* core, next to the host, where sharing needs them.
|
||||
*
|
||||
* The columns stay in the database, unused: core's drizzle migrations run
|
||||
* before this does, so dropping them there would lose the data first.
|
||||
*
|
||||
* Idempotent: an admin key already set, or a user or host that already has
|
||||
* any remote-desktop row, is skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import {
|
||||
runStatement,
|
||||
selectRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "remote-desktop";
|
||||
const PROTOCOLS = ["rdp", "vnc", "telnet"] as const;
|
||||
const DEFAULT_PORTS = { rdp: 3389, vnc: 5900, telnet: 23 };
|
||||
|
||||
type Flag = number | boolean | string | null | undefined;
|
||||
|
||||
/** true, 1 and "1" are on; null means the column was never set. */
|
||||
function flag(value: Flag): boolean | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return value === true || value === 1 || value === "1" || value === "true";
|
||||
}
|
||||
|
||||
function port(value: unknown): number | undefined {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
|
||||
? parsed
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
port: number | null;
|
||||
connection_type: string | null;
|
||||
enable_rdp: Flag;
|
||||
enable_vnc: Flag;
|
||||
enable_telnet: Flag;
|
||||
rdp_port: number | null;
|
||||
vnc_port: number | null;
|
||||
telnet_port: number | null;
|
||||
rdp_security: string | null;
|
||||
rdp_ignore_cert: Flag;
|
||||
security: string | null;
|
||||
ignore_cert: Flag;
|
||||
guacamole_config: string | null;
|
||||
enable_terminal_toolbar: Flag;
|
||||
}
|
||||
|
||||
/** The plugin's host settings for one legacy row, defaults left out. */
|
||||
export function hostSettingsFromRow(
|
||||
row: LegacyHostRow,
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
const flags = {
|
||||
rdp: flag(row.enable_rdp) === true,
|
||||
vnc: flag(row.enable_vnc) === true,
|
||||
telnet: flag(row.enable_telnet) === true,
|
||||
};
|
||||
const legacyType = PROTOCOLS.find((p) => p === row.connection_type);
|
||||
// Hosts from before the per-protocol switches only had connection_type.
|
||||
if (!flags.rdp && !flags.vnc && !flags.telnet && legacyType) {
|
||||
flags[legacyType] = true;
|
||||
}
|
||||
const ports = {
|
||||
rdp: port(row.rdp_port),
|
||||
vnc: port(row.vnc_port),
|
||||
telnet: port(row.telnet_port),
|
||||
};
|
||||
if (legacyType && ports[legacyType] === undefined) {
|
||||
ports[legacyType] = port(row.port);
|
||||
}
|
||||
|
||||
for (const protocol of PROTOCOLS) {
|
||||
const key = `enable${protocol[0].toUpperCase()}${protocol.slice(1)}`;
|
||||
if (flags[protocol]) values[key] = true;
|
||||
const value = ports[protocol];
|
||||
if (value !== undefined && value !== DEFAULT_PORTS[protocol]) {
|
||||
values[`${protocol}Port`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const security = row.rdp_security || row.security;
|
||||
if (security) values.rdpSecurity = security;
|
||||
const ignoreCert = flag(row.rdp_ignore_cert) ?? flag(row.ignore_cert);
|
||||
if (ignoreCert) values.rdpIgnoreCert = true;
|
||||
const config = jsonObject(row.guacamole_config);
|
||||
if (config && Object.keys(config).length > 0) values.guacamoleConfig = config;
|
||||
if (flag(row.enable_terminal_toolbar) === false) values.enableToolbar = false;
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Old camelCase defaults JSON -> the plugin's user settings. */
|
||||
function userSettingsFromDefaults(
|
||||
defaults: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
const depth = Number(defaults.colorDepth);
|
||||
if ([16, 24, 32].includes(depth)) values.colorDepth = String(depth);
|
||||
if (
|
||||
defaults.resizeMethod === "display-update" ||
|
||||
defaults.resizeMethod === "reconnect"
|
||||
) {
|
||||
values.resizeMethod = defaults.resizeMethod;
|
||||
}
|
||||
for (const key of [
|
||||
"forceLossless",
|
||||
"enableWallpaper",
|
||||
"enableFontSmoothing",
|
||||
"enableDesktopComposition",
|
||||
"disableAudio",
|
||||
"enablePrinting",
|
||||
"enableDrive",
|
||||
"disableCopy",
|
||||
"disablePaste",
|
||||
]) {
|
||||
if (typeof defaults[key] === "boolean") {
|
||||
values[key] = defaults[key] ? "on" : "off";
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface RemoteDesktopSettingsMigrationResult {
|
||||
moved: string[];
|
||||
usersMoved: number;
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runRemoteDesktopSettingsMigration(): Promise<RemoteDesktopSettingsMigrationResult> {
|
||||
const result: RemoteDesktopSettingsMigrationResult = {
|
||||
moved: [],
|
||||
usersMoved: 0,
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const write = (
|
||||
scope: "admin" | "user" | "host",
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) =>
|
||||
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const moves: [string, string, (raw: string) => unknown][] = [
|
||||
["guac_enabled", "enabled", (raw) => raw !== "false" && raw !== "0"],
|
||||
["guac_url", "guacdUrl", (raw) => raw],
|
||||
];
|
||||
for (const [legacyKey, field, parse] of moves) {
|
||||
const legacy = await settings.get(legacyKey);
|
||||
if (legacy === null || legacy === "") continue;
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await write("admin", null, field, parse(legacy));
|
||||
result.moved.push(legacyKey);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Remote Desktop admin settings migration failed", {
|
||||
operation: "remote_desktop_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await selectRows<{
|
||||
user_id: string;
|
||||
rdp_defaults: string | null;
|
||||
}>(sql`SELECT user_id, rdp_defaults FROM user_preferences`);
|
||||
for (const row of rows) {
|
||||
const defaults = jsonObject(row.rdp_defaults);
|
||||
if (!defaults) continue;
|
||||
const values = userSettingsFromDefaults(defaults);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const existing = await pluginSettings.getAll(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
);
|
||||
if (existing.length > 0) continue;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await write("user", row.user_id, key, value);
|
||||
}
|
||||
result.usersMoved++;
|
||||
}
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
}
|
||||
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares these columns.
|
||||
const rows = await selectRows<LegacyHostRow>(sql`
|
||||
SELECT id, port, connection_type, enable_rdp, enable_vnc, enable_telnet,
|
||||
rdp_port, vnc_port, telnet_port, rdp_security, rdp_ignore_cert,
|
||||
security, ignore_cert, guacamole_config, enable_terminal_toolbar
|
||||
FROM ssh_data
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const values = hostSettingsFromRow(row);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
|
||||
if (existing.length > 0) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await write("host", scopeId, key, value);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
|
||||
// Those same connection_type-only hosts kept the enable_ssh default of
|
||||
// on, which core used to correct on every read. FALSE and NOT read the
|
||||
// same on SQLite's integers and on real booleans.
|
||||
await runStatement(sql`
|
||||
UPDATE ssh_data SET enable_ssh = FALSE
|
||||
WHERE connection_type IN ('rdp', 'vnc', 'telnet')
|
||||
AND (enable_rdp IS NULL OR NOT enable_rdp)
|
||||
AND (enable_vnc IS NULL OR NOT enable_vnc)
|
||||
AND (enable_telnet IS NULL OR NOT enable_telnet)
|
||||
`);
|
||||
} catch {
|
||||
// A fresh install never had the columns.
|
||||
}
|
||||
|
||||
if (
|
||||
result.moved.length > 0 ||
|
||||
result.usersMoved > 0 ||
|
||||
result.hostsMoved > 0
|
||||
) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} Remote Desktop setting(s), ${result.usersMoved} user(s) and ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "remote_desktop_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Moves the session logging host column into the session-recording plugin's
|
||||
* host-scope settings, before the column is dropped from ssh_data.
|
||||
*
|
||||
* The legacy column defaulted to true; the plugin's enable switch defaults to
|
||||
* false like every other plugin's. So unlike a migration whose old and new
|
||||
* defaults agree, every host needs an explicit row here, not just the ones
|
||||
* that turned the feature off.
|
||||
*
|
||||
* Idempotent: skips a host that already has a session-recording settings row,
|
||||
* so it is safe to run on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
legacyFlag,
|
||||
selectLegacyRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface SessionRecordingSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacySessionLoggingRow {
|
||||
id: number;
|
||||
enable_session_logging: number | boolean | null;
|
||||
}
|
||||
|
||||
export async function runSessionRecordingSettingsMigration(): Promise<SessionRecordingSettingsMigrationResult> {
|
||||
const result: SessionRecordingSettingsMigrationResult = {
|
||||
moved: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring this column.
|
||||
const rows = await selectLegacyRows<LegacySessionLoggingRow>(sql`
|
||||
SELECT id, enable_session_logging FROM ssh_data
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"session-recording",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableSessionRecording",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"session-recording",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableSessionRecording",
|
||||
JSON.stringify(legacyFlag(row.enable_session_logging, true)),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved session recording settings for ${result.moved} host(s) into plugin settings`,
|
||||
{
|
||||
operation: "session_recording_settings_migration",
|
||||
moved: result.moved,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep the
|
||||
// plugin's own default (off) until this runs successfully.
|
||||
databaseLogger.warn("Session recording settings migration failed", {
|
||||
operation: "session_recording_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Moves session sharing's two settings into the session-sharing plugin:
|
||||
* the instance-wide switch (settings.session_sharing_globally_enabled) into
|
||||
* its admin setting, and ssh_data.allow_session_sharing into its host setting.
|
||||
*
|
||||
* Idempotent: nothing is written where the plugin already has a row, so it is
|
||||
* safe on every boot. Both default to on, so only a switch that was turned off
|
||||
* needs a row. The legacy settings row is left in place for a release.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import {
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "session-sharing";
|
||||
const LEGACY_GLOBAL_KEY = "session_sharing_globally_enabled";
|
||||
|
||||
export interface SessionSharingSettingsMigrationResult {
|
||||
movedGlobal: boolean;
|
||||
movedHosts: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export async function runSessionSharingSettingsMigration(): Promise<SessionSharingSettingsMigrationResult> {
|
||||
const result: SessionSharingSettingsMigrationResult = {
|
||||
movedGlobal: false,
|
||||
movedHosts: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const legacy =
|
||||
await createCurrentSettingsRepository().get(LEGACY_GLOBAL_KEY);
|
||||
if (legacy === "false") {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"globallyEnabled",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
} else {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"globallyEnabled",
|
||||
JSON.stringify(false),
|
||||
);
|
||||
result.movedGlobal = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Raw SQL, so this keeps working once schema.ts stops declaring the column.
|
||||
const rows = await selectLegacyRows<LegacyHostRow>(sql`
|
||||
SELECT id FROM ssh_data WHERE allow_session_sharing = false
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
"allowSessionSharing",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
"allowSessionSharing",
|
||||
JSON.stringify(false),
|
||||
);
|
||||
result.movedHosts++;
|
||||
}
|
||||
|
||||
if (result.movedGlobal || result.movedHosts > 0) {
|
||||
databaseLogger.info("Moved session sharing settings into the plugin", {
|
||||
operation: "session_sharing_settings_migration",
|
||||
movedGlobal: result.movedGlobal,
|
||||
movedHosts: result.movedHosts,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Never stops the backend. Sharing then stays on, the plugin's default.
|
||||
databaseLogger.warn("Session sharing settings migration failed", {
|
||||
operation: "session_sharing_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,19 +1,48 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
// Columns old SQLite snippet tables can be missing, for tables made before
|
||||
// they were added, or adopted by the plugin before core added them.
|
||||
const SNIPPET_COLUMNS: Array<[string, string]> = [
|
||||
["folder", "TEXT"],
|
||||
["order", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["host_filter", "TEXT"],
|
||||
["is_note", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["sync_id", "TEXT"],
|
||||
];
|
||||
const FOLDER_COLUMNS: Array<[string, string]> = [
|
||||
["color", "TEXT"],
|
||||
["icon", "TEXT"],
|
||||
["sync_id", "TEXT"],
|
||||
];
|
||||
|
||||
const TABLES: Array<[string, Array<[string, string]>]> = [
|
||||
["snippets", SNIPPET_COLUMNS],
|
||||
["p_snippets_snippets", SNIPPET_COLUMNS],
|
||||
["snippet_folders", FOLDER_COLUMNS],
|
||||
["p_snippets_snippet_folders", FOLDER_COLUMNS],
|
||||
];
|
||||
|
||||
/** Repair older SQLite tables before adoption, or after an incomplete 2.9 upgrade. */
|
||||
export function repairSnippetsNoteColumn(sqlite: Database.Database): void {
|
||||
for (const table of ["snippets", "p_snippets_snippets"]) {
|
||||
export function repairSnippetsColumns(sqlite: Database.Database): void {
|
||||
for (const [table, wanted] of TABLES) {
|
||||
const columns = sqlite.pragma(`table_info("${table}")`) as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
if (
|
||||
columns.length === 0 ||
|
||||
columns.some(({ name }) => name === "is_note")
|
||||
) {
|
||||
continue;
|
||||
if (columns.length === 0) continue;
|
||||
const have = new Set(columns.map(({ name }) => name));
|
||||
for (const [column, type] of wanted) {
|
||||
if (!have.has(column)) {
|
||||
sqlite.exec(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`);
|
||||
}
|
||||
}
|
||||
// Sync matches rows by sync_id, so every row needs one.
|
||||
sqlite.exec(
|
||||
`ALTER TABLE "${table}" ADD COLUMN "is_note" INTEGER NOT NULL DEFAULT 0`,
|
||||
`UPDATE "${table}" SET "sync_id" = lower(hex(randomblob(16))) WHERE "sync_id" IS NULL`,
|
||||
);
|
||||
if (!have.has("sync_id")) {
|
||||
sqlite.exec(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_${table}_sync_id" ON "${table}" ("sync_id")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Moves what core kept for snippets into the snippets plugin's settings.
|
||||
*
|
||||
* Per host: ssh_data.quick_actions becomes the quickActions host setting,
|
||||
* and terminal_config.startupSnippetId the startupSnippetId one. Per user:
|
||||
* user_preferences.confirm_snippet_execution and folders_collapsed become the
|
||||
* confirmExecution and foldersCollapsed user settings.
|
||||
*
|
||||
* The columns keep their values until 3.0.0 drops them.
|
||||
*
|
||||
* Idempotent: a host that already has either host setting, and a user key
|
||||
* already set, are skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
legacyFlag,
|
||||
selectLegacyRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "snippets";
|
||||
const HOST_KEYS = ["quickActions", "startupSnippetId"];
|
||||
|
||||
function parseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string" || !value) return value ?? undefined;
|
||||
try {
|
||||
let parsed = JSON.parse(value);
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function snippetId(value: unknown): number | null {
|
||||
const id = typeof value === "string" ? Number(value) : value;
|
||||
return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/** The snippets host settings one 2.8 ssh_data row carried. */
|
||||
export function snippetHostSettingsFromRow(row: {
|
||||
quick_actions?: unknown;
|
||||
terminal_config?: unknown;
|
||||
}): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
const actions = parseJson(row.quick_actions);
|
||||
if (Array.isArray(actions)) {
|
||||
const quickActions = actions.flatMap((entry) => {
|
||||
const id = snippetId((entry as { snippetId?: unknown })?.snippetId);
|
||||
if (id === null) return [];
|
||||
const name = (entry as { name?: unknown }).name;
|
||||
return [{ name: typeof name === "string" ? name : "", snippetId: id }];
|
||||
});
|
||||
if (quickActions.length > 0) values.quickActions = quickActions;
|
||||
}
|
||||
const config = parseJson(row.terminal_config) as
|
||||
{ startupSnippetId?: unknown } | undefined;
|
||||
const startup = snippetId(config?.startupSnippetId);
|
||||
if (startup !== null) values.startupSnippetId = startup;
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface SnippetsSettingsMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
usersMoved: number;
|
||||
}
|
||||
|
||||
export async function runSnippetsSettingsMigration(): Promise<SnippetsSettingsMigrationResult> {
|
||||
const result: SnippetsSettingsMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
try {
|
||||
const rows = await selectLegacyRows<{
|
||||
id: number;
|
||||
quick_actions: unknown;
|
||||
terminal_config: unknown;
|
||||
}>(sql`
|
||||
SELECT id, quick_actions, terminal_config FROM ssh_data
|
||||
WHERE quick_actions IS NOT NULL OR terminal_config IS NOT NULL
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const values = snippetHostSettingsFromRow(row);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
let done = false;
|
||||
for (const key of HOST_KEYS) {
|
||||
if (await pluginSettings.get(PLUGIN_ID, "host", scopeId, key)) {
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
if (done) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Snippets host settings migration failed", {
|
||||
operation: "snippets_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await selectLegacyRows<{
|
||||
user_id: string;
|
||||
confirm_snippet_execution: unknown;
|
||||
folders_collapsed: unknown;
|
||||
}>(sql`
|
||||
SELECT user_id, confirm_snippet_execution, folders_collapsed
|
||||
FROM user_preferences
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const values: Record<string, boolean> = {};
|
||||
if (row.confirm_snippet_execution != null) {
|
||||
values.confirmExecution = legacyFlag(
|
||||
row.confirm_snippet_execution,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (row.folders_collapsed != null) {
|
||||
values.foldersCollapsed = legacyFlag(row.folders_collapsed, true);
|
||||
}
|
||||
let moved = false;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
key,
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
moved = true;
|
||||
}
|
||||
if (moved) result.usersMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Snippets user settings migration failed", {
|
||||
operation: "snippets_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0 || result.usersMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved the snippet settings of ${result.hostsMoved} host(s) and ${result.usersMoved} user(s) into plugin settings`,
|
||||
{ operation: "snippets_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
/**
|
||||
* Moves the terminal's look and behavior out of core and into the
|
||||
* ssh-terminal plugin's settings.
|
||||
*
|
||||
* Per host, the terminal keys of ssh_data.terminal_config become host
|
||||
* settings: every behavior key the host saved (tmux, mosh, auto-fill, link
|
||||
* and echo modes, syntax highlighting and the rest), and its look only when
|
||||
* it saved one. 2.8 stripped the look from a host that followed the user's
|
||||
* defaults, so a host with any look key had opted out: it gets
|
||||
* inheritAppearance false and its look copied. The SSH connection options in
|
||||
* the same JSON are core's and move to ssh_data.ssh_options
|
||||
* (ssh-options-migration.ts); startupSnippetId and a legacy sudo password
|
||||
* stay where they are.
|
||||
*
|
||||
* Per user, user_preferences.terminal_defaults, custom_themes and
|
||||
* command_autocomplete become user settings.
|
||||
*
|
||||
* The columns keep their values until 3.0.0 drops them.
|
||||
*
|
||||
* Idempotent: a host that already has any of these rows, and a user key that
|
||||
* is already set, are skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
|
||||
const APPEARANCE_KEYS = [
|
||||
"theme",
|
||||
"cursorBlink",
|
||||
"cursorStyle",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"bellStyle",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImage",
|
||||
"backgroundImageOpacity",
|
||||
"customThemeColors",
|
||||
];
|
||||
|
||||
const BEHAVIOR_KEYS = [
|
||||
"rightClickSelectsWord",
|
||||
"macOptionIsMeta",
|
||||
"fastScrollModifier",
|
||||
"fastScrollSensitivity",
|
||||
"backspaceMode",
|
||||
"autoMosh",
|
||||
"moshCommand",
|
||||
"autoTmux",
|
||||
"useSSHTitle",
|
||||
"syntaxHighlighting",
|
||||
"syntaxHighlightingOptions",
|
||||
"linkClickBehavior",
|
||||
"localEcho",
|
||||
"passwordPromptAutoFill",
|
||||
"sudoPasswordAutoFill",
|
||||
];
|
||||
|
||||
const HOST_KEYS = ["inheritAppearance", ...APPEARANCE_KEYS, ...BEHAVIOR_KEYS];
|
||||
|
||||
/** The plugin's select fields, which refuse anything outside their options. */
|
||||
const ENUMS: Record<string, string[]> = {
|
||||
cursorStyle: ["block", "underline", "bar"],
|
||||
bellStyle: ["none", "sound", "visual", "both"],
|
||||
fastScrollModifier: ["alt", "ctrl", "shift"],
|
||||
backspaceMode: ["normal", "control-h"],
|
||||
linkClickBehavior: ["default", "confirm", "direct"],
|
||||
localEcho: ["default", "off", "auto", "on"],
|
||||
};
|
||||
|
||||
const NUMBER_KEYS = new Set([
|
||||
"fontSize",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImageOpacity",
|
||||
"fastScrollSensitivity",
|
||||
]);
|
||||
|
||||
const LEGACY_TERMIX_THEMES = [
|
||||
"Termix Dark",
|
||||
"Termix Light",
|
||||
"termixDark",
|
||||
"termixLight",
|
||||
];
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
try {
|
||||
let parsed = JSON.parse(value);
|
||||
// Some rows were stringified twice.
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** One key's value in the type its plugin field declares, else undefined. */
|
||||
export function terminalValue(key: string, value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (ENUMS[key]) {
|
||||
return typeof value === "string" && ENUMS[key].includes(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
if (NUMBER_KEYS.has(key)) {
|
||||
if (value === null || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
switch (key) {
|
||||
case "theme":
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
return LEGACY_TERMIX_THEMES.includes(value) ? "termix" : value;
|
||||
case "fontFamily":
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
case "backgroundImage":
|
||||
case "moshCommand":
|
||||
return value === null
|
||||
? ""
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: undefined;
|
||||
case "customThemeColors":
|
||||
return isObject(value) ? value : undefined;
|
||||
case "syntaxHighlightingOptions":
|
||||
return isObject(value) ? value : undefined;
|
||||
default:
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The ssh-terminal host settings one 2.8 terminal_config value carried. */
|
||||
export function hostSettingsFromTerminalConfig(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> {
|
||||
const config = typeof raw === "string" ? parseJson(raw) : raw;
|
||||
if (!isObject(config)) return {};
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of BEHAVIOR_KEYS) {
|
||||
const value = terminalValue(key, config[key]);
|
||||
if (value !== undefined) values[key] = value;
|
||||
}
|
||||
if (APPEARANCE_KEYS.some((key) => key in config)) {
|
||||
values.inheritAppearance = false;
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
const value = terminalValue(key, config[key]);
|
||||
if (value !== undefined) values[key] = value;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** The ssh-terminal user settings one user_preferences row carried. */
|
||||
export function userSettingsFromPreferences(row: {
|
||||
terminal_defaults?: unknown;
|
||||
custom_themes?: unknown;
|
||||
command_autocomplete?: unknown;
|
||||
}): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
const defaults = parseJson(row.terminal_defaults);
|
||||
if (isObject(defaults)) {
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
const value = terminalValue(key, defaults[key]);
|
||||
if (value !== undefined) picked[key] = value;
|
||||
}
|
||||
if (Object.keys(picked).length > 0) values.terminalDefaults = picked;
|
||||
}
|
||||
const themes = parseJson(row.custom_themes);
|
||||
if (Array.isArray(themes) && themes.length > 0) values.customThemes = themes;
|
||||
const autocomplete = row.command_autocomplete;
|
||||
if (autocomplete !== null && autocomplete !== undefined) {
|
||||
values.commandAutocomplete =
|
||||
autocomplete === true ||
|
||||
autocomplete === 1 ||
|
||||
autocomplete === "1" ||
|
||||
autocomplete === "true";
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface SshTerminalLookMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
usersMoved: number;
|
||||
}
|
||||
|
||||
export async function runSshTerminalLookMigration(): Promise<SshTerminalLookMigrationResult> {
|
||||
const result: SshTerminalLookMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
try {
|
||||
const rows = await selectLegacyRows<{
|
||||
id: number;
|
||||
terminal_config: string | null;
|
||||
}>(sql`
|
||||
SELECT id, terminal_config FROM ssh_data
|
||||
WHERE terminal_config IS NOT NULL
|
||||
`);
|
||||
const moves = rows
|
||||
.map((row) => ({
|
||||
scopeId: String(row.id),
|
||||
values: hostSettingsFromTerminalConfig(row.terminal_config),
|
||||
}))
|
||||
.filter((move) => Object.keys(move.values).length > 0);
|
||||
// A few reads for every candidate host rather than one per host.
|
||||
const done = new Set<string | null>();
|
||||
for (let i = 0; i < moves.length; i += 500) {
|
||||
const existing = await pluginSettings.getAllForScopeIds(
|
||||
"host",
|
||||
moves.slice(i, i + 500).map((move) => move.scopeId),
|
||||
);
|
||||
for (const setting of existing) {
|
||||
if (setting.pluginId === PLUGIN_ID && HOST_KEYS.includes(setting.key)) {
|
||||
done.add(setting.scopeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { scopeId, values } of moves) {
|
||||
if (done.has(scopeId)) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSH terminal host look migration failed", {
|
||||
operation: "ssh_terminal_look_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await selectLegacyRows<{
|
||||
user_id: string;
|
||||
terminal_defaults: string | null;
|
||||
custom_themes: string | null;
|
||||
command_autocomplete: unknown;
|
||||
}>(sql`
|
||||
SELECT user_id, terminal_defaults, custom_themes, command_autocomplete
|
||||
FROM user_preferences
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const values = userSettingsFromPreferences(row);
|
||||
let moved = false;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
key,
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
moved = true;
|
||||
}
|
||||
if (moved) result.usersMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSH terminal user settings migration failed", {
|
||||
operation: "ssh_terminal_look_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0 || result.usersMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved the terminal settings of ${result.hostsMoved} host(s) and ${result.usersMoved} user(s) into plugin settings`,
|
||||
{ operation: "ssh_terminal_look_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Moves user_preferences.terminal_macros into the ssh-terminal plugin's
|
||||
* macros user setting. Macros a browser kept on its own (storage mode
|
||||
* "local") are moved by the plugin's frontend the first time the panel opens.
|
||||
*
|
||||
* The column keeps its value until 3.0.0 drops it.
|
||||
*
|
||||
* Idempotent: a user whose macros setting is already set is skipped.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
const MAX_MACROS = 100;
|
||||
|
||||
/** The saved macros in a 2.8 terminal_macros value, or null for none. */
|
||||
export function macrosFromPreferences(value: unknown): unknown[] | null {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const macros = parsed
|
||||
.filter(
|
||||
(macro) =>
|
||||
!!macro &&
|
||||
typeof macro === "object" &&
|
||||
typeof (macro as { id?: unknown }).id === "string" &&
|
||||
typeof (macro as { name?: unknown }).name === "string" &&
|
||||
Array.isArray((macro as { steps?: unknown }).steps),
|
||||
)
|
||||
.slice(0, MAX_MACROS);
|
||||
return macros.length > 0 ? macros : null;
|
||||
}
|
||||
|
||||
export async function runSshTerminalMacrosMigration(): Promise<{
|
||||
usersMoved: number;
|
||||
}> {
|
||||
const result = { usersMoved: 0 };
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
try {
|
||||
const rows = await selectLegacyRows<{
|
||||
user_id: string;
|
||||
terminal_macros: unknown;
|
||||
}>(sql`
|
||||
SELECT user_id, terminal_macros FROM user_preferences
|
||||
WHERE terminal_macros IS NOT NULL
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const macros = macrosFromPreferences(row.terminal_macros);
|
||||
if (!macros) continue;
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
"macros",
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
"macros",
|
||||
JSON.stringify(macros),
|
||||
);
|
||||
result.usersMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Terminal macros migration failed", {
|
||||
operation: "ssh_terminal_macros_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.usersMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved the terminal macros of ${result.usersMoved} user(s) into plugin settings`,
|
||||
{ operation: "ssh_terminal_macros_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
/**
|
||||
* Moves the terminal's settings out of core and into the ssh-terminal
|
||||
* plugin's own settings.
|
||||
*
|
||||
* Admin keys from the core `settings` table: session persistence, command
|
||||
* history, the terminal half of the admin host defaults (a new host's font,
|
||||
* theme, cursor and auto tmux), touch input tuning and terminal image
|
||||
* storage. The image storage
|
||||
* local directory was never sent to a browser, so it lands as a secret field,
|
||||
* which keeps it that way. The legacy rows are left in place for a release;
|
||||
* nothing reads them any more.
|
||||
*
|
||||
* Host columns from ssh_data: enable_terminal, enable_terminal_toolbar and
|
||||
* enable_command_history. All three default to on in the plugin, so only
|
||||
* hosts that turned one off need a row.
|
||||
*
|
||||
* Idempotent: an admin key already set in the plugin, or a host that already
|
||||
* has an enableTerminal row, is skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
legacyFlag,
|
||||
selectLegacyRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
|
||||
type Parse = (raw: string) => unknown;
|
||||
|
||||
const asNumber: Parse = (raw) => {
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const asBoolean: Parse = (raw) =>
|
||||
raw === "true" || raw === "1"
|
||||
? true
|
||||
: raw === "false" || raw === "0"
|
||||
? false
|
||||
: undefined;
|
||||
const asString: Parse = (raw) => raw;
|
||||
const asJson: Parse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const CURSOR_STYLES = ["block", "underline", "bar"];
|
||||
/** 2.8 spellings of the theme that follows the app. */
|
||||
const LEGACY_TERMIX_THEMES = [
|
||||
"Termix Dark",
|
||||
"Termix Light",
|
||||
"termixDark",
|
||||
"termixLight",
|
||||
];
|
||||
|
||||
/** One terminal key of the admin host defaults, or undefined when unusable. */
|
||||
function newHostDefault(key: string, defaults: unknown): unknown {
|
||||
if (!defaults || typeof defaults !== "object") return undefined;
|
||||
const value = (defaults as Record<string, unknown>)[key];
|
||||
switch (key) {
|
||||
case "fontSize": {
|
||||
const size = Number(value);
|
||||
return Number.isFinite(size) && size >= 8 && size <= 36
|
||||
? size
|
||||
: undefined;
|
||||
}
|
||||
case "fontFamily":
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
case "theme":
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
return LEGACY_TERMIX_THEMES.includes(value) ? "termix" : value;
|
||||
case "cursorStyle":
|
||||
return typeof value === "string" && CURSOR_STYLES.includes(value)
|
||||
? value
|
||||
: undefined;
|
||||
default:
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy core key -> the admin field the plugin declares. */
|
||||
const ADMIN_MOVES: {
|
||||
legacyKey: string;
|
||||
field: string;
|
||||
parse: Parse;
|
||||
secret?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
legacyKey: "terminal_session_timeout_minutes",
|
||||
field: "sessionTimeoutMinutes",
|
||||
parse: asNumber,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_session_persistence_enabled",
|
||||
field: "sessionPersistence",
|
||||
parse: asBoolean,
|
||||
},
|
||||
{
|
||||
legacyKey: "command_history_enabled",
|
||||
field: "commandHistoryEnabled",
|
||||
parse: asBoolean,
|
||||
},
|
||||
{
|
||||
// The admin host defaults carried the new-host command history switch.
|
||||
legacyKey: "host_defaults",
|
||||
field: "commandHistoryForNewHosts",
|
||||
parse: (raw) => {
|
||||
const value = (asJson(raw) as { enableCommandHistory?: unknown } | null)
|
||||
?.enableCommandHistory;
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
},
|
||||
},
|
||||
...(
|
||||
[
|
||||
["fontSize", "newHostFontSize"],
|
||||
["fontFamily", "newHostFontFamily"],
|
||||
["theme", "newHostTheme"],
|
||||
["cursorStyle", "newHostCursorStyle"],
|
||||
["cursorBlink", "newHostCursorBlink"],
|
||||
["autoTmux", "newHostAutoTmux"],
|
||||
] as const
|
||||
).map(([legacyField, field]) => ({
|
||||
// The terminal half of the admin host defaults, for new hosts.
|
||||
legacyKey: "host_defaults",
|
||||
field,
|
||||
parse: (raw: string) => newHostDefault(legacyField, asJson(raw)),
|
||||
})),
|
||||
{ legacyKey: "touch_input_settings", field: "touchInput", parse: asJson },
|
||||
{
|
||||
legacyKey: "terminal_image_storage_mode",
|
||||
field: "imageStorageMode",
|
||||
parse: asString,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_image_local_dir",
|
||||
field: "imageLocalDir",
|
||||
parse: asString,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_image_host_path",
|
||||
field: "imageHostPath",
|
||||
parse: asString,
|
||||
},
|
||||
{ legacyKey: "terminal_image_ttl_ms", field: "imageTtlMs", parse: asNumber },
|
||||
{
|
||||
legacyKey: "terminal_image_max_count",
|
||||
field: "imageMaxCount",
|
||||
parse: asNumber,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_image_max_storage_bytes",
|
||||
field: "imageMaxBytes",
|
||||
parse: asNumber,
|
||||
},
|
||||
];
|
||||
|
||||
interface LegacyTerminalHostRow {
|
||||
id: number;
|
||||
enable_terminal: number | boolean | null;
|
||||
enable_terminal_toolbar: number | boolean | null;
|
||||
enable_command_history: number | boolean | null;
|
||||
}
|
||||
|
||||
/** A column that was never set keeps the old default, on. */
|
||||
function isOn(value: number | boolean | null): boolean {
|
||||
return legacyFlag(value, true);
|
||||
}
|
||||
|
||||
export interface SshTerminalSettingsMigrationResult {
|
||||
moved: string[];
|
||||
skipped: string[];
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runSshTerminalSettingsMigration(): Promise<SshTerminalSettingsMigrationResult> {
|
||||
const result: SshTerminalSettingsMigrationResult = {
|
||||
moved: [],
|
||||
skipped: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const move of ADMIN_MOVES) {
|
||||
const legacyValue = await settingsRepository.get(move.legacyKey);
|
||||
if (legacyValue === null || legacyValue === "") continue;
|
||||
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped.push(move.legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = move.parse(legacyValue);
|
||||
if (parsed === undefined) {
|
||||
result.skipped.push(move.legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stored = move.secret
|
||||
? await encryptSystemSecret(String(parsed))
|
||||
: parsed;
|
||||
await pluginSettingsRepository.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
JSON.stringify(stored),
|
||||
!!move.secret,
|
||||
);
|
||||
result.moved.push(move.legacyKey);
|
||||
}
|
||||
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring these columns.
|
||||
const rows = await selectLegacyRows<LegacyTerminalHostRow>(sql`
|
||||
SELECT id, enable_terminal, enable_terminal_toolbar, enable_command_history
|
||||
FROM ssh_data
|
||||
WHERE enable_terminal = false
|
||||
OR enable_terminal_toolbar = false
|
||||
OR enable_command_history = false
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTerminal",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const values: Record<string, boolean> = {
|
||||
enableTerminal: isOn(row.enable_terminal),
|
||||
enableTerminalToolbar: isOn(row.enable_terminal_toolbar),
|
||||
enableCommandHistory: isOn(row.enable_command_history),
|
||||
};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await pluginSettingsRepository.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
|
||||
if (result.moved.length > 0 || result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} terminal setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{
|
||||
operation: "ssh_terminal_settings_migration",
|
||||
moved: result.moved.join(", "),
|
||||
hostsMoved: result.hostsMoved,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. The terminal falls back
|
||||
// to its defaults, which is a visible, recoverable state.
|
||||
databaseLogger.warn("SSH terminal settings migration failed", {
|
||||
operation: "ssh_terminal_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
* and exactly one OIDC row is from before 2.9. An identity whose target is
|
||||
* already taken (a duplicate account made by 2.9.0) is left for an admin.
|
||||
* Idempotent.
|
||||
*
|
||||
* 2.9.1 ran it at boot. 26.10.0 runs it from the database setup, before
|
||||
* users.oidc_identifier is dropped, for an install upgrading from 2.9.0.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Moves the silent sign-in default out of core settings and into the sso
|
||||
* plugin's admin settings. Core used to own the toggle and read it on the
|
||||
* login page; the sso plugin now marks its own login instance to start on
|
||||
* its own.
|
||||
*
|
||||
* Idempotent: a value already in plugin settings wins and the core copy is
|
||||
* dropped.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "sso";
|
||||
const LEGACY_KEY = "oidc_silent_login_default";
|
||||
const FIELD = "silentLoginDefault";
|
||||
|
||||
export interface SsoSettingsMigrationResult {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
export async function runSsoSettingsMigration(): Promise<SsoSettingsMigrationResult> {
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return { moved: false };
|
||||
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const legacy = await settings.get(LEGACY_KEY);
|
||||
if (legacy === null || legacy === "") return { moved: false };
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, FIELD);
|
||||
if (!existing || existing.value === null) {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
FIELD,
|
||||
JSON.stringify(legacy === "true"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
await settings.delete(LEGACY_KEY);
|
||||
databaseLogger.info("Moved the silent sign-in default into sso settings", {
|
||||
operation: "sso_settings_migration",
|
||||
});
|
||||
return { moved: true };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSO settings migration failed", {
|
||||
operation: "sso_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { moved: false };
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Moves the 2.8 Step CA configuration into the step-ca plugin.
|
||||
*
|
||||
* The CA URL, root fingerprint, OIDC provisioner and private endpoint
|
||||
* allowlist lived in core `settings` rows. They become the plugin's admin
|
||||
* settings. An install that had a CA configured keeps sending the pre-2.9
|
||||
* redirect URI (/host/step-ca-callback), because that is the one registered
|
||||
* with its identity provider: the plugin's legacyCallback setting is turned
|
||||
* on.
|
||||
*
|
||||
* Idempotent: a value already present in plugin_settings is never
|
||||
* overwritten. The old rows stay for one release so a downgrade still works.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { parseNotificationAllowlist } from "../utils/notification-egress.js";
|
||||
|
||||
const PLUGIN_ID = "step-ca";
|
||||
|
||||
const MOVES: { legacyKey: string; field: string }[] = [
|
||||
{ legacyKey: "step_ca_url", field: "caUrl" },
|
||||
{ legacyKey: "step_ca_fingerprint", field: "fingerprint" },
|
||||
{ legacyKey: "step_ca_provisioner", field: "provisioner" },
|
||||
];
|
||||
|
||||
const LEGACY_ALLOWLIST_KEY = "step_ca_private_endpoint_allowlist";
|
||||
|
||||
export interface StepCaSettingsMigrationResult {
|
||||
moved: string[];
|
||||
legacyCallback: boolean;
|
||||
}
|
||||
|
||||
export async function runStepCaSettingsMigration(): Promise<StepCaSettingsMigrationResult> {
|
||||
const result: StepCaSettingsMigrationResult = {
|
||||
moved: [],
|
||||
legacyCallback: false,
|
||||
};
|
||||
|
||||
try {
|
||||
// plugin_settings has a foreign key to plugins: nothing to move into yet.
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const writeIfUnset = async (field: string, value: unknown) => {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) return false;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const move of MOVES) {
|
||||
const value = (await settings.get(move.legacyKey))?.trim();
|
||||
if (!value) continue;
|
||||
if (await writeIfUnset(move.field, value)) {
|
||||
result.moved.push(move.legacyKey);
|
||||
}
|
||||
}
|
||||
|
||||
const hosts = parseNotificationAllowlist(
|
||||
await settings.get(LEGACY_ALLOWLIST_KEY),
|
||||
);
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
(await writeIfUnset("privateEndpoints", hosts.join(", ")))
|
||||
) {
|
||||
result.moved.push(LEGACY_ALLOWLIST_KEY);
|
||||
}
|
||||
|
||||
if ((await settings.get("step_ca_url"))?.trim()) {
|
||||
result.legacyCallback = await writeIfUnset("legacyCallback", true);
|
||||
}
|
||||
|
||||
if (result.moved.length > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} Step CA setting(s) into plugin settings`,
|
||||
{
|
||||
operation: "step_ca_settings_migration",
|
||||
moved: result.moved.join(", "),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Must not stop the backend: the plugin then reports "not configured".
|
||||
databaseLogger.warn("Step CA settings migration failed", {
|
||||
operation: "step_ca_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Moves the Tailscale API key and base URL out of core settings.
|
||||
*
|
||||
* Both lived in the core `settings` table, written by a core route that existed
|
||||
* only to serve one plugin. A6 gives plugins their own settings, so they belong
|
||||
* in plugin_settings under the tailscale plugin.
|
||||
*
|
||||
* The key was stored in plaintext, and the route that read it back masked only
|
||||
* its tail while being open to any authenticated user. Landing it as a secret
|
||||
* field encrypts it at rest, so this migration is a fix as much as a move.
|
||||
*
|
||||
* Idempotent in two ways: it stops when a value is already present in the new
|
||||
* home, and encryptSystemSecret returns an already-encrypted value unchanged,
|
||||
* so a second pass can neither duplicate nor double-encrypt.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
|
||||
|
||||
const PLUGIN_ID = "tailscale";
|
||||
|
||||
/** Legacy core key -> the field the plugin now declares. */
|
||||
const MOVES: { legacyKey: string; field: string; secret: boolean }[] = [
|
||||
{ legacyKey: "tailscale_api_key", field: "apiKey", secret: true },
|
||||
{ legacyKey: "tailscale_api_base_url", field: "apiBaseUrl", secret: false },
|
||||
];
|
||||
|
||||
export interface TailscaleSettingsMigrationResult {
|
||||
moved: string[];
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export async function runTailscaleSettingsMigration(): Promise<TailscaleSettingsMigrationResult> {
|
||||
const moved: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
try {
|
||||
// The FK to plugins means there is nothing to migrate into until the
|
||||
// plugin has been seeded. Not an error: a build without it just has no
|
||||
// tailscale rows.
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return { moved, skipped };
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const move of MOVES) {
|
||||
const legacyValue = await settingsRepository.get(move.legacyKey);
|
||||
if (legacyValue === null || legacyValue === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
// Already moved. Drop the stale copy rather than leaving a plaintext
|
||||
// key behind for a reader that predates this change.
|
||||
await settingsRepository.delete(move.legacyKey);
|
||||
skipped.push(move.legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stored = move.secret
|
||||
? await encryptSystemSecret(legacyValue)
|
||||
: legacyValue;
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
JSON.stringify(stored),
|
||||
move.secret,
|
||||
);
|
||||
|
||||
await settingsRepository.delete(move.legacyKey);
|
||||
moved.push(move.legacyKey);
|
||||
}
|
||||
|
||||
if (moved.length > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${moved.length} Tailscale setting(s) into plugin settings`,
|
||||
{ operation: "tailscale_settings_migration", moved: moved.join(", ") },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. The plugin falls back to
|
||||
// "no key configured", which is a visible, recoverable state.
|
||||
databaseLogger.warn("Tailscale settings migration failed", {
|
||||
operation: "tailscale_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return { moved, skipped };
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Moves the 2.8 telemetry settings into the telemetry plugin: the admin
|
||||
* switch (settings.analytics_enabled) into its `enabled` admin setting, and
|
||||
* the instance id (settings.analytics_instance_id) into `instanceId`, so the
|
||||
* instance keeps reporting under the same id.
|
||||
*
|
||||
* Idempotent: nothing is written where the plugin already has a row. The
|
||||
* legacy settings rows stay in place until 3.0.0. The plugin waits a few
|
||||
* minutes before its first send, so this lands first.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "telemetry";
|
||||
|
||||
export interface TelemetrySettingsMigrationResult {
|
||||
movedEnabled: boolean;
|
||||
movedInstanceId: boolean;
|
||||
}
|
||||
|
||||
export async function runTelemetrySettingsMigration(): Promise<TelemetrySettingsMigrationResult> {
|
||||
const result: TelemetrySettingsMigrationResult = {
|
||||
movedEnabled: false,
|
||||
movedInstanceId: false,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins.
|
||||
try {
|
||||
if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const coreSettings = createCurrentSettingsRepository();
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const moves: Array<[string, string, unknown]> = [];
|
||||
if ((await coreSettings.get("analytics_enabled")) === "false") {
|
||||
moves.push(["analytics_enabled", "enabled", false]);
|
||||
}
|
||||
const instanceId = await coreSettings.get("analytics_instance_id");
|
||||
if (instanceId) {
|
||||
moves.push(["analytics_instance_id", "instanceId", instanceId]);
|
||||
}
|
||||
|
||||
for (const [, key, value] of moves) {
|
||||
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, key);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
if (key === "enabled") result.movedEnabled = true;
|
||||
else result.movedInstanceId = true;
|
||||
}
|
||||
|
||||
if (result.movedEnabled || result.movedInstanceId) {
|
||||
databaseLogger.info("Moved telemetry settings into plugin settings", {
|
||||
operation: "telemetry_settings_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Moves the tmux monitor host column into the tmux-monitor plugin's host-scope
|
||||
* settings, before the column is dropped from ssh_data.
|
||||
*
|
||||
* Idempotent: skips a host that already has a tmux-monitor settings row, so it
|
||||
* is safe to run on every boot. Lossless: reads straight off the still-present
|
||||
* ssh_data column, so it must run and finish before that column is ever
|
||||
* dropped from schema.ts.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface TmuxMonitorSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyTmuxMonitorRow {
|
||||
id: number;
|
||||
enable_tmux_monitor: number | boolean | null;
|
||||
}
|
||||
|
||||
export async function runTmuxMonitorSettingsMigration(): Promise<TmuxMonitorSettingsMigrationResult> {
|
||||
const result: TmuxMonitorSettingsMigrationResult = { moved: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring this column. The plugin's enable switch defaults to
|
||||
// off, matching the old column's default, so only hosts that turned it
|
||||
// on need a row.
|
||||
const rows = await selectLegacyRows<LegacyTmuxMonitorRow>(sql`
|
||||
SELECT id, enable_tmux_monitor
|
||||
FROM ssh_data
|
||||
WHERE enable_tmux_monitor = true
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"tmux-monitor",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTmuxMonitor",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"tmux-monitor",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTmuxMonitor",
|
||||
JSON.stringify(!!row.enable_tmux_monitor),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved tmux monitor settings for ${result.moved} host(s) into plugin settings`,
|
||||
{ operation: "tmux_monitor_settings_migration", moved: result.moved },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep the
|
||||
// monitor off, the plugin's default.
|
||||
databaseLogger.warn("Tmux monitor settings migration failed", {
|
||||
operation: "tmux_monitor_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* there sealed with the installation key (what ctx.secrets.seal writes),
|
||||
* then the users columns are cleared.
|
||||
*
|
||||
* The columns stay in the database until 3.0.0, since core's drizzle
|
||||
* The columns stay in the database until 26.10.0, since core's drizzle
|
||||
* migrations run before this does. A user whose data key cannot be opened
|
||||
* at boot is skipped and tried again on the next one. Idempotent.
|
||||
*/
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Moves the two tunnel host columns into the tunnels plugin's host-scope
|
||||
* settings, before those columns are dropped from ssh_data.
|
||||
*
|
||||
* Idempotent: skips a host that already has a tunnels settings row, so it is
|
||||
* safe to run on every boot. Lossless: reads straight off the still-present
|
||||
* ssh_data columns, so it must run and finish before those columns are ever
|
||||
* dropped from schema.ts.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface TunnelsSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyTunnelRow {
|
||||
id: number;
|
||||
enable_tunnel: number | boolean | null;
|
||||
tunnel_connections: string | null;
|
||||
}
|
||||
|
||||
function parseConnections(raw: string | null): unknown[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTunnelsSettingsMigration(): Promise<TunnelsSettingsMigrationResult> {
|
||||
const result: TunnelsSettingsMigrationResult = { moved: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring these columns. The plugin's enable switch defaults to
|
||||
// off, so every host that had tunnels on (the old column default) or
|
||||
// any saved tunnel has to be copied, not just the non-default ones.
|
||||
const rows = await selectLegacyRows<LegacyTunnelRow>(sql`
|
||||
SELECT id, enable_tunnel, tunnel_connections
|
||||
FROM ssh_data
|
||||
WHERE enable_tunnel = true OR tunnel_connections IS NOT NULL
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"tunnels",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTunnel",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"tunnels",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTunnel",
|
||||
JSON.stringify(!!row.enable_tunnel),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"tunnels",
|
||||
"host",
|
||||
scopeId,
|
||||
"tunnelConnections",
|
||||
JSON.stringify(parseConnections(row.tunnel_connections)),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved tunnel settings for ${result.moved} host(s) into plugin settings`,
|
||||
{ operation: "tunnels_settings_migration", moved: result.moved },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep
|
||||
// whatever tunnel settings they already had (none, on a fresh run).
|
||||
databaseLogger.warn("Tunnel settings migration failed", {
|
||||
operation: "tunnels_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* Moves each host's Vault signer profile out of ssh_data and into the vault
|
||||
* plugin's profileId host setting.
|
||||
*
|
||||
* An install that used Vault keeps sending the pre-2.9 redirect URI
|
||||
* (/vault/oidc/callback), because that is the one its Vault OIDC roles
|
||||
* allow: the plugin's legacyCallback setting is turned on.
|
||||
*
|
||||
* The column stays in the database, unused: core's drizzle migrations run
|
||||
* before this does, so dropping it there would lose the data first.
|
||||
*
|
||||
* Idempotent: a host or setting that already has a value is skipped, so this
|
||||
* is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "vault";
|
||||
const SETTING_KEY = "profileId";
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
vault_profile_id: number | string | null;
|
||||
}
|
||||
|
||||
export interface VaultSettingsMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
legacyCallback: boolean;
|
||||
}
|
||||
|
||||
async function legacyRows(): Promise<LegacyHostRow[]> {
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares vault_profile_id.
|
||||
return await selectRows<LegacyHostRow>(
|
||||
sql`SELECT id, vault_profile_id FROM ssh_data WHERE vault_profile_id IS NOT NULL`,
|
||||
);
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether 2.8 had any Vault profile, before or after the plugin adopted them. */
|
||||
async function hadProfiles(): Promise<boolean> {
|
||||
for (const table of ["vault_profiles", "p_vault_profiles"]) {
|
||||
try {
|
||||
const rows = await selectRows<unknown>(
|
||||
sql`SELECT 1 FROM ${sql.identifier(table)} LIMIT 1`,
|
||||
);
|
||||
if (rows.length > 0) return true;
|
||||
} catch {
|
||||
// table not there
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runVaultSettingsMigration(): Promise<VaultSettingsMigrationResult> {
|
||||
const result: VaultSettingsMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
legacyCallback: false,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const rows = await legacyRows();
|
||||
|
||||
for (const row of rows) {
|
||||
const profileId = Number(row.vault_profile_id);
|
||||
if (!Number.isInteger(profileId) || profileId <= 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
try {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
SETTING_KEY,
|
||||
);
|
||||
if (existing) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
SETTING_KEY,
|
||||
JSON.stringify(profileId),
|
||||
);
|
||||
result.hostsMoved++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Vault host settings migration failed for a host", {
|
||||
operation: "vault_settings_migration",
|
||||
hostId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Decided once, on the first boot with the plugin: written either way, so
|
||||
// profiles created on a fresh install later never turn it on.
|
||||
try {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"legacyCallback",
|
||||
);
|
||||
if (!existing || existing.value === null) {
|
||||
result.legacyCallback = rows.length > 0 || (await hadProfiles());
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"legacyCallback",
|
||||
JSON.stringify(result.legacyCallback),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Vault legacy callback migration failed", {
|
||||
operation: "vault_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved the Vault profile for ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "vault_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Moves each host's Wake-on-LAN settings out of ssh_data and into the
|
||||
* wake-on-lan plugin's host settings: mac_address becomes macAddress and
|
||||
* wol_broadcast_address becomes broadcastAddress.
|
||||
*
|
||||
* The columns stay in the database, unused: core's drizzle migrations run
|
||||
* before this does, so dropping them there would lose the data first.
|
||||
*
|
||||
* Idempotent: a host that already has any wake-on-lan row is skipped, so
|
||||
* this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "wake-on-lan";
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
mac_address: string | null;
|
||||
wol_broadcast_address: string | null;
|
||||
}
|
||||
|
||||
/** The plugin's host settings for one legacy row, defaults left out. */
|
||||
export function wakeOnLanSettingsFromRow(
|
||||
row: LegacyHostRow,
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
if (row.mac_address) values.macAddress = row.mac_address;
|
||||
if (row.wol_broadcast_address) {
|
||||
values.broadcastAddress = row.wol_broadcast_address;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface WakeOnLanSettingsMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runWakeOnLanSettingsMigration(): Promise<WakeOnLanSettingsMigrationResult> {
|
||||
const result: WakeOnLanSettingsMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
let rows: LegacyHostRow[];
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares these columns.
|
||||
rows = await selectRows<LegacyHostRow>(
|
||||
sql`SELECT id, mac_address, wol_broadcast_address FROM ssh_data`,
|
||||
);
|
||||
} catch {
|
||||
// A fresh install never had the columns.
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const values = wakeOnLanSettingsFromRow(row);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
try {
|
||||
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
|
||||
if (existing.length > 0) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Wake-on-LAN host settings migration failed for a host",
|
||||
{
|
||||
operation: "wake_on_lan_settings_migration",
|
||||
hostId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved Wake-on-LAN settings for ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "wake_on_lan_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Moves each host's Warpgate flag out of ssh_data and into the warpgate
|
||||
* plugin's useWarpgate host setting. Hosts from before 2.7 that still carry
|
||||
* auth_type "warpgate" get the setting too, and auth type "none", which is
|
||||
* what that auth type meant.
|
||||
*
|
||||
* The column stays in the database, unused: core's drizzle migrations run
|
||||
* before this does, so dropping it there would lose the data first.
|
||||
*
|
||||
* Idempotent: a host that already has the setting is skipped, so this is
|
||||
* safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import {
|
||||
runStatement,
|
||||
selectRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "warpgate";
|
||||
const SETTING_KEY = "useWarpgate";
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
use_warpgate?: number | boolean | string | null;
|
||||
auth_type: string | null;
|
||||
}
|
||||
|
||||
export function usesWarpgate(row: LegacyHostRow): boolean {
|
||||
const flag = row.use_warpgate;
|
||||
return (
|
||||
flag === true ||
|
||||
flag === 1 ||
|
||||
flag === "1" ||
|
||||
flag === "true" ||
|
||||
row.auth_type === "warpgate"
|
||||
);
|
||||
}
|
||||
|
||||
export interface WarpgateSettingsMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
authTypesFixed: number;
|
||||
}
|
||||
|
||||
async function legacyRows(): Promise<LegacyHostRow[]> {
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares use_warpgate.
|
||||
return await selectRows<LegacyHostRow>(
|
||||
sql`SELECT id, use_warpgate, auth_type FROM ssh_data`,
|
||||
);
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
try {
|
||||
return await selectRows<LegacyHostRow>(
|
||||
sql`SELECT id, auth_type FROM ssh_data WHERE auth_type = 'warpgate'`,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWarpgateSettingsMigration(): Promise<WarpgateSettingsMigrationResult> {
|
||||
const result: WarpgateSettingsMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
authTypesFixed: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of await legacyRows()) {
|
||||
if (!usesWarpgate(row)) continue;
|
||||
const scopeId = String(row.id);
|
||||
try {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
SETTING_KEY,
|
||||
);
|
||||
if (existing) {
|
||||
result.hostsSkipped++;
|
||||
} else {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
SETTING_KEY,
|
||||
JSON.stringify(true),
|
||||
);
|
||||
result.hostsMoved++;
|
||||
}
|
||||
if (row.auth_type === "warpgate") {
|
||||
await runStatement(
|
||||
sql`UPDATE ssh_data SET auth_type = 'none' WHERE id = ${row.id}`,
|
||||
);
|
||||
result.authTypesFixed++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Warpgate host settings migration failed for a host",
|
||||
{
|
||||
operation: "warpgate_settings_migration",
|
||||
hostId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved the Warpgate flag for ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "warpgate_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Moves the two web endpoint host columns into the web-endpoint plugin's
|
||||
* host-scope settings, before those columns are dropped from ssh_data.
|
||||
*
|
||||
* Idempotent: skips a host that already has a web-endpoint settings row, so
|
||||
* it is safe to run on every boot. Lossless: reads straight off the
|
||||
* still-present ssh_data columns, so it must run and finish before those
|
||||
* columns are ever dropped from schema.ts.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export interface WebEndpointSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyWebEndpointRow {
|
||||
id: number;
|
||||
enable_web_ui: number | boolean | null;
|
||||
web_ui_config: string | null;
|
||||
}
|
||||
|
||||
function parseConfig(raw: string | null): unknown {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWebEndpointSettingsMigration(): Promise<WebEndpointSettingsMigrationResult> {
|
||||
const result: WebEndpointSettingsMigrationResult = { moved: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring these columns.
|
||||
const rows = await selectLegacyRows<LegacyWebEndpointRow>(sql`
|
||||
SELECT id, enable_web_ui, web_ui_config
|
||||
FROM ssh_data
|
||||
WHERE enable_web_ui = true OR web_ui_config IS NOT NULL
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableWebUi",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableWebUi",
|
||||
JSON.stringify(!!row.enable_web_ui),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"webUiConfig",
|
||||
JSON.stringify(parseConfig(row.web_ui_config)),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved web endpoint settings for ${result.moved} host(s) into plugin settings`,
|
||||
{ operation: "web_endpoint_settings_migration", moved: result.moved },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep
|
||||
// whatever web endpoint settings they already had (none, on a fresh run).
|
||||
databaseLogger.warn("Web endpoint settings migration failed", {
|
||||
operation: "web_endpoint_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in new issue
Block a user